using System.Linq; using System.Net; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Functions.Services.Vulnerability; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Notifications; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Ingest { /// /// Partial: Core ingest processing logic — event classification, session updates, /// rule analysis, Teams notifications, and SignalR message construction. /// public partial class IngestEventsFunction { /// /// Core ingest logic: device block check, NDJSON parsing, event storage, rule engine, SignalR. /// Called by both the cert-auth Run() method and the bootstrap wrapper. /// internal async Task ProcessIngestAsync(HttpRequestData req, string tenantId, SecurityValidationResult validation) { // --- Device block check (after security, before body decompression) --- // Check if this device has been administratively blocked (e.g. rogue device sending excessive data). // We read the serial number from the header (same header used by AutopilotDeviceValidator). // Using HTTP 200 with DeviceBlocked=true so the agent does not trigger its auth-failure circuit breaker. // // Session-aware blocking: maintenance auto-blocks store BlockedSessionIds. If the current // ingest is from a DIFFERENT session (new enrollment), the device is auto-unblocked. // Kill actions and manual (whole-device) blocks are never auto-unblocked. var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber") ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() : null; IngestEventsRequest? earlyParsedRequest = null; if (!string.IsNullOrEmpty(serialNumberHeader)) { var (isBlocked, unblockAt, blockAction, blockedSessionIds) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumberHeader); if (isBlocked) { // Session-aware block: parse body to check if this is a different (new) session if (!string.IsNullOrEmpty(blockedSessionIds)) { earlyParsedRequest = await ParseNdjsonGzipRequest(req.Body, tenantId); if (earlyParsedRequest != null && !string.IsNullOrEmpty(earlyParsedRequest.SessionId)) { var (stillBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync( tenantId, serialNumberHeader, earlyParsedRequest.SessionId); if (!stillBlocked) { // Auto-unblocked — fall through to normal processing with already-parsed request isBlocked = false; } } } if (isBlocked) { var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase); _logger.LogWarning( "{Action} ingest from device: TenantId={TenantId}, SerialNumber={SerialNumber}, UnblockAt={UnblockAt}", isKill ? "KILL signal for" : "Rejected", tenantId, serialNumberHeader, unblockAt); var blockedHttpResponse = req.CreateResponse(System.Net.HttpStatusCode.OK); await blockedHttpResponse.WriteAsJsonAsync(new IngestEventsResponse { Success = false, DeviceBlocked = true, DeviceKillSignal = isKill, UnblockAt = unblockAt, Message = isKill ? "Device has been issued a remote kill signal." : "Device is temporarily blocked by an administrator.", ProcessedAt = DateTime.UtcNow }); return new IngestEventsOutput { HttpResponse = blockedHttpResponse, SignalRMessages = Array.Empty() }; } } } // --- Version block check (global, applies to all tenants) --- var agentVersionHeader = req.Headers.Contains("X-Agent-Version") ? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() : null; if (!string.IsNullOrEmpty(agentVersionHeader)) { var (isVersionBlocked, versionAction, matchedPattern) = await _blockedVersionService.IsVersionBlockedAsync(agentVersionHeader); if (isVersionBlocked) { var isVersionKill = string.Equals(versionAction, "Kill", StringComparison.OrdinalIgnoreCase); _logger.LogWarning( "Version {Action} for agent: TenantId={TenantId}, AgentVersion={AgentVersion}, MatchedPattern={Pattern}", isVersionKill ? "KILL" : "BLOCK", tenantId, agentVersionHeader, matchedPattern); var versionBlockedResponse = req.CreateResponse(System.Net.HttpStatusCode.OK); await versionBlockedResponse.WriteAsJsonAsync(new IngestEventsResponse { Success = false, DeviceBlocked = true, DeviceKillSignal = isVersionKill, Message = isVersionKill ? $"Agent version {agentVersionHeader} has been issued a remote kill signal (pattern: {matchedPattern})." : $"Agent version {agentVersionHeader} is blocked by administrator (pattern: {matchedPattern}).", ProcessedAt = DateTime.UtcNow }); return new IngestEventsOutput { HttpResponse = versionBlockedResponse, SignalRMessages = Array.Empty() }; } } // --- Parse NDJSON+gzip request body (only after security is cleared) --- // If session-aware block check already parsed the body, reuse that result (stream is consumed) var request = earlyParsedRequest ?? await ParseNdjsonGzipRequest(req.Body, tenantId); if (request?.Events == null || request.Events.Count == 0) { return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "No events provided"); } if (string.IsNullOrEmpty(request.SessionId) || string.IsNullOrEmpty(request.TenantId)) { return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "SessionId and TenantId are required"); } // Ensure body TenantId matches the validated header TenantId (prevent body spoofing) if (!string.Equals(request.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning("TenantId mismatch: header={HeaderTenantId}, body={BodyTenantId}", tenantId, request.TenantId); return await CreateErrorResponse(req, HttpStatusCode.Forbidden, "TenantId mismatch between header and payload"); } var sessionPrefix = $"[Session: {request.SessionId.Substring(0, Math.Min(8, request.SessionId.Length))}]"; _logger.LogInformation($"{sessionPrefix} IngestEvents: Processing {request.Events.Count} events (Device: {validation.CertificateThumbprint}, Hardware: {validation.Manufacturer} {validation.Model}, Rate: {validation.RateLimitResult?.RequestsInWindow}/{validation.RateLimitResult?.MaxRequests})"); // Stamp server-side receive time, and authoritative TenantId/SessionId on all events. // TenantId/SessionId come from the validated request metadata — never trust per-event agent values. var receivedAt = DateTime.UtcNow; StampServerFields(request.Events, request.TenantId, request.SessionId, receivedAt); // Sanitize agent-side timestamps: clamp out-of-range values to prevent storage/sorting issues. // Original timestamps are preserved in OriginalTimestamp for troubleshooting. SanitizeEventTimestamps(request.Events, receivedAt, _logger); // Store events in Azure Table Storage (batch write for efficiency) var storedEvents = await _sessionRepo.StoreEventsBatchAsync(request.Events); int processedCount = storedEvents.Count; // Update EventTypeIndex and DeviceSnapshot indexes (fire-and-forget, non-blocking) var indexTenantId = request.TenantId; var indexSessionId = request.SessionId; var indexEvents = storedEvents.ToList(); _ = Task.WhenAll( _sessionRepo.UpsertEventTypeIndexBatchAsync( indexTenantId, indexSessionId, indexEvents.Select(e => e.EventType).Distinct()), _sessionRepo.UpsertDeviceSnapshotAsync( indexTenantId, indexSessionId, indexEvents) ).ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Index update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); // Store IME agent version on session if detected in this batch var imeVersionEvent = request.Events.FirstOrDefault(e => e.EventType == "ime_agent_version" && e.Data?.ContainsKey("agentVersion") == true); if (imeVersionEvent != null) { var imeVersion = imeVersionEvent.Data["agentVersion"]?.ToString(); if (!string.IsNullOrEmpty(imeVersion)) { _ = _sessionRepo.UpdateSessionImeAgentVersionAsync(request.TenantId, request.SessionId, imeVersion) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "ImeAgentVersion update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); // Track IME version in permanent history (fire-and-forget, non-blocking) _ = _sessionRepo.RecordImeVersionAsync(imeVersion, request.TenantId, request.SessionId) .ContinueWith(async t => { if (t.IsFaulted) { _logger.LogWarning(t.Exception?.InnerException, "ImeVersionHistory update failed (non-fatal)"); } else if (t.Result) { // New version discovered — log OpsEvent await _opsEventService.RecordNewImeVersionDetectedAsync( imeVersion, request.TenantId, request.SessionId); } }, TaskScheduler.Default); } } // Classify events for downstream processing var classification = ClassifyEvents(storedEvents); // Store app install summaries foreach (var summary in classification.AppInstallUpdates.Values) { await _metricsRepo.StoreAppInstallSummaryAsync(summary.Summary); } // Extract geo-location data and merge into session row (fire-and-forget — // subsequent event batches arrive frequently so the UI sees it almost immediately) if (classification.DeviceLocationEvent?.Data != null) { var geoData = classification.DeviceLocationEvent.Data; var geoTenantId = request.TenantId; var geoSessionId = request.SessionId; _ = _sessionRepo.UpdateSessionGeoAsync( geoTenantId, geoSessionId, geoData.ContainsKey("country") ? geoData["country"]?.ToString() : null, geoData.ContainsKey("region") ? geoData["region"]?.ToString() : null, geoData.ContainsKey("city") ? geoData["city"]?.ToString() : null, geoData.ContainsKey("loc") ? geoData["loc"]?.ToString() : null ).ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget UpdateSessionGeoAsync failed"), TaskContinuationOptions.OnlyOnFaulted); } // Update session status based on events var (statusTransitioned, whiteGloveStatusTransitioned, failureReason) = await UpdateSessionStatusAsync( request, sessionPrefix, classification); // Always increment event count when events were stored if (processedCount > 0) { await _sessionRepo.IncrementSessionEventCountAsync( request.TenantId, request.SessionId, processedCount, classification.EarliestEventTimestamp, classification.LatestEventTimestamp, currentPhase: classification.LastPhaseChangeEvent?.Phase ); } // Run full analysis when enrollment ends — fire-and-forget so the agent // gets its HTTP 200 immediately. Rule results are stored in Table Storage and // the UI picks them up on the next poll or via the existing "Re-Analyze" button. // This avoids 500ms-10s+ blocking on the critical ingest path. var newRuleResults = new List(); if (classification.CompletionEvent != null || classification.FailureEvent != null) { var ruleSessionId = request.SessionId; var ruleTenantId = request.TenantId; var rulePrefix = sessionPrefix; _ = Task.Run(async () => { try { var ruleEngine = new RuleEngine(_analyzeRuleService, _ruleRepo, _sessionRepo, _logger); var results = await ruleEngine.AnalyzeSessionAsync(ruleTenantId, ruleSessionId); foreach (var result in results) { await _ruleRepo.StoreRuleResultAsync(result); } if (results.Count > 0) { _logger.LogInformation("{Prefix} Enrollment-end analysis (async): {Count} issue(s) detected", rulePrefix, results.Count); // Push SignalR notification so the UI fetches results immediately await _signalRNotification.NotifyRuleResultsAvailableAsync( ruleTenantId, ruleSessionId, results.Count); } // Increment platform stats for detected issues if (results.Count > 0) { _ = _metricsRepo.IncrementPlatformStatAsync("IssuesDetected", results.Count) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); } } catch (Exception ruleEx) { _logger.LogWarning(ruleEx, "{Prefix} Enrollment-end analysis failed (async, non-fatal)", rulePrefix); } }); } // Vulnerability Correlation — fire-and-forget when shutdown inventory arrives. // Runs asynchronously so it never blocks the HTTP response to the agent. // The report is stored in the VulnerabilityCache table (not Events) because // it is a server-side analysis result, not an agent event. var shutdownInventoryDetected = storedEvents.Any(e => e.EventType == AutopilotMonitor.Shared.Constants.EventTypes.SoftwareInventoryAnalysis && e.Data != null && e.Data.ContainsKey("triggered_at") && e.Data["triggered_at"]?.ToString() == "shutdown" && e.Data.ContainsKey("chunk_index") && Convert.ToInt32(e.Data["chunk_index"]) == 0); if (shutdownInventoryDetected) { // Capture everything we need before going async (request object may be disposed) var capturedSessionId = request.SessionId; var capturedTenantId = request.TenantId; var capturedPrefix = sessionPrefix; // Merge all inventory chunks from this batch into a self-contained list var allInventoryItems = new List>(); var inventoryChunks = storedEvents .Where(e => e.EventType == AutopilotMonitor.Shared.Constants.EventTypes.SoftwareInventoryAnalysis && e.Data != null && e.Data.ContainsKey("triggered_at") && e.Data["triggered_at"]?.ToString() == "shutdown" && e.Data.ContainsKey("inventory")) .OrderBy(e => Convert.ToInt32(e.Data.GetValueOrDefault("chunk_index", 0))) .ToList(); foreach (var chunk in inventoryChunks) { if (chunk.Data["inventory"] is System.Collections.IEnumerable items) { foreach (var item in items) { if (item is Dictionary dict) allInventoryItems.Add(dict); } } } // Detect White Glove part tag from inventory events (1 = pre-provisioning, 2 = user enrollment) int? whiteGlovePart = null; var firstShutdownChunk = inventoryChunks.FirstOrDefault(); if (firstShutdownChunk?.Data != null && firstShutdownChunk.Data.TryGetValue("whiteglove_part", out var wgPartObj)) { whiteGlovePart = Convert.ToInt32(wgPartObj); } if (allInventoryItems.Count > 0) { var capturedWhiteGlovePart = whiteGlovePart; _ = Task.Run(async () => { try { var adminConfig = await _adminConfigService.GetConfigurationAsync(); if (adminConfig?.VulnerabilityCorrelationEnabled != true) return; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); var reportData = await _vulnerabilityCorrelation.CorrelateAsync( capturedSessionId, capturedTenantId, allInventoryItems, cts.Token); if (reportData != null) { // Tag findings with phase label for White Glove scenarios var phaseLabel = capturedWhiteGlovePart == 1 ? "device_setup" : capturedWhiteGlovePart == 2 ? "user_enrollment" : (string?)null; if (phaseLabel != null && reportData.ContainsKey("findings") && reportData["findings"] is List> tagFindings) { foreach (var f in tagFindings) f.TryAdd("phase", phaseLabel); } // White Glove Part 2: merge with existing Part 1 report if (capturedWhiteGlovePart == 2) { try { var existingReport = await _vulnRepo.GetVulnerabilityReportAsync( capturedTenantId, capturedSessionId); if (existingReport != null) { reportData = VulnerabilityCorrelationService.MergeReports( existingReport, reportData, existingPhaseLabel: "device_setup", newPhaseLabel: "user_enrollment"); _logger.LogInformation( "{Prefix} WhiteGlove Part 2: merged vulnerability report with Part 1 findings", capturedPrefix); } } catch (Exception ex) { _logger.LogWarning(ex, "{Prefix} Failed to load Part 1 report for merge (storing Part 2 standalone)", capturedPrefix); } } await _vulnRepo.StoreVulnerabilityReportAsync( capturedTenantId, capturedSessionId, reportData); _logger.LogInformation("{Prefix} Vulnerability correlation complete (async, whiteGlovePart={Part})", capturedPrefix, capturedWhiteGlovePart?.ToString() ?? "none"); // Update CveIndex for searchable CVE queries var findings = reportData.ContainsKey("findings") ? reportData["findings"] as List> : null; if (findings != null && findings.Count > 0) { _ = _sessionRepo.UpsertCveIndexEntriesAsync(capturedTenantId, capturedSessionId, findings) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "CveIndex update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); } // Push SignalR notification so the UI fetches the report immediately var overallRisk = reportData.ContainsKey("scan_summary") && reportData["scan_summary"] is Dictionary summary && summary.ContainsKey("overall_risk") ? summary["overall_risk"]?.ToString() ?? "unknown" : "unknown"; await _signalRNotification.NotifyVulnerabilityReportAvailableAsync( capturedTenantId, capturedSessionId, overallRisk); } } catch (Exception ex) { _logger.LogWarning(ex, "{Prefix} Vulnerability correlation failed (async, non-fatal)", capturedPrefix); } }); } } // Increment platform stats (fire-and-forget, non-blocking) // Note: IssuesDetected is now incremented inside the async rule engine task above. _ = _metricsRepo.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); if (classification.CompletionEvent != null) _ = _metricsRepo.IncrementPlatformStatAsync("SuccessfulEnrollments") .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); // Store diagnostics blob name on session (if agent uploaded a diagnostics package) if (classification.DiagnosticsUploadedEvent != null) { var blobName = classification.DiagnosticsUploadedEvent.Data?.ContainsKey("blobName") == true ? classification.DiagnosticsUploadedEvent.Data["blobName"]?.ToString() : null; if (!string.IsNullOrEmpty(blobName)) { await _sessionRepo.UpdateSessionDiagnosticsBlobAsync( request.TenantId, request.SessionId, blobName); } } // Retrieve updated session data to include in SignalR messages var updatedSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); // Session age warning: log if session >4h old and still InProgress (observability only) if (updatedSession != null && updatedSession.Status == SessionStatus.InProgress) { var sessionAge = DateTime.UtcNow - updatedSession.StartedAt; if (sessionAge.TotalHours > 4) { _logger.LogWarning("Session {SessionId} (tenant {TenantId}) still InProgress after {Hours:F1}h — may be stuck", request.SessionId, request.TenantId, sessionAge.TotalHours); } } // Log warning if WhiteGlove status update was not persisted despite retries and fallback. if (classification.WhiteGloveEvent != null && updatedSession?.IsPreProvisioned != true) { _logger.LogError( "{SessionPrefix} WhiteGlove status update not persisted after retries and fallback. " + "IsPreProvisioned={IsPreProvisioned}, Status={Status}. " + "Proceeding with 200 to allow agent spool drain.", sessionPrefix, updatedSession?.IsPreProvisioned, updatedSession?.Status); } // Send webhook notifications (fire-and-forget, non-fatal) await SendWebhookNotificationsAsync( request, sessionPrefix, classification, updatedSession, statusTransitioned, whiteGloveStatusTransitioned, failureReason, newRuleResults); // Check if an admin has externally marked the session as terminal. // Only signal the agent when this batch did NOT contain the terminal event itself // (i.e., the agent didn't just report completion — the admin did it out-of-band). string? adminAction = null; if (updatedSession != null && classification.CompletionEvent == null && classification.FailureEvent == null && (updatedSession.Status == SessionStatus.Succeeded || updatedSession.Status == SessionStatus.Failed)) { adminAction = updatedSession.Status.ToString(); _logger.LogInformation("{SessionPrefix} Admin override detected — signaling agent: AdminAction={AdminAction}", sessionPrefix, adminAction); } // Build HTTP response + SignalR messages var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new IngestEventsResponse { Success = true, EventsReceived = request.Events.Count, EventsProcessed = processedCount, Message = $"Successfully stored {processedCount} of {request.Events.Count} events", ProcessedAt = DateTime.UtcNow, AdminAction = adminAction }); var signalRMessages = BuildSignalRMessages(request, updatedSession, processedCount, newRuleResults); return new IngestEventsOutput { HttpResponse = response, SignalRMessages = signalRMessages }; } /// /// Classifies stored events by type for downstream processing. /// private EventClassification ClassifyEvents(List storedEvents) { var classification = new EventClassification(); foreach (var evt in storedEvents) { if (!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value) classification.EarliestEventTimestamp = evt.Timestamp; if (!classification.LatestEventTimestamp.HasValue || evt.Timestamp > classification.LatestEventTimestamp.Value) classification.LatestEventTimestamp = evt.Timestamp; switch (evt.EventType) { case "phase_changed": case "esp_phase_changed": classification.LastPhaseChangeEvent = evt; break; case "enrollment_complete": classification.CompletionEvent = evt; break; case "gather_rules_collection_completed": classification.GatherCompletionEvent = evt; break; case "enrollment_failed": classification.FailureEvent = evt; break; case "diagnostics_uploaded": classification.DiagnosticsUploadedEvent = evt; break; case "whiteglove_complete": classification.WhiteGloveEvent = evt; break; case "whiteglove_resumed": classification.WhiteGloveResumedEvent = evt; break; case "esp_failure": classification.EspFailureEvent = evt; break; case "device_location": classification.DeviceLocationEvent = evt; break; case "session_stalled": classification.SessionStalledEvent = evt; break; } // Track whether this batch contains any non-periodic real event (used for // Stalled→InProgress healing). Exclude telemetry snapshots and stall-probe // self-reports which should not count as "agent is productively working". if (!IsPeriodicOrStallEvent(evt.EventType)) classification.HasNonPeriodicRealEvent = true; AggregateAppInstallEvent(evt, storedEvents[0].TenantId!, storedEvents[0].SessionId!, classification.AppInstallUpdates); } return classification; } /// /// Classifies whether an event type should count as "real enrollment activity" for /// Stalled→InProgress healing. Periodic telemetry and stall-probe self-reports are /// excluded so that a silent agent that only emits heartbeats cannot unmark itself /// as stalled without real progress. /// private static bool IsPeriodicOrStallEvent(string? eventType) => eventType switch { "performance_snapshot" => true, "agent_metrics_snapshot" => true, "performance_collector_stopped" => true, "agent_metrics_collector_stopped" => true, "stall_probe_check" => true, "stall_probe_result" => true, "session_stalled" => true, "modern_deployment_log" => true, _ => false }; /// /// Updates session status based on classified events. /// Returns (statusTransitioned, whiteGloveStatusTransitioned, failureReason). /// private async Task<(bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason)> UpdateSessionStatusAsync( IngestEventsRequest request, string sessionPrefix, EventClassification c) { bool statusTransitioned = false; bool whiteGloveStatusTransitioned = false; string? failureReason = null; if (c.CompletionEvent != null) { statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Succeeded, c.CompletionEvent.Phase, completedAt: c.CompletionEvent.Timestamp, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); _logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned})", sessionPrefix, statusTransitioned); } else if (c.FailureEvent != null) { failureReason = c.FailureEvent.Data?.ContainsKey("errorCode") == true ? $"{c.FailureEvent.Message} ({c.FailureEvent.Data["errorCode"]})" : c.FailureEvent.Message; statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Failed, c.FailureEvent.Phase, failureReason, completedAt: c.FailureEvent.Timestamp, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); _logger.LogWarning("{SessionPrefix} Status: Failed - {FailureReason} (transitioned={Transitioned})", sessionPrefix, failureReason, statusTransitioned); } else if (c.EspFailureEvent != null) { failureReason = c.EspFailureEvent.Message ?? "ESP failure (backend fallback)"; statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Failed, c.EspFailureEvent.Phase, failureReason, completedAt: c.EspFailureEvent.Timestamp, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); _logger.LogWarning("{SessionPrefix} Status: Failed via esp_failure fallback - {FailureReason} (transitioned={Transitioned})", sessionPrefix, failureReason, statusTransitioned); } else if (c.GatherCompletionEvent != null) { await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Succeeded, c.GatherCompletionEvent.Phase, completedAt: c.GatherCompletionEvent.Timestamp, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); _logger.LogInformation("{SessionPrefix} Status: Succeeded (gather_rules)", sessionPrefix); } else if (c.WhiteGloveEvent != null) { whiteGloveStatusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Pending, c.WhiteGloveEvent.Phase, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, isPreProvisioned: true, isUserDriven: false); if (!whiteGloveStatusTransitioned) { _logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix); try { await _sessionRepo.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false); whiteGloveStatusTransitioned = true; _logger.LogInformation("{SessionPrefix} WhiteGlove fallback succeeded: IsPreProvisioned + Status=Pending set via unconditional merge", sessionPrefix); } catch (Exception fallbackEx) { _logger.LogError(fallbackEx, "{SessionPrefix} WhiteGlove fallback SetSessionPreProvisionedAsync also failed", sessionPrefix); } } _logger.LogInformation("{SessionPrefix} Status: Pending (WhiteGlove pre-provisioning complete, transitioned={Transitioned})", sessionPrefix, whiteGloveStatusTransitioned); } else if (c.WhiteGloveResumedEvent != null) { var currentSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); if (currentSession?.Status == SessionStatus.Pending) { await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.InProgress, c.WhiteGloveResumedEvent.Phase, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, isUserDriven: true, resumedAt: c.WhiteGloveResumedEvent.Timestamp); _logger.LogInformation("{SessionPrefix} Status: InProgress (WhiteGlove Part 2 resumed, IsUserDriven=true)", sessionPrefix); } else { _logger.LogInformation("{SessionPrefix} WhiteGlove resumed skipped, session already {Status}", sessionPrefix, currentSession?.Status); } } // Stalled-status transitions are independent of Succeeded/Failed/Pending/WhiteGlove paths. // They can coexist with normal processing: if a batch contains session_stalled, mark Stalled; // if a batch contains any real (non-periodic) event while the session is currently Stalled, // heal it back to InProgress. if (c.SessionStalledEvent != null) { var stalledReason = "Agent reported stall after 60min without progress (stall_probe)"; var stalledTransitioned = await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Stalled, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, stalledAt: c.SessionStalledEvent.Timestamp, failureReason: stalledReason); if (stalledTransitioned) _logger.LogWarning("{SessionPrefix} Status: Stalled (agent-reported via session_stalled event)", sessionPrefix); } else if (c.HasNonPeriodicRealEvent && !statusTransitioned && !whiteGloveStatusTransitioned) { // Only attempt healing if the current session is actually Stalled (one read). // The repository guard will no-op this call when the session is in any other state. var currentSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); if (currentSession?.Status == SessionStatus.Stalled) { var healed = await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.InProgress, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, clearStalledAt: true, clearFailureReason: true); if (healed) _logger.LogInformation("{SessionPrefix} Status: InProgress (healed from Stalled by new real event)", sessionPrefix); } } return (statusTransitioned, whiteGloveStatusTransitioned, failureReason); } /// /// Sends webhook notifications for enrollment completion, WhiteGlove, and ESP failure events. /// Uses the channel-agnostic notification system with provider-specific renderers. /// private async Task SendWebhookNotificationsAsync( IngestEventsRequest request, string sessionPrefix, EventClassification c, SessionSummary? updatedSession, bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason, List ruleResults) { // Read config once (was 3 separate reads before) var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); var (webhookUrl, providerTypeInt) = tenantConfig.GetEffectiveWebhookConfig(); if (string.IsNullOrEmpty(webhookUrl) || providerTypeInt == 0) return; var providerType = (WebhookProviderType)providerTypeInt; var sessionUrl = updatedSession != null ? $"https://www.autopilotmonitor.com/session/{request.TenantId}/{request.SessionId}" : null; // Enrollment completion/failure notification // Only send when statusTransitioned=true to prevent duplicates on retry/double-upload if (statusTransitioned && (c.CompletionEvent != null || c.FailureEvent != null)) { var notifySuccess = c.CompletionEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess(); var notifyFailure = c.FailureEvent != null && tenantConfig.GetEffectiveNotifyOnFailure(); if (notifySuccess || notifyFailure) { var duration = updatedSession?.DurationSeconds != null ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) : (TimeSpan?)null; // For WhiteGlove sessions: show user enrollment duration only (Duration 2) if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null) { var completionTime = c.CompletionEvent?.Timestamp ?? c.FailureEvent?.Timestamp; if (completionTime.HasValue) duration = completionTime.Value - updatedSession.ResumedAt.Value; } var alert = NotificationAlertBuilder.BuildEnrollmentAlert( updatedSession?.DeviceName, updatedSession?.SerialNumber, updatedSession?.Manufacturer, updatedSession?.Model, success: c.CompletionEvent != null, failureReason: failureReason, duration: duration, sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert); } } // WhiteGlove pre-provisioning completion if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess()) { var duration = updatedSession?.DurationSeconds != null ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) : (TimeSpan?)null; var alert = NotificationAlertBuilder.BuildWhiteGloveAlert( updatedSession?.DeviceName, updatedSession?.SerialNumber, updatedSession?.Manufacturer, updatedSession?.Model, success: true, duration: duration, sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert); } // WhiteGlove pre-provisioning failure via esp_failure if (c.EspFailureEvent != null && updatedSession?.IsPreProvisioned == true && tenantConfig.GetEffectiveNotifyOnFailure()) { var duration = updatedSession?.DurationSeconds != null ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) : (TimeSpan?)null; var alert = NotificationAlertBuilder.BuildWhiteGloveAlert( updatedSession?.DeviceName, updatedSession?.SerialNumber, updatedSession?.Manufacturer, updatedSession?.Model, success: false, duration: duration, sessionUrl: sessionUrl); NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert); } } /// /// Builds SignalR messages for tenant-level and session-level real-time updates. /// private SignalRMessageAction[] BuildSignalRMessages( IngestEventsRequest request, SessionSummary? updatedSession, int processedCount, List newRuleResults) { // 1. Summary notification for session list updates (tenant-specific only) // Send only mutable fields as delta update object? sessionDelta = updatedSession != null ? new { updatedSession.CurrentPhase, updatedSession.CurrentPhaseDetail, updatedSession.Status, updatedSession.FailureReason, updatedSession.EventCount, updatedSession.DurationSeconds, updatedSession.CompletedAt, updatedSession.DiagnosticsBlobName, updatedSession.IsPreProvisioned } : null; var summaryMessage = new SignalRMessageAction("newevents") { GroupName = $"tenant-{request.TenantId}", Arguments = new object[] { new { sessionId = request.SessionId, tenantId = request.TenantId, eventCount = processedCount, sessionUpdate = sessionDelta } } }; // 2. Signal for real-time event streaming on detail pages (session-specific) var slimRuleResults = newRuleResults.Count > 0 ? newRuleResults.Select(r => new { r.ResultId, r.RuleId, r.RuleTitle, r.Severity, r.Category, r.ConfidenceScore, r.Explanation, r.Remediation, r.RelatedDocs, r.MatchedConditions, r.DetectedAt }).ToList() : null; var eventsMessage = new SignalRMessageAction("eventStream") { GroupName = $"session-{request.TenantId}-{request.SessionId}", Arguments = new object[] { new { sessionId = request.SessionId, tenantId = request.TenantId, newEventCount = processedCount, newRuleResults = slimRuleResults } } }; return new[] { summaryMessage, eventsMessage }; } /// /// Stamps authoritative server-side fields onto all events before storage. /// TenantId and SessionId always come from the validated request metadata, /// overriding any values the agent may have sent per-event. /// Exposed as internal for unit testing. /// internal static void StampServerFields( List events, string tenantId, string sessionId, DateTime receivedAt) { foreach (var evt in events) { evt.ReceivedAt = receivedAt; evt.TenantId = tenantId; evt.SessionId = sessionId; } } /// /// Sanitizes agent-side timestamps on all events by clamping out-of-range values. /// When a timestamp is clamped, the original value is preserved in OriginalTimestamp /// and TimestampClamped is set to true — keeping the raw data available for /// troubleshooting and root-cause analysis of clock issues on devices. /// /// Emits structured logs for observability: /// - Debug level per clamped event (TenantId/SessionId/EventType/drift) — opt-in via log level /// - One Warning per ingest batch that had any clamping, with aggregate counts and max drifts. /// This is what to query in App Insights to find bad-clock devices: /// traces | where message startswith "Agent clock skew" /// /// Exposed as internal for unit testing. /// internal static void SanitizeEventTimestamps(List events, DateTime utcNow, ILogger? logger = null) { int clampedPast = 0; int clampedFuture = 0; double maxPastDriftHours = 0; double maxFutureDriftHours = 0; foreach (var evt in events) { var original = evt.Timestamp; var sanitized = EventTimestampValidator.SanitizeTimestamp(original, utcNow); if (sanitized == original) continue; evt.OriginalTimestamp = original; evt.TimestampClamped = true; evt.Timestamp = sanitized; // Classify the clamping direction (for aggregate stats) and track max drift. // Compare in UTC so Local/Unspecified Kinds don't skew the direction check. // Note: catastrophic values like DateTime.MinValue fall into the "past" bucket // with a very large drift — this is intentional and makes them easy to spot in logs. var originalUtc = EventTimestampValidator.EnsureUtc(original); if (originalUtc > utcNow) { clampedFuture++; var drift = (originalUtc - utcNow).TotalHours; if (drift > maxFutureDriftHours) maxFutureDriftHours = drift; } else { clampedPast++; var drift = (utcNow - originalUtc).TotalHours; if (drift > maxPastDriftHours) maxPastDriftHours = drift; } logger?.LogDebug( "Event timestamp clamped: TenantId={TenantId}, SessionId={SessionId}, EventType={EventType}, Original={Original:O}, Sanitized={Sanitized:O}", evt.TenantId, evt.SessionId, evt.EventType, original, sanitized); } if (clampedPast + clampedFuture > 0 && logger != null) { // Pull tenant/session from the first clamped event (all events in a batch share the same context). var firstClamped = events.Find(e => e.TimestampClamped); logger.LogWarning( "Agent clock skew detected: TenantId={TenantId}, SessionId={SessionId}, TotalEvents={TotalEvents}, ClampedPast={ClampedPast}, ClampedFuture={ClampedFuture}, MaxPastDriftHours={MaxPastDriftHours:F1}, MaxFutureDriftHours={MaxFutureDriftHours:F1}", firstClamped?.TenantId, firstClamped?.SessionId, events.Count, clampedPast, clampedFuture, maxPastDriftHours, maxFutureDriftHours); } } } /// /// Holds classified events from an ingest batch for downstream processing. /// internal class EventClassification { public EnrollmentEvent? LastPhaseChangeEvent { get; set; } public EnrollmentEvent? CompletionEvent { get; set; } public EnrollmentEvent? FailureEvent { get; set; } public EnrollmentEvent? GatherCompletionEvent { get; set; } public EnrollmentEvent? DiagnosticsUploadedEvent { get; set; } public EnrollmentEvent? WhiteGloveEvent { get; set; } public EnrollmentEvent? WhiteGloveResumedEvent { get; set; } public EnrollmentEvent? EspFailureEvent { get; set; } public EnrollmentEvent? SessionStalledEvent { get; set; } public bool HasNonPeriodicRealEvent { get; set; } public EnrollmentEvent? DeviceLocationEvent { get; set; } public DateTime? EarliestEventTimestamp { get; set; } public DateTime? LatestEventTimestamp { get; set; } public Dictionary AppInstallUpdates { get; } = new(StringComparer.OrdinalIgnoreCase); } }