using System.Net; using System.Web; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Raw { public class QueryRawEventsFunction { private readonly ILogger _logger; private readonly ISessionRepository _sessionRepo; public QueryRawEventsFunction(ILogger logger, ISessionRepository sessionRepo) { _logger = logger; _sessionRepo = sessionRepo; } /// /// GET /api/raw/events — Tenant-scoped raw event query (cross-session) /// [Function("QueryRawEvents")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "raw/events")] HttpRequestData req) { try { var tenantId = TenantHelper.GetTenantId(req); return await QueryEvents(req, tenantId, scope: "raw-events:tenant", basePath: "/api/raw/events", filterTenantId: null); } catch (UnauthorizedAccessException) { var err = req.CreateResponse(HttpStatusCode.Unauthorized); await err.WriteAsJsonAsync(new { error = "Unauthorized" }); return err; } catch (Exception ex) { return await req.InternalServerErrorAsync(_logger, ex, "Query raw events"); } } /// /// GET /api/global/raw/events — Cross-tenant raw event query (GlobalAdminOnly) /// Omit tenantId to query across all tenants. /// [Function("QueryRawEventsGlobal")] public async Task RunGlobal( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/events")] HttpRequestData req) { try { var filterTenantId = req.Query["tenantId"]; var effectiveTenantId = string.IsNullOrEmpty(filterTenantId) ? null : filterTenantId; return await QueryEvents(req, effectiveTenantId, scope: "raw-events:global", basePath: "/api/global/raw/events", filterTenantId: effectiveTenantId); } catch (Exception ex) { return await req.InternalServerErrorAsync(_logger, ex, "Query global raw events"); } } private async Task QueryEvents( HttpRequestData req, string? tenantId, string scope, string basePath, string? filterTenantId) { var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); var sessionId = query["sessionId"]; var eventType = query["eventType"]; var severity = query["severity"]; var source = query["source"]; var startedAfter = query["startedAfter"]; var startedBefore = query["startedBefore"]; var fields = query["fields"]; var pagination = QueryRawEventsPagination.ParsePagination(query); if (pagination.Error != null) { var bad = req.CreateResponse(HttpStatusCode.BadRequest); await bad.WriteAsJsonAsync(new { error = pagination.Error }); return bad; } var callerTenantId = TenantHelper.GetTenantId(req); // Single-session path — paginated session-events walk so sessions // with more events than pageSize remain fully reachable across // multiple calls. Continuation token binds caller + sessionId so // a cursor from session A cannot be replayed for session B. if (!string.IsNullOrEmpty(sessionId)) { // GA cross-tenant convenience: a sessionId query may omit tenantId; resolve it from // the session so the contract matches GetSessionEventsFunction. Only on the global // scope — the tenant-scoped path always has a JWT-bound tenantId (TenantHelper // throws when unauthenticated), so it never reaches this branch with an empty value. if (string.IsNullOrEmpty(tenantId) && scope == "raw-events:global") { tenantId = await _sessionRepo.ResolveSessionTenantIdAsync(sessionId); } if (string.IsNullOrEmpty(tenantId)) { var bad = req.CreateResponse(HttpStatusCode.BadRequest); await bad.WriteAsJsonAsync(new { error = "tenantId is required when querying by sessionId (or the session was not found)" }); return bad; } string? singleAzureToken = null; if (pagination.Continuation != null) { if (!QueryRawEventsPagination.TryAcceptContinuation( pagination.Continuation, scope, callerTenantId, filterTenantId, sessionId, eventType, source, severity, startedAfter, startedBefore, out singleAzureToken, out var rejectReason)) { _logger.LogWarning("QueryRawEvents: single-session continuation rejected ({Reason})", rejectReason); var bad = req.CreateResponse(HttpStatusCode.BadRequest); await bad.WriteAsJsonAsync(new { error = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", }); return bad; } } var sessionPage = await _sessionRepo.GetSessionEventsRawPageAsync( tenantId, sessionId, pagination.PageSize, singleAzureToken); var filtered = ApplyClientFilters( sessionPage.Items.ToList(), eventType, severity, source, startedAfter, startedBefore); filtered = filtered .OrderBy(RawTimestamp) .ThenBy(RawSequence) .ToList(); // No error-code enrichment here by design: the raw endpoint returns the stored // bytes verbatim (DataJson as the stored string). Decoded error meanings live on // the enriched get_session_events / search_events tools. string? singleNextLink = null; if (!string.IsNullOrEmpty(sessionPage.NextRawToken)) { var fp = QueryRawEventsPagination.Fingerprint( scope, callerTenantId, filterTenantId, sessionId, eventType, source, severity, startedAfter, startedBefore); var wireToken = ContinuationToken.Encode(sessionPage.NextRawToken!, callerTenantId, fp); singleNextLink = QueryRawEventsPagination.BuildNextLink( basePath, pagination.PageSize, wireToken, query); } return await req.OkAsync(new { tenantId, count = filtered.Count, events = RawEntityProjection.Project(filtered, fields), nextLink = singleNextLink, }); } if (string.IsNullOrEmpty(eventType)) { var bad = req.CreateResponse(HttpStatusCode.BadRequest); await bad.WriteAsJsonAsync(new { error = "Either sessionId or eventType is required for raw event queries" }); return bad; } // Cross-session path — paginated EventTypeIndex walk replaces the // legacy hard-coded limit:20 (recall-loss bug from the audit). string? azureToken = null; if (pagination.Continuation != null) { if (!QueryRawEventsPagination.TryAcceptContinuation( pagination.Continuation, scope, callerTenantId, filterTenantId, sessionId: null, eventType, source, severity, startedAfter, startedBefore, out azureToken, out var rejectReason)) { _logger.LogWarning("QueryRawEvents: continuation rejected ({Reason})", rejectReason); var bad = req.CreateResponse(HttpStatusCode.BadRequest); await bad.WriteAsJsonAsync(new { error = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", }); return bad; } } var sessionsPage = await _sessionRepo.SearchSessionsByEventPageAsync( tenantId, eventType, source, severity, phase: null, pageSize: pagination.PageSize, continuation: azureToken); var events = new List>(); foreach (var session in sessionsPage.Items) { var sessionEvents = await _sessionRepo.GetSessionEventsRawByTypeAsync( session.TenantId, session.SessionId, eventType, maxResults: 200); events.AddRange(sessionEvents.Where(e => MatchesSeverity(e, severity) && MatchesSource(e, source))); } events = ApplyDateFilters(events, startedAfter, startedBefore) .OrderBy(RawTimestamp).ThenBy(RawSequence).ToList(); // No enrichment — raw rows only (see single-session path). string? nextLink = null; if (!string.IsNullOrEmpty(sessionsPage.NextRawToken)) { var fp = QueryRawEventsPagination.Fingerprint( scope, callerTenantId, filterTenantId, sessionId: null, eventType, source, severity, startedAfter, startedBefore); var wireToken = ContinuationToken.Encode(sessionsPage.NextRawToken!, callerTenantId, fp); nextLink = QueryRawEventsPagination.BuildNextLink( basePath, pagination.PageSize, wireToken, query); } return await req.OkAsync(new { tenantId, count = events.Count, events = RawEntityProjection.Project(events, fields), nextLink, }); } // ── Raw-row filtering helpers ───────────────────────────────────────── // These operate on the literal stored columns (PascalCase) instead of the typed // EnrollmentEvent. They preserve the exact match semantics of the former enriched path: // eventType exact, severity by enum NAME (Severity is stored as an Int32), source substring. private static List> ApplyClientFilters( List> events, string? eventType, string? severity, string? source, string? startedAfter, string? startedBefore) { if (!string.IsNullOrEmpty(eventType)) events = events.Where(e => string.Equals(RawString(e, "EventType"), eventType, StringComparison.Ordinal)).ToList(); if (!string.IsNullOrEmpty(severity)) events = events.Where(e => MatchesSeverity(e, severity)).ToList(); if (!string.IsNullOrEmpty(source)) events = events.Where(e => MatchesSource(e, source)).ToList(); return ApplyDateFilters(events, startedAfter, startedBefore); } private static List> ApplyDateFilters( List> events, string? startedAfter, string? startedBefore) { if (!string.IsNullOrEmpty(startedAfter) && DateTime.TryParse(startedAfter, out var after)) events = events.Where(e => RawTimestamp(e) >= after).ToList(); if (!string.IsNullOrEmpty(startedBefore) && DateTime.TryParse(startedBefore, out var before)) events = events.Where(e => RawTimestamp(e) <= before).ToList(); return events; } private static bool MatchesSeverity(IReadOnlyDictionary row, string? severity) => string.IsNullOrEmpty(severity) || string.Equals(RawSeverityName(row), severity, StringComparison.OrdinalIgnoreCase); private static bool MatchesSource(IReadOnlyDictionary row, string? source) => string.IsNullOrEmpty(source) || (RawString(row, "Source") ?? string.Empty).Contains(source, StringComparison.OrdinalIgnoreCase); private static string? RawString(IReadOnlyDictionary row, string key) => row.TryGetValue(key, out var v) ? v?.ToString() : null; private static DateTime RawTimestamp(IReadOnlyDictionary row) { if (row.TryGetValue("Timestamp", out var v) && v != null) { switch (v) { case DateTimeOffset dto: return dto.UtcDateTime; case DateTime dt: return dt; default: if (DateTime.TryParse(v.ToString(), out var parsed)) return parsed; break; } } return DateTime.MinValue; } private static long RawSequence(IReadOnlyDictionary row) => row.TryGetValue("Sequence", out var v) && v != null && long.TryParse(v.ToString(), out var n) ? n : 0L; // Severity is stored as an Int32; map it back to the EventSeverity name so the severity= // filter keeps the same "by name" semantics the enriched path used. Unknown ints fall back // to the raw number string. private static string? RawSeverityName(IReadOnlyDictionary row) { if (!row.TryGetValue("Severity", out var v) || v == null) return null; if (int.TryParse(v.ToString(), out var n)) return Enum.IsDefined(typeof(EventSeverity), n) ? ((EventSeverity)n).ToString() : n.ToString(); return v.ToString(); } } }