Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs
T
Oliver Kieselbach 88cd29d3b9 backend: lifecycle completeness nets + session-scope point-read + server-side dashboard search (audit P5/P6)
P5.1 Table lifecycle buckets: TableLifecycleBucketTests classifies every
TableNames constant (offboarding buckets via reflection, per-session set from a
real DeletionManifestBuilder run, reviewed KeptByDesign list). Fixes found by
the audit: UsageMetrics offboarding wipe matched 0 rows (PK=date/RK=tenantId)
-> new SafeWipe Variant D (RowKey-anchored, verify protects the "global" row);
PlatformStats tenant partition + ScriptNameCache never wiped -> tenant-PK bucket.

P5.2 SessionsIndex field manifest: SessionIndexFieldManifest (32 always / 18
conditional / 4 primary-only) pinned bidirectionally against
BuildSessionIndexEntity and MapToSessionSummary; full rebuilds now
TableUpdateMode.Replace (heals stale conditional columns); merge sites warn on
non-manifest keys; force-path now mirrors StalledAt write + clear heals.

P5.3/P6.1 Session scope resolution: ResolveSessionScopeAsync (RequestContext)
replaces the 16-site copy-paste fallback; ResolveSessionTenantIdAsync does a
point-read on the new SessionTenantLookup table (PK=sessionId) with legacy
index-scan fallback + self-heal, replacing the cross-partition scan; the
"Count == 0 -> rescan" heuristics are gone. Write paths gate cross-tenant
resolution on IsGlobalAdmin. RecomputeTriggerGate enforces the decided
read-only semantics API-side: reanalyze/rescan now 403 for Viewer/GlobalReader/
cross-tenant tenant roles. Lookup rows ride the deletion cascade (step 22).

P6.2 Server-side dashboard search: q= on search/sessions (+global) matching
exactly the client-side searchable fields, page-backfill loop (max 10 Azure
pages/request), q bound into the pagination fingerprint; the web dashboard's
searchAll() replaces the loadAll() full-history sweep (dedupe-merge, delegated
callers keep the legacy sweep).

P6.3 Context value memoization: TenantConfigContext + Auth/Notification/
Tenant/Theme providers (cuts the externally-triggered re-render cascade).

Verified: solution build 0W/0E; Functions.Tests 4106 (+37), DecisionCore 519,
V2.Core 2206, SummaryDialog 44; tsc/eslint clean; vitest 722 (+4). Drift
detection of both new manifest tests proven by mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:29:27 +02:00

300 lines
14 KiB
C#

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<QueryRawEventsFunction> _logger;
private readonly ISessionRepository _sessionRepo;
public QueryRawEventsFunction(ILogger<QueryRawEventsFunction> logger, ISessionRepository sessionRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
}
/// <summary>
/// GET /api/raw/events — Tenant-scoped raw event query (cross-session)
/// </summary>
[Function("QueryRawEvents")]
public async Task<HttpResponseData> 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");
}
}
/// <summary>
/// GET /api/global/raw/events — Cross-tenant raw event query (GlobalAdminOnly)
/// Omit tenantId to query across all tenants.
/// </summary>
[Function("QueryRawEventsGlobal")]
public async Task<HttpResponseData> 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<HttpResponseData> 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<IReadOnlyDictionary<string, object?>>();
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<IReadOnlyDictionary<string, object?>> ApplyClientFilters(
List<IReadOnlyDictionary<string, object?>> 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<IReadOnlyDictionary<string, object?>> ApplyDateFilters(
List<IReadOnlyDictionary<string, object?>> 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<string, object?> row, string? severity)
=> string.IsNullOrEmpty(severity) ||
string.Equals(RawSeverityName(row), severity, StringComparison.OrdinalIgnoreCase);
private static bool MatchesSource(IReadOnlyDictionary<string, object?> row, string? source)
=> string.IsNullOrEmpty(source) ||
(RawString(row, "Source") ?? string.Empty).Contains(source, StringComparison.OrdinalIgnoreCase);
private static string? RawString(IReadOnlyDictionary<string, object?> row, string key)
=> row.TryGetValue(key, out var v) ? v?.ToString() : null;
private static DateTime RawTimestamp(IReadOnlyDictionary<string, object?> 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<string, object?> 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<string, object?> 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();
}
}
}