mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
2b9bf3cd3f
Helpers/QueryParams replaces fourteen private days/limit parsers, the three TryParseUtc copies, eleven identical pageSize blocks and ~50 inline TryParse sites under Functions/ and Pagination/. Lenient (Int/IntOrNull/UtcInstant): absent, garbage or below-minimum means the call site's default, above-maximum clamps. Strict (TryInt/TryPageSize/TryUtcInstant): absent is null, anything else invalid is a 400 with a message that names the parameter. Integers are invariant-culture, instants are invariant UTC (AssumeUniversal | AdjustToUniversal), whitespace counts as absent. Defaults and ranges stay arguments at every call site. Settled differences: the Apps windows clamp above 365 instead of falling back to 30, days=0 or negative yields the default everywhere, the two global geo endpoints and the session list cap days at 365, SLA months is capped at SlaMetricsService.MaxMonths on the HTTP layer, a whitespace date means no filter on every route, the admin batch sizes reject values above their maximum, and the three culture-dependent DateTime.TryParse sites parse as UTC. QueryParamGuardTests ratchets TryParse out of Functions/ and Pagination/ (baseline: headers, raw cells, the agent version major, the se= of a stored SAS URL); QueryParamsTests carries the cases the removed copies pinned. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
147 lines
6.5 KiB
C#
147 lines
6.5 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Specialized;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using AutopilotMonitor.Shared.Pagination;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
|
|
namespace AutopilotMonitor.Functions.Pagination
|
|
{
|
|
/// <summary>
|
|
/// Pure helpers for <c>/api/raw/events</c> + <c>/api/global/raw/events</c>.
|
|
/// PR-6 of mcp-pagination-rollout: replaces the legacy <c>?limit=</c> wire
|
|
/// shape and the hard-coded <c>limit:20</c> EventTypeIndex lookup with
|
|
/// <c>?pageSize=</c> + <c>?continuation=</c> + <c>nextLink</c> over the
|
|
/// underlying index walk.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Pagination unit is <em>EventTypeIndex rows scanned per page</em>, NOT
|
|
/// events returned: a single indexed (session, eventType) pair may yield
|
|
/// many events. Total events per page can therefore exceed
|
|
/// <c>pageSize</c>; AI/clients should follow <c>nextLink</c> until absent
|
|
/// for forensics-grade exact recall.
|
|
/// </para>
|
|
/// <para>
|
|
/// Continuation tokens bind <c>(scope, callerTenantId, filterTenantId,
|
|
/// eventType, source, severity, startedAfter, startedBefore)</c> via
|
|
/// fingerprint — flipping any filter invalidates the cursor (no recall
|
|
/// drift between pages).
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class QueryRawEventsPagination
|
|
{
|
|
public const int DefaultPageSize = 200;
|
|
|
|
/// <summary>
|
|
/// Slack between an event's sanitized time and the write time of its EventTypeIndex
|
|
/// row: ingest clamps agent times to at most <c>MaxFutureToleranceHours</c> ahead of
|
|
/// receipt, plus one hour for request-internal ordering. Used to turn
|
|
/// <c>startedAfter</c> into a server-side <c>Timestamp ge</c> pre-filter on the index
|
|
/// that can never exclude a session holding a matching event.
|
|
/// </summary>
|
|
public static readonly TimeSpan IndexWriteTimeSlack =
|
|
TimeSpan.FromHours(Services.EventTimestampValidator.MaxFutureToleranceHours + 1);
|
|
|
|
/// <summary>Lower bound on index-row write time implied by <paramref name="startedAfterUtc"/>; null when no lower bound was given.</summary>
|
|
public static DateTime? IndexWrittenAfterHint(DateTime? startedAfterUtc)
|
|
=> startedAfterUtc.HasValue ? startedAfterUtc.Value - IndexWriteTimeSlack : null;
|
|
|
|
public static string Fingerprint(
|
|
string scope,
|
|
string callerTenantId,
|
|
string? filterTenantId,
|
|
string? sessionId,
|
|
string? eventType,
|
|
string? source,
|
|
string? severity,
|
|
string? startedAfter,
|
|
string? startedBefore)
|
|
{
|
|
return ContinuationToken.ComputeFingerprint(new[]
|
|
{
|
|
new KeyValuePair<string, string?>("scope", scope),
|
|
new KeyValuePair<string, string?>("tenantId", callerTenantId),
|
|
new KeyValuePair<string, string?>("filterTenantId", filterTenantId),
|
|
// sessionId is null on the cross-session path; on the single-session
|
|
// path it MUST be in the fingerprint so a cursor from session A cannot
|
|
// be replayed against session B by the same caller.
|
|
new KeyValuePair<string, string?>("sessionId", sessionId),
|
|
new KeyValuePair<string, string?>("eventType", eventType),
|
|
new KeyValuePair<string, string?>("source", source),
|
|
new KeyValuePair<string, string?>("severity", severity),
|
|
new KeyValuePair<string, string?>("startedAfter", startedAfter),
|
|
new KeyValuePair<string, string?>("startedBefore", startedBefore),
|
|
});
|
|
}
|
|
|
|
public sealed class Parsed
|
|
{
|
|
public int PageSize { get; init; }
|
|
public string? Continuation { get; init; }
|
|
public string? Error { get; init; }
|
|
}
|
|
|
|
public static Parsed ParsePagination(NameValueCollection? query)
|
|
{
|
|
var pageSizeRaw = query?["pageSize"];
|
|
var continuationRaw = query?["continuation"];
|
|
|
|
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
|
|
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
|
|
var pageSize = pageSizeOrNull ?? DefaultPageSize;
|
|
|
|
return new Parsed
|
|
{
|
|
PageSize = pageSize,
|
|
Continuation = string.IsNullOrEmpty(continuationRaw) ? null : continuationRaw,
|
|
};
|
|
}
|
|
|
|
public static bool TryAcceptContinuation(
|
|
string raw,
|
|
string scope,
|
|
string callerTenantId,
|
|
string? filterTenantId,
|
|
string? sessionId,
|
|
string? eventType,
|
|
string? source,
|
|
string? severity,
|
|
string? startedAfter,
|
|
string? startedBefore,
|
|
out string azureToken,
|
|
out string? rejectReason)
|
|
{
|
|
var fp = Fingerprint(scope, callerTenantId, filterTenantId, sessionId, eventType, source, severity, startedAfter, startedBefore);
|
|
return ContinuationToken.TryDecode(raw, callerTenantId, fp, out azureToken, out rejectReason);
|
|
}
|
|
|
|
public static string BuildNextLink(
|
|
string basePath,
|
|
int pageSize,
|
|
string wireContinuation,
|
|
NameValueCollection originalQuery)
|
|
{
|
|
var sb = new StringBuilder(basePath);
|
|
sb.Append('?');
|
|
sb.Append("pageSize=").Append(pageSize.ToString(CultureInfo.InvariantCulture));
|
|
sb.Append("&continuation=").Append(System.Uri.EscapeDataString(wireContinuation));
|
|
|
|
// Echo every filter param the caller sent so the bookmark survives
|
|
// round-trips. Drop pageSize/continuation/limit (legacy/owned).
|
|
foreach (string? key in originalQuery.AllKeys)
|
|
{
|
|
if (string.IsNullOrEmpty(key)) continue;
|
|
if (string.Equals(key, "pageSize", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (string.Equals(key, "continuation", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (string.Equals(key, "limit", StringComparison.OrdinalIgnoreCase)) continue;
|
|
var value = originalQuery[key];
|
|
if (string.IsNullOrEmpty(value)) continue;
|
|
sb.Append('&').Append(System.Uri.EscapeDataString(key!)).Append('=').Append(System.Uri.EscapeDataString(value!));
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
}
|
|
}
|