mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
88cd29d3b9
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>
300 lines
14 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|