feat(backend,mcp): typed API contract COMPLETED — every remaining top-level success body is a manifest-exported wire DTO, and the guard now catches the bypass shapes that let them escape. The 08-31 migration's ratchet only saw inline WriteAsJsonAsync(new {...}); four bypass classes had kept ~20 endpoint families untyped: (A) Shared classes without IApiResponse — GeographicMetricsResponse, FleetHealthMetrics, ImePatternHealthResponse, SlaMetricsResponse, PlatformUsageMetrics now implement it (wire byte-identical, interface is empty); (B) Functions-local named response families moved verbatim to Shared — PlatformAgentMetricsResponse/SessionAgentMetric/DeliveryLatencyMetrics/CrashRateMetrics/CrashExceptionSummary, AgentEfficiencyMetricsResponse family (CloneAsCacheHit relocated into the service; init→set for netstandard2.0), OffboardResponse; (C) anonymous/object builders converted to DTOs with old-vs-new wire-parity facts (TypedContractCompletionWireParityTests, 14 facts incl. every vanishing-key case): rule-stats x2 (RuleStatsResponse; uniqueRules is global-only via an optional slot at its exact wire position), verdict-calibration (builder renamed VerdictCalibrationResponseBuilder, PathRow/TrendWindow to Shared), metrics/app (MetricsMath), apps list/analytics/sessions x6 routes (AppsAnalyticsHelper; legacy + paged list share ONE DTO via optional paging keys), ime-versions lean projection (ImeVersionHistoryLeanEntry [WireContract]; bare-array envelope stays, typed as IReadOnlyList<object>), device-journeys + time-attribution fleet envelopes, feature-flags, diagnostics/paths, session restore, cascade-delete Enqueued arm (error arms stay anonymous by design), feedback eligibility/list; (D) Serialize+WriteString path — ListBackupsResponse under the backup surface's own serializer options; AppInsightsQuery's Serialize is an outgoing REQUEST body (not response debt, baselined as such). TypedResponseGuardTests gains three ratchets with mutation-proven red/green: WriteAsJsonAsync(<variable assigned an anonymous object>) (empty baseline), JsonSerializer.Serialize(new{ (error-tolerant; 1 documented request-body entry), and object/Task<object>-returning Build*/Compute*/*Payload/*Response methods (1 documented union entry); the local-WriteJson-wrapper class is closed structurally (generic T : IApiResponse — an interface-typed parameter would serialize zero properties, the generic keeps the concrete type). Manifest regenerated 208→298 exported types; MCP adopts the new types (get_rule_stats typed with the trend-drop as explicit output shaping, get_platform_metrics drops its hand-declared mirror for PlatformAgentMetricsResponse, verdict-calibration-shape derives its rows via extends Omit<generated wire row> so backend renames go tsc-red). Six backend shape-test files switched from default-options serialization to the production wire options via TestWire (ApiJsonOptions; the old default-options runs only matched while payloads were anonymous with lowercase members), and three assertions that pinned null-VALUED keys now pin key ABSENCE — production always omitted them (WhenWritingNull); the parity facts prove the wire itself is unchanged. Deliberately still untyped: auth/me, CheckMcpAccess dictionary, TenantAdmins/GlobalAdmins entity-row arrays and the object-slot payloads (RuleDryRun, HealthCheck, TenantConfigFieldSchema, ...) — the documented read-model follow-up. Suites: Backend 5323, DecisionCore 577, Agent 2805, SummaryDialog 44, MCP 617, Web tsc 0 + vitest 957, generator idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-31 04:32:51 +02:00
parent c04280cf68
commit 51737174b2
53 changed files with 10263 additions and 700 deletions
@@ -265,7 +265,7 @@ public class AppInstallIdentityAndEspBlockingTests
Summary("Company Portal", status: "Failed", collision: true), Summary("Company Portal", status: "Failed", collision: true),
}; };
var root = JsonSerializer.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries)); var root = TestWire.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
Assert.Equal(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group Assert.Equal(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group
Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged
@@ -282,7 +282,7 @@ public class AppInstallIdentityAndEspBlockingTests
Summary("Company Portal", collision: true), Summary("Company Portal", collision: true),
}; };
var root = JsonSerializer.SerializeToElement( var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var apps = root.GetProperty("apps").EnumerateArray().ToList(); var apps = root.GetProperty("apps").EnumerateArray().ToList();
@@ -305,7 +305,7 @@ public class AppInstallIdentityAndEspBlockingTests
Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure
}; };
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, repo.Object, "Company Portal", days: 30)); summaries, repo.Object, "Company Portal", days: 30));
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32()); Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
@@ -324,7 +324,7 @@ public class AppInstallIdentityAndEspBlockingTests
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) }; var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, repo.Object, "Company Portal", days: 30)); summaries, repo.Object, "Company Portal", days: 30));
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32()); Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
@@ -363,7 +363,7 @@ public class AppVersionRegressionRadarTests
Install("1.0", 50, status: "Failed"), // failed — excluded from durations Install("1.0", 50, status: "Failed"), // failed — excluded from durations
}; };
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
rows, sessionRepo.Object, App, days: 30)); rows, sessionRepo.Object, App, days: 30));
var version = root.GetProperty("versionBreakdown")[0]; var version = root.GetProperty("versionBreakdown")[0];
@@ -383,19 +383,18 @@ public class AppVersionRegressionRadarTests
.ReturnsAsync((SessionSummary?)null); .ReturnsAsync((SessionSummary?)null);
var alert = Alert(); var alert = Alert();
// The Functions host serializes camelCase on the wire; mirror that here so the // TestWire serializes with the REAL wire options (ApiJsonOptions — camelCase), so the
// asserted property names match what the web client actually parses. // asserted property names match what the web client actually parses.
var wireOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
// Empty-data early return still surfaces the block (episodes can outlive window data). // Empty-data early return still surfaces the block (episodes can outlive window data).
var emptyRoot = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30, new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
new List<AppVersionRegressionAlert> { alert }), wireOptions); new List<AppVersionRegressionAlert> { alert }));
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength()); Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary> { Install("1.0", 100) }, sessionRepo.Object, App, days: 30, new List<AppInstallSummary> { Install("1.0", 100) }, sessionRepo.Object, App, days: 30,
new List<AppVersionRegressionAlert> { alert }), wireOptions); new List<AppVersionRegressionAlert> { alert }));
var regression = root.GetProperty("versionRegressions")[0]; var regression = root.GetProperty("versionRegressions")[0];
Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString()); Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString());
@@ -53,7 +53,7 @@ public class AppsAnalyticsFailureRateTests
A("InProgress"), A("InProgress"),
}; };
var root = JsonSerializer.SerializeToElement( var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0]; var app = root.GetProperty("apps")[0];
@@ -67,7 +67,7 @@ public class AppsAnalyticsFailureRateTests
{ {
var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") }; var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") };
var root = JsonSerializer.SerializeToElement( var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble()); Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble());
@@ -90,7 +90,7 @@ public class AppsAnalyticsFailureRateTests
for (var i = 0; i < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf)); for (var i = 0; i < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf));
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: secondHalf)); for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: secondHalf));
var root = JsonSerializer.SerializeToElement( var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0]; var app = root.GetProperty("apps")[0];
@@ -109,12 +109,13 @@ public class AppsAnalyticsFailureRateTests
for (var i = 0; i < 4; i++) summaries.Add(A("Failed", startedAt: now.AddDays(-5))); for (var i = 0; i < 4; i++) summaries.Add(A("Failed", startedAt: now.AddDays(-5)));
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: now.AddDays(-5))); for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: now.AddDays(-5)));
var root = JsonSerializer.SerializeToElement( var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0]; var app = root.GetProperty("apps")[0];
Assert.Equal("stable", app.GetProperty("trend").GetString()); Assert.Equal("stable", app.GetProperty("trend").GetString());
Assert.Equal(JsonValueKind.Null, app.GetProperty("trendDelta").ValueKind); // Wire options omit null keys (WhenWritingNull): a withheld delta has no key at all.
Assert.False(app.TryGetProperty("trendDelta", out _));
} }
// ── /apps/{appName}/analytics ─────────────────────────────────────────── // ── /apps/{appName}/analytics ───────────────────────────────────────────
@@ -131,7 +132,7 @@ public class AppsAnalyticsFailureRateTests
} }
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, sessionRepo, "Contoso App", days: 30); summaries, sessionRepo, "Contoso App", days: 30);
return JsonSerializer.SerializeToElement(result); return TestWire.SerializeToElement(result);
} }
[Fact] [Fact]
@@ -176,7 +177,7 @@ public class AppsAnalyticsFailureRateTests
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured }; var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
var root = JsonSerializer.SerializeToElement( var root = TestWire.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0]; var app = root.GetProperty("apps")[0];
@@ -24,7 +24,7 @@ public class AppsAnalyticsHelperPagingTests
} }
private static JsonElement Build(int? pageSize, int skip = 0) => private static JsonElement Build(int? pageSize, int skip = 0) =>
JsonSerializer.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse( TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
FiveApps(), days: 30, pageSize: pageSize, skip: skip, FiveApps(), days: 30, pageSize: pageSize, skip: skip,
nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}")); nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}"));
@@ -55,12 +55,13 @@ public class AppsAnalyticsHelperPagingTests
} }
[Fact] [Fact]
public void PageSize_LastPage_HasNullNextLink() public void PageSize_LastPage_OmitsNextLink()
{ {
var root = Build(pageSize: 2, skip: 4); var root = Build(pageSize: 2, skip: 4);
Assert.Equal(1, root.GetProperty("count").GetInt32()); Assert.Equal(1, root.GetProperty("count").GetInt32());
Assert.Equal("Echo", root.GetProperty("apps")[0].GetProperty("appName").GetString()); Assert.Equal("Echo", root.GetProperty("apps")[0].GetProperty("appName").GetString());
Assert.Equal(JsonValueKind.Null, root.GetProperty("nextLink").ValueKind); // Wire options omit null keys (WhenWritingNull): no further page → no nextLink key.
Assert.False(root.TryGetProperty("nextLink", out _));
} }
} }
@@ -159,16 +159,18 @@ public class DeleteSessionFunctionTests
// ── Anonymous-object reflection helpers ─────────────────────────────────────────────── // ── Anonymous-object reflection helpers ───────────────────────────────────────────────
// IgnoreCase: the Enqueued arm is a typed DTO (PascalCase properties, camelCased on the
// wire by ApiJsonOptions); the error arms stay anonymous (camelCase). Both resolve here.
private static void AssertProperty(object body, string propertyName, object? expected) private static void AssertProperty(object body, string propertyName, object? expected)
{ {
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
Assert.NotNull(prop); Assert.NotNull(prop);
Assert.Equal(expected, prop!.GetValue(body)); Assert.Equal(expected, prop!.GetValue(body));
} }
private static T GetProperty<T>(object body, string propertyName) private static T GetProperty<T>(object body, string propertyName)
{ {
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
Assert.NotNull(prop); Assert.NotNull(prop);
var value = prop!.GetValue(body); var value = prop!.GetValue(body);
return (T)value!; return (T)value!;
@@ -702,7 +702,7 @@ public class DeviceJourneyAndFtrTests
}, },
}; };
var totals = DeviceJourneyMetricsResponse.SumAggregates(daily); var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(daily);
Assert.Equal(3, totals.CompletedJourneys); Assert.Equal(3, totals.CompletedJourneys);
Assert.Equal(2, totals.FirstTimeRight); Assert.Equal(2, totals.FirstTimeRight);
@@ -716,7 +716,7 @@ public class DeviceJourneyAndFtrTests
[Fact] [Fact]
public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero() public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero()
{ {
var totals = DeviceJourneyMetricsResponse.SumAggregates(new List<DeviceJourneyDailyAggregate> var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(new List<DeviceJourneyDailyAggregate>
{ {
new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 }, new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 },
}); });
@@ -734,7 +734,7 @@ public class DeviceJourneyAndFtrTests
[InlineData("999", 180)] // clamped to aggregate retention [InlineData("999", 180)] // clamped to aggregate retention
public void ClampDays_DefaultsAndClamps(string? raw, int expected) public void ClampDays_DefaultsAndClamps(string? raw, int expected)
{ {
Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw)); Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
} }
[Fact] [Fact]
@@ -743,8 +743,8 @@ public class DeviceJourneyAndFtrTests
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys — // Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
// days=1 summed yesterday AND today. // days=1 summed yesterday AND today.
var today = new DateTime(2026, 7, 27); var today = new DateTime(2026, 7, 27);
Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1)); Assert.Equal(today, DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 1));
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30)); Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 30));
} }
private static DeviceHistory RepeaterHistory( private static DeviceHistory RepeaterHistory(
@@ -780,7 +780,7 @@ public class DeviceJourneyAndFtrTests
Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))), Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))),
}; };
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30)); var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
Assert.Equal(2, selected.Count); Assert.Equal(2, selected.Count);
Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc
@@ -799,7 +799,7 @@ public class DeviceJourneyAndFtrTests
Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i)))); Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i))));
} }
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30)); var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
Assert.Equal(10, selected.Count); Assert.Equal(10, selected.Count);
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
@@ -19,7 +19,7 @@ public class GetTenantFeatureFlagsPayloadTests
private static JsonElement Serialize(TenantConfiguration config) private static JsonElement Serialize(TenantConfiguration config)
{ {
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now); var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
var json = JsonSerializer.Serialize(payload); var json = TestWire.Serialize(payload);
return JsonDocument.Parse(json).RootElement; return JsonDocument.Parse(json).RootElement;
} }
@@ -49,7 +49,9 @@ public class GetTenantFeatureFlagsPayloadTests
"isTrial", "isTrial",
"showScriptOutput", "showScriptOutput",
"trialAvailable", "trialAvailable",
"trialExpiresUtc", // trialExpiresUtc is absent here: the default tenant is not on a trial and the
// wire options omit null keys (WhenWritingNull) — exactly what production always
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
"unrestrictedMode", "unrestrictedMode",
"validateAutopilotDevice", "validateAutopilotDevice",
}, fieldNames); }, fieldNames);
@@ -162,7 +164,7 @@ public class GetTenantFeatureFlagsPayloadTests
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook", WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
}; };
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now)); var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
Assert.DoesNotContain("secret-sas-token", json); Assert.DoesNotContain("secret-sas-token", json);
Assert.DoesNotContain("secret-team-hook", json); Assert.DoesNotContain("secret-team-hook", json);
@@ -270,7 +272,7 @@ public class GetTenantFeatureFlagsPayloadTests
[Fact] [Fact]
public void Payload_ContactEmailSet_NeverLeaksTheAddress() public void Payload_ContactEmailSet_NeverLeaksTheAddress()
{ {
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload( var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now)); new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
Assert.DoesNotContain("secret-contact", json); Assert.DoesNotContain("secret-contact", json);
@@ -18,7 +18,7 @@ public class MetricsMathAppPayloadTests
private static JsonElement Build(IEnumerable<AppInstallSummary> summaries) private static JsonElement Build(IEnumerable<AppInstallSummary> summaries)
{ {
var payload = MetricsMath.BuildAppMetricsPayload(summaries); var payload = MetricsMath.BuildAppMetricsPayload(summaries);
return JsonSerializer.SerializeToElement(payload); return TestWire.SerializeToElement(payload);
} }
[Fact] [Fact]
@@ -0,0 +1,21 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Serializes test payloads with the PRODUCTION wire options (<see cref="ApiJsonOptions"/> —
/// camelCase, WhenWritingNull), the policy every test harness with its own serializer must
/// follow. Shape tests serialized DTOs with default options before the 2026-08-31 completion
/// pass; that only worked while the payloads were anonymous objects with lowercase members.
/// Generic so the CONCRETE type reaches System.Text.Json (a declared object parameter would
/// too, but the generic also keeps compile-time typing at the call site).
/// </summary>
internal static class TestWire
{
private static readonly JsonSerializerOptions Options = ApiJsonOptions.Create();
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
public static JsonElement SerializeToElement<T>(T value) => JsonSerializer.SerializeToElement(value, Options);
}
@@ -465,6 +465,6 @@ public class TimeAttributionPersistenceAndAggregationTests
// returned 31 day keys under a "windowDays: 30" label. // returned 31 day keys under a "windowDays: 30" label.
var today = new System.DateTime(2026, 7, 27); var today = new System.DateTime(2026, 7, 27);
Assert.Equal(today.AddDays(-29), Assert.Equal(today.AddDays(-29),
Functions.Metrics.TimeAttributionResponse.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponse.WindowDays)); Functions.Metrics.TimeAttributionResponseBuilder.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponseBuilder.WindowDays));
} }
} }
@@ -0,0 +1,879 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Backup;
using AutopilotMonitor.Shared.Models.Metrics;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire-identity proofs for the 2026-08-31 typed-contract COMPLETION pass: the endpoints the
/// original migration missed because their anonymous literal was smuggled past the ratchet
/// (variable indirection, object-returning builders, Serialize+WriteString, local WriteJson
/// wrappers). Same contract as <see cref="ApiResponseWireParityTests"/>: OLD anonymous
/// literal (copied from the pre-conversion diff) vs NEW DTO, ordinal JSON equality, plus a
/// null-slot case wherever a key must vanish. Sites converted by MOVING an already-named
/// class (PlatformAgentMetricsResponse family, AgentEfficiency family, VerdictCalibration
/// path rows, DeviceJourneyWindowTotals, OffboardResponse) are wire-identical by
/// construction (System.Text.Json ignores namespaces) and carry no fact.
/// </summary>
public class TypedContractCompletionWireParityTests
{
private static void AssertWireIdentical(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
// ── rule-stats (tenant + global) ─────────────────────────────────────────────────────
[Fact]
public void RuleStatsResponse_matches_the_tenant_shape_without_uniqueRules()
{
var regressions = new List<RuleRegressionAlert>();
AssertWireIdentical(
new
{
rules = new[]
{
new
{
ruleId = "ANALYZE-NET-001",
ruleType = "analyze",
ruleTitle = "Network issue",
category = "network",
severity = "warning",
fireCount = 3,
evaluationCount = 10,
sessionsEvaluated = 9,
hitRate = 30.0,
avgConfidenceScore = 71.5,
trend = new[] { new { date = "2026-08-30", fireCount = 3, evaluationCount = 10 } },
},
},
regressions,
summary = new
{
totalEvaluations = 10,
totalFires = 3,
overallHitRate = 30.0,
topRuleByFireCount = (string?)"ANALYZE-NET-001",
period = new { start = "2026-08-01", end = "2026-08-31" },
},
},
new RuleStatsResponse
{
Rules = new[]
{
new RuleStatsRuleAggregate
{
RuleId = "ANALYZE-NET-001",
RuleType = "analyze",
RuleTitle = "Network issue",
Category = "network",
Severity = "warning",
FireCount = 3,
EvaluationCount = 10,
SessionsEvaluated = 9,
HitRate = 30.0,
AvgConfidenceScore = 71.5,
Trend = new[] { new RuleTrendPoint { Date = "2026-08-30", FireCount = 3, EvaluationCount = 10 } },
},
},
Regressions = regressions,
// Tenant route: UniqueRules stays null → the key is absent, matching the
// historical tenant shape which never carried it.
Summary = new RuleStatsSummary
{
TotalEvaluations = 10,
TotalFires = 3,
OverallHitRate = 30.0,
TopRuleByFireCount = "ANALYZE-NET-001",
UniqueRules = null,
Period = new RuleStatsPeriod { Start = "2026-08-01", End = "2026-08-31" },
},
});
}
[Fact]
public void RuleStatsResponse_matches_the_global_shape_with_uniqueRules_and_null_topRule()
{
var regressions = new List<RuleRegressionAlert>();
AssertWireIdentical(
new
{
rules = Array.Empty<object>(),
regressions,
summary = new
{
totalEvaluations = 0,
totalFires = 0,
overallHitRate = 0.0,
topRuleByFireCount = (string?)null, // empty window → key vanishes
uniqueRules = 0,
period = new { start = "2026-08-01", end = "2026-08-31" },
},
},
new RuleStatsResponse
{
Rules = Array.Empty<RuleStatsRuleAggregate>(),
Regressions = regressions,
Summary = new RuleStatsSummary
{
TotalEvaluations = 0,
TotalFires = 0,
OverallHitRate = 0.0,
TopRuleByFireCount = null,
UniqueRules = 0,
Period = new RuleStatsPeriod { Start = "2026-08-01", End = "2026-08-31" },
},
});
}
// ── verdict-calibration ──────────────────────────────────────────────────────────────
[Fact]
public void VerdictCalibrationResponse_matches_the_envelope_incl_computedAt_omission()
{
// Path rows and alerts were ALREADY named classes on the old wire (moved verbatim) —
// the envelope keys are what the conversion changed, so the fact pins those with the
// row lists empty; the no-data window additionally proves computedAt vanishes.
var paths = new List<VerdictCalibrationPathRow>();
var alerts = new List<VerdictCalibrationAlert>();
AssertWireIdentical(
new
{
success = true,
tenantId = "global",
windowDays = 30,
windowStart = "2026-08-02",
windowEnd = "2026-08-31",
computedAt = (DateTime?)null,
versions = new[] { 1, 2 },
totals = new { sessions = 0, terminal = 0, derived = 0, days = 0 },
trend = new { windowDays = 7, baselineDays = 28, windowSessions = 0, baselineSessions = 0 },
paths,
alerts,
},
new VerdictCalibrationResponse
{
Success = true,
TenantId = "global",
WindowDays = 30,
WindowStart = "2026-08-02",
WindowEnd = "2026-08-31",
ComputedAt = null,
Versions = new[] { 1, 2 },
Totals = new VerdictCalibrationTotals { Sessions = 0, Terminal = 0, Derived = 0, Days = 0 },
Trend = new VerdictCalibrationTrendMeta { WindowDays = 7, BaselineDays = 28, WindowSessions = 0, BaselineSessions = 0 },
Paths = paths,
Alerts = alerts,
});
}
// ── metrics/app ──────────────────────────────────────────────────────────────────────
[Fact]
public void AppMetricsResponse_matches_the_anonymous_shape()
{
AssertWireIdentical(
new
{
success = true,
totalApps = 1,
totalInstalls = 4,
totalSkipped = 1,
totalUnmeasured = 1,
totalCollisionExcluded = 0,
slowestApps = new[]
{
new
{
appName = "Contoso App",
totalInstalls = 4,
succeeded = 2,
skipped = 1,
unmeasured = 1,
failed = 1,
failureRate = 33.3,
avgDurationSeconds = 120.0,
maxDurationSeconds = 240,
measuredInstalls = 1,
avgDownloadBytes = 1048576L,
doTotalBytesDownloaded = 2097152L,
doBytesFromPeers = 1048576L,
doBytesFromCacheServer = 0L,
doBytesFromHttp = 1048576L,
peerOffloadPercent = 50.0,
topFailureCodes = new[] { new { code = "0x80070005", count = 1 } },
},
},
topFailingApps = Array.Empty<object>(),
deliveryOptimization = new
{
totalBytesDownloaded = 2097152L,
fromPeers = 1048576L,
fromCacheServer = 0L,
fromHttp = 1048576L,
peerOffloadPercent = 50.0,
},
},
new AppMetricsResponse
{
Success = true,
TotalApps = 1,
TotalInstalls = 4,
TotalSkipped = 1,
TotalUnmeasured = 1,
TotalCollisionExcluded = 0,
SlowestApps = new[]
{
new AppMetricsAppGroup
{
AppName = "Contoso App",
TotalInstalls = 4,
Succeeded = 2,
Skipped = 1,
Unmeasured = 1,
Failed = 1,
FailureRate = 33.3,
AvgDurationSeconds = 120.0,
MaxDurationSeconds = 240,
MeasuredInstalls = 1,
AvgDownloadBytes = 1048576L,
DoTotalBytesDownloaded = 2097152L,
DoBytesFromPeers = 1048576L,
DoBytesFromCacheServer = 0L,
DoBytesFromHttp = 1048576L,
PeerOffloadPercent = 50.0,
TopFailureCodes = new[] { new AppFailureCodeCount { Code = "0x80070005", Count = 1 } },
},
},
TopFailingApps = Array.Empty<AppMetricsAppGroup>(),
DeliveryOptimization = new AppMetricsDeliveryOptimization
{
TotalBytesDownloaded = 2097152L,
FromPeers = 1048576L,
FromCacheServer = 0L,
FromHttp = 1048576L,
PeerOffloadPercent = 50.0,
},
});
}
// ── metrics/ime-versions (non-global projection) ─────────────────────────────────────
[Fact]
public void ImeVersionHistoryLeanEntry_matches_the_anonymous_projection()
{
var seen = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
var expected = JsonSerializer.Serialize(
new { Version = "1.104.102.0", FirstSeenAt = seen, LastSeenAt = seen.AddDays(3), SessionCount = 12 },
ApiJsonOptions.Create());
var actual = JsonSerializer.Serialize(
new ImeVersionHistoryLeanEntry
{
Version = "1.104.102.0",
FirstSeenAt = seen,
LastSeenAt = seen.AddDays(3),
SessionCount = 12,
},
ApiJsonOptions.Create());
Assert.Equal(expected, actual);
}
// ── metrics/time-attribution (fleet) ─────────────────────────────────────────────────
[Fact]
public void TimeAttributionMetricsResponse_matches_the_envelope()
{
// Row lists are TimeAttributionDailyAggregate on BOTH sides (identical class) —
// the envelope keys are the conversion.
var rows = new List<TimeAttributionDailyAggregate>();
AssertWireIdentical(
new { success = true, windowDays = 30, classes = rows, daily = rows },
new TimeAttributionMetricsResponse { Success = true, WindowDays = 30, Classes = rows, Daily = rows });
}
// ── metrics/device-journeys ──────────────────────────────────────────────────────────
[Fact]
public void DeviceJourneyMetricsResponse_matches_the_envelope_incl_repeatDevices_omission()
{
var totals = new DeviceJourneyWindowTotals { CompletedJourneys = 0, FirstTimeRight = 0, FtrRatePct = null, ExcludedSessions = 0 };
var daily = new List<DeviceJourneyDailyAggregate>();
// Global aggregate: repeatDevices=null → the key vanishes (the disclosed gap).
AssertWireIdentical(
new { success = true, windowDays = 30, totals, daily, repeatDevices = (List<object>?)null },
new DeviceJourneyMetricsResponse { Success = true, WindowDays = 30, Totals = totals, Daily = daily, RepeatDevices = null });
}
[Fact]
public void DeviceJourneyRepeatDevice_matches_the_anonymous_row()
{
var started = new DateTime(2026, 8, 29, 8, 0, 0, DateTimeKind.Utc);
AssertWireIdentical(
new
{
success = true,
windowDays = 30,
totals = new DeviceJourneyWindowTotals(),
daily = new List<DeviceJourneyDailyAggregate>(),
repeatDevices = new[]
{
new
{
serialNumber = "SER123",
manufacturer = "Contoso",
model = "Book 3",
attempts = 3,
journeyCount = 2,
lastStatus = "Failed",
lastSessionId = "22222222-2222-2222-2222-222222222222",
lastStartedAt = started,
lastFailureReason = "ESP timeout",
},
},
},
new DeviceJourneyMetricsResponse
{
Success = true,
WindowDays = 30,
Totals = new DeviceJourneyWindowTotals(),
Daily = new List<DeviceJourneyDailyAggregate>(),
RepeatDevices = new[]
{
new DeviceJourneyRepeatDevice
{
SerialNumber = "SER123",
Manufacturer = "Contoso",
Model = "Book 3",
Attempts = 3,
JourneyCount = 2,
LastStatus = "Failed",
LastSessionId = "22222222-2222-2222-2222-222222222222",
LastStartedAt = started,
LastFailureReason = "ESP timeout",
},
},
});
}
// ── config/{tenantId}/feature-flags ──────────────────────────────────────────────────
[Fact]
public void TenantFeatureFlagsResponse_matches_the_anonymous_shape_incl_null_omissions()
{
AssertWireIdentical(
new
{
bootstrapTokenEnabled = true,
diagnosticsUploadConfigured = false,
validateAutopilotDevice = true,
appHomingFunnelActive = false,
showScriptOutput = true,
enableSoftwareInventoryAnalyzer = false,
enableIntegrityBypassAnalyzer = true,
unrestrictedMode = false,
edition = "community",
isTrial = false,
trialExpiresUtc = (DateTime?)null, // not on trial → key vanishes
trialAvailable = true,
contactEmailSet = false,
entitlements = new
{
retentionCapDays = 90,
userRateLimitPerMinute = (int?)null, // platform default → key vanishes
delegatedAdminAllowed = false,
mcpUsagePlan = "community",
},
},
new TenantFeatureFlagsResponse
{
BootstrapTokenEnabled = true,
DiagnosticsUploadConfigured = false,
ValidateAutopilotDevice = true,
AppHomingFunnelActive = false,
ShowScriptOutput = true,
EnableSoftwareInventoryAnalyzer = false,
EnableIntegrityBypassAnalyzer = true,
UnrestrictedMode = false,
Edition = "community",
IsTrial = false,
TrialExpiresUtc = null,
TrialAvailable = true,
ContactEmailSet = false,
Entitlements = new TenantFeatureEntitlements
{
RetentionCapDays = 90,
UserRateLimitPerMinute = null,
DelegatedAdminAllowed = false,
McpUsagePlan = "community",
},
});
}
// ── diagnostics/paths ────────────────────────────────────────────────────────────────
[Fact]
public void DiagnosticsPathsResponse_matches_the_anonymous_shape()
{
var globalPaths = new List<DiagnosticsLogPath>();
AssertWireIdentical(
new
{
builtIn = new[]
{
new
{
id = "AgentLogs",
zipFolder = "AgentLogs",
sourceFolder = "%ProgramData%/AutopilotMonitor",
patterns = new[] { "*.log" },
includeSubfolders = false,
description = "Agent log files",
condition = "Always",
},
},
globalPaths,
},
new DiagnosticsPathsResponse
{
BuiltIn = new[]
{
new DiagnosticsBuiltInSectionWire
{
Id = "AgentLogs",
ZipFolder = "AgentLogs",
SourceFolder = "%ProgramData%/AutopilotMonitor",
Patterns = new[] { "*.log" },
IncludeSubfolders = false,
Description = "Agent log files",
Condition = "Always",
},
},
GlobalPaths = globalPaths,
});
}
// ── sessions restore / delete-queued ─────────────────────────────────────────────────
[Fact]
public void SessionRestoreResponse_matches_the_anonymous_shape_incl_null_omissions()
{
var rows = new Dictionary<string, int>(StringComparer.Ordinal) { ["Sessions"] = 1 };
var empty = new Dictionary<string, int>(StringComparer.Ordinal);
AssertWireIdentical(
new
{
success = true,
outcome = "Restored",
mode = (string?)"full",
message = (string?)null, // clean success → key vanishes
currentState = (string?)null,
pendingManifestId = (string?)null,
rowsRestoredByTable = rows,
rowsSkippedByTable = empty,
wouldRestoreByTable = empty,
inventoryReIncrements = 2,
durationMs = 1234L,
},
new SessionRestoreResponse
{
Success = true,
Outcome = "Restored",
Mode = "full",
Message = null,
CurrentState = null,
PendingManifestId = null,
RowsRestoredByTable = rows,
RowsSkippedByTable = empty,
WouldRestoreByTable = empty,
InventoryReIncrements = 2,
DurationMs = 1234L,
});
}
[Fact]
public void SessionDeletionQueuedResponse_matches_the_anonymous_Enqueued_arm()
{
AssertWireIdentical(
new
{
success = true,
status = "queued",
manifestId = (string?)"01J0123456789ABCDEFGHIJKLM",
message = "Cascade deletion queued; worker will drain asynchronously.",
},
new SessionDeletionQueuedResponse
{
Success = true,
Status = "queued",
ManifestId = "01J0123456789ABCDEFGHIJKLM",
Message = "Cascade deletion queued; worker will drain asynchronously.",
});
}
// ── feedback ─────────────────────────────────────────────────────────────────────────
[Fact]
public void Feedback_responses_match_the_anonymous_shapes()
{
AssertWireIdentical(new { eligible = true }, new FeedbackEligibilityResponse { Eligible = true });
AssertWireIdentical(
new
{
feedback = new[]
{
new
{
type = "InApp",
upn = "alice@contoso.com",
tenantId = "11111111-1111-1111-1111-111111111111",
displayName = "Alice",
rating = (int?)5,
comment = (string?)"Great",
dismissed = false,
submitted = true,
interactedAt = (string?)"2026-08-30T10:00:00.0000000Z",
historyRowKey = (string?)null, // in-app entry → key vanishes
domainName = (string?)null,
},
},
},
new FeedbackListResponse
{
Feedback = new[]
{
new FeedbackEntryWire
{
Type = "InApp",
Upn = "alice@contoso.com",
TenantId = "11111111-1111-1111-1111-111111111111",
DisplayName = "Alice",
Rating = 5,
Comment = "Great",
Dismissed = false,
Submitted = true,
InteractedAt = "2026-08-30T10:00:00.0000000Z",
HistoryRowKey = null,
DomainName = null,
},
},
});
}
// ── global/backups (custom serializer options) ───────────────────────────────────────
[Fact]
public void ListBackupsResponse_matches_the_anonymous_shape_under_backup_options()
{
var ids = new List<string> { "20260831T010203Z_abcd1234" };
var expected = JsonSerializer.Serialize(new { backupIds = ids }, BackupManifestJson.SerializerOptions);
var actual = JsonSerializer.Serialize(new ListBackupsResponse { BackupIds = ids }, BackupManifestJson.SerializerOptions);
Assert.Equal(expected, actual);
}
// ── apps list / analytics / sessions ─────────────────────────────────────────────────
[Fact]
public void AppsListResponse_matches_the_legacy_and_paged_shapes()
{
var item = new AppsListItem
{
AppName = "Contoso App",
AppType = "Win32",
TotalInstalls = 5,
Succeeded = 3,
Skipped = 1,
Unmeasured = 0,
Failed = 1,
FailureRate = 25.0,
AvgDurationSeconds = 60.0,
MaxDurationSeconds = 120,
AvgDownloadBytes = 1024L,
Trend = "stable",
TrendDelta = null, // under 5 finished installs in a half → key vanishes
LastSeenAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
};
var anonymousItem = new
{
appName = "Contoso App",
appType = "Win32",
totalInstalls = 5,
succeeded = 3,
skipped = 1,
unmeasured = 0,
failed = 1,
failureRate = 25.0,
avgDurationSeconds = 60.0,
maxDurationSeconds = 120,
avgDownloadBytes = 1024L,
trend = "stable",
trendDelta = (double?)null,
lastSeenAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
};
// Legacy mode: no paging keys at all.
AssertWireIdentical(
new { success = true, totalApps = 1, totalInstalls = 5, collisionExcluded = 0, windowDays = 30, apps = new[] { anonymousItem } },
new AppsListResponse
{
Success = true,
TotalApps = 1,
TotalInstalls = 5,
CollisionExcluded = 0,
WindowDays = 30,
Apps = new[] { item },
});
// Paged mode incl. last-page nextLink omission.
AssertWireIdentical(
new
{
success = true,
totalApps = 1,
totalInstalls = 5,
collisionExcluded = 0,
windowDays = 30,
count = 1,
offset = 0,
pageSize = 100,
apps = new[] { anonymousItem },
nextLink = (string?)null,
},
new AppsListResponse
{
Success = true,
TotalApps = 1,
TotalInstalls = 5,
CollisionExcluded = 0,
WindowDays = 30,
Count = 1,
Offset = 0,
PageSize = 100,
Apps = new[] { item },
NextLink = null,
});
}
[Fact]
public void AppAnalyticsResponse_matches_the_empty_window_shape()
{
var regressions = new List<AppVersionRegressionAlert>();
AssertWireIdentical(
new
{
success = true,
appName = "Contoso App",
appType = string.Empty,
windowDays = 30,
collisionExcluded = 0,
bucket = "day",
summary = new
{
totalInstalls = 0,
succeeded = 0,
skipped = 0,
unmeasured = 0,
failed = 0,
failureRate = 0.0,
avgDurationSeconds = 0.0,
p95DurationSeconds = 0,
avgDownloadBytes = 0L,
trend = "stable",
trendDelta = (double?)null,
flakinessScore = 0.0,
},
timeSeries = Array.Empty<object>(),
versionBreakdown = Array.Empty<object>(),
installerPhaseBreakdown = Array.Empty<object>(),
topFailureCodes = Array.Empty<object>(),
detectionLiesCount = 0,
deviceModelBreakdown = Array.Empty<object>(),
versionRegressions = regressions,
},
new AppAnalyticsResponse
{
Success = true,
AppName = "Contoso App",
AppType = string.Empty,
WindowDays = 30,
CollisionExcluded = 0,
Bucket = "day",
Summary = new AppAnalyticsSummary
{
TotalInstalls = 0,
Succeeded = 0,
Skipped = 0,
Unmeasured = 0,
Failed = 0,
FailureRate = 0.0,
AvgDurationSeconds = 0.0,
P95DurationSeconds = 0,
AvgDownloadBytes = 0L,
Trend = "stable",
TrendDelta = null,
FlakinessScore = 0.0,
},
TimeSeries = Array.Empty<AppAnalyticsTimeBucket>(),
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
DetectionLiesCount = 0,
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
VersionRegressions = regressions,
});
}
[Fact]
public void AppAnalytics_inner_rows_match_their_anonymous_shapes()
{
var bucketStart = new DateTime(2026, 8, 29, 0, 0, 0, DateTimeKind.Utc);
var wire = ApiJsonOptions.Create();
Assert.Equal(
JsonSerializer.Serialize(new
{
bucketStart,
installs = 4,
succeeded = 3,
failed = 1,
failureRate = 25.0,
avgDurationSeconds = 90.0,
}, wire),
JsonSerializer.Serialize(new AppAnalyticsTimeBucket
{
BucketStart = bucketStart,
Installs = 4,
Succeeded = 3,
Failed = 1,
FailureRate = 25.0,
AvgDurationSeconds = 90.0,
}, wire));
Assert.Equal(
JsonSerializer.Serialize(new
{
appVersion = "1.2.3",
installs = 4,
failed = 1,
failureRate = 25.0,
measuredInstalls = 3,
medianDurationSeconds = 60,
p95DurationSeconds = 110,
}, wire),
JsonSerializer.Serialize(new AppVersionBreakdownItem
{
AppVersion = "1.2.3",
Installs = 4,
Failed = 1,
FailureRate = 25.0,
MeasuredInstalls = 3,
MedianDurationSeconds = 60,
P95DurationSeconds = 110,
}, wire));
Assert.Equal(
JsonSerializer.Serialize(new { phase = "DownloadStart", failed = 2 }, wire),
JsonSerializer.Serialize(new AppInstallerPhaseCount { Phase = "DownloadStart", Failed = 2 }, wire));
Assert.Equal(
JsonSerializer.Serialize(new
{
code = "0x80070005",
exitCode = (int?)5,
count = 2,
sampleMessage = "Access denied",
}, wire),
JsonSerializer.Serialize(new AppAnalyticsFailureCode
{
Code = "0x80070005",
ExitCode = 5,
Count = 2,
SampleMessage = "Access denied",
}, wire));
Assert.Equal(
JsonSerializer.Serialize(new
{
manufacturer = "Contoso",
model = "Book 3",
installs = 8,
failed = 2,
failureRate = 25.0,
liftVsBaseline = 1.5,
}, wire),
JsonSerializer.Serialize(new AppDeviceModelBreakdownItem
{
Manufacturer = "Contoso",
Model = "Book 3",
Installs = 8,
Failed = 2,
FailureRate = 25.0,
LiftVsBaseline = 1.5,
}, wire));
}
[Fact]
public void AppSessionsResponse_matches_the_anonymous_shape()
{
var started = new DateTime(2026, 8, 30, 7, 30, 0, DateTimeKind.Utc);
AssertWireIdentical(
new
{
success = true,
total = 1,
offset = 0,
limit = 50,
items = new[]
{
new
{
sessionId = "22222222-2222-2222-2222-222222222222",
tenantId = "11111111-1111-1111-1111-111111111111",
deviceName = "DESKTOP-1",
manufacturer = "Contoso",
model = "Book 3",
appVersion = "1.2.3",
status = "Failed",
installerPhase = "Install",
failureCode = "0x80070005",
exitCode = (int?)5,
attemptNumber = 2,
startedAt = started,
durationSeconds = 90,
installPassCount = 2,
},
},
},
new AppSessionsResponse
{
Success = true,
Total = 1,
Offset = 0,
Limit = 50,
Items = new[]
{
new AppSessionItem
{
SessionId = "22222222-2222-2222-2222-222222222222",
TenantId = "11111111-1111-1111-1111-111111111111",
DeviceName = "DESKTOP-1",
Manufacturer = "Contoso",
Model = "Book 3",
AppVersion = "1.2.3",
Status = "Failed",
InstallerPhase = "Install",
FailureCode = "0x80070005",
ExitCode = 5,
AttemptNumber = 2,
StartedAt = started,
DurationSeconds = 90,
InstallPassCount = 2,
},
},
});
}
}
@@ -47,6 +47,50 @@ public class TypedResponseGuardTests
/// </summary> /// </summary>
private static readonly Dictionary<string, int> WriteBaseline = new(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary<string, int> WriteBaseline = new(StringComparer.OrdinalIgnoreCase);
// ── Bypass-shape guards (closed 2026-08-31) ─────────────────────────────────────────
// Regexes A/B only see the INLINE literal `...(new { ... })`. Three bypass shapes let a
// response ship untyped anyway and are ratcheted here with EMPTY baselines:
// C: the literal parked in a variable first (`var result = new { ... };` →
// `WriteAsJsonAsync(result)`), which is how rule-stats escaped the 08-31 migration,
// D: hand-serialized bodies (`WriteStringAsync(JsonSerializer.Serialize(new { ... }))`),
// E: builder methods declared `object` / `Task<object>` returning `new { ... }`
// (verdict-calibration's Build). A fourth shape — local `WriteJson(req, object)`
// wrappers — is closed structurally: wrappers take a `T : IApiResponse` generic.
/// <summary>Identifier passed to WriteAsJsonAsync — flagged when the SAME file assigns that identifier an anonymous object.</summary>
private static readonly Regex WriteAsJsonIdentifier =
new(@"WriteAsJsonAsync\(\s*([A-Za-z_]\w*)\s*[,)]", RegexOptions.Compiled);
private static readonly Regex SerializeAnonymous =
new(@"JsonSerializer\.Serialize(?:<[^>]+>)?\(\s*new\s*\{", RegexOptions.Compiled);
private static readonly Regex ObjectReturningBuilder =
new(@"\b(?:object|Task<object>)\s+\w*(?:Build|Compute|Payload|Response)\w*\s*\(", RegexOptions.Compiled);
/// <summary>EMPTY — an anonymous object smuggled through a local variable is the same regression as an inline one.</summary>
private static readonly Dictionary<string, int> IdentifierBaseline = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. The single entry is
/// an outgoing HTTP CLIENT request body (App Insights query POST) — not response debt.
/// Everything else must be a typed DTO (error shapes are tolerated by the shape check).
/// </summary>
private static readonly Dictionary<string, int> SerializeBaseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Raw/AppInsightsQueryFunction.cs"] = 1,
};
/// <summary>
/// A Build*/Compute*/*Payload/*Response method declared object hides its wire shape from
/// every type check. The single tolerated site is BuildV2ResponseBody: its SUCCESS arm is
/// the typed <c>SessionDeletionQueuedResponse</c>, but the method returns a union with the
/// anonymous ERROR arms (tolerated by design), so its declared type must stay object.
/// </summary>
private static readonly Dictionary<string, int> ObjectBuilderBaseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Sessions/DeleteSessionFunction.cs"] = 1,
};
[Fact] [Fact]
public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline() public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline()
{ {
@@ -66,6 +110,42 @@ public class TypedResponseGuardTests
"anonymous success WriteAsJsonAsync(new { ... })"); "anonymous success WriteAsJsonAsync(new { ... })");
} }
[Fact]
public void Anonymous_objects_smuggled_through_a_variable_into_WriteAsJsonAsync_are_flagged()
{
AssertRatchet(
text => WriteAsJsonIdentifier.Matches(text).Count(m =>
{
var ident = m.Groups[1].Value;
if (ident == "new") return false;
var assign = new Regex(@"(?:var\s+)?" + Regex.Escape(ident) + @"\s*=\s*new\s*\{");
var a = assign.Match(text);
// Tolerate error-shaped variables the same way inline literals are tolerated.
return a.Success && !IsErrorShape(text, a.Index + a.Length);
}),
IdentifierBaseline,
"WriteAsJsonAsync(<variable holding an anonymous object>)");
}
[Fact]
public void Anonymous_JsonSerializer_Serialize_success_bodies_never_exceed_the_frozen_baseline()
{
AssertRatchet(
text => SerializeAnonymous.Matches(text)
.Count(m => !IsErrorShape(text, m.Index + m.Length)),
SerializeBaseline,
"anonymous success JsonSerializer.Serialize(new { ... })");
}
[Fact]
public void Object_returning_response_builders_are_flagged()
{
AssertRatchet(
text => ObjectReturningBuilder.Matches(text).Count,
ObjectBuilderBaseline,
"object/Task<object>-returning Build*/Compute*/*Payload/*Response method");
}
/// <summary> /// <summary>
/// Wire DTOs must be flat: System.Text.Json serializes derived properties BEFORE base /// Wire DTOs must be flat: System.Text.Json serializes derived properties BEFORE base
/// properties, so any base class would silently reorder JSON keys — and key order is part /// properties, so any base class would silently reorder JSON keys — and key order is part
@@ -217,7 +217,7 @@ public class VerdictCalibrationTests
private static readonly JsonSerializerOptions Wire = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private static readonly JsonSerializerOptions Wire = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private static JsonElement BuildJson(IReadOnlyList<VerdictCalibrationDailyAggregate> daily, DateTime today, int days) private static JsonElement BuildJson(IReadOnlyList<VerdictCalibrationDailyAggregate> daily, DateTime today, int days)
=> JsonSerializer.SerializeToElement(VerdictCalibrationResponse.Build(daily, "global", today, days), Wire); => JsonSerializer.SerializeToElement(VerdictCalibrationResponseBuilder.Build(daily, "global", today, days), Wire);
[Fact] [Fact]
public void Response_sums_the_window_and_computes_share_rate_and_trend() public void Response_sums_the_window_and_computes_share_rate_and_trend()
@@ -297,5 +297,5 @@ public class VerdictCalibrationTests
[InlineData("500", 180)] [InlineData("500", 180)]
[InlineData("14", 14)] [InlineData("14", 14)]
public void ClampDays_bounds_the_window(string? raw, int expected) public void ClampDays_bounds_the_window(string? raw, int expected)
=> Assert.Equal(expected, VerdictCalibrationResponse.ClampDays(raw)); => Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw));
} }
@@ -750,24 +750,8 @@ public class TenantOffboardFunction
} }
} }
/// <summary> // OffboardResponse (the 202/200 offboarding body) moved to AutopilotMonitor.Shared.Models
/// 202/200 response body for the offboarding endpoint. Fields point the caller at the // (Models/OffboardApiModels.cs) so the shared manifest exports it as a wire type.
/// History row so subsequent reporting / status polling can resolve back to the audit
/// trail. <see cref="EarliestProcessingAt"/> drives the "data deletion starts in mm ss"
/// countdown in the Web UI's drain-barrier state.
/// </summary>
public class OffboardResponse
{
public string TenantId { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string HistoryPartitionKey { get; set; } = string.Empty;
public string HistoryRowKey { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
/// <summary>UTC timestamp before which the worker MUST NOT start Phase 2. Drives the
/// cache-drain-barrier countdown UI. Null on the idempotent-Completed/Failed branches.</summary>
public DateTime? EarliestProcessingAt { get; set; }
}
/// <summary> /// <summary>
/// Decision the resume-path produced; consumed by <see cref="TenantOffboardFunction.HandleExistingMarkerAsync"/> /// Decision the resume-path produced; consumed by <see cref="TenantOffboardFunction.HandleExistingMarkerAsync"/>
@@ -128,7 +128,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
/// (so an MCP caller can page a large fleet's app list). <paramref name="nextLinkForOffset"/> builds the /// (so an MCP caller can page a large fleet's app list). <paramref name="nextLinkForOffset"/> builds the
/// route-specific nextLink for the next offset; it is only invoked when more pages remain. /// route-specific nextLink for the next offset; it is only invoked when more pages remain.
/// </summary> /// </summary>
public static object BuildAppsListResponse( public static AppsListResponse BuildAppsListResponse(
List<AppInstallSummary> allSummaries, List<AppInstallSummary> allSummaries,
int days, int days,
int? pageSize = null, int? pageSize = null,
@@ -161,40 +161,40 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var secondHalf = g.Where(s => s.StartedAt >= midpoint).ToList(); var secondHalf = g.Where(s => s.StartedAt >= midpoint).ToList();
var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf); var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf);
return new return new AppsListItem
{ {
appName = g.Key, AppName = g.Key,
appType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty, AppType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
totalInstalls = total, TotalInstalls = total,
succeeded = installed.Count, Succeeded = installed.Count,
skipped, Skipped = skipped,
unmeasured = installed.Count - measured.Count, Unmeasured = installed.Count - measured.Count,
failed, Failed = failed,
failureRate, FailureRate = failureRate,
avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0, AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0, MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0, AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
trend, Trend = trend,
trendDelta, TrendDelta = trendDelta,
lastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt) LastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt)
}; };
}) })
.OrderByDescending(a => a.failed) .OrderByDescending(a => a.Failed)
.ThenByDescending(a => a.failureRate) .ThenByDescending(a => a.FailureRate)
.ThenBy(a => a.appName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors .ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors
.ToList(); .ToList();
// Legacy mode: caller did not opt into pagination → full array (web UI pages client-side). // Legacy mode: caller did not opt into pagination → full array (web UI pages client-side).
if (pageSize == null) if (pageSize == null)
{ {
return new return new AppsListResponse
{ {
success = true, Success = true,
totalApps = apps.Count, TotalApps = apps.Count,
totalInstalls = summaries.Count, TotalInstalls = summaries.Count,
collisionExcluded, CollisionExcluded = collisionExcluded,
windowDays = days, WindowDays = days,
apps Apps = apps
}; };
} }
@@ -204,18 +204,18 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var nextOffset = offset + page.Count; var nextOffset = offset + page.Count;
var hasMore = nextOffset < apps.Count; var hasMore = nextOffset < apps.Count;
return new return new AppsListResponse
{ {
success = true, Success = true,
totalApps = apps.Count, TotalApps = apps.Count,
totalInstalls = summaries.Count, TotalInstalls = summaries.Count,
collisionExcluded, CollisionExcluded = collisionExcluded,
windowDays = days, WindowDays = days,
count = page.Count, Count = page.Count,
offset, Offset = offset,
pageSize = pageSize.Value, PageSize = pageSize.Value,
apps = page, Apps = page,
nextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null NextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null
}; };
} }
@@ -225,7 +225,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
/// Builds the analytics response body for a single app. /// Builds the analytics response body for a single app.
/// Loads sessions individually via the session repository for the device-model join. /// Loads sessions individually via the session repository for the device-model join.
/// </summary> /// </summary>
public static async Task<object> BuildAnalyticsResponseAsync( public static async Task<AppAnalyticsResponse> BuildAnalyticsResponseAsync(
List<AppInstallSummary> allSummaries, List<AppInstallSummary> allSummaries,
ISessionRepository sessionRepo, ISessionRepository sessionRepo,
string appName, string appName,
@@ -252,38 +252,38 @@ namespace AutopilotMonitor.Functions.Functions.Apps
if (summaries.Count == 0) if (summaries.Count == 0)
{ {
return new return new AppAnalyticsResponse
{ {
success = true, Success = true,
appName, AppName = appName,
appType = string.Empty, AppType = string.Empty,
windowDays = days, WindowDays = days,
collisionExcluded, CollisionExcluded = collisionExcluded,
bucket = "day", Bucket = "day",
summary = new Summary = new AppAnalyticsSummary
{ {
totalInstalls = 0, TotalInstalls = 0,
succeeded = 0, Succeeded = 0,
skipped = 0, Skipped = 0,
unmeasured = 0, Unmeasured = 0,
failed = 0, Failed = 0,
failureRate = 0, FailureRate = 0,
avgDurationSeconds = 0, AvgDurationSeconds = 0,
p95DurationSeconds = 0, P95DurationSeconds = 0,
avgDownloadBytes = 0, AvgDownloadBytes = 0,
trend = "stable", Trend = "stable",
trendDelta = (double?)null, TrendDelta = null,
flakinessScore = 0.0 FlakinessScore = 0.0
}, },
timeSeries = Array.Empty<object>(), TimeSeries = Array.Empty<AppAnalyticsTimeBucket>(),
versionBreakdown = Array.Empty<object>(), VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
installerPhaseBreakdown = Array.Empty<object>(), InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
topFailureCodes = Array.Empty<object>(), TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
detectionLiesCount = 0, DetectionLiesCount = 0,
deviceModelBreakdown = Array.Empty<object>(), DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
// Lockstep with the full response: episodes can outlive the window's data // Lockstep with the full response: episodes can outlive the window's data
// (e.g. a shrunk days= selection), so the block is still surfaced here. // (e.g. a shrunk days= selection), so the block is still surfaced here.
versionRegressions = appVersionRegressions VersionRegressions = appVersionRegressions
}; };
} }
@@ -329,38 +329,38 @@ namespace AutopilotMonitor.Functions.Functions.Apps
.Where(MetricsMath.HasMeasuredDuration) .Where(MetricsMath.HasMeasuredDuration)
.Select(s => s.DurationSeconds) .Select(s => s.DurationSeconds)
.ToList(); .ToList();
return new return new AppVersionBreakdownItem
{ {
appVersion = g.Key, AppVersion = g.Key,
installs = vTotal, Installs = vTotal,
failed = vFailed, Failed = vFailed,
failureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded), FailureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded),
measuredInstalls = vDurations.Count, MeasuredInstalls = vDurations.Count,
medianDurationSeconds = Percentile(vDurations, 0.50), MedianDurationSeconds = Percentile(vDurations, 0.50),
p95DurationSeconds = Percentile(vDurations, 0.95) P95DurationSeconds = Percentile(vDurations, 0.95)
}; };
}) })
.OrderByDescending(v => v.installs) .OrderByDescending(v => v.Installs)
.ToList(); .ToList();
var installerPhaseBreakdown = summaries var installerPhaseBreakdown = summaries
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.InstallerPhase)) .Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.InstallerPhase))
.GroupBy(s => s.InstallerPhase) .GroupBy(s => s.InstallerPhase)
.Select(g => new { phase = g.Key, failed = g.Count() }) .Select(g => new AppInstallerPhaseCount { Phase = g.Key, Failed = g.Count() })
.OrderByDescending(p => p.failed) .OrderByDescending(p => p.Failed)
.ToList(); .ToList();
var topFailureCodes = summaries var topFailureCodes = summaries
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.FailureCode)) .Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.FailureCode))
.GroupBy(s => s.FailureCode) .GroupBy(s => s.FailureCode)
.Select(g => new .Select(g => new AppAnalyticsFailureCode
{ {
code = g.Key, Code = g.Key,
exitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue), ExitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue),
count = g.Count(), Count = g.Count(),
sampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty SampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty
}) })
.OrderByDescending(f => f.count) .OrderByDescending(f => f.Count)
.Take(5) .Take(5)
.ToList(); .ToList();
@@ -397,57 +397,57 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var lift = failureRate > 0 var lift = failureRate > 0
? Math.Round(modelFailureRate / failureRate, 2) ? Math.Round(modelFailureRate / failureRate, 2)
: 0; : 0;
return new return new AppDeviceModelBreakdownItem
{ {
manufacturer = g.Key.Manufacturer, Manufacturer = g.Key.Manufacturer,
model = g.Key.Model, Model = g.Key.Model,
installs = modelTotal, Installs = modelTotal,
failed = modelFailed, Failed = modelFailed,
failureRate = modelFailureRate, FailureRate = modelFailureRate,
liftVsBaseline = lift LiftVsBaseline = lift
}; };
}) })
.OrderByDescending(m => m.liftVsBaseline) .OrderByDescending(m => m.LiftVsBaseline)
.ToList(); .ToList();
var appType = summaries.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty; var appType = summaries.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty;
return new return new AppAnalyticsResponse
{ {
success = true, Success = true,
appName, AppName = appName,
appType, AppType = appType,
windowDays = days, WindowDays = days,
collisionExcluded, CollisionExcluded = collisionExcluded,
bucket, Bucket = bucket,
summary = new Summary = new AppAnalyticsSummary
{ {
totalInstalls = total, TotalInstalls = total,
succeeded, Succeeded = succeeded,
skipped, Skipped = skipped,
unmeasured = installed.Count - measured.Count, Unmeasured = installed.Count - measured.Count,
failed, Failed = failed,
failureRate, FailureRate = failureRate,
avgDurationSeconds, AvgDurationSeconds = avgDurationSeconds,
p95DurationSeconds, P95DurationSeconds = p95DurationSeconds,
avgDownloadBytes, AvgDownloadBytes = avgDownloadBytes,
trend, Trend = trend,
trendDelta, TrendDelta = trendDelta,
flakinessScore FlakinessScore = flakinessScore
}, },
timeSeries, TimeSeries = timeSeries,
versionBreakdown, VersionBreakdown = versionBreakdown,
installerPhaseBreakdown, InstallerPhaseBreakdown = installerPhaseBreakdown,
topFailureCodes, TopFailureCodes = topFailureCodes,
detectionLiesCount, DetectionLiesCount = detectionLiesCount,
deviceModelBreakdown, DeviceModelBreakdown = deviceModelBreakdown,
versionRegressions = appVersionRegressions VersionRegressions = appVersionRegressions
}; };
} }
// ── /apps/{appName}/sessions ──────────────────────────────────────── // ── /apps/{appName}/sessions ────────────────────────────────────────
public static async Task<object> BuildSessionsResponseAsync( public static async Task<AppSessionsResponse> BuildSessionsResponseAsync(
List<AppInstallSummary> allSummaries, List<AppInstallSummary> allSummaries,
ISessionRepository sessionRepo, ISessionRepository sessionRepo,
string appName, string appName,
@@ -499,34 +499,34 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var items = page.Select(s => var items = page.Select(s =>
{ {
sessionLookup.TryGetValue($"{s.TenantId}|{s.SessionId}", out var sess); sessionLookup.TryGetValue($"{s.TenantId}|{s.SessionId}", out var sess);
return new return new AppSessionItem
{ {
sessionId = s.SessionId, SessionId = s.SessionId,
tenantId = s.TenantId, TenantId = s.TenantId,
deviceName = sess?.DeviceName ?? string.Empty, DeviceName = sess?.DeviceName ?? string.Empty,
manufacturer = sess?.Manufacturer ?? string.Empty, Manufacturer = sess?.Manufacturer ?? string.Empty,
model = sess?.Model ?? string.Empty, Model = sess?.Model ?? string.Empty,
appVersion = s.AppVersion, AppVersion = s.AppVersion,
status = s.Status, Status = s.Status,
installerPhase = s.InstallerPhase, InstallerPhase = s.InstallerPhase,
failureCode = s.FailureCode, FailureCode = s.FailureCode,
exitCode = s.ExitCode, ExitCode = s.ExitCode,
attemptNumber = s.AttemptNumber, AttemptNumber = s.AttemptNumber,
startedAt = s.StartedAt, StartedAt = s.StartedAt,
durationSeconds = s.DurationSeconds, DurationSeconds = s.DurationSeconds,
// 2+ = the IME processed this app in multiple passes (device-ESP // 2+ = the IME processed this app in multiple passes (device-ESP
// evaluation + real install) — explains a completion far after startedAt. // evaluation + real install) — explains a completion far after startedAt.
installPassCount = s.InstallPassCount InstallPassCount = s.InstallPassCount
}; };
}).ToList(); }).ToList();
return new return new AppSessionsResponse
{ {
success = true, Success = true,
total, Total = total,
offset, Offset = offset,
limit, Limit = limit,
items Items = items
}; };
} }
@@ -559,7 +559,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
return (trend, delta); return (trend, delta);
} }
private static List<object> BuildTimeSeries(List<AppInstallSummary> summaries, DateTime cutoff, DateTime now, string bucket) private static List<AppAnalyticsTimeBucket> BuildTimeSeries(List<AppInstallSummary> summaries, DateTime cutoff, DateTime now, string bucket)
{ {
var start = bucket == "week" ? StartOfWeek(cutoff) : cutoff.Date; var start = bucket == "week" ? StartOfWeek(cutoff) : cutoff.Date;
var end = now.Date; var end = now.Date;
@@ -589,14 +589,14 @@ namespace AutopilotMonitor.Functions.Functions.Apps
// PR0 convention: skips are not attempts; durations read measured rows only. // PR0 convention: skips are not attempts; durations read measured rows only.
var bInstalled = items.Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s)).ToList(); var bInstalled = items.Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s)).ToList();
var bMeasured = bInstalled.Where(MetricsMath.HasMeasuredDuration).ToList(); var bMeasured = bInstalled.Where(MetricsMath.HasMeasuredDuration).ToList();
return (object)new return new AppAnalyticsTimeBucket
{ {
bucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc), BucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc),
installs = bTotal, Installs = bTotal,
succeeded = bInstalled.Count, Succeeded = bInstalled.Count,
failed = bFailed, Failed = bFailed,
failureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count), FailureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count),
avgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0 AvgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0
}; };
}) })
.ToList(); .ToList();
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup
var response = req.CreateResponse(HttpStatusCode.OK); var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "application/json; charset=utf-8"); response.Headers.Add("Content-Type", "application/json; charset=utf-8");
var json = JsonSerializer.Serialize(new { backupIds = ids }, BackupManifestJson.SerializerOptions); var json = JsonSerializer.Serialize(new ListBackupsResponse { BackupIds = ids }, BackupManifestJson.SerializerOptions);
await response.WriteStringAsync(json); await response.WriteStringAsync(json);
return response; return response;
} }
@@ -70,56 +70,56 @@ namespace AutopilotMonitor.Functions.Functions.Config
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the /// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
/// read-time edition resolution (trial expiry degrades automatically). /// read-time edition resolution (trial expiry degrades automatically).
/// </summary> /// </summary>
internal static object BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false) internal static TenantFeatureFlagsResponse BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false)
{ {
var edition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc); var edition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
var entitlements = FeatureEntitlementCatalog.Get(edition); var entitlements = FeatureEntitlementCatalog.Get(edition);
var isTrial = edition == TenantEdition.Pro && var isTrial = edition == TenantEdition.Pro &&
!FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier); !FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
return new return new TenantFeatureFlagsResponse
{ {
// EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive // EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive
// Community enable) — field name kept for web compatibility. // Community enable) — field name kept for web compatibility.
bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(config, nowUtc), BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(config, nowUtc),
// Session-detail "Collect Logs" button: whether an on-demand diagnostics upload can // Session-detail "Collect Logs" button: whether an on-demand diagnostics upload can
// succeed right now (mode not Off + a usable destination). Members below Admin cannot // succeed right now (mode not Off + a usable destination). Members below Admin cannot
// read the full config, so this boolean is their only signal. Deliberately exposes no // read the full config, so this boolean is their only signal. Deliberately exposes no
// destination detail — just "would an upload work". // destination detail — just "would an upload work".
diagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config), DiagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
// Drives the "Autopilot Device Validation disabled" dashboard banner // Drives the "Autopilot Device Validation disabled" dashboard banner
// (useTenantSecurityConfig). // (useTenantSecurityConfig).
validateAutopilotDevice = config.ValidateAutopilotDevice, ValidateAutopilotDevice = config.ValidateAutopilotDevice,
// Dual app-reg self-service migration: when true, running the consent flow (or // Dual app-reg self-service migration: when true, running the consent flow (or
// "Detect existing access") targets the NEW app registration and auto-flips this // "Detect existing access") targets the NEW app registration and auto-flips this
// tenant's homing after verification — drives the explanatory banner in the // tenant's homing after verification — drives the explanatory banner in the
// Autopilot Validation settings section. Non-sensitive: exposes no client ids. // Autopilot Validation settings section. Non-sensitive: exposes no client ids.
appHomingFunnelActive, AppHomingFunnelActive = appHomingFunnelActive,
// Session-detail UI flags (useSessionTenantConfig). Nullable in the model; // Session-detail UI flags (useSessionTenantConfig). Nullable in the model;
// surface the agent-side defaults so the UI does not need a second nullable layer. // surface the agent-side defaults so the UI does not need a second nullable layer.
showScriptOutput = config.ShowScriptOutput ?? true, ShowScriptOutput = config.ShowScriptOutput ?? true,
enableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false, EnableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
enableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true, EnableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
// Gather-rules page validation indicator. EFFECTIVE value (requires Pro edition + // Gather-rules page validation indicator. EFFECTIVE value (requires Pro edition +
// GA gate + tenant toggle) — the privileged toggle is UnrestrictedModeEnabled // GA gate + tenant toggle) — the privileged toggle is UnrestrictedModeEnabled
// (admin-only, stays in the full config response). // (admin-only, stays in the full config response).
unrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc), UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc),
// Edition/entitlement surface (read-time resolution — non-sensitive by design): // Edition/entitlement surface (read-time resolution — non-sensitive by design):
// drives the EditionBadge, trial CTA and retention hint in the web UI. // drives the EditionBadge, trial CTA and retention hint in the web UI.
edition = edition.ToString().ToLowerInvariant(), Edition = edition.ToString().ToLowerInvariant(),
isTrial, IsTrial = isTrial,
trialExpiresUtc = isTrial ? config.TrialExpiresUtc : null, TrialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
trialAvailable = !config.TrialConsumed && edition == TenantEdition.Community, TrialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
// Pro-requires-contact surface: drives the trial CTA gate and the dashboard // Pro-requires-contact surface: drives the trial CTA gate and the dashboard
// "set a contact address" banner for Pro tenants. Boolean only — the address // "set a contact address" banner for Pro tenants. Boolean only — the address
// itself stays in the admin-gated full config response. // itself stays in the admin-gated full config response.
contactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail), ContactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
entitlements = new Entitlements = new TenantFeatureEntitlements
{ {
retentionCapDays = entitlements.RetentionCapDays, RetentionCapDays = entitlements.RetentionCapDays,
userRateLimitPerMinute = entitlements.UserRateLimitPerMinute, UserRateLimitPerMinute = entitlements.UserRateLimitPerMinute,
delegatedAdminAllowed = entitlements.DelegatedAdminAllowed, DelegatedAdminAllowed = entitlements.DelegatedAdminAllowed,
mcpUsagePlan = entitlements.McpUsagePlanName McpUsagePlan = entitlements.McpUsagePlanName
} }
}; };
} }
@@ -59,19 +59,19 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
/// <c>condition</c> travels as the enum NAME ("Always" | "RealmJoinWatcher" | /// <c>condition</c> travels as the enum NAME ("Always" | "RealmJoinWatcher" |
/// "DevicePreparation"), never the integer — the web switches on the string. /// "DevicePreparation"), never the integer — the web switches on the string.
/// </summary> /// </summary>
internal static object BuildPayload(AdminConfiguration adminConfig) => new internal static DiagnosticsPathsResponse BuildPayload(AdminConfiguration adminConfig) => new()
{ {
builtIn = DiagnosticsBuiltInSections.All.Select(s => new BuiltIn = DiagnosticsBuiltInSections.All.Select(s => new DiagnosticsBuiltInSectionWire
{ {
id = s.Id, Id = s.Id,
zipFolder = s.ZipFolder, ZipFolder = s.ZipFolder,
sourceFolder = s.SourceFolder, SourceFolder = s.SourceFolder,
patterns = s.Patterns, Patterns = s.Patterns,
includeSubfolders = s.IncludeSubfolders, IncludeSubfolders = s.IncludeSubfolders,
description = s.Description, Description = s.Description,
condition = s.Condition.ToString(), Condition = s.Condition.ToString(),
}).ToArray(), }).ToArray(),
globalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(), GlobalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(),
}; };
} }
} }
@@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
// four extra Table reads on every poll from every active user. // four extra Table reads on every poll from every active user.
var adminConfig = await _adminConfigService.GetConfigurationAsync(); var adminConfig = await _adminConfigService.GetConfigurationAsync();
if (!adminConfig.FeedbackEnabled) if (!adminConfig.FeedbackEnabled)
return await WriteJson(req, new { eligible = false }); return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
// 2. User identity // 2. User identity
string tenantId = TenantHelper.GetTenantId(req); string tenantId = TenantHelper.GetTenantId(req);
@@ -86,17 +86,17 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
var roleInfo = EntraAppRoleResolver.Resolve( var roleInfo = EntraAppRoleResolver.Resolve(
tableState, tableRole, context.GetUser()?.GetAppRoles(), tenantConfig.EntraAppRolesEnabled); tableState, tableRole, context.GetUser()?.GetAppRoles(), tenantConfig.EntraAppRolesEnabled);
if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer) if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer)
return await WriteJson(req, new { eligible = false }); return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
// 4. Tenant age check // 4. Tenant age check
if (tenantConfig.OnboardedAt == null || if (tenantConfig.OnboardedAt == null ||
(DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays) (DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays)
return await WriteJson(req, new { eligible = false }); return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
// 5. Sessions check — at least 1 session exists. // 5. Sessions check — at least 1 session exists.
var sessionPage = sessionsTask.Result; var sessionPage = sessionsTask.Result;
if (sessionPage.Items.Count == 0) if (sessionPage.Items.Count == 0)
return await WriteJson(req, new { eligible = false }); return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
// 6. Cooldown check // 6. Cooldown check
var feedbackEntry = feedbackTask.Result; var feedbackEntry = feedbackTask.Result;
@@ -104,14 +104,14 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
{ {
// Cooldown = 0 means single wave only // Cooldown = 0 means single wave only
if (adminConfig.FeedbackCooldownDays == 0) if (adminConfig.FeedbackCooldownDays == 0)
return await WriteJson(req, new { eligible = false }); return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
var daysSinceInteraction = (DateTime.UtcNow - feedbackEntry.InteractedAt.Value).TotalDays; var daysSinceInteraction = (DateTime.UtcNow - feedbackEntry.InteractedAt.Value).TotalDays;
if (daysSinceInteraction < adminConfig.FeedbackCooldownDays) if (daysSinceInteraction < adminConfig.FeedbackCooldownDays)
return await WriteJson(req, new { eligible = false }); return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
} }
return await WriteJson(req, new { eligible = true }); return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = true });
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {
@@ -122,7 +122,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
{ {
_logger.LogError(ex, "Error checking feedback status"); _logger.LogError(ex, "Error checking feedback status");
// Fail closed — don't show bubble on errors // Fail closed — don't show bubble on errors
return await WriteJson(req, new { eligible = false }); return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
} }
} }
@@ -197,7 +197,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})", _logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
body.Dismissed ? "dismissed" : "submitted", upn, tenantId); body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
return await WriteJson(req, new { success = true }); return await WriteJson(req, new SuccessOnlyResponse { Success = true });
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {
@@ -222,22 +222,22 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
try try
{ {
var allEntries = await _feedbackRepo.GetAllAsync(); var allEntries = await _feedbackRepo.GetAllAsync();
var entries = allEntries.Select(e => new var entries = allEntries.Select(e => new FeedbackEntryWire
{ {
type = e.Type, Type = e.Type,
upn = e.Upn, Upn = e.Upn,
tenantId = e.TenantId, TenantId = e.TenantId,
displayName = e.DisplayName, DisplayName = e.DisplayName,
rating = e.Rating, Rating = e.Rating,
comment = e.Comment, Comment = e.Comment,
dismissed = e.Dismissed, Dismissed = e.Dismissed,
submitted = e.Submitted, Submitted = e.Submitted,
interactedAt = e.InteractedAt?.ToString("o"), InteractedAt = e.InteractedAt?.ToString("o"),
historyRowKey = e.HistoryRowKey, HistoryRowKey = e.HistoryRowKey,
domainName = e.DomainName, DomainName = e.DomainName,
}).ToList(); }).ToList();
return await WriteJson(req, new { feedback = entries }); return await WriteJson(req, new FeedbackListResponse { Feedback = entries });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -248,7 +248,11 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
} }
} }
private static async Task<HttpResponseData> WriteJson(HttpRequestData req, object data) // Generic with an IApiResponse constraint (NOT an IApiResponse parameter): success
// bodies through this wrapper are typed by construction — an anonymous body no longer
// compiles — while the CONCRETE type still reaches the serializer (a declared interface
// type would serialize zero properties under System.Text.Json).
private static async Task<HttpResponseData> WriteJson<T>(HttpRequestData req, T data) where T : IApiResponse
{ {
var response = req.CreateResponse(HttpStatusCode.OK); var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(data); await response.WriteAsJsonAsync(data);
@@ -129,10 +129,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req); var tenantId = TenantHelper.GetTenantId(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]); var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
var response = req.CreateResponse(HttpStatusCode.OK); var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync( await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync(
_metricsRepo, _sessionRepo, tenantId, days, includeRepeatDevices: true)); _metricsRepo, _sessionRepo, tenantId, days, includeRepeatDevices: true));
return response; return response;
} }
@@ -177,11 +177,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware // Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var tenantIdFilter = query["tenantId"]; var tenantIdFilter = query["tenantId"];
var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]); var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!; var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
var response = req.CreateResponse(HttpStatusCode.OK); var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync( await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync(
_metricsRepo, _sessionRepo, partition, days, includeRepeatDevices: partition != "global")); _metricsRepo, _sessionRepo, partition, days, includeRepeatDevices: partition != "global"));
return response; return response;
} }
@@ -195,8 +195,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
} }
} }
/// <summary>Shared response shape for the tenant and global variants (mirrors TimeAttributionResponse).</summary> /// <summary>
internal static class DeviceJourneyMetricsResponse /// Shared response builder for the tenant and global variants (mirrors
/// TimeAttributionResponseBuilder). The envelope/row DTOs
/// (<see cref="DeviceJourneyMetricsResponse"/>, <see cref="DeviceJourneyWindowTotals"/>,
/// <see cref="DeviceJourneyRepeatDevice"/>) live in Shared so the manifest exports them.
/// </summary>
internal static class DeviceJourneyMetricsResponseBuilder
{ {
internal const int DefaultWindowDays = 30; internal const int DefaultWindowDays = 30;
internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist
@@ -215,7 +220,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
/// </summary> /// </summary>
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1)); internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
internal static async Task<object> BuildAsync( internal static async Task<DeviceJourneyMetricsResponse> BuildAsync(
IMetricsRepository metricsRepo, ISessionRepository sessionRepo, IMetricsRepository metricsRepo, ISessionRepository sessionRepo,
string partition, int days, bool includeRepeatDevices) string partition, int days, bool includeRepeatDevices)
{ {
@@ -223,12 +228,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var daily = await metricsRepo.GetDeviceJourneyAggregatesAsync(partition, InclusiveWindowStart(today, days), today); var daily = await metricsRepo.GetDeviceJourneyAggregatesAsync(partition, InclusiveWindowStart(today, days), today);
var totals = SumAggregates(daily); var totals = SumAggregates(daily);
List<object>? repeatDevices = null; List<DeviceJourneyRepeatDevice>? repeatDevices = null;
if (includeRepeatDevices) if (includeRepeatDevices)
{ {
var histories = await metricsRepo.GetDeviceHistoriesByTenantAsync(partition); var histories = await metricsRepo.GetDeviceHistoriesByTenantAsync(partition);
var candidates = SelectRepeatDevices(histories, today.AddDays(-days)); var candidates = SelectRepeatDevices(histories, today.AddDays(-days));
repeatDevices = new List<object>(candidates.Count); repeatDevices = new List<DeviceJourneyRepeatDevice>(candidates.Count);
foreach (var candidate in candidates) foreach (var candidate in candidates)
{ {
// Failure reason of the newest failed attempt — a bounded point-read per top // Failure reason of the newest failed attempt — a bounded point-read per top
@@ -246,44 +251,31 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// fail-soft — the row renders without a reason // fail-soft — the row renders without a reason
} }
} }
repeatDevices.Add(new repeatDevices.Add(new DeviceJourneyRepeatDevice
{ {
serialNumber = candidate.History.SerialNumber, SerialNumber = candidate.History.SerialNumber,
manufacturer = candidate.History.Manufacturer, Manufacturer = candidate.History.Manufacturer,
model = candidate.History.Model, Model = candidate.History.Model,
attempts = candidate.History.CurrentJourneyAttempts, Attempts = candidate.History.CurrentJourneyAttempts,
journeyCount = candidate.History.JourneyCount, JourneyCount = candidate.History.JourneyCount,
lastStatus = candidate.Newest.Status, LastStatus = candidate.Newest.Status,
lastSessionId = candidate.Newest.SessionId, LastSessionId = candidate.Newest.SessionId,
lastStartedAt = candidate.Newest.StartedAt, LastStartedAt = candidate.Newest.StartedAt,
lastFailureReason, LastFailureReason = lastFailureReason,
}); });
} }
} }
return new return new DeviceJourneyMetricsResponse
{ {
success = true, Success = true,
windowDays = days, WindowDays = days,
totals, Totals = totals,
daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(), Daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
repeatDevices, RepeatDevices = repeatDevices,
}; };
} }
/// <summary>Window totals (serialized camelCase by the worker's global serializer, like the model rows).</summary>
internal sealed class DeviceJourneyWindowTotals
{
public int CompletedJourneys { get; init; }
public int FirstTimeRight { get; init; }
/// <summary>Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1).</summary>
public double? FtrRatePct { get; init; }
public int ExcludedSessions { get; init; }
public List<DeviceJourneyAttemptBucket> AttemptHistogram { get; init; } = new();
}
/// <summary> /// <summary>
/// Window sums over the daily rows — honest because every field is an additive count /// Window sums over the daily rows — honest because every field is an additive count
/// (rule: the FTR window rate IS the ratio of summed counts). internal static: pinned /// (rule: the FTR window rate IS the ratio of summed counts). internal static: pinned
@@ -66,11 +66,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
/// Global scope sees every row, unconfirmed ones included — that is where a bogus claim /// Global scope sees every row, unconfirmed ones included — that is where a bogus claim
/// is investigated. /// is investigated.
/// ///
/// Returned as <see cref="object"/> on purpose — System.Text.Json serializes an /// Returned as <c>IReadOnlyList&lt;object&gt;</c> — System.Text.Json serializes each
/// `object` declared type using the RUNTIME type, so both branches keep the exact /// element using its RUNTIME type, so both branches keep the exact wire format they
/// wire format they had while the projection was inline. /// had while the projection was inline. Both element types are manifest-exported
/// (<see cref="ImeVersionHistoryEntry"/> / <see cref="ImeVersionHistoryLeanEntry"/>).
/// </summary> /// </summary>
public static object BuildResponsePayload( public static IReadOnlyList<object> BuildResponsePayload(
IEnumerable<ImeVersionHistoryEntry> versions, bool hasGlobalScope) IEnumerable<ImeVersionHistoryEntry> versions, bool hasGlobalScope)
{ {
if (hasGlobalScope) if (hasGlobalScope)
@@ -80,7 +81,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return versions return versions
.Where(IsFleetConfirmed) .Where(IsFleetConfirmed)
.Select(v => new { v.Version, v.FirstSeenAt, v.LastSeenAt, v.SessionCount }) .Select(v => new ImeVersionHistoryLeanEntry
{
Version = v.Version,
FirstSeenAt = v.FirstSeenAt,
LastSeenAt = v.LastSeenAt,
SessionCount = v.SessionCount,
})
.ToList(); .ToList();
} }
@@ -93,7 +93,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req); var tenantId = TenantHelper.GetTenantId(req);
var response = req.CreateResponse(HttpStatusCode.OK); var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(await TimeAttributionResponse.BuildAsync(_metricsRepo, tenantId)); await response.WriteAsJsonAsync(await TimeAttributionResponseBuilder.BuildAsync(_metricsRepo, tenantId));
return response; return response;
} }
catch (Exception ex) catch (Exception ex)
@@ -132,7 +132,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!; var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
var response = req.CreateResponse(HttpStatusCode.OK); var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(await TimeAttributionResponse.BuildAsync(_metricsRepo, partition)); await response.WriteAsJsonAsync(await TimeAttributionResponseBuilder.BuildAsync(_metricsRepo, partition));
return response; return response;
} }
catch (Exception ex) catch (Exception ex)
@@ -145,8 +145,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
} }
} }
/// <summary>Shared response shape for the tenant and global variants (mirrors the MetricsMath single-source pattern).</summary> /// <summary>
internal static class TimeAttributionResponse /// Shared response builder for the tenant and global variants (mirrors the MetricsMath
/// single-source pattern). The envelope DTO (<see cref="TimeAttributionMetricsResponse"/>)
/// lives in Shared so the manifest exports it.
/// </summary>
internal static class TimeAttributionResponseBuilder
{ {
internal const int WindowDays = 30; internal const int WindowDays = 30;
@@ -164,7 +168,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
/// </summary> /// </summary>
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1)); internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
internal static async Task<object> BuildAsync(IMetricsRepository metricsRepo, string partition) internal static async Task<TimeAttributionMetricsResponse> BuildAsync(IMetricsRepository metricsRepo, string partition)
{ {
var freshCutoff = DateTime.UtcNow - RollingMaxAge; var freshCutoff = DateTime.UtcNow - RollingMaxAge;
var rolling = (await metricsRepo.GetRollingTimeAttributionAggregatesAsync(partition)) var rolling = (await metricsRepo.GetRollingTimeAttributionAggregatesAsync(partition))
@@ -173,14 +177,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var today = DateTime.UtcNow.Date; var today = DateTime.UtcNow.Date;
var daily = await metricsRepo.GetTimeAttributionAggregatesAsync(partition, InclusiveWindowStart(today, WindowDays), today); var daily = await metricsRepo.GetTimeAttributionAggregatesAsync(partition, InclusiveWindowStart(today, WindowDays), today);
return new return new TimeAttributionMetricsResponse
{ {
success = true, Success = true,
windowDays = WindowDays, WindowDays = WindowDays,
// Range statistics per enrollment class (never mixed) — the UI gates rendering // Range statistics per enrollment class (never mixed) — the UI gates rendering
// at cleanSessionCount >= 20 and shows "insufficient data (n=…)" below it. // at cleanSessionCount >= 20 and shows "insufficient data (n=…)" below it.
classes = rolling.OrderBy(r => r.EnrollmentClass, StringComparer.Ordinal).ToList(), Classes = rolling.OrderBy(r => r.EnrollmentClass, StringComparer.Ordinal).ToList(),
daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(), Daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
}; };
} }
} }
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware // Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var tenantIdFilter = query["tenantId"]; var tenantIdFilter = query["tenantId"];
var days = VerdictCalibrationResponse.ClampDays(query["days"]); var days = VerdictCalibrationResponseBuilder.ClampDays(query["days"]);
var partition = "global"; var partition = "global";
if (!string.IsNullOrWhiteSpace(tenantIdFilter)) if (!string.IsNullOrWhiteSpace(tenantIdFilter))
{ {
@@ -57,14 +57,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var today = DateTime.UtcNow.Date; var today = DateTime.UtcNow.Date;
// One read covers both the requested window and the trend horizon (7d window + 28d baseline). // One read covers both the requested window and the trend horizon (7d window + 28d baseline).
var readDays = Math.Max(days, VerdictCalibrationResponse.TrendHorizonDays); var readDays = Math.Max(days, VerdictCalibrationResponseBuilder.TrendHorizonDays);
var daily = await _metricsRepo.GetVerdictCalibrationAggregatesAsync( var daily = await _metricsRepo.GetVerdictCalibrationAggregatesAsync(
partition, VerdictCalibrationResponse.InclusiveWindowStart(today, readDays), today); partition, VerdictCalibrationResponseBuilder.InclusiveWindowStart(today, readDays), today);
// Active drift episodes of this partition (tracker keyspace; fail-soft empty). // Active drift episodes of this partition (tracker keyspace; fail-soft empty).
var alerts = await _tracker.GetVerdictCalibrationAlertsAsync(partition); var alerts = await _tracker.GetVerdictCalibrationAlertsAsync(partition);
var response = req.CreateResponse(HttpStatusCode.OK); var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(VerdictCalibrationResponse.Build(daily, partition, today, days, alerts)); await response.WriteAsJsonAsync(VerdictCalibrationResponseBuilder.Build(daily, partition, today, days, alerts));
return response; return response;
} }
catch (Exception ex) catch (Exception ex)
@@ -77,8 +77,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
} }
} }
/// <summary>Pure response builder — internal static so the matrix arithmetic is pinned by tests.</summary> /// <summary>
internal static class VerdictCalibrationResponse /// Pure response builder — internal static so the matrix arithmetic is pinned by tests.
/// The row/envelope DTOs live in AutopilotMonitor.Shared.Models
/// (Models/Metrics/VerdictCalibrationApiModels.cs) so the manifest exports them.
/// </summary>
internal static class VerdictCalibrationResponseBuilder
{ {
internal const int DefaultWindowDays = 30; internal const int DefaultWindowDays = 30;
internal const int MaxWindowDays = 180; // aggregate retention internal const int MaxWindowDays = 180; // aggregate retention
@@ -95,37 +99,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
/// <summary>"Last N days" = exactly N calendar day keys including today (both range ends inclusive).</summary> /// <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)); internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
internal sealed class PathRow
{
public string VerdictPath { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public int Count { get; set; }
public double SharePct { get; set; }
public int DerivedCount { get; set; }
public int Eligible7d { get; set; }
public int ReEnrolled7d { get; set; }
/// <summary>Null below the minimum eligible count — never a rate on a handful of sessions.</summary>
public double? ReEnrollRatePct { get; set; }
public int OverriddenByAdmin { get; set; }
public int OverriddenByLateCompletion { get; set; }
public int OverriddenOther { get; set; }
public TrendWindow Window7 { get; set; } = new();
public TrendWindow Baseline28 { get; set; } = new();
/// <summary>Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented).</summary>
public double? Lift { get; set; }
}
internal sealed class TrendWindow
{
public int Count { get; set; }
public int Sessions { get; set; }
public double SharePct { get; set; }
}
/// <summary>Minimum eligible sessions before a re-enrollment rate is stated (truthfulness: no rate on n&lt;20).</summary> /// <summary>Minimum eligible sessions before a re-enrollment rate is stated (truthfulness: no rate on n&lt;20).</summary>
internal const int MinEligibleForRate = 20; internal const int MinEligibleForRate = 20;
internal static object Build( internal static VerdictCalibrationResponse Build(
IReadOnlyList<VerdictCalibrationDailyAggregate> daily, string partition, DateTime today, int days, IReadOnlyList<VerdictCalibrationDailyAggregate> daily, string partition, DateTime today, int days,
IReadOnlyList<VerdictCalibrationAlert>? alerts = null) IReadOnlyList<VerdictCalibrationAlert>? alerts = null)
{ {
@@ -133,18 +110,18 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var trendWindowStart = Key(InclusiveWindowStart(today, TrendWindowDays)); var trendWindowStart = Key(InclusiveWindowStart(today, TrendWindowDays));
var baselineStart = Key(InclusiveWindowStart(today, TrendHorizonDays)); var baselineStart = Key(InclusiveWindowStart(today, TrendHorizonDays));
var rows = new Dictionary<(string Path, string Status), PathRow>(); var rows = new Dictionary<(string Path, string Status), VerdictCalibrationPathRow>();
int sessions = 0, terminal = 0, derived = 0; int sessions = 0, terminal = 0, derived = 0;
int trendSessions = 0, baselineSessions = 0; int trendSessions = 0, baselineSessions = 0;
DateTime? computedAt = null; DateTime? computedAt = null;
var versions = new HashSet<int>(); var versions = new HashSet<int>();
PathRow Row(VerdictCalibrationBucket b) VerdictCalibrationPathRow Row(VerdictCalibrationBucket b)
{ {
var key = (b.VerdictPath, b.Status); var key = (b.VerdictPath, b.Status);
if (!rows.TryGetValue(key, out var row)) if (!rows.TryGetValue(key, out var row))
{ {
row = new PathRow { VerdictPath = b.VerdictPath, Status = b.Status }; row = new VerdictCalibrationPathRow { VerdictPath = b.VerdictPath, Status = b.Status };
rows[key] = row; rows[key] = row;
} }
return row; return row;
@@ -206,20 +183,32 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
: null; : null;
} }
return new return new VerdictCalibrationResponse
{ {
success = true, Success = true,
tenantId = partition, TenantId = partition,
windowDays = days, WindowDays = days,
windowStart, WindowStart = windowStart,
windowEnd = Key(today), WindowEnd = Key(today),
computedAt, ComputedAt = computedAt,
versions = versions.OrderBy(v => v).ToArray(), Versions = versions.OrderBy(v => v).ToArray(),
totals = new { sessions, terminal, derived, days = daily.Count(d => string.CompareOrdinal(d.Date, windowStart) >= 0) }, Totals = new VerdictCalibrationTotals
trend = new { windowDays = TrendWindowDays, baselineDays = TrendBaselineDays, windowSessions = trendSessions, baselineSessions }, {
paths = list, Sessions = sessions,
Terminal = terminal,
Derived = derived,
Days = daily.Count(d => string.CompareOrdinal(d.Date, windowStart) >= 0),
},
Trend = new VerdictCalibrationTrendMeta
{
WindowDays = TrendWindowDays,
BaselineDays = TrendBaselineDays,
WindowSessions = trendSessions,
BaselineSessions = baselineSessions,
},
Paths = list,
// Wording contract shared with the rule radar: a dimension is correlated, not causal. // Wording contract shared with the rule radar: a dimension is correlated, not causal.
alerts = (alerts ?? Array.Empty<VerdictCalibrationAlert>()) Alerts = (alerts ?? Array.Empty<VerdictCalibrationAlert>())
.OrderByDescending(a => a.FirstNotifiedAt) .OrderByDescending(a => a.FirstNotifiedAt)
.ToList(), .ToList(),
}; };
@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
} }
/// <summary> /// <summary>
/// GET /api/metrics/rule-stats?startDate=...&endDate=...&ruleType=analyze /// GET /api/metrics/rule-stats?startDate=...&amp;endDate=...&amp;ruleType=analyze
/// Returns rule stats for the caller's tenant (MemberRead). /// Returns rule stats for the caller's tenant (MemberRead).
/// </summary> /// </summary>
[Function("GetRuleStats")] [Function("GetRuleStats")]
@@ -48,55 +49,16 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
tenantId, startDate, endDate); tenantId, startDate, endDate);
var entries = await _metricsRepo.GetRuleStatsAsync(tenantId, startDate, endDate, ruleType); var entries = await _metricsRepo.GetRuleStatsAsync(tenantId, startDate, endDate, ruleType);
var aggregated = BuildRuleAggregates(entries);
// Aggregate across dates per ruleId var result = new RuleStatsResponse
var aggregated = entries
.GroupBy(e => e.RuleId)
.Select(g => new
{
ruleId = g.Key,
ruleType = g.First().RuleType,
ruleTitle = g.First().RuleTitle,
category = g.First().Category,
severity = g.First().Severity,
fireCount = g.Sum(e => e.FireCount),
evaluationCount = g.Sum(e => e.EvaluationCount),
sessionsEvaluated = g.Sum(e => e.SessionsEvaluated),
hitRate = g.Sum(e => e.EvaluationCount) > 0
? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1)
: 0.0,
avgConfidenceScore = g.Sum(e => e.FireCount) > 0
? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1)
: 0.0,
trend = g.OrderBy(e => e.Date).Select(e => new
{
date = e.Date,
fireCount = e.FireCount,
evaluationCount = e.EvaluationCount
})
})
.OrderByDescending(r => r.fireCount)
.ToList();
var totalEvaluations = aggregated.Sum(r => r.evaluationCount);
var totalFires = aggregated.Sum(r => r.fireCount);
var result = new
{ {
rules = aggregated, Rules = aggregated,
// F3 PR6: active regression episodes (tracker rows) — the rules-page badge // F3 PR6: active regression episodes (tracker rows) — the rules-page badge
// and MCP get_rule_stats read them from here. Empty list = nothing regressed. // and MCP get_rule_stats read them from here. Empty list = nothing regressed.
regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId), Regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId),
summary = new // The tenant route historically carries no uniqueRules key — keep it absent.
{ Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: null),
totalEvaluations,
totalFires,
overallHitRate = totalEvaluations > 0
? Math.Round(100.0 * totalFires / totalEvaluations, 1)
: 0.0,
topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId,
period = new { start = startDate, end = endDate }
}
}; };
var response = req.CreateResponse(HttpStatusCode.OK); var response = req.CreateResponse(HttpStatusCode.OK);
@@ -113,7 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
} }
/// <summary> /// <summary>
/// GET /api/global/metrics/rule-stats?startDate=...&endDate=...&ruleType=analyze&tenantId=... /// GET /api/global/metrics/rule-stats?startDate=...&amp;endDate=...&amp;ruleType=analyze&amp;tenantId=...
/// Returns cross-tenant global rule stats (Global Admin only). /// Returns cross-tenant global rule stats (Global Admin only).
/// When tenantId is provided, returns tenant-specific stats instead of global aggregates. /// When tenantId is provided, returns tenant-specific stats instead of global aggregates.
/// </summary> /// </summary>
@@ -136,57 +98,17 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
queryTenantId, startDate, endDate); queryTenantId, startDate, endDate);
var entries = await _metricsRepo.GetRuleStatsAsync(queryTenantId, startDate, endDate, ruleType); var entries = await _metricsRepo.GetRuleStatsAsync(queryTenantId, startDate, endDate, ruleType);
var aggregated = BuildRuleAggregates(entries);
var aggregated = entries var result = new RuleStatsResponse
.GroupBy(e => e.RuleId)
.Select(g => new
{
ruleId = g.Key,
ruleType = g.First().RuleType,
ruleTitle = g.First().RuleTitle,
category = g.First().Category,
severity = g.First().Severity,
fireCount = g.Sum(e => e.FireCount),
evaluationCount = g.Sum(e => e.EvaluationCount),
sessionsEvaluated = g.Sum(e => e.SessionsEvaluated),
hitRate = g.Sum(e => e.EvaluationCount) > 0
? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1)
: 0.0,
avgConfidenceScore = g.Sum(e => e.FireCount) > 0
? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1)
: 0.0,
trend = g.OrderBy(e => e.Date).Select(e => new
{
date = e.Date,
fireCount = e.FireCount,
evaluationCount = e.EvaluationCount
})
})
.OrderByDescending(r => r.fireCount)
.ToList();
var totalEvaluations = aggregated.Sum(r => r.evaluationCount);
var totalFires = aggregated.Sum(r => r.fireCount);
var result = new
{ {
rules = aggregated, Rules = aggregated,
// Regression episodes are tenant-scoped: present when drilling into one // Regression episodes are tenant-scoped: present when drilling into one
// tenant, empty for the cross-tenant "global" aggregate (no episode there). // tenant, empty for the cross-tenant "global" aggregate (no episode there).
regressions = queryTenantId != "global" Regressions = queryTenantId != "global"
? await _notificationTracker.GetRuleRegressionsAsync(queryTenantId) ? await _notificationTracker.GetRuleRegressionsAsync(queryTenantId)
: new List<AutopilotMonitor.Shared.Models.RuleRegressionAlert>(), : new List<RuleRegressionAlert>(),
summary = new Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: aggregated.Count),
{
totalEvaluations,
totalFires,
overallHitRate = totalEvaluations > 0
? Math.Round(100.0 * totalFires / totalEvaluations, 1)
: 0.0,
topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId,
uniqueRules = aggregated.Count,
period = new { start = startDate, end = endDate }
}
}; };
var response = req.CreateResponse(HttpStatusCode.OK); var response = req.CreateResponse(HttpStatusCode.OK);
@@ -201,5 +123,53 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return errorResponse; return errorResponse;
} }
} }
/// <summary>Aggregate the per-date entries across dates per ruleId, fires descending.</summary>
internal static List<RuleStatsRuleAggregate> BuildRuleAggregates(IReadOnlyList<RuleStatsEntry> entries)
=> entries
.GroupBy(e => e.RuleId)
.Select(g => new RuleStatsRuleAggregate
{
RuleId = g.Key,
RuleType = g.First().RuleType,
RuleTitle = g.First().RuleTitle,
Category = g.First().Category,
Severity = g.First().Severity,
FireCount = g.Sum(e => e.FireCount),
EvaluationCount = g.Sum(e => e.EvaluationCount),
SessionsEvaluated = g.Sum(e => e.SessionsEvaluated),
HitRate = g.Sum(e => e.EvaluationCount) > 0
? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1)
: 0.0,
AvgConfidenceScore = g.Sum(e => e.FireCount) > 0
? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1)
: 0.0,
Trend = g.OrderBy(e => e.Date).Select(e => new RuleTrendPoint
{
Date = e.Date,
FireCount = e.FireCount,
EvaluationCount = e.EvaluationCount
}).ToList(),
})
.OrderByDescending(r => r.FireCount)
.ToList();
internal static RuleStatsSummary BuildSummary(
IReadOnlyList<RuleStatsRuleAggregate> aggregated, string startDate, string endDate, int? uniqueRules)
{
var totalEvaluations = aggregated.Sum(r => r.EvaluationCount);
var totalFires = aggregated.Sum(r => r.FireCount);
return new RuleStatsSummary
{
TotalEvaluations = totalEvaluations,
TotalFires = totalFires,
OverallHitRate = totalEvaluations > 0
? Math.Round(100.0 * totalFires / totalEvaluations, 1)
: 0.0,
TopRuleByFireCount = aggregated.Count > 0 ? aggregated[0].RuleId : null,
UniqueRules = uniqueRules,
Period = new RuleStatsPeriod { Start = startDate, End = endDate },
};
}
} }
} }
@@ -2,6 +2,7 @@ using System.Net;
using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Deletion; using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Deletion; using AutopilotMonitor.Shared.Models.Deletion;
using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Http;
@@ -172,12 +173,12 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
/// </summary> /// </summary>
internal static object BuildV2ResponseBody(SessionDeletionEnqueueResult result, string sessionId) => result.Outcome switch internal static object BuildV2ResponseBody(SessionDeletionEnqueueResult result, string sessionId) => result.Outcome switch
{ {
SessionDeletionEnqueueOutcome.Enqueued => new SessionDeletionEnqueueOutcome.Enqueued => new SessionDeletionQueuedResponse
{ {
success = true, Success = true,
status = "queued", Status = "queued",
manifestId = result.ManifestId, ManifestId = result.ManifestId,
message = "Cascade deletion queued; worker will drain asynchronously.", Message = "Cascade deletion queued; worker will drain asynchronously.",
}, },
SessionDeletionEnqueueOutcome.AlreadyInFlight => new SessionDeletionEnqueueOutcome.AlreadyInFlight => new
{ {
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Deletion; using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -170,19 +171,19 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{ {
var status = MapOutcomeToStatus(result.Outcome); var status = MapOutcomeToStatus(result.Outcome);
var body = new var body = new SessionRestoreResponse
{ {
success = status == HttpStatusCode.OK, Success = status == HttpStatusCode.OK,
outcome = result.Outcome.ToString(), Outcome = result.Outcome.ToString(),
mode = result.Mode, Mode = result.Mode,
message = result.Message, Message = result.Message,
currentState = result.CurrentState, CurrentState = result.CurrentState,
pendingManifestId = result.PendingManifestId, PendingManifestId = result.PendingManifestId,
rowsRestoredByTable = result.RowsRestoredByTable, RowsRestoredByTable = result.RowsRestoredByTable,
rowsSkippedByTable = result.RowsSkippedByTable, RowsSkippedByTable = result.RowsSkippedByTable,
wouldRestoreByTable = result.WouldRestoreByTable, WouldRestoreByTable = result.WouldRestoreByTable,
inventoryReIncrements = result.InventoryReIncrements, InventoryReIncrements = result.InventoryReIncrements,
durationMs = result.DurationMs, DurationMs = result.DurationMs,
}; };
return await WriteJsonAsync(req, status, body); return await WriteJsonAsync(req, status, body);
@@ -71,7 +71,7 @@ public static class MetricsMath
/// final status. Peer bytes and Microsoft Connected Cache (MCC) bytes are reported separately — /// final status. Peer bytes and Microsoft Connected Cache (MCC) bytes are reported separately —
/// MCC is counted apart from peers by DO — and offload% credits both as "not pulled from the CDN". /// MCC is counted apart from peers by DO — and offload% credits both as "not pulled from the CDN".
/// </summary> /// </summary>
public static object BuildAppMetricsPayload(IEnumerable<AppInstallSummary> summaries) public static AppMetricsResponse BuildAppMetricsPayload(IEnumerable<AppInstallSummary> summaries)
{ {
var summaryList = summaries as IList<AppInstallSummary> ?? summaries.ToList(); var summaryList = summaries as IList<AppInstallSummary> ?? summaries.ToList();
@@ -101,65 +101,66 @@ public static class MetricsMath
// row reports source bytes but no DoTotalBytesDownloaded — so that telemetry is not lost. // row reports source bytes but no DoTotalBytesDownloaded — so that telemetry is not lost.
var doG = DoAggregator.Compute(g); var doG = DoAggregator.Compute(g);
return new return new AppMetricsAppGroup
{ {
appName = g.Key, AppName = g.Key,
totalInstalls = total, TotalInstalls = total,
succeeded = installed.Count, Succeeded = installed.Count,
skipped = skipped.Count, Skipped = skipped.Count,
unmeasured = installed.Count - measured.Count, Unmeasured = installed.Count - measured.Count,
failed = failed.Count, Failed = failed.Count,
// Skips leave the rate (they are not attempts); legacy rows stay in it. // Skips leave the rate (they are not attempts); legacy rows stay in it.
failureRate = TerminalFailureRatePct(failed.Count, installed.Count), FailureRate = TerminalFailureRatePct(failed.Count, installed.Count),
avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0, AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0, MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
measuredInstalls = measured.Count, MeasuredInstalls = measured.Count,
avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0, AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
doTotalBytesDownloaded = doG.TotalBytesDownloaded, DoTotalBytesDownloaded = doG.TotalBytesDownloaded,
doBytesFromPeers = doG.BytesFromPeers, DoBytesFromPeers = doG.BytesFromPeers,
doBytesFromCacheServer = doG.BytesFromCacheServer, DoBytesFromCacheServer = doG.BytesFromCacheServer,
doBytesFromHttp = doG.BytesFromHttp, DoBytesFromHttp = doG.BytesFromHttp,
peerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded), PeerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded),
topFailureCodes = failed TopFailureCodes = failed
.Where(f => !string.IsNullOrEmpty(f.FailureCode)) .Where(f => !string.IsNullOrEmpty(f.FailureCode))
.GroupBy(f => f.FailureCode) .GroupBy(f => f.FailureCode)
.OrderByDescending(fc => fc.Count()) .OrderByDescending(fc => fc.Count())
.Take(3) .Take(3)
.Select(fc => new { code = fc.Key, count = fc.Count() }) .Select(fc => new AppFailureCodeCount { Code = fc.Key, Count = fc.Count() })
.ToList()
}; };
}).ToList(); }).ToList();
// Slowest ranking gates on MEASURED installs — an app whose durations were mostly // Slowest ranking gates on MEASURED installs — an app whose durations were mostly
// unobserved must not rank as "fast" on a handful of zeros (audit §0.5). // unobserved must not rank as "fast" on a handful of zeros (audit §0.5).
var slowestApps = SelectSlowestApps( var slowestApps = SelectSlowestApps(
appGroups, a => a.measuredInstalls, a => (double)a.avgDurationSeconds, minSamples: 3, take: 10); appGroups, a => a.MeasuredInstalls, a => a.AvgDurationSeconds, minSamples: 3, take: 10);
var topFailingApps = appGroups var topFailingApps = appGroups
.Where(a => a.failed > 0) .Where(a => a.Failed > 0)
.OrderByDescending(a => a.failed) .OrderByDescending(a => a.Failed)
.ThenByDescending(a => a.failureRate) .ThenByDescending(a => a.FailureRate)
.Take(10) .Take(10)
.ToList(); .ToList();
var doAll = DoAggregator.Compute(summaryList); var doAll = DoAggregator.Compute(summaryList);
return new return new AppMetricsResponse
{ {
success = true, Success = true,
totalApps = appGroups.Count, TotalApps = appGroups.Count,
totalInstalls = summaryList.Count, TotalInstalls = summaryList.Count,
totalSkipped = appGroups.Sum(a => a.skipped), TotalSkipped = appGroups.Sum(a => a.Skipped),
totalUnmeasured = appGroups.Sum(a => a.unmeasured), TotalUnmeasured = appGroups.Sum(a => a.Unmeasured),
totalCollisionExcluded, TotalCollisionExcluded = totalCollisionExcluded,
slowestApps, SlowestApps = slowestApps,
topFailingApps, TopFailingApps = topFailingApps,
deliveryOptimization = new DeliveryOptimization = new AppMetricsDeliveryOptimization
{ {
totalBytesDownloaded = doAll.TotalBytesDownloaded, TotalBytesDownloaded = doAll.TotalBytesDownloaded,
fromPeers = doAll.BytesFromPeers, FromPeers = doAll.BytesFromPeers,
fromCacheServer = doAll.BytesFromCacheServer, FromCacheServer = doAll.BytesFromCacheServer,
fromHttp = doAll.BytesFromHttp, FromHttp = doAll.BytesFromHttp,
peerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded), PeerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded),
} }
}; };
} }
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Functions.Services
// Shallow copy so flipping FromCache never mutates the instance an earlier // Shallow copy so flipping FromCache never mutates the instance an earlier
// (fresh) caller may still be holding/serializing. The buckets are shared // (fresh) caller may still be holding/serializing. The buckets are shared
// read-only — nothing downstream writes into a response. // read-only — nothing downstream writes into a response.
return entry.metrics.CloneAsCacheHit(); return CloneAsCacheHit(entry.metrics);
} }
} }
@@ -287,6 +287,21 @@ namespace AutopilotMonitor.Functions.Services
})); }));
} }
/// <summary>Shallow copy flagged as a cache hit — see the cache-read path.</summary>
private static AgentEfficiencyMetricsResponse CloneAsCacheHit(AgentEfficiencyMetricsResponse source) => new()
{
WindowDays = source.WindowDays,
SessionLimit = source.SessionLimit,
TenantId = source.TenantId,
SessionsScanned = source.SessionsScanned,
SessionsWithSnapshots = source.SessionsWithSnapshots,
ByVersion = source.ByVersion,
Overall = source.Overall,
ComputedAt = source.ComputedAt,
ComputeDurationMs = source.ComputeDurationMs,
FromCache = true
};
private static PercentileStats? BuildStats(IEnumerable<double> values) private static PercentileStats? BuildStats(IEnumerable<double> values)
{ {
var sorted = values.OrderBy(v => v).ToList(); var sorted = values.OrderBy(v => v).ToList();
@@ -303,76 +318,7 @@ namespace AutopilotMonitor.Functions.Services
} }
} }
// ── Response DTOs ──────────────────────────────────────────────────────────── // Response DTOs (AgentEfficiencyMetricsResponse family) moved to
// AutopilotMonitor.Shared.Models (Models/Metrics/AgentPerformanceMetrics.cs) so the
public class AgentEfficiencyMetricsResponse // shared manifest exports them as wire types.
{
public int WindowDays { get; set; }
public int SessionLimit { get; set; }
/// <summary>Echo of the requested tenant filter; null = cross-tenant aggregate.</summary>
public string? TenantId { get; set; }
/// <summary>Sessions the scan covered — compare against <see cref="SessionLimit"/> for truncation.</summary>
public int SessionsScanned { get; set; }
public int SessionsWithSnapshots { get; set; }
public List<AgentVersionEfficiency> ByVersion { get; set; } = new();
public AgentVersionEfficiency? Overall { get; set; }
public DateTime ComputedAt { get; set; }
public int ComputeDurationMs { get; set; }
public bool FromCache { get; set; }
/// <summary>Shallow copy flagged as a cache hit — see the cache-read path.</summary>
internal AgentEfficiencyMetricsResponse CloneAsCacheHit() => new()
{
WindowDays = WindowDays,
SessionLimit = SessionLimit,
TenantId = TenantId,
SessionsScanned = SessionsScanned,
SessionsWithSnapshots = SessionsWithSnapshots,
ByVersion = ByVersion,
Overall = Overall,
ComputedAt = ComputedAt,
ComputeDurationMs = ComputeDurationMs,
FromCache = true
};
}
public class AgentVersionEfficiency
{
/// <summary>Null on the cross-version "overall" bucket (omitted on the wire).</summary>
public string? AgentVersion { get; set; }
public int SessionsScanned { get; set; }
public int SessionsWithSnapshots { get; set; }
public int SpoolPressureSessions { get; set; }
public PercentileStats? AvgCpuPercent { get; set; }
public PercentileStats? MaxCpuPercent { get; set; }
public PercentileStats? MaxWorkingSetMb { get; set; }
public PercentileStats? MaxPrivateBytesMb { get; set; }
public PercentileStats? MaxThreadCount { get; set; }
public PercentileStats? MaxHandleCount { get; set; }
public PercentileStats? MaxSpoolDepth { get; set; }
public PercentileStats? MaxSpoolFileBytes { get; set; }
public PercentileStats? ApiLatencyMs { get; set; }
public PercentileStats? ApiRequestCount { get; set; }
public CrashRateMetrics? CrashRate { get; set; }
public List<EfficiencyOffender>? TopOffenders { get; set; }
}
/// <summary>Distribution of a per-session statistic across a version bucket.</summary>
public class PercentileStats
{
public double P50 { get; set; }
public double P95 { get; set; }
public double Max { get; set; }
public double Avg { get; set; }
public int SampleCount { get; set; }
}
public class EfficiencyOffender
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string? DeviceName { get; set; }
public string Dimension { get; set; } = string.Empty;
public double Value { get; set; }
}
} }
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services namespace AutopilotMonitor.Functions.Services
{ {
@@ -328,81 +328,7 @@ namespace AutopilotMonitor.Functions.Services
=> AgentMetricsAggregation.GetString(data, key); => AgentMetricsAggregation.GetString(data, key);
} }
// ── Response DTOs ──────────────────────────────────────────────────────────── // Response DTOs (PlatformAgentMetricsResponse family) moved to
// AutopilotMonitor.Shared.Models (Models/Metrics/AgentPerformanceMetrics.cs) so the
public class PlatformAgentMetricsResponse // shared manifest exports them as wire types.
{
public List<SessionAgentMetric> Sessions { get; set; } = new();
public DeliveryLatencyMetrics? DeliveryLatency { get; set; }
public CrashRateMetrics? CrashRate { get; set; }
public DateTime ComputedAt { get; set; }
public int ComputeDurationMs { get; set; }
public bool FromCache { get; set; }
public int WindowDays { get; set; }
public int SessionLimit { get; set; }
/// <summary>
/// Sessions the scan actually covered in the window (before the has-snapshots filter
/// that shapes <see cref="Sessions"/>). Callers must compare THIS against
/// <see cref="SessionLimit"/> to decide whether the window was truncated —
/// <c>Sessions.Count</c> understates truncation on fleets where many sessions
/// emit no agent_metrics_snapshot.
/// </summary>
public int SessionsScanned { get; set; }
}
public class SessionAgentMetric
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string? DeviceName { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? StartedAt { get; set; }
public string? Status { get; set; }
public string? AgentVersion { get; set; }
public int SnapshotCount { get; set; }
public double TotalBytesUp { get; set; }
public double TotalBytesDown { get; set; }
public double TotalRequests { get; set; }
public double AvgCpu { get; set; }
public double MaxCpu { get; set; }
public double AvgWorkingSet { get; set; }
public double MaxWorkingSet { get; set; }
public double AvgPrivateBytes { get; set; }
public double AvgLatency { get; set; }
public double AvgSpoolDepth { get; set; }
public double MaxSpoolDepth { get; set; }
public double PeakSpoolDepth { get; set; }
public double MaxSpoolFileBytes { get; set; }
public double TotalEventsEmitted { get; set; }
public bool SpoolPressureDetected { get; set; }
}
public class DeliveryLatencyMetrics
{
public double P50Ms { get; set; }
public double P95Ms { get; set; }
public double P99Ms { get; set; }
public double AvgMs { get; set; }
public int SampleCount { get; set; }
public double ClockSkewPercent { get; set; }
}
public class CrashRateMetrics
{
public int TotalStarts { get; set; }
public int CleanExits { get; set; }
public int ExceptionCrashes { get; set; }
public int HardKills { get; set; }
public int RebootKills { get; set; }
public int FirstRuns { get; set; }
public double CrashRatePercent { get; set; }
public List<CrashExceptionSummary> TopExceptions { get; set; } = new();
}
public class CrashExceptionSummary
{
public string ExceptionType { get; set; } = string.Empty;
public int Count { get; set; }
}
} }
File diff suppressed because it is too large Load Diff
@@ -14,8 +14,12 @@ import type {
GetMcpUserUsageResponse, GetMcpUserUsageResponse,
GetMyMcpUsageResponse, GetMyMcpUsageResponse,
GetUnmatchedSoftwareResponse, GetUnmatchedSoftwareResponse,
PlatformAgentMetricsResponse,
QueryRawTableResponse, QueryRawTableResponse,
RuleStatsResponse,
RuleStatsRuleAggregate,
SoftwareInventoryResponse, SoftwareInventoryResponse,
VerdictCalibrationResponse,
} from '../generated/wire-types.generated.js'; } from '../generated/wire-types.generated.js';
/** /**
@@ -457,31 +461,12 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
}, },
async (args) => withToolTelemetry('get_platform_metrics', args, async () => { async (args) => withToolTelemetry('get_platform_metrics', args, async () => {
try { try {
type SessionMetric = {
sessionId: string; tenantId: string; deviceName?: string; model?: string; status?: string;
agentVersion?: string; snapshotCount: number;
avgCpu: number; maxCpu: number; avgWorkingSet: number; maxWorkingSet: number;
avgPrivateBytes: number; avgLatency: number;
totalBytesUp: number; totalBytesDown: number; totalRequests: number;
avgSpoolDepth: number; maxSpoolDepth: number; peakSpoolDepth: number;
maxSpoolFileBytes: number; totalEventsEmitted: number; spoolPressureDetected: boolean;
};
// Backend fans out one storage query per session (bounded to 32 concurrent), so // Backend fans out one storage query per session (bounded to 32 concurrent), so
// 2000-session windows take longer than the default 30s client timeout — same // 2000-session windows take longer than the default 30s client timeout — same
// override precedent as get_session_summary. Warm (cached) calls return instantly. // override precedent as get_session_summary. Warm (cached) calls return instantly.
const raw = await apiFetch( const raw = await apiFetch(
`/api/global/metrics/platform${buildQuery({ days: args.days, limit: args.maxSessions })}`, `/api/global/metrics/platform${buildQuery({ days: args.days, limit: args.maxSessions })}`,
{ signal: AbortSignal.timeout(90_000) }) as { signal: AbortSignal.timeout(90_000) }) as PlatformAgentMetricsResponse;
{
sessions?: SessionMetric[]; windowDays?: number; sessionLimit?: number; sessionsScanned?: number;
deliveryLatency?: { p50Ms: number; p95Ms: number; p99Ms: number; avgMs: number; sampleCount: number; clockSkewPercent: number };
crashRate?: {
totalStarts: number; cleanExits: number; exceptionCrashes: number; hardKills: number;
rebootKills: number; firstRuns: number; crashRatePercent: number;
topExceptions?: { exceptionType: string; count: number }[];
};
computedAt?: string; computeDurationMs?: number; fromCache?: boolean;
};
const sessions = raw?.sessions ?? []; const sessions = raw?.sessions ?? [];
const requested = { days: args.days, limit: args.maxSessions }; const requested = { days: args.days, limit: args.maxSessions };
// Backend-computed blocks that exist independently of snapshot availability // Backend-computed blocks that exist independently of snapshot availability
@@ -898,7 +883,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
async (args) => withToolTelemetry('get_verdict_calibration', args, async () => { async (args) => withToolTelemetry('get_verdict_calibration', args, async () => {
try { try {
const { tenantId, days, minSharePct, top } = args; const { tenantId, days, minSharePct, top } = args;
const data = await apiFetch(`/api/global/metrics/verdict-calibration${buildQuery({ tenantId, days })}`); const data = await apiFetch(`/api/global/metrics/verdict-calibration${buildQuery({ tenantId, days })}`) as VerdictCalibrationResponse;
return toolResultText(shapeVerdictCalibration(data, { minSharePct, top }), MAX_RESULT_SIZE_CHARS.adminStream); return toolResultText(shapeVerdictCalibration(data, { minSharePct, top }), MAX_RESULT_SIZE_CHARS.adminStream);
} catch (error: unknown) { } catch (error: unknown) {
return toolError('get_verdict_calibration', args, error); return toolError('get_verdict_calibration', args, error);
@@ -1490,15 +1475,15 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
}; };
if (tenantId) params.tenantId = tenantId; if (tenantId) params.tenantId = tenantId;
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId); const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`) as { const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`) as
rules?: Array<Record<string, unknown>>; RuleStatsResponse & { trendsNote?: string };
trendsNote?: string;
};
// The per-rule daily trend arrays are the size driver (one row per rule // The per-rule daily trend arrays are the size driver (one row per rule
// per day; a 30-day platform-scope response reaches 150k+ chars and gets // per day; a 30-day platform-scope response reaches 150k+ chars and gets
// host-truncated). Aggregates stay intact — trends only on request. // host-truncated). Aggregates stay intact — trends only on request.
// (Dropping `trend` and adding `trendsNote` is this tool's OUTPUT contract,
// layered on the wire type — hence the Partial view for the delete.)
if (!args.includeTrends && Array.isArray(data?.rules)) { if (!args.includeTrends && Array.isArray(data?.rules)) {
for (const rule of data.rules) delete rule.trend; for (const rule of data.rules) delete (rule as Partial<RuleStatsRuleAggregate>).trend;
data.trendsNote = 'Per-rule daily trend rows omitted. Re-call with includeTrends=true (plus a narrow startDate/endDate window) to include them.'; data.trendsNote = 'Per-rule daily trend rows omitted. Re-call with includeTrends=true (plus a narrow startDate/endDate window) to include them.';
} }
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
@@ -12,21 +12,23 @@
* in `omitted` so a filtered response never reads as complete coverage. * in `omitted` so a filtered response never reads as complete coverage.
*/ */
interface TrendCell { import type {
count: number; VerdictCalibrationPathRow as WirePathRow,
VerdictCalibrationTrendWindow as WireTrendWindow,
} from './generated/wire-types.generated.js';
// Derived from the generated wire types so a backend rename of any read field goes
// tsc-red here; the deltas below are THIS tool's output contract, not the wire.
interface TrendCell extends Omit<WireTrendWindow, 'sessions'> {
/** Dropped in shaped output — the denominators live once in `trend`. */
sessions?: number; sessions?: number;
sharePct: number;
} }
export interface VerdictCalibrationPathRow { export interface VerdictCalibrationPathRow
verdictPath: string; extends Omit<WirePathRow, 'reEnrollRatePct' | 'lift' | 'window7' | 'baseline28'> {
status: string; /** Explicit null in shaped output (wire omits the key when withheld). */
count: number;
sharePct: number;
overriddenByAdmin: number;
overriddenByLateCompletion: number;
overriddenOther: number;
reEnrollRatePct?: number | null; reEnrollRatePct?: number | null;
/** Explicit null in shaped output (wire omits the key when withheld). */
lift?: number | null; lift?: number | null;
window7: TrendCell; window7: TrendCell;
baseline28: TrendCell; baseline28: TrendCell;
@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models.Backup
{
/// <summary>
/// Response of <c>GET global/backups</c>: every backupId in the critical-table-backups
/// container, newest first. Serialized with <see cref="BackupManifestJson.SerializerOptions"/>
/// (the backup surface's own options), not the ApiJsonOptions pipeline.
/// </summary>
public class ListBackupsResponse : IApiResponse
{
public IReadOnlyList<string> BackupIds { get; set; } = default!;
}
}
@@ -0,0 +1,48 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// Response of <c>GET config/{tenantId}/feature-flags</c>: the member-readable subset of
/// the tenant configuration — UI display toggles, feature switches and the read-time
/// edition/entitlement surface. Adding a field here is a deliberate decision that the
/// field is non-sensitive (no webhook URLs, SAS tokens, allowlists, addresses).
/// </summary>
public class TenantFeatureFlagsResponse : IApiResponse
{
/// <summary>EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive Community enable) — field name kept for web compatibility.</summary>
public bool BootstrapTokenEnabled { get; set; }
/// <summary>Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail.</summary>
public bool DiagnosticsUploadConfigured { get; set; }
/// <summary>Drives the "Autopilot Device Validation disabled" dashboard banner.</summary>
public bool ValidateAutopilotDevice { get; set; }
/// <summary>Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids.</summary>
public bool AppHomingFunnelActive { get; set; }
public bool ShowScriptOutput { get; set; }
public bool EnableSoftwareInventoryAnalyzer { get; set; }
public bool EnableIntegrityBypassAnalyzer { get; set; }
/// <summary>EFFECTIVE gather-rule unrestricted mode (Pro edition + GA gate + tenant toggle).</summary>
public bool UnrestrictedMode { get; set; }
/// <summary>Resolved edition, lowercase ("community" / "pro").</summary>
public string Edition { get; set; } = string.Empty;
public bool IsTrial { get; set; }
/// <summary>Absent unless the tenant is on an active trial.</summary>
public DateTime? TrialExpiresUtc { get; set; }
public bool TrialAvailable { get; set; }
/// <summary>Whether a contact address is stored (boolean only — the address stays admin-gated).</summary>
public bool ContactEmailSet { get; set; }
public TenantFeatureEntitlements Entitlements { get; set; } = default!;
}
/// <summary>Read-time entitlement surface of the resolved edition.</summary>
public class TenantFeatureEntitlements
{
public int RetentionCapDays { get; set; }
/// <summary>Absent when the platform default applies.</summary>
public int? UserRateLimitPerMinute { get; set; }
public bool DelegatedAdminAllowed { get; set; }
public string McpUsagePlan { get; set; } = string.Empty;
}
}
@@ -0,0 +1,34 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// Response of <c>GET diagnostics/paths</c>: what every diagnostics package collects
/// before a tenant's own entries — the built-in section catalog (compiled into the agent)
/// and the platform-wide global paths set by Global Admins. Member-readable by design.
/// </summary>
public class DiagnosticsPathsResponse : IApiResponse
{
public IReadOnlyList<DiagnosticsBuiltInSectionWire> BuiltIn { get; set; } = default!;
public IReadOnlyList<DiagnosticsLogPath> GlobalPaths { get; set; } = default!;
}
/// <summary>
/// Wire projection of one built-in diagnostics section. <c>Condition</c> travels as the
/// enum NAME ("Always" | "RealmJoinWatcher" | "DevicePreparation"), never the integer —
/// the web switches on the string.
/// </summary>
public class DiagnosticsBuiltInSectionWire
{
public string Id { get; set; } = string.Empty;
public string ZipFolder { get; set; } = string.Empty;
/// <summary>UNEXPANDED source folder (may contain %ProgramData% or the user-profile token).</summary>
public string SourceFolder { get; set; } = string.Empty;
public IReadOnlyList<string> Patterns { get; set; } = default!;
public bool IncludeSubfolders { get; set; }
public string Description { get; set; } = string.Empty;
public string Condition { get; set; } = string.Empty;
}
}
@@ -0,0 +1,37 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>Response of GET feedback/eligibility: whether the caller should be shown the feedback prompt.</summary>
public class FeedbackEligibilityResponse : IApiResponse
{
public bool Eligible { get; set; }
}
/// <summary>Response of GET feedback/all (Global Admin dashboard): every stored feedback entry.</summary>
public class FeedbackListResponse : IApiResponse
{
public IReadOnlyList<FeedbackEntryWire> Feedback { get; set; } = default!;
}
/// <summary>One feedback interaction as rendered on the Global Admin dashboard.</summary>
public class FeedbackEntryWire
{
public string? Type { get; set; }
public string? Upn { get; set; }
public string? TenantId { get; set; }
public string? DisplayName { get; set; }
/// <summary>Absent on dismissals.</summary>
public int? Rating { get; set; }
/// <summary>Absent on dismissals.</summary>
public string? Comment { get; set; }
public bool Dismissed { get; set; }
public bool Submitted { get; set; }
/// <summary>ISO-8601 round-trip string; absent when never stamped.</summary>
public string? InteractedAt { get; set; }
public string? HistoryRowKey { get; set; }
public string? DomainName { get; set; }
}
}
@@ -10,7 +10,7 @@ namespace AutopilotMonitor.Shared.Models
/// Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the /// Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the
/// client; this payload carries the data, not the formatting. /// client; this payload carries the data, not the formatting.
/// </summary> /// </summary>
public sealed class FleetHealthMetrics public sealed class FleetHealthMetrics : IApiResponse
{ {
public bool Success { get; set; } = true; public bool Success { get; set; } = true;
public int Days { get; set; } public int Days { get; set; }
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.Models
/// <summary> /// <summary>
/// Response containing geographic performance metrics aggregated by location. /// Response containing geographic performance metrics aggregated by location.
/// </summary> /// </summary>
public class GeographicMetricsResponse public class GeographicMetricsResponse : IApiResponse
{ {
public bool Success { get; set; } public bool Success { get; set; }
public List<LocationMetrics> Locations { get; set; } = new(); public List<LocationMetrics> Locations { get; set; } = new();
@@ -32,7 +32,7 @@ namespace AutopilotMonitor.Shared.Models
} }
/// <summary>Response of <c>GET metrics/ime-pattern-health</c>.</summary> /// <summary>Response of <c>GET metrics/ime-pattern-health</c>.</summary>
public class ImePatternHealthResponse public class ImePatternHealthResponse : IApiResponse
{ {
public string? BaselineVersion { get; set; } public string? BaselineVersion { get; set; }
public int MinBaselineSessions { get; set; } public int MinBaselineSessions { get; set; }
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Agent performance / efficiency wire DTOs, moved verbatim from the Functions-local
// service files (PlatformMetricsService.cs / AgentEfficiencyMetricsService.cs) so the
// manifest exports them: property names and DECLARATION ORDER are the wire contract
// and must not change in a move-only refactor.
/// <summary>Response of <c>GET global/metrics/platform</c> (GetGlobalPlatformMetrics).</summary>
public class PlatformAgentMetricsResponse : IApiResponse
{
public List<SessionAgentMetric> Sessions { get; set; } = new();
public DeliveryLatencyMetrics? DeliveryLatency { get; set; }
public CrashRateMetrics? CrashRate { get; set; }
public DateTime ComputedAt { get; set; }
public int ComputeDurationMs { get; set; }
public bool FromCache { get; set; }
public int WindowDays { get; set; }
public int SessionLimit { get; set; }
/// <summary>
/// Sessions the scan actually covered in the window (before the has-snapshots filter
/// that shapes <see cref="Sessions"/>). Callers must compare THIS against
/// <see cref="SessionLimit"/> to decide whether the window was truncated —
/// <c>Sessions.Count</c> understates truncation on fleets where many sessions
/// emit no agent_metrics_snapshot.
/// </summary>
public int SessionsScanned { get; set; }
}
public class SessionAgentMetric
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string? DeviceName { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? StartedAt { get; set; }
public string? Status { get; set; }
public string? AgentVersion { get; set; }
public int SnapshotCount { get; set; }
public double TotalBytesUp { get; set; }
public double TotalBytesDown { get; set; }
public double TotalRequests { get; set; }
public double AvgCpu { get; set; }
public double MaxCpu { get; set; }
public double AvgWorkingSet { get; set; }
public double MaxWorkingSet { get; set; }
public double AvgPrivateBytes { get; set; }
public double AvgLatency { get; set; }
public double AvgSpoolDepth { get; set; }
public double MaxSpoolDepth { get; set; }
public double PeakSpoolDepth { get; set; }
public double MaxSpoolFileBytes { get; set; }
public double TotalEventsEmitted { get; set; }
public bool SpoolPressureDetected { get; set; }
}
public class DeliveryLatencyMetrics
{
public double P50Ms { get; set; }
public double P95Ms { get; set; }
public double P99Ms { get; set; }
public double AvgMs { get; set; }
public int SampleCount { get; set; }
public double ClockSkewPercent { get; set; }
}
public class CrashRateMetrics
{
public int TotalStarts { get; set; }
public int CleanExits { get; set; }
public int ExceptionCrashes { get; set; }
public int HardKills { get; set; }
public int RebootKills { get; set; }
public int FirstRuns { get; set; }
public double CrashRatePercent { get; set; }
public List<CrashExceptionSummary> TopExceptions { get; set; } = new();
}
public class CrashExceptionSummary
{
public string ExceptionType { get; set; } = string.Empty;
public int Count { get; set; }
}
/// <summary>Response of <c>GET global/metrics/agent-efficiency</c> (GetGlobalAgentEfficiency).</summary>
public class AgentEfficiencyMetricsResponse : IApiResponse
{
public int WindowDays { get; set; }
public int SessionLimit { get; set; }
/// <summary>Echo of the requested tenant filter; null = cross-tenant aggregate.</summary>
public string? TenantId { get; set; }
/// <summary>Sessions the scan covered — compare against <see cref="SessionLimit"/> for truncation.</summary>
public int SessionsScanned { get; set; }
public int SessionsWithSnapshots { get; set; }
public List<AgentVersionEfficiency> ByVersion { get; set; } = new();
public AgentVersionEfficiency? Overall { get; set; }
public DateTime ComputedAt { get; set; }
public int ComputeDurationMs { get; set; }
public bool FromCache { get; set; }
}
public class AgentVersionEfficiency
{
/// <summary>Null on the cross-version "overall" bucket (omitted on the wire).</summary>
public string? AgentVersion { get; set; }
public int SessionsScanned { get; set; }
public int SessionsWithSnapshots { get; set; }
public int SpoolPressureSessions { get; set; }
public PercentileStats? AvgCpuPercent { get; set; }
public PercentileStats? MaxCpuPercent { get; set; }
public PercentileStats? MaxWorkingSetMb { get; set; }
public PercentileStats? MaxPrivateBytesMb { get; set; }
public PercentileStats? MaxThreadCount { get; set; }
public PercentileStats? MaxHandleCount { get; set; }
public PercentileStats? MaxSpoolDepth { get; set; }
public PercentileStats? MaxSpoolFileBytes { get; set; }
public PercentileStats? ApiLatencyMs { get; set; }
public PercentileStats? ApiRequestCount { get; set; }
public CrashRateMetrics? CrashRate { get; set; }
public List<EfficiencyOffender>? TopOffenders { get; set; }
}
/// <summary>Distribution of a per-session statistic across a version bucket.</summary>
public class PercentileStats
{
public double P50 { get; set; }
public double P95 { get; set; }
public double Max { get; set; }
public double Avg { get; set; }
public int SampleCount { get; set; }
}
public class EfficiencyOffender
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string? DeviceName { get; set; }
public string Dimension { get; set; } = string.Empty;
public double Value { get; set; }
}
}
@@ -0,0 +1,68 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// Response of <c>GET metrics/app</c> and <c>GET global/metrics/app</c>: per-app install
/// health over the requested window (slowest apps by average FINAL-attempt duration, top
/// failing apps) plus the fleet Delivery Optimization rollup.
/// </summary>
public class AppMetricsResponse : IApiResponse
{
public bool Success { get; set; }
public int TotalApps { get; set; }
public int TotalInstalls { get; set; }
public int TotalSkipped { get; set; }
public int TotalUnmeasured { get; set; }
/// <summary>Rows excluded from per-app groups because their name-keyed row merged distinct appIds.</summary>
public int TotalCollisionExcluded { get; set; }
public IReadOnlyList<AppMetricsAppGroup> SlowestApps { get; set; } = default!;
public IReadOnlyList<AppMetricsAppGroup> TopFailingApps { get; set; } = default!;
public AppMetricsDeliveryOptimization DeliveryOptimization { get; set; } = default!;
}
/// <summary>One app's aggregate across its install rows in the window.</summary>
public class AppMetricsAppGroup
{
public string AppName { get; set; } = string.Empty;
public int TotalInstalls { get; set; }
public int Succeeded { get; set; }
public int Skipped { get; set; }
public int Unmeasured { get; set; }
public int Failed { get; set; }
/// <summary>Failed / (failed + succeeded) as a percentage; skips never count as attempts.</summary>
public double FailureRate { get; set; }
/// <summary>Average measured FINAL-attempt duration (whole seconds); 0 with no measured installs.</summary>
public double AvgDurationSeconds { get; set; }
public int MaxDurationSeconds { get; set; }
public int MeasuredInstalls { get; set; }
public long AvgDownloadBytes { get; set; }
public long DoTotalBytesDownloaded { get; set; }
public long DoBytesFromPeers { get; set; }
public long DoBytesFromCacheServer { get; set; }
public long DoBytesFromHttp { get; set; }
public double PeerOffloadPercent { get; set; }
/// <summary>Top 3 failure codes by count, descending.</summary>
public IReadOnlyList<AppFailureCodeCount> TopFailureCodes { get; set; } = default!;
}
/// <summary>One failure code with its occurrence count.</summary>
public class AppFailureCodeCount
{
public string Code { get; set; } = string.Empty;
public int Count { get; set; }
}
/// <summary>Fleet-wide Delivery Optimization rollup across every install row in the window.</summary>
public class AppMetricsDeliveryOptimization
{
public long TotalBytesDownloaded { get; set; }
public long FromPeers { get; set; }
public long FromCacheServer { get; set; }
public long FromHttp { get; set; }
/// <summary>Share of bytes not pulled from the CDN (peers + Microsoft Connected Cache), 0-100 one decimal.</summary>
public double PeerOffloadPercent { get; set; }
}
}
@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order. App Dashboard endpoints (apps/list, apps/{app}/analytics,
// apps/{app}/sessions and their global variants) — lifted from the Functions-local
// anonymous builders in AppsAnalyticsHelper so the manifest exports them.
/// <summary>
/// Response of <c>GET apps/list</c> and <c>GET global/apps/list</c>. Legacy mode (no
/// pageSize) returns the full array with the paging keys absent; opt-in pagination adds
/// count/offset/pageSize and a nextLink while more pages remain.
/// </summary>
public class AppsListResponse : IApiResponse
{
public bool Success { get; set; }
public int TotalApps { get; set; }
public int TotalInstalls { get; set; }
/// <summary>Rows excluded because their name-keyed row merged distinct appIds.</summary>
public int CollisionExcluded { get; set; }
public int WindowDays { get; set; }
/// <summary>Rows on this page; absent in legacy full-array mode.</summary>
public int? Count { get; set; }
/// <summary>Absent in legacy full-array mode.</summary>
public int? Offset { get; set; }
/// <summary>Absent in legacy full-array mode.</summary>
public int? PageSize { get; set; }
public IReadOnlyList<AppsListItem> Apps { get; set; } = default!;
/// <summary>Next-page link; absent in legacy mode and on the last page.</summary>
public string? NextLink { get; set; }
}
/// <summary>One app row of the apps list (failed desc, then failure rate, then name).</summary>
public class AppsListItem
{
public string AppName { get; set; } = string.Empty;
public string AppType { get; set; } = string.Empty;
public int TotalInstalls { get; set; }
public int Succeeded { get; set; }
public int Skipped { get; set; }
public int Unmeasured { get; set; }
public int Failed { get; set; }
public double FailureRate { get; set; }
public double AvgDurationSeconds { get; set; }
public int MaxDurationSeconds { get; set; }
public long AvgDownloadBytes { get; set; }
/// <summary>"improving" | "worsening" | "stable".</summary>
public string Trend { get; set; } = string.Empty;
/// <summary>Failure-rate delta between window halves; absent when either half has under 5 finished installs.</summary>
public double? TrendDelta { get; set; }
public DateTime LastSeenAt { get; set; }
}
/// <summary>Response of <c>GET apps/{appName}/analytics</c> and its global variant.</summary>
public class AppAnalyticsResponse : IApiResponse
{
public bool Success { get; set; }
public string AppName { get; set; } = string.Empty;
public string AppType { get; set; } = string.Empty;
public int WindowDays { get; set; }
public int CollisionExcluded { get; set; }
/// <summary>"day" (windows up to 30 days) or "week".</summary>
public string Bucket { get; set; } = string.Empty;
public AppAnalyticsSummary Summary { get; set; } = default!;
public IReadOnlyList<AppAnalyticsTimeBucket> TimeSeries { get; set; } = default!;
public IReadOnlyList<AppVersionBreakdownItem> VersionBreakdown { get; set; } = default!;
public IReadOnlyList<AppInstallerPhaseCount> InstallerPhaseBreakdown { get; set; } = default!;
public IReadOnlyList<AppAnalyticsFailureCode> TopFailureCodes { get; set; } = default!;
/// <summary>Succeeded installs whose detection re-check reported NotDetected.</summary>
public int DetectionLiesCount { get; set; }
public IReadOnlyList<AppDeviceModelBreakdownItem> DeviceModelBreakdown { get; set; } = default!;
/// <summary>Active duration-regression episodes for this app (tracker rows).</summary>
public IReadOnlyList<AppVersionRegressionAlert> VersionRegressions { get; set; } = default!;
}
/// <summary>Headline aggregate of one app's analytics window.</summary>
public class AppAnalyticsSummary
{
public int TotalInstalls { get; set; }
public int Succeeded { get; set; }
public int Skipped { get; set; }
public int Unmeasured { get; set; }
public int Failed { get; set; }
public double FailureRate { get; set; }
public double AvgDurationSeconds { get; set; }
public int P95DurationSeconds { get; set; }
public long AvgDownloadBytes { get; set; }
/// <summary>"improving" | "worsening" | "stable".</summary>
public string Trend { get; set; } = string.Empty;
/// <summary>Absent when either window half has under 5 finished installs.</summary>
public double? TrendDelta { get; set; }
/// <summary>Share of installs with AttemptNumber &gt; 1 (0-1, three decimals).</summary>
public double FlakinessScore { get; set; }
}
/// <summary>One day/week bucket of the analytics time series.</summary>
public class AppAnalyticsTimeBucket
{
public DateTime BucketStart { get; set; }
public int Installs { get; set; }
public int Succeeded { get; set; }
public int Failed { get; set; }
public double FailureRate { get; set; }
public double AvgDurationSeconds { get; set; }
}
/// <summary>Per-app-version aggregate (installs descending).</summary>
public class AppVersionBreakdownItem
{
public string AppVersion { get; set; } = string.Empty;
public int Installs { get; set; }
public int Failed { get; set; }
public double FailureRate { get; set; }
public int MeasuredInstalls { get; set; }
public int MedianDurationSeconds { get; set; }
public int P95DurationSeconds { get; set; }
}
/// <summary>Failed installs per installer phase (descending).</summary>
public class AppInstallerPhaseCount
{
public string Phase { get; set; } = string.Empty;
public int Failed { get; set; }
}
/// <summary>One of the top 5 failure codes of the analytics window.</summary>
public class AppAnalyticsFailureCode
{
public string Code { get; set; } = string.Empty;
/// <summary>First observed exit code; absent when none of the rows carried one.</summary>
public int? ExitCode { get; set; }
public int Count { get; set; }
public string SampleMessage { get; set; } = string.Empty;
}
/// <summary>Per device-model failure aggregate (lift vs the app's baseline rate, descending).</summary>
public class AppDeviceModelBreakdownItem
{
public string Manufacturer { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public int Installs { get; set; }
public int Failed { get; set; }
public double FailureRate { get; set; }
public double LiftVsBaseline { get; set; }
}
/// <summary>Response of <c>GET apps/{appName}/sessions</c> and its global variant (offset-paged).</summary>
public class AppSessionsResponse : IApiResponse
{
public bool Success { get; set; }
public int Total { get; set; }
public int Offset { get; set; }
public int Limit { get; set; }
public IReadOnlyList<AppSessionItem> Items { get; set; } = default!;
}
/// <summary>One install row of the app-sessions drilldown (failed first, then in-progress, newest first).</summary>
public class AppSessionItem
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string DeviceName { get; set; } = string.Empty;
public string Manufacturer { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public string AppVersion { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string InstallerPhase { get; set; } = string.Empty;
public string FailureCode { get; set; } = string.Empty;
/// <summary>Absent when the install carried no exit code.</summary>
public int? ExitCode { get; set; }
public int AttemptNumber { get; set; }
public DateTime StartedAt { get; set; }
public int DurationSeconds { get; set; }
/// <summary>2+ = the IME processed this app in multiple passes (device-ESP evaluation + real install).</summary>
public int InstallPassCount { get; set; }
}
}
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order. Envelopes of the F1/F2 insights fleet endpoints,
// lifted from the Functions-local anonymous builders so the manifest exports them.
/// <summary>
/// Response of <c>GET metrics/time-attribution</c> and <c>GET global/metrics/time-attribution</c>:
/// the rolling 30-day range statistics per enrollment class plus the daily rows for the
/// per-day trend. The range window is FIXED at the sweep's 30 days.
/// </summary>
public class TimeAttributionMetricsResponse : IApiResponse
{
public bool Success { get; set; }
public int WindowDays { get; set; }
/// <summary>Range statistics per enrollment class (never mixed), class-name ordinal order.</summary>
public IReadOnlyList<TimeAttributionDailyAggregate> Classes { get; set; } = default!;
/// <summary>Daily rows of the window, date ordinal order.</summary>
public IReadOnlyList<TimeAttributionDailyAggregate> Daily { get; set; } = default!;
}
/// <summary>
/// Response of <c>GET metrics/device-journeys</c> and <c>GET global/metrics/device-journeys</c>:
/// daily First-Time-Right rows of the window plus their sums, the merged attempt histogram,
/// and the repeat-devices violator list (absent on the cross-tenant aggregate).
/// </summary>
public class DeviceJourneyMetricsResponse : IApiResponse
{
public bool Success { get; set; }
public int WindowDays { get; set; }
public DeviceJourneyWindowTotals Totals { get; set; } = default!;
/// <summary>Daily rows of the window, date ordinal order.</summary>
public IReadOnlyList<DeviceJourneyDailyAggregate> Daily { get; set; } = default!;
/// <summary>Devices whose current journey took at least 2 attempts; absent on the cross-tenant aggregate (no per-device drill there).</summary>
public IReadOnlyList<DeviceJourneyRepeatDevice>? RepeatDevices { get; set; }
}
/// <summary>Window totals of the device-journey response (additive sums over the daily rows).</summary>
public class DeviceJourneyWindowTotals
{
public int CompletedJourneys { get; set; }
public int FirstTimeRight { get; set; }
/// <summary>Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1).</summary>
public double? FtrRatePct { get; set; }
public int ExcludedSessions { get; set; }
public List<DeviceJourneyAttemptBucket> AttemptHistogram { get; set; } = new();
}
/// <summary>One repeat-device violator row (current journey took at least 2 attempts, newest terminal session in the window).</summary>
public class DeviceJourneyRepeatDevice
{
public string SerialNumber { get; set; } = string.Empty;
public string Manufacturer { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public int Attempts { get; set; }
public int JourneyCount { get; set; }
public string LastStatus { get; set; } = string.Empty;
public string LastSessionId { get; set; } = string.Empty;
public DateTime LastStartedAt { get; set; }
/// <summary>Failure reason of the newest failed attempt; empty when unavailable (fail-soft point-read).</summary>
public string LastFailureReason { get; set; } = string.Empty;
}
}
@@ -0,0 +1,67 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order (STJ serializes in declaration order; the MCP hands
// raw response JSON to an LLM, so key order is part of the contract).
/// <summary>
/// Response of <c>GET metrics/rule-stats</c> and <c>GET global/metrics/rule-stats</c>:
/// per-rule firing aggregates over the requested window, the active rule-frequency
/// regression episodes (tenant scope only — empty on the cross-tenant aggregate), and
/// window totals.
/// </summary>
public class RuleStatsResponse : IApiResponse
{
public IReadOnlyList<RuleStatsRuleAggregate> Rules { get; set; } = default!;
public IReadOnlyList<RuleRegressionAlert> Regressions { get; set; } = default!;
public RuleStatsSummary Summary { get; set; } = default!;
}
/// <summary>One rule's aggregate across all dates in the window (fires descending).</summary>
public class RuleStatsRuleAggregate
{
public string RuleId { get; set; } = string.Empty;
public string RuleType { get; set; } = string.Empty;
public string RuleTitle { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Severity { get; set; } = string.Empty;
public int FireCount { get; set; }
public int EvaluationCount { get; set; }
public int SessionsEvaluated { get; set; }
/// <summary>Fires per evaluation as a percentage (one decimal); 0 with no evaluations.</summary>
public double HitRate { get; set; }
/// <summary>Average confidence score across fires (one decimal); 0 with no fires.</summary>
public double AvgConfidenceScore { get; set; }
/// <summary>Per-day trend rows, oldest first (one row per stored date).</summary>
public IReadOnlyList<RuleTrendPoint> Trend { get; set; } = default!;
}
/// <summary>One day of a rule's trend ("yyyy-MM-dd").</summary>
public class RuleTrendPoint
{
public string Date { get; set; } = string.Empty;
public int FireCount { get; set; }
public int EvaluationCount { get; set; }
}
/// <summary>Window totals of a rule-stats response.</summary>
public class RuleStatsSummary
{
public int TotalEvaluations { get; set; }
public int TotalFires { get; set; }
public double OverallHitRate { get; set; }
/// <summary>Rule id with the most fires; absent when the window holds no rules.</summary>
public string? TopRuleByFireCount { get; set; }
/// <summary>Distinct rule count — set on the global route only (the key is absent on the tenant route, preserving its historical shape).</summary>
public int? UniqueRules { get; set; }
public RuleStatsPeriod Period { get; set; } = default!;
}
/// <summary>Echo of the effective date window ("yyyy-MM-dd").</summary>
public class RuleStatsPeriod
{
public string Start { get; set; } = string.Empty;
public string End { get; set; } = string.Empty;
}
}
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.Models.Metrics
/// <summary> /// <summary>
/// SLA metrics response for a given tenant and time window. /// SLA metrics response for a given tenant and time window.
/// </summary> /// </summary>
public class SlaMetricsResponse public class SlaMetricsResponse : IApiResponse
{ {
// Target echo (so the dashboard knows the configured targets) // Target echo (so the dashboard knows the configured targets)
public decimal? TargetSuccessRate { get; set; } public decimal? TargetSuccessRate { get; set; }
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Shared.Models
/// <summary> /// <summary>
/// Platform usage metrics response /// Platform usage metrics response
/// </summary> /// </summary>
public class PlatformUsageMetrics public class PlatformUsageMetrics : IApiResponse
{ {
/// <summary> /// <summary>
/// Session metrics /// Session metrics
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order. Moved verbatim from the Functions-local
// VerdictCalibrationResponse builder (GetVerdictCalibrationFunction.cs) so the shared
// manifest exports the calibration matrix as wire types.
/// <summary>
/// Response of <c>GET global/metrics/verdict-calibration</c>: per verdict path, how many
/// sessions it produced in the window, its share, overrides attributed to the prior path,
/// the 7-day re-enrollment proxy, a 7d-vs-28d trend, and the active drift alerts.
/// Operator-only classifier diagnostics.
/// </summary>
public class VerdictCalibrationResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>Partition echo: a tenant GUID, or "global" for the cross-tenant aggregate.</summary>
public string TenantId { get; set; } = string.Empty;
public int WindowDays { get; set; }
/// <summary>Inclusive window start ("yyyy-MM-dd").</summary>
public string WindowStart { get; set; } = string.Empty;
/// <summary>Inclusive window end ("yyyy-MM-dd", today).</summary>
public string WindowEnd { get; set; } = string.Empty;
/// <summary>Newest aggregate compute time in the window; absent when the window holds no rows.</summary>
public DateTime? ComputedAt { get; set; }
/// <summary>Distinct aggregation algorithm versions contributing to the window, ascending.</summary>
public int[] Versions { get; set; } = default!;
public VerdictCalibrationTotals Totals { get; set; } = default!;
public VerdictCalibrationTrendMeta Trend { get; set; } = default!;
/// <summary>Rows ordered by count descending, then path/status ordinal.</summary>
public IReadOnlyList<VerdictCalibrationPathRow> Paths { get; set; } = default!;
/// <summary>Active drift episodes, newest first.</summary>
public IReadOnlyList<VerdictCalibrationAlert> Alerts { get; set; } = default!;
}
/// <summary>Window totals of the calibration matrix.</summary>
public class VerdictCalibrationTotals
{
public int Sessions { get; set; }
public int Terminal { get; set; }
public int Derived { get; set; }
/// <summary>Aggregate days that contributed to the window.</summary>
public int Days { get; set; }
}
/// <summary>Trend denominators shared by every row (single source, never per-row copies).</summary>
public class VerdictCalibrationTrendMeta
{
public int WindowDays { get; set; }
public int BaselineDays { get; set; }
public int WindowSessions { get; set; }
public int BaselineSessions { get; set; }
}
/// <summary>One verdict path × status row of the calibration matrix.</summary>
public class VerdictCalibrationPathRow
{
public string VerdictPath { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public int Count { get; set; }
public double SharePct { get; set; }
public int DerivedCount { get; set; }
public int Eligible7d { get; set; }
public int ReEnrolled7d { get; set; }
/// <summary>Null below the minimum eligible count — never a rate on a handful of sessions.</summary>
public double? ReEnrollRatePct { get; set; }
public int OverriddenByAdmin { get; set; }
public int OverriddenByLateCompletion { get; set; }
public int OverriddenOther { get; set; }
public VerdictCalibrationTrendWindow Window7 { get; set; } = new();
public VerdictCalibrationTrendWindow Baseline28 { get; set; } = new();
/// <summary>Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented).</summary>
public double? Lift { get; set; }
}
/// <summary>One trend window of a path row.</summary>
public class VerdictCalibrationTrendWindow
{
public int Count { get; set; }
public int Sessions { get; set; }
public double SharePct { get; set; }
}
}
@@ -0,0 +1,25 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// 202/200 response body for the offboarding endpoint. Fields point the caller at the
/// History row so subsequent reporting / status polling can resolve back to the audit
/// trail. <see cref="EarliestProcessingAt"/> drives the "data deletion starts in mm ss"
/// countdown in the Web UI's drain-barrier state.
/// </summary>
public class OffboardResponse : IApiResponse
{
public string TenantId { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string HistoryPartitionKey { get; set; } = string.Empty;
public string HistoryRowKey { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
/// <summary>UTC timestamp before which the worker MUST NOT start Phase 2. Drives the
/// cache-drain-barrier countdown UI. Absent on the idempotent-Completed/Failed branches.</summary>
public DateTime? EarliestProcessingAt { get; set; }
}
}
@@ -671,6 +671,20 @@ namespace AutopilotMonitor.Shared.Models
public string? MsiSourceUrl { get; set; } public string? MsiSourceUrl { get; set; }
} }
/// <summary>
/// Redacted IME version row served to non-global callers of <c>GET metrics/ime-versions</c>:
/// the platform-wide rollout facts without the first-seen tenant/session identifiers or
/// the archive columns (see <c>GetImeVersionHistoryFunction.BuildResponsePayload</c>).
/// </summary>
[WireContract]
public class ImeVersionHistoryLeanEntry
{
public string Version { get; set; } = default!;
public DateTime FirstSeenAt { get; set; }
public DateTime LastSeenAt { get; set; }
public int SessionCount { get; set; }
}
/// <summary> /// <summary>
/// Result of recording one IME version sighting (<c>RecordImeVersionAsync</c>): whether /// Result of recording one IME version sighting (<c>RecordImeVersionAsync</c>): whether
/// the version is new to the fleet, plus the archive columns the ingest path needs to /// the version is new to the fleet, plus the archive columns the ingest path needs to
@@ -0,0 +1,43 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// Body of POST global/sessions/{id}/restore for every outcome (success AND reject —
/// the outcome/status mapping decides the HTTP code, the shape stays one).
/// </summary>
public class SessionRestoreResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>SessionRestoreOutcome name (e.g. "Restored", "DryRunOk", "RejectManifestNotFound").</summary>
public string Outcome { get; set; } = string.Empty;
/// <summary>"full" | "partial" | "dryRun"; absent on early rejects.</summary>
public string? Mode { get; set; }
/// <summary>Operator-readable reason; absent on clean successes.</summary>
public string? Message { get; set; }
/// <summary>Reject diagnostics; absent otherwise.</summary>
public string? CurrentState { get; set; }
/// <summary>Reject diagnostics; absent otherwise.</summary>
public string? PendingManifestId { get; set; }
public Dictionary<string, int> RowsRestoredByTable { get; set; } = default!;
public Dictionary<string, int> RowsSkippedByTable { get; set; } = default!;
public Dictionary<string, int> WouldRestoreByTable { get; set; } = default!;
public int InventoryReIncrements { get; set; }
public long DurationMs { get; set; }
}
/// <summary>
/// Success body (202 Accepted) of the V2 cascade-delete enqueue — the non-success arms
/// stay anonymous error bodies by design (one shape: success=false + message).
/// </summary>
public class SessionDeletionQueuedResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>Always "queued".</summary>
public string Status { get; set; } = string.Empty;
public string? ManifestId { get; set; }
public string Message { get; set; } = string.Empty;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff