backend: one query-parameter parser (QueryParams) with a lenient and a strict contract, ratcheted (D-202)

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>
This commit is contained in:
Oliver Kieselbach
2026-09-05 23:26:21 +02:00
parent d99e16314b
commit 2b9bf3cd3f
56 changed files with 564 additions and 521 deletions
@@ -726,17 +726,6 @@ public class DeviceJourneyAndFtrTests
Assert.Equal(3, totals.ExcludedSessions);
}
[Theory]
[InlineData(null, 30)] // default window
[InlineData("garbage", 30)]
[InlineData("7", 7)]
[InlineData("0", 1)] // clamped low
[InlineData("999", 180)] // clamped to aggregate retention
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
{
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
}
[Fact]
public void InclusiveWindowStart_YieldsExactlyNCalendarDays()
{
@@ -0,0 +1,88 @@
using System.Text.RegularExpressions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ratchet: HTTP query parameters are parsed through <c>Helpers/QueryParams.cs</c> only. A
/// <c>int/long/DateTime/DateTimeOffset.TryParse</c> under <c>Functions/</c> or <c>Pagination/</c>
/// is a new private parser copy — the state the 2026-09-04 API audit found six times over for
/// <c>days</c> alone, each with its own default, cap and out-of-range rule. The baseline lists
/// the sites that parse something other than a query parameter (a header, a stored cell, a
/// version string); it only shrinks.
/// </summary>
public class QueryParamGuardTests
{
private static readonly Regex TryParseCall = new(
@"\b(int|long|DateTime|DateTimeOffset)\.TryParse(Exact)?\(", RegexOptions.Compiled);
/// <summary>Non-query parses, reviewed: file → why it is allowed.</summary>
private static readonly IReadOnlyDictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Functions/Config/GetAgentConfigFunction.cs"] = "major of the X-Agent-Version header",
["Functions/Ingest/IngestTelemetryFunction.cs"] = "X-Send-Time-Utc header (RoundtripKind, deliberately not a query instant)",
["Functions/Raw/QueryRawEventsFunction.cs"] = "Severity / Sequence cells of raw table rows",
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "se= expiry inside the tenant's stored SAS URL",
// Content-Length header guard on body-taking routes (long.TryParse on the header value).
["Functions/Admin/SubmitOffboardingFeedbackFunction.cs"] = "Content-Length header",
["Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = "Content-Length header",
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = "Content-Length header",
["Functions/Config/UpdateAdminConfigurationFunction.cs"] = "Content-Length header",
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = "Content-Length header",
["Functions/Feedback/FeedbackFunction.cs"] = "Content-Length header",
["Functions/Infrastructure/SignalRAddToGroupFunction.cs"] = "Content-Length header",
["Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs"] = "Content-Length header",
["Functions/Ingest/ReportAgentErrorFunction.cs"] = "Content-Length header",
["Functions/Ingest/ReportDistressFunction.cs"] = "Content-Length header",
["Functions/Reports/SubmitDiagFilesReportFunction.cs"] = "Content-Length header",
["Functions/Reports/SubmitSessionReportFunction.cs"] = "Content-Length header",
["Functions/Rules/AnalyzeRulesFunction.cs"] = "Content-Length header",
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = "Content-Length header",
["Functions/Rules/GatherRulesFunction.cs"] = "Content-Length header",
["Functions/Rules/GlobalRulesFunction.cs"] = "Content-Length header",
["Functions/Rules/ImeLogPatternsFunction.cs"] = "Content-Length header",
["Functions/Rules/TestLogPatternFunction.cs"] = "Content-Length header",
["Functions/Sessions/RegisterSessionFunction.cs"] = "Content-Length header",
};
private static string FunctionsRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
}
private static IEnumerable<string> ScannedFiles(string root) =>
new[] { "Functions", "Pagination" }
.SelectMany(sub => Directory.EnumerateFiles(Path.Combine(root, sub), "*.cs", SearchOption.AllDirectories))
.Select(p => Path.GetRelativePath(root, p).Replace('\\', '/'))
.OrderBy(p => p, StringComparer.Ordinal);
[Fact]
public void Query_parameters_are_parsed_through_QueryParams_only()
{
var root = FunctionsRoot();
var offenders = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var rel in ScannedFiles(root))
{
var source = File.ReadAllText(Path.Combine(root, rel));
if (!TryParseCall.IsMatch(source)) continue;
seen.Add(rel);
if (!Baseline.ContainsKey(rel)) offenders.Add(rel);
}
Assert.True(offenders.Count == 0,
"TryParse outside Helpers/QueryParams.cs — parse query parameters through QueryParams " +
"(Int / TryInt / TryPageSize / UtcInstant / TryUtcInstant), or add the file to the baseline " +
"with the non-query reason:\n " + string.Join("\n ", offenders));
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
Assert.True(stale.Count == 0,
"Baseline entries without a TryParse any more — remove them (the ratchet only shrinks):\n " +
string.Join("\n ", stale));
}
}
@@ -0,0 +1,192 @@
using System.Globalization;
using AutopilotMonitor.Functions.Helpers;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The one query-parameter parser (<see cref="QueryParams"/>). The lenient theories carry the
/// cases the former per-function copies pinned (RuleHitSessions, DeviceJourney, VerdictCalibration,
/// the Apps reject-to-default variant) so their behaviour is decided once, here: garbage and
/// below-minimum mean default, above-maximum clamps. The strict theories pin the 400 messages
/// the paginators and admin routes return.
/// </summary>
public class QueryParamsTests
{
private static readonly DateTime Occurred = new(2026, 8, 29, 20, 11, 0, DateTimeKind.Utc);
// ── Lenient ────────────────────────────────────────────────────────────────────────
[Theory]
[InlineData(null, 14)]
[InlineData("", 14)]
[InlineData(" ", 14)]
[InlineData("garbage", 14)]
[InlineData("14", 14)]
[InlineData("1", 1)]
[InlineData("0", 14)] // below min ⇒ default (the old ClampDays copies answered 1)
[InlineData("-5", 14)]
[InlineData("90", 90)]
[InlineData("365", 90)] // above max ⇒ clamped (the old Apps copies answered the default)
[InlineData("2147483648", 14)] // overflow ⇒ default
public void Int_defaults_below_min_and_clamps_above_max(string? raw, int expected)
{
Assert.Equal(expected, QueryParams.Int(raw, @default: 14, min: 1, max: 90));
}
[Fact]
public void Int_reads_the_named_parameter_from_a_query_collection()
{
var query = System.Web.HttpUtility.ParseQueryString("days=7&limit=abc");
Assert.Equal(7, QueryParams.Int(query, "days", 30, 1, 365));
Assert.Equal(100, QueryParams.Int(query, "limit", 100, 1, 2000));
Assert.Equal(30, QueryParams.Int((System.Collections.Specialized.NameValueCollection?)null, "days", 30, 1, 365));
}
[Fact]
public void Int_is_culture_invariant()
{
var previous = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
Assert.Equal(1000, QueryParams.Int("1000", 1, 1, 5000));
Assert.Equal(1, QueryParams.Int("1.000", 1, 1, 5000)); // thousands separator is not a number
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData("x", null)]
[InlineData("-3", -3)]
[InlineData("12", 12)]
public void IntOrNull_has_no_range(string? raw, int? expected)
{
Assert.Equal(expected, QueryParams.IntOrNull(raw));
}
[Fact]
public void UtcInstant_is_null_for_absent_or_garbage_and_utc_otherwise()
{
Assert.Null(QueryParams.UtcInstant(null));
Assert.Null(QueryParams.UtcInstant(" "));
Assert.Null(QueryParams.UtcInstant("yesterday"));
var parsed = QueryParams.UtcInstant("2026-08-29T22:11:00+02:00");
Assert.Equal(Occurred, parsed);
Assert.Equal(DateTimeKind.Utc, parsed!.Value.Kind);
}
// ── Strict ─────────────────────────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TryInt_absent_is_null_and_ok(string? raw)
{
Assert.True(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
Assert.Null(value);
Assert.Null(error);
}
[Theory]
[InlineData("banana", "days must be an integer")]
[InlineData("1.5", "days must be an integer")]
[InlineData("0", "days must be between 1 and 365")]
[InlineData("-1", "days must be between 1 and 365")]
[InlineData("366", "days must be between 1 and 365")]
public void TryInt_rejects_garbage_and_out_of_range_with_a_named_message(string raw, string expectedError)
{
Assert.False(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
Assert.Null(value);
Assert.Equal(expectedError, error);
}
[Theory]
[InlineData("1", 1)]
[InlineData("365", 365)]
public void TryInt_accepts_the_range_ends(string raw, int expected)
{
Assert.True(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
Assert.Equal(expected, value);
Assert.Null(error);
}
[Fact]
public void TryInt_without_an_upper_bound_names_only_the_minimum()
{
Assert.False(QueryParams.TryInt("-1", "skip", 0, int.MaxValue, out _, out var error));
Assert.Equal("skip must be an integer of at least 0", error);
Assert.True(QueryParams.TryInt("2147483647", "skip", 0, int.MaxValue, out var max, out _));
Assert.Equal(int.MaxValue, max);
}
[Theory]
[InlineData(null, null, null)]
[InlineData("1", 1, null)]
[InlineData("1000", 1000, null)]
[InlineData("1001", null, "pageSize must be between 1 and 1000")]
[InlineData("0", null, "pageSize must be between 1 and 1000")]
[InlineData("ten", null, "pageSize must be an integer")]
public void TryPageSize_is_strict_within_one_to_MaxPageSize(string? raw, int? expected, string? expectedError)
{
var ok = QueryParams.TryPageSize(raw, out var pageSize, out var error);
Assert.Equal(expectedError == null, ok);
Assert.Equal(expected, pageSize);
Assert.Equal(expectedError, error);
}
// ── Instants ───────────────────────────────────────────────────────────────────────
[Fact]
public void TryUtcInstant_reads_offset_bare_and_date_only_values_as_utc()
{
Assert.True(QueryParams.TryUtcInstant("2026-08-29T20:11:00Z", "d", out var z, out _));
Assert.True(QueryParams.TryUtcInstant("2026-08-29T22:11:00+02:00", "d", out var offset, out _));
Assert.True(QueryParams.TryUtcInstant("2026-08-29T20:11:00", "d", out var bare, out _));
Assert.True(QueryParams.TryUtcInstant("2026-08-29", "d", out var dateOnly, out _));
Assert.Equal(Occurred, z);
Assert.Equal(Occurred, offset);
Assert.Equal(Occurred, bare);
Assert.Equal(Occurred.Date, dateOnly);
Assert.Equal(DateTimeKind.Utc, z!.Value.Kind);
Assert.Equal(DateTimeKind.Utc, bare!.Value.Kind);
Assert.Equal(DateTimeKind.Utc, dateOnly!.Value.Kind);
}
[Fact]
public void TryUtcInstant_treats_absent_and_whitespace_as_no_filter_and_garbage_as_error()
{
Assert.True(QueryParams.TryUtcInstant(null, "dateFrom", out var none, out var noneError));
Assert.Null(none);
Assert.Null(noneError);
Assert.True(QueryParams.TryUtcInstant(" ", "dateFrom", out var blank, out _));
Assert.Null(blank);
Assert.False(QueryParams.TryUtcInstant("yesterday", "dateFrom", out var bad, out var error));
Assert.Null(bad);
Assert.Equal("dateFrom must be an ISO 8601 date or date-time", error);
}
[Fact]
public void TryUtcInstant_is_culture_invariant()
{
var previous = CultureInfo.CurrentCulture;
try
{
// de-DE would read "08/29/2026" day-first and fail; invariant parsing does not.
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
Assert.True(QueryParams.TryUtcInstant("08/29/2026 20:11", "d", out var parsed, out _));
Assert.Equal(Occurred, parsed);
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
}
@@ -116,30 +116,4 @@ public class RawEventTimeTests
Assert.Equal(Occurred - expectedSlack, QueryRawEventsPagination.IndexWrittenAfterHint(Occurred));
Assert.Null(QueryRawEventsPagination.IndexWrittenAfterHint(null));
}
// ===== TryParseUtc =====
[Fact]
public void TryParseUtc_reads_offset_and_bare_values_as_utc()
{
Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T20:11:00Z", out var z));
Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T22:11:00+02:00", out var offset));
Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T20:11:00", out var bare));
Assert.Equal(Occurred, z);
Assert.Equal(Occurred, offset);
Assert.Equal(Occurred, bare);
Assert.Equal(DateTimeKind.Utc, z!.Value.Kind);
Assert.Equal(DateTimeKind.Utc, bare!.Value.Kind);
}
[Fact]
public void TryParseUtc_treats_empty_as_no_filter_and_garbage_as_error()
{
Assert.True(QueryRawEventsPagination.TryParseUtc(null, out var none));
Assert.Null(none);
Assert.True(QueryRawEventsPagination.TryParseUtc(" ", out var blank));
Assert.Null(blank);
Assert.False(QueryRawEventsPagination.TryParseUtc("yesterday", out _));
}
}
@@ -10,21 +10,6 @@ namespace AutopilotMonitor.Functions.Tests;
/// </summary>
public class RuleHitSessionsFunctionTests
{
[Theory]
[InlineData(null, 14)] // not supplied → default window
[InlineData("", 14)] // empty → default
[InlineData("garbage", 14)] // non-numeric → default
[InlineData("14", 14)]
[InlineData("1", 1)]
[InlineData("0", 1)] // below range → clamped up
[InlineData("-5", 1)]
[InlineData("90", 90)]
[InlineData("365", 90)] // above range → clamped down
public void ParseDays_Defaults_And_Clamps(string? raw, int expected)
{
Assert.Equal(expected, RuleHitSessionsFunction.ParseDays(raw));
}
[Fact]
public void MaxSessionIds_Matches_Repository_Default_Cap()
{
@@ -289,13 +289,4 @@ public class VerdictCalibrationTests
Assert.Equal(0, json.GetProperty("totals").GetProperty("sessions").GetInt32());
Assert.Equal(JsonValueKind.Null, json.GetProperty("computedAt").ValueKind);
}
[Theory]
[InlineData(null, 30)]
[InlineData("abc", 30)]
[InlineData("0", 1)]
[InlineData("500", 180)]
[InlineData("14", 14)]
public void ClampDays_bounds_the_window(string? raw, int expected)
=> Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw));
}
@@ -57,15 +57,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin
var dryRun = !string.Equals(req.Query["dryRun"], "false", StringComparison.OrdinalIgnoreCase);
var continuation = req.Query["continuation"];
var maxRows = DefaultMaxRows;
if (!string.IsNullOrEmpty(req.Query["maxRows"]))
if (!QueryParams.TryInt(req.Query["maxRows"], "maxRows", 1, MaxMaxRows, out var maxRowsOrNull, out var maxRowsError))
{
if (!int.TryParse(req.Query["maxRows"], out maxRows) || maxRows < 1)
{
return await req.BadRequestAsync($"maxRows must be a positive integer (max {MaxMaxRows})");
}
maxRows = Math.Min(maxRows, MaxMaxRows);
return await req.BadRequestAsync(maxRowsError!);
}
var maxRows = maxRowsOrNull ?? DefaultMaxRows;
_logger.LogInformation(
"OccurredUtc backfill requested by {User}: table={Table} dryRun={DryRun} maxRows={MaxRows} hasContinuation={HasContinuation}",
@@ -36,9 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
try
{
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
var windowMinutes = DefaultWindowMinutes;
if (int.TryParse(query["windowMinutes"], out var parsed))
windowMinutes = Math.Clamp(parsed, MinWindowMinutes, MaxWindowMinutes);
var windowMinutes = QueryParams.Int(query["windowMinutes"], DefaultWindowMinutes, MinWindowMinutes, MaxWindowMinutes);
var now = DateTime.UtcNow;
var active = await _metricsRepo.GetActivePresenceAsync(TimeSpan.FromMinutes(windowMinutes));
@@ -59,15 +59,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin
return await req.BadRequestAsync("tenantId must be a GUID");
}
var maxSessions = DefaultMaxSessions;
if (!string.IsNullOrEmpty(req.Query["maxSessions"]))
if (!QueryParams.TryInt(req.Query["maxSessions"], "maxSessions", 1, MaxMaxSessions, out var maxSessionsOrNull, out var maxSessionsError))
{
if (!int.TryParse(req.Query["maxSessions"], out maxSessions) || maxSessions < 1)
{
return await req.BadRequestAsync($"maxSessions must be a positive integer (max {MaxMaxSessions})");
}
maxSessions = Math.Min(maxSessions, MaxMaxSessions);
return await req.BadRequestAsync(maxSessionsError!);
}
var maxSessions = maxSessionsOrNull ?? DefaultMaxSessions;
_logger.LogInformation(
"Legacy reclassification requested by {User}: mode={Mode} dryRun={DryRun} tenantScope={TenantScope} maxSessions={MaxSessions}",
@@ -51,18 +51,14 @@ namespace AutopilotMonitor.Functions.Functions.Admin
var dateParam = req.Query["date"];
var aggregateOnlyParam = req.Query["aggregateOnly"];
DateTime? targetDate = null;
if (!string.IsNullOrEmpty(dateParam))
if (!QueryParams.TryUtcInstant(dateParam, "date", out var dateInstant, out _))
{
if (DateTime.TryParse(dateParam, out var parsedDate))
{
targetDate = parsedDate.Date;
_logger.LogInformation($"Manual maintenance for date: {targetDate:yyyy-MM-dd}");
}
else
{
return await req.BadRequestAsync("Invalid date format. Use yyyy-MM-dd");
}
return await req.BadRequestAsync("Invalid date format. Use yyyy-MM-dd");
}
DateTime? targetDate = dateInstant?.Date;
if (targetDate.HasValue)
{
_logger.LogInformation($"Manual maintenance for date: {targetDate:yyyy-MM-dd}");
}
bool aggregateOnly = aggregateOnlyParam?.ToLower() == "true";
@@ -51,19 +51,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps
/// </summary>
public static AppsPaging ParseAppsPaging(NameValueCollection query)
{
var pageSizeRaw = query["pageSize"];
if (string.IsNullOrEmpty(pageSizeRaw))
if (!QueryParams.TryInt(query["pageSize"], "pageSize", 1, MaxAppsPageSize, out var pageSize, out var error))
return new AppsPaging { Error = error };
if (pageSize == null)
return new AppsPaging { PageSize = null, Skip = 0 };
if (!int.TryParse(pageSizeRaw, out var pageSize) || pageSize < 1 || pageSize > MaxAppsPageSize)
return new AppsPaging { Error = $"pageSize must be between 1 and {MaxAppsPageSize}" };
if (!QueryParams.TryInt(query["skip"], "skip", 0, int.MaxValue, out var skip, out error))
return new AppsPaging { Error = error };
var skip = 0;
var skipRaw = query["skip"];
if (!string.IsNullOrEmpty(skipRaw) && (!int.TryParse(skipRaw, out skip) || skip < 0))
return new AppsPaging { Error = "skip must be a non-negative integer" };
return new AppsPaging { PageSize = pageSize, Skip = skip };
return new AppsPaging { PageSize = pageSize, Skip = skip ?? 0 };
}
// ── Data loaders ────────────────────────────────────────────────────
@@ -47,9 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
}
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
// Active duration-regression episodes for this app (fail-soft: empty on error).
@@ -46,21 +46,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps
}
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
var modelFilter = query["model"];
var versionFilter = query["version"];
int offset = 0;
if (int.TryParse(query["offset"], out var parsedOffset) && parsedOffset >= 0)
offset = parsedOffset;
var offset = QueryParams.Int(query["offset"], @default: 0, min: 0, max: int.MaxValue);
int limit = DefaultLimit;
if (int.TryParse(query["limit"], out var parsedLimit) && parsedLimit > 0)
limit = Math.Min(parsedLimit, MaxLimit);
var limit = QueryParams.Int(query["limit"], DefaultLimit, 1, MaxLimit);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
@@ -34,9 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var tenantId = TenantHelper.GetTenantId(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var paging = AppsAnalyticsHelper.ParseAppsPaging(query);
if (paging.Error != null)
@@ -52,9 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
{
return await req.BadRequestAsync("tenantId must be a valid GUID");
}
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
_logger.LogInformation(
"Global apps/{App}/analytics requested (user: {User}, tenantId: {TenantId}, days: {Days})",
@@ -51,21 +51,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps
{
return await req.BadRequestAsync("tenantId must be a valid GUID");
}
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
var modelFilter = query["model"];
var versionFilter = query["version"];
int offset = 0;
if (int.TryParse(query["offset"], out var parsedOffset) && parsedOffset >= 0)
offset = parsedOffset;
var offset = QueryParams.Int(query["offset"], @default: 0, min: 0, max: int.MaxValue);
int limit = DefaultLimit;
if (int.TryParse(query["limit"], out var parsedLimit) && parsedLimit > 0)
limit = Math.Min(parsedLimit, MaxLimit);
var limit = QueryParams.Int(query["limit"], DefaultLimit, 1, MaxLimit);
_logger.LogInformation(
"Global apps/{App}/sessions requested (user: {User}, tenantId: {TenantId})",
@@ -39,9 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
{
return await req.BadRequestAsync("tenantId must be a valid GUID");
}
int days = 30;
if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365)
days = parsedDays;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
_logger.LogInformation(
"Global apps/list requested (user: {User}, tenantId: {TenantId}, days: {Days})",
@@ -44,9 +44,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var requestCtx = req.GetRequestContext();
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var max = int.TryParse(query["max"], out var parsed)
? Math.Clamp(parsed, 1, MaxListSize)
: MaxListSize;
var max = QueryParams.Int(query["max"], MaxListSize, 1, MaxListSize);
var backups = await _backupRepo.ListByPartitionAsync(requestCtx.TargetTenantId, max);
@@ -31,12 +31,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Optional time filter: ?days=7 (default: 30, clamped to [1, 365] for consistency
// with metrics/summary, metrics/usage, and metrics/platform).
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
if (days < 1) days = 1;
if (days > 365) days = 365;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var cutoff = DateTime.UtcNow.AddDays(-days);
@@ -124,7 +124,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
var days = QueryParams.Int(query["days"], DeviceJourneyMetricsResponseBuilder.DefaultWindowDays, 1, DeviceJourneyMetricsResponseBuilder.MaxWindowDays);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync(
@@ -169,7 +169,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var tenantIdFilter = query["tenantId"];
var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
var days = QueryParams.Int(query["days"], DeviceJourneyMetricsResponseBuilder.DefaultWindowDays, 1, DeviceJourneyMetricsResponseBuilder.MaxWindowDays);
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -196,12 +196,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist
internal const int MaxRepeatDevices = 10;
internal static int ClampDays(string? raw)
{
if (!int.TryParse(raw, out var days)) return DefaultWindowDays;
return Math.Clamp(days, 1, MaxWindowDays);
}
/// <summary>
/// "Last N days" = exactly N calendar day keys including today — both range ends are
/// inclusive, so subtracting the full N would return N+1 days (Codex review: days=1
@@ -35,12 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var tenantId = TenantHelper.GetTenantId(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
if (days < 1) days = 1;
if (days > 365) days = 365;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
_logger.LogInformation("Fetching fleet health for tenant {TenantId} (days={Days})", tenantId, days);
@@ -46,12 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.BadRequestAsync("locationKey or country parameter is required");
}
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
if (days < 1) days = 1;
if (days > 365) days = 365;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var groupBy = query["groupBy"] ?? "city";
// Default response shape is lean (~15 fields/row) so MCP-driven callers fit
@@ -36,12 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
_logger.LogInformation("Fetching geographic metrics for tenant {TenantId}", tenantId);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
if (days < 1) days = 1;
if (days > 365) days = 365;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var groupBy = query["groupBy"] ?? "city";
@@ -35,8 +35,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var days = ParseIntParam(query["days"], defaultValue: 30, min: 1, max: 365);
var limit = ParseIntParam(query["limit"], defaultValue: 500, min: 1, max: 2000);
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var limit = QueryParams.Int(query["limit"], @default: 500, min: 1, max: 2000);
var tenantIdRaw = query["tenantId"];
string? tenantId = null;
@@ -62,15 +62,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "GetGlobalAgentEfficiency");
}
}
private static int ParseIntParam(string? raw, int defaultValue, int min, int max)
{
var value = defaultValue;
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
value = parsed;
if (value < min) value = min;
if (value > max) value = max;
return value;
}
}
}
@@ -32,14 +32,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
_logger.LogInformation($"Fetching global app metrics (User: {userEmail})");
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
// Clamped to [1, 365] like the tenant variant and the other metrics endpoints — an
// unbounded days= here was the one way to request an unlimited cross-tenant scan.
if (days < 1) days = 1;
if (days > 365) days = 365;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var tenantIdFilter = query["tenantId"];
@@ -35,12 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var userEmail = TenantHelper.GetUserIdentifier(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
if (days < 1) days = 1;
if (days > 365) days = 365;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var tenantIdFilter = query["tenantId"];
@@ -45,10 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.BadRequestAsync("locationKey or country parameter is required");
}
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var groupBy = query["groupBy"] ?? "city";
var full = string.Equals(query["full"], "1", StringComparison.Ordinal);
@@ -36,10 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
_logger.LogInformation("Fetching global geographic metrics (User: {UserEmail})", userEmail);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var groupBy = query["groupBy"] ?? "city";
var tenantIdFilter = query["tenantId"];
@@ -34,8 +34,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
var days = ParseDays(req);
var limit = ParseLimit(req);
var days = QueryParams.Int(req.Query["days"], @default: 90, min: 1, max: 365);
// limit drives the per-session work the service performs (each session
// triggers its own GetSessionEventsAsync call). The UI dropdown sets it
// explicitly so the user controls the analysis depth (e.g. 90 days x 1000
// sessions); the service bounds its concurrency internally, and the hard
// ceiling of 2000 protects against runaway query strings.
var limit = QueryParams.Int(req.Query["limit"], @default: 100, min: 1, max: 2000);
var metrics = await _platformMetricsService.ComputePlatformMetricsAsync(days, limit);
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -48,35 +53,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "GetGlobalPlatformMetrics");
}
}
private static int ParseDays(HttpRequestData req)
{
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var raw = query["days"];
var days = 90;
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
days = parsed;
if (days < 1) days = 1;
if (days > 365) days = 365;
return days;
}
// Drives the per-session work the service performs (each session
// triggers its own GetSessionEventsAsync call). The UI dropdown sets
// this explicitly so the user controls the analysis depth (e.g.
// 90 days × 1000 sessions). The service uses bounded concurrency
// internally so even large limits don't blow the Function timeout.
// Hard ceiling 2000 protects against runaway query strings.
private static int ParseLimit(HttpRequestData req)
{
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var raw = query["limit"];
var limit = 100;
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
limit = parsed;
if (limit < 1) limit = 1;
if (limit > 2000) limit = 2000;
return limit;
}
}
}
@@ -50,8 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.BadRequestAsync("tenantId query parameter is required");
}
if (int.TryParse(qs.Get("months"), out var parsedMonths))
months = parsedMonths;
months = QueryParams.Int(qs.Get("months"), months, 1, SlaMetricsService.MaxMonths);
fresh = qs.Get("fresh") == "1";
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var tenantIdFilter = query["tenantId"];
var days = VerdictCalibrationResponseBuilder.ClampDays(query["days"]);
var days = QueryParams.Int(query["days"], VerdictCalibrationResponseBuilder.DefaultWindowDays, 1, VerdictCalibrationResponseBuilder.MaxWindowDays);
var partition = "global";
if (!string.IsNullOrWhiteSpace(tenantIdFilter))
{
@@ -85,12 +85,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
internal const int TrendBaselineDays = 28;
internal const int TrendHorizonDays = TrendWindowDays + TrendBaselineDays;
internal static int ClampDays(string? raw)
{
if (!int.TryParse(raw, out var days)) return DefaultWindowDays;
return Math.Clamp(days, 1, MaxWindowDays);
}
/// <summary>"Last N days" = exactly N calendar day keys including today (both range ends inclusive).</summary>
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
@@ -26,7 +26,7 @@ public class MetricsSummaryFunction
try
{
var tenantId = TenantHelper.GetTenantId(req);
var days = ParseDays(req);
var days = QueryParams.Int(req.Query["days"], @default: 30, min: 1, max: 365);
var summary = await _metricsRepo.GetMetricsSummaryAsync(tenantId, days);
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -45,7 +45,7 @@ public class MetricsSummaryFunction
{
try
{
var days = ParseDays(req);
var days = QueryParams.Int(req.Query["days"], @default: 30, min: 1, max: 365);
// Optional tenantId filter — when set, GA scopes the cross-tenant summary to one
// tenant. When absent, returns the full cross-tenant view (null → all tenants).
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
@@ -73,16 +73,4 @@ public class MetricsSummaryFunction
return await req.InternalServerErrorAsync(_logger, ex, "Get global metrics summary");
}
}
private static int ParseDays(HttpRequestData req)
{
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var raw = query["days"];
var days = 30;
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
days = parsed;
if (days < 1) days = 1;
if (days > 365) days = 365;
return days;
}
}
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Optional tenantId query parameter: when provided, return tenant-specific metrics
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var tenantId = query["tenantId"];
var days = ParseDays(query);
var days = QueryParams.Int(query["days"], @default: 90, min: 1, max: 365);
var metrics = !string.IsNullOrEmpty(tenantId)
? await _usageMetricsService.ComputeTenantUsageMetricsAsync(tenantId, days)
@@ -57,16 +57,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "PlatformUsageMetrics");
}
}
private static int ParseDays(System.Collections.Specialized.NameValueCollection query)
{
var raw = query["days"];
var days = 90;
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
days = parsed;
if (days < 1) days = 1;
if (days > 365) days = 365;
return days;
}
}
}
@@ -48,7 +48,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.BadRequestAsync("ruleId is required");
}
var days = ParseDays(query["days"]);
// Window in days: default 14, capped at 90 (RuleResults follow the session
// retention cascade, so anything past 90 days cannot widen the result).
var days = QueryParams.Int(query["days"], @default: 14, min: 1, max: 90);
var sinceUtc = DateTime.UtcNow.AddDays(-days);
var sessionIds = await _ruleRepo.GetRuleHitSessionIdsAsync(
requestCtx.TargetTenantId, ruleId, sinceUtc, MaxSessionIds);
@@ -68,16 +70,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "RuleHitSessions");
}
}
/// <summary>
/// Window in days: default 14, clamped to 1..90 (RuleResults follow the session
/// retention cascade, so anything past 90 days cannot widen the result).
/// </summary>
internal static int ParseDays(string? raw)
{
if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var parsed))
return 14;
return Math.Clamp(parsed, 1, 90);
}
}
}
@@ -43,8 +43,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
{
var qs = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? "");
if (int.TryParse(qs.Get("months"), out var parsedMonths))
months = parsedMonths;
months = QueryParams.Int(qs.Get("months"), months, 1, SlaMetricsService.MaxMonths);
fresh = qs.Get("fresh") == "1";
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
string tenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
var days = ParseDays(req);
var days = QueryParams.Int(req.Query["days"], @default: 90, min: 1, max: 365);
_logger.LogInformation("Fetching usage metrics for tenant {TenantId} by user {User} (days={Days})", tenantId, userIdentifier, days);
var metrics = await _usageMetricsService.ComputeTenantUsageMetricsAsync(tenantId, days);
@@ -55,17 +55,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "UsageMetrics");
}
}
private static int ParseDays(HttpRequestData req)
{
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var raw = query["days"];
var days = 90;
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
days = parsed;
if (days < 1) days = 1;
if (days > 365) days = 365;
return days;
}
}
}
@@ -87,10 +87,13 @@ namespace AutopilotMonitor.Functions.Functions.Raw
// Date window: an unparsable value is an error, not a silently dropped filter —
// a caller that believes it narrowed the window must never get the whole table.
if (!QueryRawEventsPagination.TryParseUtc(startedAfter, out var afterUtc)
|| !QueryRawEventsPagination.TryParseUtc(startedBefore, out var beforeUtc))
if (!QueryParams.TryUtcInstant(startedAfter, "startedAfter", out var afterUtc, out var dateError))
{
return await req.BadRequestAsync("startedAfter/startedBefore must be ISO 8601 datetimes");
return await req.BadRequestAsync(dateError!);
}
if (!QueryParams.TryUtcInstant(startedBefore, "startedBefore", out var beforeUtc, out dateError))
{
return await req.BadRequestAsync(dateError!);
}
var callerTenantId = TenantHelper.GetTenantId(req);
@@ -115,10 +115,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
OsBuild = string.IsNullOrEmpty(osBuild) ? null : osBuild,
GeoCountry = string.IsNullOrEmpty(geoCountry) ? null : geoCountry,
};
if (!string.IsNullOrEmpty(startedAfter) && DateTime.TryParse(startedAfter, out var after))
filter.StartedAfter = after;
if (!string.IsNullOrEmpty(startedBefore) && DateTime.TryParse(startedBefore, out var before))
filter.StartedBefore = before;
filter.StartedAfter = QueryParams.UtcInstant(startedAfter);
filter.StartedBefore = QueryParams.UtcInstant(startedBefore);
if (!string.IsNullOrEmpty(isPreProvisioned) && bool.TryParse(isPreProvisioned, out var pp))
filter.IsPreProvisioned = pp;
if (!string.IsNullOrEmpty(isHybridJoin) && bool.TryParse(isHybridJoin, out var hj))
@@ -127,8 +125,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
filter.IsSelfDeployingProfile = sdp;
if (!string.IsNullOrEmpty(isCloudPc) && bool.TryParse(isCloudPc, out var icp))
filter.IsCloudPc = icp;
if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax;
filter.RebootCountMin = QueryParams.IntOrNull(query["rebootCountMin"]);
filter.RebootCountMax = QueryParams.IntOrNull(query["rebootCountMax"]);
var connectionType = query["connectionType"];
if (!string.IsNullOrEmpty(connectionType)) filter.ConnectionType = connectionType;
@@ -61,19 +61,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
return await req.BadRequestAsync("Query parameter 'state' must be one of: Preparing, Queued, Running, Poisoned.");
}
int? strandedSinceMinutes = null;
var strandedRaw = query["strandedSinceMinutes"];
if (!string.IsNullOrEmpty(strandedRaw))
// 10080 minutes = 7 days.
if (!QueryParams.TryInt(query["strandedSinceMinutes"], "strandedSinceMinutes", 1, 10080, out var strandedSinceMinutes, out var strandedError))
{
if (state != SessionDeletionState.Queued)
{
return await req.BadRequestAsync("strandedSinceMinutes is only valid with state=Queued.");
}
if (!int.TryParse(strandedRaw, out var minutes) || minutes <= 0 || minutes > 10080)
{
return await req.BadRequestAsync("strandedSinceMinutes must be a positive integer up to 10080 (7 days).");
}
strandedSinceMinutes = minutes;
return await req.BadRequestAsync(strandedError!);
}
if (strandedSinceMinutes.HasValue && state != SessionDeletionState.Queued)
{
return await req.BadRequestAsync("strandedSinceMinutes is only valid with state=Queued.");
}
var cutoffUtc = strandedSinceMinutes.HasValue
@@ -65,19 +65,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
internal static bool TryParseDays(string? raw, out int days, out string? error)
{
days = DefaultDays;
error = null;
if (string.IsNullOrEmpty(raw)) return true;
if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|| parsed < 1 || parsed > MaxDays)
{
error = $"days must be a positive integer between 1 and {MaxDays}";
return false;
}
days = parsed;
return true;
var ok = QueryParams.TryInt(raw, "days", 1, MaxDays, out var parsed, out error);
days = parsed ?? DefaultDays;
return ok;
}
}
}
@@ -158,10 +158,10 @@ public class SearchSessionsFunction
if (bool.TryParse(query["isHybridJoin"], out var ihj)) filter.IsHybridJoin = ihj;
if (bool.TryParse(query["isSelfDeployingProfile"], out var isdp)) filter.IsSelfDeployingProfile = isdp;
if (bool.TryParse(query["isCloudPc"], out var icp)) filter.IsCloudPc = icp;
if (DateTime.TryParse(query["startedAfter"], out var sa)) filter.StartedAfter = sa;
if (DateTime.TryParse(query["startedBefore"], out var sb)) filter.StartedBefore = sb;
if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax;
filter.StartedAfter = QueryParams.UtcInstant(query["startedAfter"]);
filter.StartedBefore = QueryParams.UtcInstant(query["startedBefore"]);
filter.RebootCountMin = QueryParams.IntOrNull(query["rebootCountMin"]);
filter.RebootCountMax = QueryParams.IntOrNull(query["rebootCountMax"]);
// Dynamic device property filters: any query param starting with "prop."
var deviceProperties = new Dictionary<string, string>();
@@ -51,12 +51,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var query = HttpUtility.ParseQueryString(req.Url.Query);
var skip = 0;
var take = DefaultTake;
if (int.TryParse(query["skip"], out var parsedSkip) && parsedSkip >= 0)
skip = parsedSkip;
if (int.TryParse(query["take"], out var parsedTake) && parsedTake >= 1)
take = Math.Min(parsedTake, MaxTake);
var skip = QueryParams.Int(query["skip"], @default: 0, min: 0, max: int.MaxValue);
var take = QueryParams.Int(query["take"], DefaultTake, 1, MaxTake);
// Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri).
// Server-side filtering on CpeUri is NOT possible: rows re-created by the cascade-delete
@@ -66,8 +66,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
try
{
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
var days = int.TryParse(query["days"], out var d) ? Math.Clamp(d, 1, 365) : 30;
var topN = int.TryParse(query["topN"], out var t) ? Math.Clamp(t, 1, 100) : 20;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var topN = QueryParams.Int(query["topN"], @default: 20, min: 1, max: 100);
_logger.LogInformation(
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN})",
@@ -0,0 +1,140 @@
using System.Collections.Specialized;
using System.Globalization;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// The one parser for HTTP query parameters. Every function reads its numbers and instants
/// through here (<c>QueryParamGuardTests</c> fails on a stray <c>int.TryParse</c> /
/// <c>DateTime.TryParse</c> under <c>Functions/</c> or <c>Pagination/</c>); the default and
/// the range stay arguments at the call site because they are per-endpoint wire behaviour
/// (a 7-day stats window and a 90-day usage window are both right).
/// <para>
/// Two contracts, deliberately not one:
/// <list type="bullet">
/// <item><b>Lenient</b> (<see cref="Int(string?, int, int, int)"/>, <see cref="UtcInstant"/>,
/// <see cref="IntOrNull"/>): a missing, unparseable or below-minimum value means "use the
/// default", a value above the maximum is clamped. For windows and top-N knobs where a
/// dashboard link with a stale or odd value should still render.</item>
/// <item><b>Strict</b> (<see cref="TryInt"/>, <see cref="TryPageSize"/>,
/// <see cref="TryUtcInstant"/>): a missing value is <c>null</c> (the caller applies its
/// default), anything else that does not parse or falls outside the range is an error
/// message for a 400. For pagination and admin batch sizes, where a silently altered
/// value would misreport what was done.</item>
/// </list>
/// Numbers are parsed invariant (<see cref="NumberStyles.Integer"/>); instants are parsed
/// invariant with <c>AssumeUniversal | AdjustToUniversal</c>, so <c>2026-04-20</c>,
/// <c>2026-04-20T10:00:00</c> and <c>2026-04-20T12:00:00+02:00</c> all yield the same UTC
/// value with <see cref="DateTimeKind.Utc"/>. Whitespace-only counts as absent.
/// </para>
/// </summary>
internal static class QueryParams
{
/// <summary>Upper bound of every <c>pageSize</c> parameter (continuation-token pagination).</summary>
public const int MaxPageSize = 1000;
private const DateTimeStyles UtcStyles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal;
// ── Lenient ─────────────────────────────────────────────────────────────────────────
/// <summary>Lenient integer: absent / unparseable / below <paramref name="min"/> ⇒ <paramref name="default"/>; above <paramref name="max"/> ⇒ <paramref name="max"/>.</summary>
public static int Int(string? raw, int @default, int min, int max)
{
if (!TryParseInvariant(raw, out var value) || value < min) return @default;
return value > max ? max : value;
}
/// <inheritdoc cref="Int(string?, int, int, int)"/>
public static int Int(NameValueCollection? query, string name, int @default, int min, int max)
=> Int(query?[name], @default, min, max);
/// <summary>Lenient optional integer without a range: absent or unparseable ⇒ <c>null</c>.</summary>
public static int? IntOrNull(string? raw)
=> TryParseInvariant(raw, out var value) ? value : (int?)null;
/// <summary>Lenient optional instant: absent or unparseable ⇒ <c>null</c>, otherwise UTC.</summary>
public static DateTime? UtcInstant(string? raw)
=> TryParseUtc(raw, out var value) ? value : (DateTime?)null;
// ── Strict ──────────────────────────────────────────────────────────────────────────
/// <summary>
/// Strict integer: absent ⇒ <c>null</c> + true; unparseable or outside
/// [<paramref name="min"/>, <paramref name="max"/>] ⇒ false with an error message that names
/// the parameter. Pass <c>int.MaxValue</c> as <paramref name="max"/> for "at least min".
/// </summary>
public static bool TryInt(string? raw, string name, int min, int max, out int? value, out string? error)
{
value = null;
error = null;
if (string.IsNullOrWhiteSpace(raw)) return true;
if (!TryParseInvariant(raw, out var parsed))
{
error = $"{name} must be an integer";
return false;
}
if (parsed < min || parsed > max)
{
error = max == int.MaxValue
? $"{name} must be an integer of at least {min}"
: $"{name} must be between {min} and {max}";
return false;
}
value = parsed;
return true;
}
/// <summary>Strict <c>pageSize</c>: absent ⇒ <c>null</c> (the paginator applies its default), otherwise 1..<see cref="MaxPageSize"/>.</summary>
public static bool TryPageSize(string? raw, out int? pageSize, out string? error)
=> TryInt(raw, "pageSize", 1, MaxPageSize, out pageSize, out error);
/// <summary>Strict instant: absent ⇒ <c>null</c> + true; unparseable ⇒ false with an error message; otherwise UTC.</summary>
public static bool TryUtcInstant(string? raw, string name, out DateTime? value, out string? error)
{
value = null;
error = null;
if (string.IsNullOrWhiteSpace(raw)) return true;
if (!TryParseUtc(raw, out var parsed))
{
error = $"{name} must be an ISO 8601 date or date-time";
return false;
}
value = parsed;
return true;
}
// ── Core ────────────────────────────────────────────────────────────────────────────
private static bool TryParseInvariant(string? raw, out int value)
{
if (string.IsNullOrWhiteSpace(raw))
{
value = 0;
return false;
}
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
}
private static bool TryParseUtc(string? raw, out DateTime value)
{
if (string.IsNullOrWhiteSpace(raw))
{
value = default;
return false;
}
if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture, UtcStyles, out var parsed))
{
value = default;
return false;
}
// AdjustToUniversal yields Kind=Utc; SpecifyKind is belt and braces for the one
// shape (a bare date) where some runtimes leave it Unspecified.
value = parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
return true;
}
}
@@ -5,6 +5,7 @@ using System.Globalization;
using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -35,7 +36,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class DateWindowPagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
public static readonly TimeSpan DefaultWindow = TimeSpan.FromDays(30);
/// <summary>
@@ -67,19 +67,10 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
DateTime? dateFrom = null, dateTo = null;
if (!string.IsNullOrEmpty(dateFromRaw))
{
if (!TryParseUtc(dateFromRaw!, out var parsed))
return new Parsed { Error = "dateFrom must be ISO 8601" };
dateFrom = parsed;
}
if (!string.IsNullOrEmpty(dateToRaw))
{
if (!TryParseUtc(dateToRaw!, out var parsed))
return new Parsed { Error = "dateTo must be ISO 8601" };
dateTo = parsed;
}
if (!QueryParams.TryUtcInstant(dateFromRaw, "dateFrom", out var dateFrom, out var dateError))
return new Parsed { Error = dateError };
if (!QueryParams.TryUtcInstant(dateToRaw, "dateTo", out var dateTo, out dateError))
return new Parsed { Error = dateError };
if (dateFrom.HasValue && dateTo.HasValue && dateFrom > dateTo)
{
return new Parsed { Error = "dateFrom must be <= dateTo" };
@@ -97,15 +88,8 @@ namespace AutopilotMonitor.Functions.Pagination
dateTo = anchor;
}
int? pageSize = null;
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError))
return new Parsed { Error = pageSizeError };
// continuation is meaningless without pageSize — silently drop.
var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw)
@@ -196,23 +180,5 @@ namespace AutopilotMonitor.Functions.Pagination
}
return sb.ToString();
}
private static bool TryParseUtc(string s, out DateTime result)
{
// Accept ISO 8601 in any of the standard forms; force UTC.
// AdjustToUniversal + AssumeUniversal handles strings without a TZ
// offset (treat as UTC) and converts strings with an offset to UTC;
// RoundtripKind is mutually exclusive with these and not needed.
if (DateTime.TryParse(
s, CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out var parsed))
{
result = parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
return true;
}
result = default;
return false;
}
}
}
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -32,7 +33,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class QueryRawEventsPagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
/// <summary>
/// Slack between an event's sanitized time and the write time of its EventTypeIndex
@@ -48,22 +48,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static DateTime? IndexWrittenAfterHint(DateTime? startedAfterUtc)
=> startedAfterUtc.HasValue ? startedAfterUtc.Value - IndexWriteTimeSlack : null;
/// <summary>
/// Parses an ISO-8601 query value as a UTC instant. Returns false only for a non-empty
/// value that does not parse — an absent/empty value yields <c>null</c> and true.
/// A bare (offset-less) value is taken as UTC, never as server-local time.
/// </summary>
public static bool TryParseUtc(string? raw, out DateTime? utc)
{
utc = null;
if (string.IsNullOrWhiteSpace(raw)) return true;
if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
return false;
utc = parsed;
return true;
}
public static string Fingerprint(
string scope,
string callerTenantId,
@@ -104,15 +88,9 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
int pageSize = DefaultPageSize;
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
var pageSize = pageSizeOrNull ?? DefaultPageSize;
return new Parsed
{
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class RawTablePagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
public static string Fingerprint(
string callerTenantId,
@@ -49,15 +49,9 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
int pageSize = DefaultPageSize;
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
var pageSize = pageSizeOrNull ?? DefaultPageSize;
return new Parsed
{
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SearchSessionsByCvePagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
public static string Fingerprint(
string scope,
@@ -50,15 +50,9 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
int pageSize = DefaultPageSize;
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
var pageSize = pageSizeOrNull ?? DefaultPageSize;
return new Parsed
{
@@ -4,6 +4,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SearchSessionsByEventPagination
{
public const int DefaultPageSize = 50;
public const int MaxPageSize = 1000;
public static string Fingerprint(
string scope,
@@ -46,15 +46,9 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
int pageSize = DefaultPageSize;
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
var pageSize = pageSizeOrNull ?? DefaultPageSize;
return new Parsed
{
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -20,7 +21,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SearchSessionsPagination
{
public const int DefaultPageSize = 50;
public const int MaxPageSize = 1000;
public static string Fingerprint(string scope, string callerTenantId, string? filterTenantId, SessionSearchFilter filter)
{
@@ -79,15 +79,9 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
int pageSize = DefaultPageSize;
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
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,
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -19,7 +20,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SessionAnnotationsPagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
/// <summary>
/// Cap on the free-text note search (<c>?q=</c>). Notes themselves are capped at 4096
/// characters; a search term longer than this is never a real query.
@@ -62,16 +62,10 @@ namespace AutopilotMonitor.Functions.Pagination
public static Parsed ParseQuery(NameValueCollection? query)
{
var pageSize = DefaultPageSize;
var pageSizeRaw = query?["pageSize"];
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
return new Parsed { Error = pageSizeError };
var pageSize = pageSizeOrNull ?? DefaultPageSize;
var lane = Normalize(query?["lane"]);
if (lane != null && !AnnotationLanes.All.Contains(lane))
@@ -81,10 +75,10 @@ namespace AutopilotMonitor.Functions.Pagination
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
return new Parsed { Error = $"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}" };
if (!TryParseDate(query?["dateFrom"], out var dateFrom))
return new Parsed { Error = "dateFrom must be an ISO 8601 date" };
if (!TryParseDate(query?["dateTo"], out var dateTo))
return new Parsed { Error = "dateTo must be an ISO 8601 date" };
if (!QueryParams.TryUtcInstant(query?["dateFrom"], "dateFrom", out var dateFrom, out var dateError))
return new Parsed { Error = dateError };
if (!QueryParams.TryUtcInstant(query?["dateTo"], "dateTo", out var dateTo, out dateError))
return new Parsed { Error = dateError };
var search = NullIfEmpty(query?["q"]?.Trim());
if (search != null && search.Length > MaxQueryLength)
@@ -149,18 +143,6 @@ namespace AutopilotMonitor.Functions.Pagination
sb.Append('&').Append(name).Append('=').Append(Uri.EscapeDataString(value!));
}
private static bool TryParseDate(string? raw, out DateTime? value)
{
value = null;
if (string.IsNullOrEmpty(raw)) return true;
if (!DateTime.TryParse(
raw, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
return false;
value = parsed;
return true;
}
private static string? Normalize(string? raw) =>
string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -14,7 +15,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SessionEventsPagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
/// <summary>Fingerprint binding tokens to <c>(tenantId, sessionId)</c>.</summary>
public static string Fingerprint(string tenantId, string sessionId) =>
@@ -51,19 +51,8 @@ namespace AutopilotMonitor.Functions.Pagination
var rawPageSize = query["pageSize"];
var rawContinuation = query["continuation"];
int? pageSize = null;
if (!string.IsNullOrEmpty(rawPageSize))
{
if (!int.TryParse(rawPageSize, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
{
return new Parsed { Error = "pageSize must be an integer" };
}
if (n < 1 || n > MaxPageSize)
{
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
}
pageSize = n;
}
if (!QueryParams.TryPageSize(rawPageSize, out var pageSize, out var pageSizeError))
return new Parsed { Error = pageSizeError };
// continuation without pageSize is meaningless — silently drop it
// rather than 400, so a caller that toggles pageSize off does not get
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -16,7 +17,8 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SessionListPagination
{
public const int DefaultPageSize = 100;
public const int MaxPageSize = 1000;
/// <summary>Scan-cost cap on <c>days</c>; a wider window is a 400, never a silent narrowing.</summary>
public const int MaxDays = 365;
/// <summary>
/// Fingerprint binding the token to <c>(scope, callerTenantId, days, filterTenantId)</c>.
@@ -47,23 +49,12 @@ namespace AutopilotMonitor.Functions.Pagination
var daysRaw = query?["days"];
var filterTenantIdRaw = acceptFilterTenantId ? query?["tenantId"] : null;
int pageSize = DefaultPageSize;
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError))
return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError };
var pageSize = pageSizeOrNull ?? DefaultPageSize;
int? days = null;
if (!string.IsNullOrEmpty(daysRaw))
{
if (!int.TryParse(daysRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d) || d < 1)
return new Parsed { PageSize = pageSize, Error = "days must be a positive integer" };
days = d;
}
if (!QueryParams.TryInt(daysRaw, "days", 1, MaxDays, out var days, out var daysError))
return new Parsed { PageSize = pageSize, Error = daysError };
return new Parsed
{
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -18,7 +19,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class SessionReportsPagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
public static string Fingerprint(string callerTenantId, string? filterTenantId) =>
ContinuationToken.ComputeFingerprint(new[]
@@ -42,15 +42,8 @@ namespace AutopilotMonitor.Functions.Pagination
var pageSizeRaw = query?["pageSize"];
var continuationRaw = query?["continuation"];
int? pageSize = null;
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError))
return new Parsed { Error = pageSizeError };
// continuation is meaningless without pageSize — silently drop.
var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw)
@@ -3,6 +3,7 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Text;
using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Pagination
{
@@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination
public static class TenantConfigPagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
public static string Fingerprint(string callerTenantId) =>
ContinuationToken.ComputeFingerprint(new[]
@@ -46,15 +46,8 @@ namespace AutopilotMonitor.Functions.Pagination
var continuationRaw = query?["continuation"];
var fieldsRaw = query?["fields"];
int? pageSize = null;
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError))
return new Parsed { Error = pageSizeError };
// continuation is meaningless without pageSize — silently drop.
var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw)
@@ -55,9 +55,12 @@ namespace AutopilotMonitor.Functions.Services
_cache.TryRemove(key, out _);
}
/// <summary>Widest window the SLA aggregates serve; the HTTP layer caps <c>months</c> at this too.</summary>
public const int MaxMonths = 6;
public async Task<SlaMetricsResponse> ComputeSlaMetricsAsync(string tenantId, int months = 3, bool fresh = false)
{
months = Math.Clamp(months, 1, 6);
months = Math.Clamp(months, 1, MaxMonths);
var cacheKey = $"{tenantId}:{months}";