diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs new file mode 100644 index 00000000..4bfee637 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs @@ -0,0 +1,333 @@ +using System.Text.Json; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Functions.Apps; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// F1 PR1 (insights spec §F1, source-data audit Q2/Q3): app identity on the name-keyed +/// summary row and the ESP-blocking join. +/// +/// Ingest side: appId is adopted from the app events; a second appId under the same +/// name flags the row () — in-batch and +/// cross-batch. Store side: sentinel-gated columns + sticky collision. Join side: +/// is positive evidence only, and +/// never stamps collision rows, +/// id-less rows, or non-members. Aggregate side: collision rows leave every per-app fleet +/// aggregate with a disclosed exclusion count. +/// +public class AppInstallIdentityAndEspBlockingTests +{ + private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc); + + private const string IdA = "aaaaaaaa-1111-2222-3333-444444444444"; + private const string IdB = "bbbbbbbb-1111-2222-3333-444444444444"; + + // ── ingest fold: appId adoption + in-batch collision ──────────────────── + + private static Dictionary Aggregate(params EnrollmentEvent[] events) + { + var summaries = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var evt in events) + EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", "session", summaries); + return summaries; + } + + private static EnrollmentEvent AppEvent(string eventType, string? appId = null, string appName = "App") + { + var evt = new EnrollmentEvent + { + EventType = eventType, + Timestamp = T0, + Data = new Dictionary { ["appName"] = appName }, + }; + if (appId != null) evt.Data["appId"] = appId; + return evt; + } + + [Fact] + public void Ingest_AdoptsAppId_FromFirstObservingEvent() + { + var s = Aggregate( + AppEvent("app_install_started", IdA), + AppEvent("app_install_completed", IdA))["App"].Summary; + + Assert.Equal(IdA, s.AppId); + Assert.False(s.AppIdCollision); + } + + [Fact] + public void Ingest_SameName_DifferentAppId_FlagsCollision_FirstSeenIdWins() + { + // Device- + user-scope assignment of the same display name in one batch (audit Q3: + // observed in production as two "Company Portal" appIds in one session). + var s = Aggregate( + AppEvent("app_install_started", IdA), + AppEvent("app_install_started", IdB))["App"].Summary; + + Assert.True(s.AppIdCollision); + Assert.Equal(IdA, s.AppId); + } + + [Fact] + public void Ingest_AppIdCaseDrift_IsNotACollision() + { + var s = Aggregate( + AppEvent("app_install_started", IdA), + AppEvent("app_install_completed", IdA.ToUpperInvariant()))["App"].Summary; + + Assert.False(s.AppIdCollision); + } + + [Fact] + public void Ingest_EventsWithoutAppId_LeaveSentinelEmpty() + { + var s = Aggregate(AppEvent("app_install_completed"))["App"].Summary; + Assert.Equal(string.Empty, s.AppId); + Assert.False(s.AppIdCollision); + } + + // ── reconcile: cross-batch identity ───────────────────────────────────── + + [Fact] + public void Reconcile_AdoptsStoredAppId_WhenIncomingBatchHasNone() + { + var existing = new TableEntity("tenant", "session_App") { ["AppId"] = IdA }; + var summary = new AppInstallSummary { AppName = "App", StartedAt = T0 }; + + TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing); + + Assert.Equal(IdA, summary.AppId); + Assert.False(summary.AppIdCollision); + } + + [Fact] + public void Reconcile_CrossBatchDifferentAppId_FlagsCollision_StoredIdWins() + { + var existing = new TableEntity("tenant", "session_App") { ["AppId"] = IdA }; + var summary = new AppInstallSummary { AppName = "App", AppId = IdB, StartedAt = T0 }; + + TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing); + + Assert.True(summary.AppIdCollision); + Assert.Equal(IdA, summary.AppId); // first-seen (stored) identity wins, matching the fold rule + } + + [Fact] + public void Reconcile_CollisionIsSticky_AndEspBlockingVerdictIsAdopted() + { + var existing = new TableEntity("tenant", "session_App") + { + ["AppId"] = IdA, + ["AppIdCollision"] = true, + ["EspBlocking"] = true, + }; + var summary = new AppInstallSummary { AppName = "App", AppId = IdA, StartedAt = T0 }; + + TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing); + + Assert.True(summary.AppIdCollision); // one-way sticky + Assert.True(summary.EspBlocking); // terminal-path verdict survives late batches + } + + // ── entity builder: sentinel gating ───────────────────────────────────── + + [Fact] + public void EntityBuilder_IdentityColumns_AreSentinelGated() + { + var empty = TableStorageService.BuildAppInstallSummaryEntity( + new AppInstallSummary { AppName = "App" }, "rk"); + // Absent columns → Merge-mode preserves prior values / stays unknown. + Assert.False(empty.ContainsKey("AppId")); + Assert.False(empty.ContainsKey("EspBlocking")); + Assert.False(empty.ContainsKey("AppIdCollision")); + + var full = TableStorageService.BuildAppInstallSummaryEntity( + new AppInstallSummary { AppName = "App", AppId = IdA, EspBlocking = true, AppIdCollision = true }, "rk"); + Assert.Equal(IdA, full.GetString("AppId")); + Assert.True(full.GetBoolean("EspBlocking")); + Assert.True(full.GetBoolean("AppIdCollision")); + } + + // ── EspBlockingSets: positive-evidence parsing ────────────────────────── + + private static Dictionary Lists( + object? win32 = null, object? userWin32 = null, object? msi = null, object? pfn = null, + long? win32Count = null) + { + var d = new Dictionary { ["source"] = "registry_firstsync" }; + if (win32 != null) d["espTrackedWin32AppIds"] = win32; + if (userWin32 != null) d["espTrackedUserWin32AppIds"] = userWin32; + if (msi != null) d["espTrackedMsiProductCodes"] = msi; + if (pfn != null) d["espTrackedModernAppPfns"] = pfn; + if (win32Count.HasValue) d["espTrackedWin32Count"] = win32Count.Value; + return d; + } + + [Fact] + public void Sets_PayloadWithoutListKeys_YieldsNull_NoListsIsNotEmptyLists() + { + Assert.Null(EspBlockingSets.FromEventData(null)); + Assert.Null(EspBlockingSets.FromEventData(Lists())); // probe found no Diagnostics key + } + + [Fact] + public void Sets_MembershipIsCaseInsensitive_AcrossAllFourLists() + { + // Storage rehydration shape: List (EventDataNormalizer). + var sets = EspBlockingSets.FromEventData(Lists( + win32: new List { IdA }, + msi: new List { "{11111111-2222-3333-4444-555555555555}" }, + pfn: new List { "Contoso.App_abc123" }))!; + + Assert.True(sets.Contains(IdA.ToUpperInvariant())); + Assert.True(sets.Contains("{11111111-2222-3333-4444-555555555555}")); + Assert.True(sets.Contains("Contoso.App_abc123")); + Assert.False(sets.Contains(IdB)); // absent ⇒ unknown at the caller, never false here + Assert.False(sets.Contains(null)); + Assert.Equal(3, sets.ListedCount); + Assert.False(sets.IsTruncated); + } + + [Fact] + public void Sets_EmittedCountAboveListLength_MeansTruncated() + { + var sets = EspBlockingSets.FromEventData(Lists( + win32: new List { IdA }, win32Count: 60))!; + + Assert.True(sets.IsTruncated); + } + + [Fact] + public void Sets_UserScopeSubset_IsExposedForCoverageLabeling() + { + var sets = EspBlockingSets.FromEventData(Lists( + win32: new List { IdA, IdB }, + userWin32: new List { IdB }))!; + + Assert.Contains(IdB, sets.UserWin32AppIds); + Assert.DoesNotContain(IdA, sets.UserWin32AppIds); + } + + // ── terminal-path stamping predicate ──────────────────────────────────── + + private static TableEntity Row(string? appId = null, bool? collision = null, bool? espBlocking = null) + { + var row = new TableEntity("tenant", "session_App"); + if (appId != null) row["AppId"] = appId; + if (collision.HasValue) row["AppIdCollision"] = collision.Value; + if (espBlocking.HasValue) row["EspBlocking"] = espBlocking.Value; + return row; + } + + [Fact] + public void Stamp_OnlyPositiveMembers_NeverIdlessCollidedOrAlreadyStamped() + { + var sets = EspBlockingSets.FromEventData(Lists(win32: new List { IdA }))!; + + Assert.True(TableStorageService.ShouldStampEspBlocking(Row(IdA), sets)); + Assert.False(TableStorageService.ShouldStampEspBlocking(Row(), sets)); // no identity → unknown + Assert.False(TableStorageService.ShouldStampEspBlocking(Row(IdB), sets)); // absent ⇒ unknown, never false + Assert.False(TableStorageService.ShouldStampEspBlocking(Row(IdA, collision: true), sets)); // ambiguous identity + Assert.False(TableStorageService.ShouldStampEspBlocking(Row(IdA, espBlocking: true), sets)); // idempotent + } + + // ── per-app fleet aggregates: collision rows leave, disclosed ─────────── + + private static AppInstallSummary Summary( + string appName, string status = "Succeeded", bool collision = false, int duration = 30) + => new() + { + TenantId = "00000000-0000-0000-0000-000000000fa1", + SessionId = "s-1", + AppName = appName, + Status = status, + TerminalState = status == "Succeeded" ? "Installed" : "Error", + DurationSeconds = duration, + StartedAt = DateTime.UtcNow.AddHours(-2), + AppIdCollision = collision, + }; + + [Fact] + public void AppMetricsPayload_ExcludesCollisionRowsFromGroups_DisclosesCount() + { + var summaries = new List + { + Summary("Clean App"), + Summary("Clean App"), + Summary("Company Portal", collision: true), + Summary("Company Portal", status: "Failed", collision: true), + }; + + var root = JsonSerializer.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries)); + + 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(2, root.GetProperty("totalCollisionExcluded").GetInt32()); + Assert.Empty(root.GetProperty("topFailingApps").EnumerateArray()); // the collided Failed row ranks nowhere + } + + [Fact] + public void AppsList_ExcludesCollisionRows_DisclosesCount() + { + var summaries = new List + { + Summary("Clean App"), + Summary("Company Portal", collision: true), + }; + + var root = JsonSerializer.SerializeToElement( + AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); + + var apps = root.GetProperty("apps").EnumerateArray().ToList(); + Assert.Single(apps); + Assert.Equal("Clean App", apps[0].GetProperty("appName").GetString()); + Assert.Equal(2, root.GetProperty("totalInstalls").GetInt32()); + Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32()); + } + + [Fact] + public async Task AppAnalytics_ExcludesCollisionRows_DisclosesCount() + { + var repo = new Mock(); + repo.Setup(r => r.GetSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((SessionSummary?)null); + + var summaries = new List + { + Summary("Company Portal"), + Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure + }; + + var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( + summaries, repo.Object, "Company Portal", days: 30)); + + Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32()); + var stats = root.GetProperty("summary"); + Assert.Equal(1, stats.GetProperty("totalInstalls").GetInt32()); + Assert.Equal(0, stats.GetProperty("failed").GetInt32()); // ambiguous failure not attributed + Assert.Equal(0d, stats.GetProperty("failureRate").GetDouble()); + } + + [Fact] + public async Task AppAnalytics_AllRowsCollided_ReturnsEmptyShape_WithDisclosure() + { + var repo = new Mock(); + repo.Setup(r => r.GetSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((SessionSummary?)null); + + var summaries = new List { Summary("Company Portal", collision: true) }; + + var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( + summaries, repo.Object, "Company Portal", days: 30)); + + Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32()); + Assert.Equal(0, root.GetProperty("summary").GetProperty("totalInstalls").GetInt32()); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs new file mode 100644 index 00000000..9614aa0b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs @@ -0,0 +1,459 @@ +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Golden fixtures for (F1 PR1, insights spec §F1) — +/// synthetic sessions only (no customer data), each pinning one semantic decision: +/// linear phase partition, clock skew, late start, WhiteGlove two-window split, missing / +/// truncated blocking lists, IME retry occupancy, and reboot gaps. EVERY fixture asserts the +/// exact-partition invariant: sum(span seconds) + unattributed == wall clock == +/// the session's DurationSeconds (truthfulness rule 2 — never normalized, never interpolated). +/// +public class TimeAttributionCalculatorTests +{ + private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc); + + private const string AppA = "aaaaaaaa-1111-2222-3333-444444444444"; // in blocking list + private const string AppB = "bbbbbbbb-1111-2222-3333-444444444444"; // in blocking list, never observed + private const string AppC = "cccccccc-1111-2222-3333-444444444444"; // observed, NOT listed + + private static long _seq; + + private static EnrollmentEvent Evt( + DateTime ts, string eventType, + EnrollmentPhase phase = EnrollmentPhase.Unknown, + Dictionary? data = null) + => new() + { + EventType = eventType, + Timestamp = ts, + Sequence = ++_seq, + Phase = phase, + Data = data ?? new Dictionary(), + }; + + private static Dictionary AppData(string appId, string appName, string? state = null) + { + var d = new Dictionary { ["appId"] = appId, ["appName"] = appName }; + if (state != null) d["state"] = state; + return d; + } + + /// Storage-shape blocking lists (EventDataNormalizer yields List<object>). + private static Dictionary EspLists(long? win32Count = null, params string[] win32Ids) + => new() + { + ["espTrackedWin32AppIds"] = new List(win32Ids), + ["espTrackedUserWin32AppIds"] = new List(), + ["espTrackedMsiProductCodes"] = new List(), + ["espTrackedModernAppPfns"] = new List(), + ["espTrackedWin32Count"] = win32Count ?? win32Ids.Length, + ["espTrackedMsiCount"] = 0L, + ["espTrackedModernCount"] = 0L, + }; + + private static TimeAttributionInput Input( + List events, DateTime completedAt, int durationSeconds, + string status = "Succeeded", bool wg = false, DateTime? resumedAt = null) + => new() + { + TenantId = "00000000-0000-0000-0000-0000000000t1", + SessionId = "00000000-0000-0000-0000-0000000000s1", + Status = status, + StartedAt = T0, + CompletedAt = completedAt, + DurationSeconds = durationSeconds, + IsPreProvisioned = wg, + ResumedAt = resumedAt, + Events = events, + }; + + /// The invariant every fixture must satisfy exactly (spec §0 rule 2). + private static void AssertExactPartition(SessionTimeBreakdown b) + { + Assert.Equal(b.WallClockSeconds, b.Segments.Sum(s => s.Seconds) + b.UnattributedSeconds); + Assert.True(b.UnattributedSeconds >= 0, "unattributed must never be negative"); + } + + // ── golden: linear UDE session ────────────────────────────────────────── + + private static List LinearSession() + => new() + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(T0.AddMinutes(2), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA, AppB })), + Evt(T0.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.AppsDevice), + Evt(T0.AddMinutes(6), "app_install_started", data: AppData(AppA, "App A")), + Evt(T0.AddMinutes(10), "app_install_completed", data: AppData(AppA, "App A", "Installed")), + Evt(T0.AddMinutes(11), "app_install_started", data: AppData(AppC, "Untracked App")), + Evt(T0.AddMinutes(13), "app_install_completed", data: AppData(AppC, "Untracked App", "Installed")), + Evt(T0.AddMinutes(15), "esp_phase_changed", EnrollmentPhase.AccountSetup), + Evt(T0.AddMinutes(20), "esp_phase_changed", EnrollmentPhase.AppsUser), + Evt(T0.AddMinutes(25), "esp_phase_changed", EnrollmentPhase.FinalizingSetup), + Evt(T0.AddMinutes(28), "desktop_arrived"), + Evt(T0.AddMinutes(30), "enrollment_complete"), + }; + + [Fact] + public void Linear_Session_PartitionsIntoAllFiveSegments_Exactly() + { + var b = TimeAttributionCalculator.Compute( + Input(LinearSession(), completedAt: T0.AddMinutes(30), durationSeconds: 1800))!; + + AssertExactPartition(b); + Assert.Equal(1800, b.WallClockSeconds); + Assert.Equal(0, b.UnattributedSeconds); + Assert.Equal(TimeAttributionFlags.None, b.QualityFlags); + Assert.Equal(TimeAttributionCalculator.CurrentVersion, b.AttributionVersion); + + var totals = b.GetSegmentTotals(); + Assert.Equal(300, totals[TimeAttributionSegments.DevicePrep]); // T0 → apps entry (+5m) + Assert.Equal(600, totals[TimeAttributionSegments.EspApps]); // +5m → +15m + Assert.Equal(480, totals[TimeAttributionSegments.IdentityHello]); // AccountSetup 300 + Finalizing 180 + Assert.Equal(300, totals[TimeAttributionSegments.UserEsp]); // +20m → +25m + Assert.Equal(120, totals[TimeAttributionSegments.DesktopHandoff]); // desktop_arrived → terminal + } + + [Fact] + public void Linear_Session_BlockingJoin_IsPositiveEvidenceOnly() + { + var b = TimeAttributionCalculator.Compute( + Input(LinearSession(), completedAt: T0.AddMinutes(30), durationSeconds: 1800))!; + + // App A is listed → blocking, interval from EVENT timestamps (started → completed). + var interval = Assert.Single(b.BlockingApps); + Assert.Equal(AppA, interval.AppId); + Assert.Equal(240, interval.Seconds); // +6m → +10m + // App C installed fine but is NOT in the lists → unknown, NEVER "not blocking": + // it appears nowhere (no negative claim), and only App A counts as matched. + Assert.Equal(1, b.BlockingAppCount); + // Occupancy = App A's interval clipped to the esp_apps span. + Assert.Equal(240, b.EspAppsOccupancySeconds); + } + + // ── skew: backward anchor is dropped, dust stays honest ───────────────── + + [Fact] + public void BackwardPhaseAnchor_IsDropped_WithClockSkewFlag() + { + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(T0.AddMinutes(10), "esp_phase_changed", EnrollmentPhase.AppsDevice), + // Replayed declaration whose timestamp runs BACKWARD in canonical order. + Evt(T0.AddMinutes(3), "esp_phase_changed", EnrollmentPhase.AccountSetup), + Evt(T0.AddMinutes(30), "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt: T0.AddMinutes(30), durationSeconds: 1800))!; + + AssertExactPartition(b); + Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.ClockSkewDropped)); + var totals = b.GetSegmentTotals(); + Assert.Equal(600, totals[TimeAttributionSegments.DevicePrep]); + Assert.Equal(1200, totals[TimeAttributionSegments.EspApps]); // runs to window end — no phantom AccountSetup + Assert.False(totals.ContainsKey(TimeAttributionSegments.IdentityHello)); + } + + [Fact] + public void SubSecondBoundaries_FloorIntoUnattributedDust_NeverInvented() + { + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(T0.AddSeconds(90.5), "esp_phase_changed", EnrollmentPhase.AppsDevice), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt: T0.AddSeconds(300), durationSeconds: 300))!; + + AssertExactPartition(b); + var totals = b.GetSegmentTotals(); + Assert.Equal(90, totals[TimeAttributionSegments.DevicePrep]); // floor(90.5) + Assert.Equal(209, totals[TimeAttributionSegments.EspApps]); // floor(209.5) + Assert.Equal(1, b.UnattributedSeconds); // the dust, disclosed + } + + // ── late start ────────────────────────────────────────────────────────── + + [Fact] + public void AgentLateStart_SetsPartialObservation() + { + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(T0.AddSeconds(1), "agent_late_start"), + Evt(T0.AddMinutes(10), "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!; + + AssertExactPartition(b); + Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.PartialObservation)); + } + + // ── WhiteGlove: two windows in ONE session, pause never attributed ────── + + [Fact] + public void WhiteGlove_SplitsIntoTwoWindows_PauseExcluded_WallClockIsDurationSeconds() + { + var part1End = T0.AddMinutes(20); + var resumedAt = T0.AddDays(2); // 2-day pause + var completedAt = resumedAt.AddMinutes(10); // part 2 = 600 s + // Stored combined duration: part1 (1200 s) + part2 (600 s) — pause excluded by design. + const int duration = 1800; + + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })), + Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice), + Evt(part1End, "whiteglove_part1_complete"), + // — pause — + Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.AccountSetup), + Evt(resumedAt.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.FinalizingSetup), + Evt(resumedAt.AddMinutes(8), "desktop_arrived"), + Evt(completedAt, "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt, duration, wg: true, resumedAt: resumedAt))!; + + AssertExactPartition(b); + // Wall clock is the stored DurationSeconds — NOT CompletedAt − StartedAt (≈ 2 days). + Assert.Equal(1800, b.WallClockSeconds); + Assert.Equal(TimeAttributionFlags.None, b.QualityFlags); // part1 anchored on the event + + var totals = b.GetSegmentTotals(); + Assert.Equal(120, totals[TimeAttributionSegments.DevicePrep]); // W1: T0 → +2m + Assert.Equal(1080, totals[TimeAttributionSegments.EspApps]); // W1: +2m → part1End + Assert.Equal(450, totals[TimeAttributionSegments.IdentityHello]); // W2: +30s → +8m + Assert.Equal(120, totals[TimeAttributionSegments.DesktopHandoff]); // W2: +8m → +10m + // W2's 30 s resume gap has no phase context — unattributed, not guessed. + Assert.Equal(30, b.UnattributedSeconds); + // No span may live inside the pause. + Assert.DoesNotContain(b.Segments, s => s.StartUtc > part1End && s.EndUtc < resumedAt); + } + + [Fact] + public void WhiteGlove_WithoutPart1CompleteEvent_FallsBackAndFlags() + { + var resumedAt = T0.AddDays(1); + var completedAt = resumedAt.AddMinutes(10); + var lastPart1Event = T0.AddMinutes(20); + + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(lastPart1Event, "esp_phase_changed", EnrollmentPhase.AppsDevice), + Evt(resumedAt.AddSeconds(10), "agent_started", EnrollmentPhase.AccountSetup), + Evt(completedAt, "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!; + + AssertExactPartition(b); + Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.WhiteGloveAnchorsIncomplete)); + Assert.Equal(1800, b.WallClockSeconds); + } + + [Fact] + public void WhiteGlove_Part2LongerThanStoredDuration_DegradesToSingleWindow_Flagged() + { + // Stored duration (600 s) contradicts the resume stamp (part 2 alone spans 900 s): + // no honest two-window split exists — degrade to the single derived window instead + // of inventing a negative part 1. + var resumedAt = T0.AddDays(1); + var completedAt = resumedAt.AddMinutes(15); + + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(resumedAt.AddSeconds(5), "agent_started", EnrollmentPhase.AccountSetup), + Evt(completedAt, "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt, durationSeconds: 600, wg: true, resumedAt: resumedAt))!; + + AssertExactPartition(b); + Assert.Equal(600, b.WallClockSeconds); + Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.WhiteGloveAnchorsIncomplete)); + } + + // ── blocking list missing / truncated ─────────────────────────────────── + + [Fact] + public void NoBlockingLists_MeansUnknown_NotZero() + { + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice), + Evt(T0.AddMinutes(3), "app_install_started", data: AppData(AppA, "App A")), + Evt(T0.AddMinutes(8), "app_install_completed", data: AppData(AppA, "App A", "Installed")), + Evt(T0.AddMinutes(10), "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!; + + AssertExactPartition(b); + Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.BlockingSetUnknown)); + Assert.Empty(b.BlockingApps); + Assert.Equal(0, b.BlockingAppCount); + Assert.Null(b.EspAppsOccupancySeconds); // unknown — NOT 0 (rule 1) + } + + [Fact] + public void EspConfigWithoutListKeys_IsNotEvidence_LaterEmissionNeverErasesEarlierLists() + { + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })), + Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice), + Evt(T0.AddMinutes(3), "app_install_started", data: AppData(AppA, "App A")), + Evt(T0.AddMinutes(8), "app_install_completed", data: AppData(AppA, "App A", "Installed")), + // Later emission whose probe found no Diagnostics key — no espTracked* keys at all. + Evt(T0.AddMinutes(9), "esp_config_detected", + data: new Dictionary { ["source"] = "registry_firstsync" }), + Evt(T0.AddMinutes(10), "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!; + + Assert.False(b.QualityFlags.HasFlag(TimeAttributionFlags.BlockingSetUnknown)); + Assert.Equal(AppA, Assert.Single(b.BlockingApps).AppId); + } + + [Fact] + public void TruncatedBlockingList_SetsFlag_AbsentAppsStayUnknown() + { + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + // Uncapped total (60) exceeds the emitted list (1 id) → cap dropped entries. + Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Count: 60, AppA)), + Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice), + Evt(T0.AddMinutes(3), "app_install_started", data: AppData(AppC, "Maybe Capped App")), + Evt(T0.AddMinutes(7), "app_install_completed", data: AppData(AppC, "Maybe Capped App", "Installed")), + Evt(T0.AddMinutes(10), "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!; + + Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.BlockingSetTruncated)); + // App C is absent from the (capped) list → it stays UNKNOWN: no interval, no claim. + Assert.Empty(b.BlockingApps); + Assert.Equal(0, b.BlockingAppCount); + } + + // ── retry: occupancy spans to the LAST terminal event (audit Q1) ──────── + + [Fact] + public void Retry_IntervalRunsFromFirstStart_ToLastTerminalEvent() + { + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })), + Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice), + Evt(T0.AddMinutes(6), "app_install_started", data: AppData(AppA, "App A")), + Evt(T0.AddMinutes(8), "app_install_failed", data: AppData(AppA, "App A")), + Evt(T0.AddMinutes(9), "app_install_started", data: AppData(AppA, "App A")), // IME retry + Evt(T0.AddMinutes(12), "app_install_completed", data: AppData(AppA, "App A", "Installed")), + Evt(T0.AddMinutes(15), "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt: T0.AddMinutes(15), durationSeconds: 900))!; + + AssertExactPartition(b); + var interval = Assert.Single(b.BlockingApps); + // First started (+6m) → LAST terminal (+12m) = 360 s. The payload timing would have + // frozen at the first failure (+8m) — event timestamps are the only valid source. + Assert.Equal(360, interval.Seconds); + Assert.Equal(360, b.EspAppsOccupancySeconds); + } + + // ── reboots: spans from event gaps, never the detection event's stamp ─── + + [Fact] + public void RebootSpan_ComesFromEventGapAroundLastBootUtc_NotFromDetectionTimestamp() + { + var boot = T0.AddMinutes(12); + var events = new List + { + Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup), + Evt(T0.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.AppsDevice), + Evt(T0.AddMinutes(10), "app_install_started", data: AppData(AppA, "App A")), // last pre-reboot + Evt(T0.AddMinutes(14), "system_reboot_detected", // next run, detection-time + data: new Dictionary { ["lastBootUtc"] = boot.ToString("o") }), + // Duplicate detection of the SAME boot (batch replay) — must not double-count. + Evt(T0.AddMinutes(14.5), "system_reboot_detected", + data: new Dictionary { ["lastBootUtc"] = boot.ToString("o") }), + Evt(T0.AddMinutes(20), "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!; + + AssertExactPartition(b); + var span = Assert.Single(b.RebootSpans); + // Gap = last event before boot (+10m) → first observation at/after boot (+14m). + Assert.Equal(T0.AddMinutes(10), span.StartUtc); + Assert.Equal(T0.AddMinutes(14), span.EndUtc); + Assert.Equal(240, span.Seconds); + Assert.Equal(240, b.RebootSeconds); + // The reboot began inside the apps phase — cross-cutting annotation, segment unchanged. + Assert.Equal(TimeAttributionSegments.EspApps, span.SegmentKey); + Assert.Equal(1200 - 300, b.GetSegmentTotals()[TimeAttributionSegments.EspApps]); + } + + // ── non-computable sessions ───────────────────────────────────────────── + + [Theory] + [InlineData("Incomplete")] // terminal but deliberately NO DurationSeconds + [InlineData("InProgress")] + [InlineData("Pending")] + public void NonComputableStatuses_ReturnNull(string status) + { + var input = Input(LinearSession(), completedAt: T0.AddMinutes(30), durationSeconds: 1800, status: status); + Assert.Null(TimeAttributionCalculator.Compute(input)); + } + + [Fact] + public void MissingDuration_ReturnsNull_NeverGuessesFromCompletedMinusStarted() + { + var input = Input(LinearSession(), completedAt: T0.AddMinutes(30), durationSeconds: 0); + Assert.Null(TimeAttributionCalculator.Compute(input)); + } + + // ── no phase evidence at all ──────────────────────────────────────────── + + [Fact] + public void NoPhaseDeclarations_LeaveEverythingUnattributed() + { + var events = new List + { + Evt(T0.AddMinutes(1), "network_state_change"), + Evt(T0.AddMinutes(9), "enrollment_complete"), + }; + + var b = TimeAttributionCalculator.Compute( + Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!; + + AssertExactPartition(b); + Assert.Empty(b.Segments); + Assert.Equal(600, b.UnattributedSeconds); // no evidence → no claim (rule 1) + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index 088c41df..06c2ae8a 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -84,7 +84,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage // otherwise a swept-Incomplete session keeps whatever stale live count it had. // Idempotent + fail-soft, so the redundancy is cheap. if (transitioned && (status == SessionStatus.Succeeded || status == SessionStatus.Failed || status == SessionStatus.Incomplete)) + { await _storage.ReconcileSessionCountersAsync(tenantId, sessionId); + // F1 PR1: same single-writer seam — join the session's app rows against its + // latest esp_config_detected lists exactly once, when the event stream is + // complete. Positive evidence only (listed ⇒ EspBlocking=true, absent ⇒ stays + // unknown). Idempotent + fail-soft like the counter reconcile above. + await _storage.ResolveEspBlockingForSessionAsync(tenantId, sessionId); + } return transitioned; } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs index 53f26342..4fca336b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs @@ -141,7 +141,11 @@ namespace AutopilotMonitor.Functions.Functions.Apps var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); - var apps = summaries.GroupBy(s => s.AppName).Select(g => + // F1 PR1 (audit Q3): appId-collision rows merge two distinct apps under one name — + // they are excluded from every per-app group and disclosed via collisionExcluded. + var collisionExcluded = summaries.Count(s => s.AppIdCollision); + + var apps = summaries.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g => { // PR0 classification (see MetricsMath.IsSkipTerminalState / HasMeasuredDuration): // skips leave the rate + durations; duration stats read measured rows only. @@ -188,6 +192,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps success = true, totalApps = apps.Count, totalInstalls = summaries.Count, + collisionExcluded, windowDays = days, apps }; @@ -204,6 +209,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps success = true, totalApps = apps.Count, totalInstalls = summaries.Count, + collisionExcluded, windowDays = days, count = page.Count, offset, @@ -229,11 +235,16 @@ namespace AutopilotMonitor.Functions.Functions.Apps var cutoff = now.AddDays(-days); var midpoint = now.AddDays(-days / 2.0); - var summaries = allSummaries + var inWindow = allSummaries .Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase) && s.StartedAt >= cutoff) .ToList(); + // F1 PR1 (audit Q3): collision rows under this name mix in a second app's outcomes — + // excluded from every stat below, disclosed via collisionExcluded. + var collisionExcluded = inWindow.Count(s => s.AppIdCollision); + var summaries = inWindow.Where(s => !s.AppIdCollision).ToList(); + if (summaries.Count == 0) { return new @@ -242,6 +253,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps appName, appType = string.Empty, windowDays = days, + collisionExcluded, bucket = "day", summary = new { @@ -387,6 +399,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps appName, appType, windowDays = days, + collisionExcluded, bucket, summary = new { diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/EspBlockingSets.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/EspBlockingSets.cs new file mode 100644 index 00000000..fdeefc58 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/EspBlockingSets.cs @@ -0,0 +1,115 @@ +using System.Collections; + +namespace AutopilotMonitor.Functions.Helpers; + +/// +/// Parsed ESP blocking/tracking lists from an esp_config_detected event payload +/// (agent emitter: EspTrackingInfoProbe → DeviceInfoCollector.CollectEspConfiguration). +/// +/// Semantics (source-data audit Q2, insights spec §0.5): the lists are a POSITIVE-EVIDENCE set. +/// An app id found in any list is proven ESP-blocking; an id that is absent is UNKNOWN — never +/// "not blocking" — because the registry lists grow progressively (one timestamped subkey per +/// CSP status write), the user-scope S-<SID> lists usually appear only after +/// sign-in, each list is capped at 50 entries (uncapped totals ride in the espTracked*Count +/// fields), and MSI ProductCodes / PFNs live in namespaces the IME appId may not match. +/// Consumers must therefore always use the LATEST payload of a session that carries lists, and +/// must map "absent" to null/unknown, never to false. +/// +public sealed class EspBlockingSets +{ + private readonly HashSet _all; + + private EspBlockingSets( + HashSet all, + HashSet userWin32AppIds, + int listedTotal, + bool isTruncated) + { + _all = all; + UserWin32AppIds = userWin32AppIds; + ListedCount = listedTotal; + IsTruncated = isTruncated; + } + + /// Distinct ids across all four lists (Win32 device+user, MSI, PFN). + public int ListedCount { get; } + + /// + /// True when any espTracked*Count exceeds its emitted list length — the 50-per-category + /// cap dropped entries, so even the positive evidence is incomplete for that category. + /// + public bool IsTruncated { get; } + + /// + /// The user-scope Win32 subset (espTrackedUserWin32AppIds). Usually empty until the + /// agent-side re-emit fix ships (audit Q2) — exposed so callers can label user-ESP coverage + /// as unknown rather than inferring it from the merged list. + /// + public IReadOnlyCollection UserWin32AppIds { get; } + + /// Positive-evidence membership test (case-insensitive, all four lists). + public bool Contains(string? appId) + => !string.IsNullOrEmpty(appId) && _all.Contains(appId!); + + /// + /// Parses the payload of one esp_config_detected event. Returns null when the payload + /// carries none of the espTracked* list keys (probe found no Diagnostics key — e.g. + /// non-Autopilot device or pre-list agent build): "no lists observed" must stay + /// distinguishable from "empty lists observed". + /// Handles both value shapes: live-ingest / storage-rehydrated data (List<object> + /// via EventDataNormalizer) and test fixtures (string[] / List<string>). + /// + public static EspBlockingSets? FromEventData(IReadOnlyDictionary? data) + { + if (data == null) return null; + + var win32 = ReadStringList(data, "espTrackedWin32AppIds"); + var userWin32 = ReadStringList(data, "espTrackedUserWin32AppIds"); + var msi = ReadStringList(data, "espTrackedMsiProductCodes"); + var pfn = ReadStringList(data, "espTrackedModernAppPfns"); + + if (win32 == null && userWin32 == null && msi == null && pfn == null) + return null; + + var all = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var list in new[] { win32, userWin32, msi, pfn }) + if (list != null) + foreach (var id in list) + all.Add(id); + + var userSet = new HashSet(userWin32 ?? new List(), StringComparer.OrdinalIgnoreCase); + + // The agent emits uncapped distinct totals alongside the capped lists. The Win32 count + // covers the merged device+user list; user-Win32 has no own count field. + var truncated = + CountExceeds(data, "espTrackedWin32Count", win32) || + CountExceeds(data, "espTrackedMsiCount", msi) || + CountExceeds(data, "espTrackedModernCount", pfn); + + return new EspBlockingSets(all, userSet, all.Count, truncated); + } + + private static bool CountExceeds(IReadOnlyDictionary data, string countKey, List? list) + { + if (list == null) return false; + if (!data.TryGetValue(countKey, out var raw)) return false; + return long.TryParse(raw?.ToString(), out var count) && count > list.Count; + } + + private static List? ReadStringList(IReadOnlyDictionary data, string key) + { + if (!data.TryGetValue(key, out var raw) || raw == null) return null; + // Strings enumerate as chars — exclude explicitly before the IEnumerable fallback. + if (raw is string) return null; + if (raw is not IEnumerable enumerable) return null; + + var result = new List(); + foreach (var item in enumerable) + { + var s = item?.ToString()?.Trim(); + if (!string.IsNullOrEmpty(s)) + result.Add(s!); + } + return result; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs index 24cdfbd4..fa024c3f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs @@ -58,7 +58,13 @@ public static class MetricsMath { var summaryList = summaries as IList ?? summaries.ToList(); - var appGroups = summaryList.GroupBy(s => s.AppName).Select(g => + // F1 PR1 (audit Q3): a name-keyed row that merged two distinct appIds carries an + // unattributable status/duration mix — it must not shape any per-app group. Excluded + // here with a disclosed count (truthfulness rule 7); the fleet-wide DO rollup below + // keeps every row, since transferred bytes are real regardless of identity mixing. + var totalCollisionExcluded = summaryList.Count(s => s.AppIdCollision); + + var appGroups = summaryList.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g => { // PR0 (2026-07-26) classification — see IsSkipTerminalState / HasMeasuredDuration: // skipped = no real install attempt (TerminalState Skipped/Postponed) @@ -127,6 +133,7 @@ public static class MetricsMath totalInstalls = summaryList.Count, totalSkipped = appGroups.Sum(a => a.skipped), totalUnmeasured = appGroups.Sum(a => a.unmeasured), + totalCollisionExcluded, slowestApps, topFailingApps, deliveryOptimization = new diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs new file mode 100644 index 00000000..91ecadc5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs @@ -0,0 +1,586 @@ +using System.Globalization; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Helpers; + +/// +/// Inputs for — the session facts the terminal +/// writer persisted plus the session's full event stream in canonical order. +/// +public sealed class TimeAttributionInput +{ + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// Session status string. Only "Succeeded" and "Failed" are computable (Incomplete deliberately carries no DurationSeconds). + public string Status { get; set; } = string.Empty; + + public DateTime StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + + /// + /// The session's authoritative duration (second-sweep finding, insights spec §0.5): for + /// WhiteGlove it is part 1 + part 2 WITHOUT the pause, and re-terminal stamps move + /// CompletedAt — so this is the wall clock, never CompletedAt − StartedAt. + /// + public int? DurationSeconds { get; set; } + + public bool IsPreProvisioned { get; set; } + + /// WhiteGlove part-2 resume timestamp (session row), when present. + public DateTime? ResumedAt { get; set; } + + /// Session events. Canonical order is Sequence ascending; the calculator re-sorts defensively. + public IReadOnlyList Events { get; set; } = Array.Empty(); +} + +/// +/// Deterministic, I/O-free computation of a session's +/// (F1, insights spec — AttributionVersion 1). Every number is traceable to observed events; +/// missing signals produce "unknown"/unattributed, never guesses (truthfulness rules 1/2). +/// +/// Semantics pinned by the golden-fixture tests: +/// +/// Wall clock = session DurationSeconds. Observation windows are constructed to sum +/// to it exactly: non-WhiteGlove = [CompletedAt − Duration, CompletedAt]; WhiteGlove = two windows +/// in ONE session — [part1End − part1, part1End] and [ResumedAt, CompletedAt], where +/// part1 = Duration − part2 and part1End is anchored on whiteglove_part1_complete. +/// The pause between the windows is never attributed. +/// Segments from phase-declaration events only (phase strategy: only declaration +/// events carry Phase != Unknown), bucketed Start/DevicePreparation/DeviceSetup → device_prep, +/// AppsDevice → esp_apps, AccountSetup + FinalizingSetup → identity_hello, AppsUser → user_esp, +/// Complete / desktop_arrived → desktop_handoff. A Failed-phase declaration ends +/// attribution (the tail is unattributed, not relabeled). Anchors that go backward in canonical +/// order are dropped (). +/// Exact-partition invariant: span seconds are floored, the remainder — +/// unattributed holes plus sub-second dust — is reported as UnattributedSeconds, so +/// sum(spans) + unattributed == wall clock holds exactly and is never normalized. +/// Per-app intervals use event timestamps (first started/download event → LAST +/// terminal event, covering IME retries) — never the agent payload timing fields, which freeze +/// at the first terminal transition (audit Q1). +/// Blocking membership is positive evidence from the LATEST +/// esp_config_detected lists: listed ⇒ blocking; absent ⇒ unknown, never false (audit Q2). +/// No lists ⇒ and occupancy null. +/// Reboot spans from event gaps around the lastBootUtc payload — the +/// system_reboot_detected event is detection-time-stamped by the next run and never used +/// as the reboot moment (audit Q7). Reboots are a cross-cutting annotation, not a segment. +/// +/// +public static class TimeAttributionCalculator +{ + /// Bump on any semantic change so aggregates never mix definitions (truthfulness rule 8). + public const int CurrentVersion = 1; + + /// Cap for the persisted per-app interval list (spec: top 20 by duration + uncapped count). + internal const int MaxBlockingAppIntervals = 20; + + private readonly struct Window + { + public Window(DateTime start, DateTime end) { Start = start; End = end; } + public DateTime Start { get; } + public DateTime End { get; } + } + + private sealed class Anchor + { + public DateTime Ts; + /// Segment key, or null = attribution ends here (Failed-phase declaration). + public string? Bucket; + } + + private sealed class AppActivity + { + public string AppId = string.Empty; + public string AppName = string.Empty; + public DateTime? FirstStart; + public DateTime? LastTerminal; + } + + /// + /// Returns the breakdown, or null when the session has no computable wall clock + /// (non-terminal, Incomplete — which deliberately stores no DurationSeconds — or a + /// missing/non-positive duration). + /// + public static SessionTimeBreakdown? Compute(TimeAttributionInput input) + { + if (input.Status != "Succeeded" && input.Status != "Failed") return null; + if (!input.CompletedAt.HasValue) return null; + if (!input.DurationSeconds.HasValue || input.DurationSeconds.Value <= 0) return null; + + var completedAt = input.CompletedAt.Value; + var wallClock = input.DurationSeconds.Value; + var flags = TimeAttributionFlags.None; + + // Canonical order (Sequence asc) — the authoritative event order per repo convention. + var events = input.Events.OrderBy(e => e.Sequence).ToList(); + + var windows = BuildWindows(input, completedAt, wallClock, events, ref flags); + + var anchors = BuildAnchors(events, ref flags); + var spans = BuildSegmentSpans(windows, anchors); + + var attributedSeconds = spans.Sum(s => s.Seconds); + var unattributed = wallClock - attributedSeconds; + + // Blocking set: latest esp_config_detected emission that carries lists (positive + // evidence is never erased by a later probe failure; lists grow progressively). + EspBlockingSets? blockingSets = null; + for (var i = events.Count - 1; i >= 0 && blockingSets == null; i--) + { + if (events[i].EventType == Constants.EventTypes.EspConfigDetected) + blockingSets = EspBlockingSets.FromEventData(events[i].Data); + } + if (blockingSets == null || blockingSets.ListedCount == 0) + flags |= TimeAttributionFlags.BlockingSetUnknown; + else if (blockingSets.IsTruncated) + flags |= TimeAttributionFlags.BlockingSetTruncated; + + var (blockingApps, blockingAppCount) = BuildBlockingAppIntervals(events, blockingSets, windows, ref flags); + + int? occupancy = null; + if (blockingSets != null && blockingSets.ListedCount > 0) + { + var espAppsSpans = spans.Where(s => s.SegmentKey == TimeAttributionSegments.EspApps).ToList(); + occupancy = ComputeOccupancySeconds(blockingApps, espAppsSpans); + } + + var rebootSpans = BuildRebootSpans(events, windows, spans); + + if (events.Any(e => e.EventType == Constants.EventTypes.AgentLateStart)) + flags |= TimeAttributionFlags.PartialObservation; + + return new SessionTimeBreakdown + { + TenantId = input.TenantId, + SessionId = input.SessionId, + AttributionVersion = CurrentVersion, + WallClockSeconds = wallClock, + Segments = spans, + UnattributedSeconds = unattributed, + RebootSeconds = rebootSpans.Sum(r => r.Seconds), + RebootSpans = rebootSpans, + BlockingApps = blockingApps + .OrderByDescending(a => a.Seconds) + .ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase) + .Take(MaxBlockingAppIntervals) + .ToList(), + BlockingAppCount = blockingAppCount, + EspAppsOccupancySeconds = occupancy, + QualityFlags = flags, + }; + } + + // ── observation windows ───────────────────────────────────────────────── + + /// + /// Windows are END-anchored on the authoritative timestamps (CompletedAt; part1End for WG + /// part 1) with the start derived so window lengths sum EXACTLY to DurationSeconds — the + /// invariant is against the stored duration, not against CompletedAt − StartedAt. + /// + private static List BuildWindows( + TimeAttributionInput input, DateTime completedAt, int wallClock, + List events, ref TimeAttributionFlags flags) + { + if (input.IsPreProvisioned) + { + if (input.ResumedAt.HasValue && input.ResumedAt.Value < completedAt) + { + var resumedAt = input.ResumedAt.Value; + // Same truncation the terminal writer used for part2, so part1 comes out as the + // exact stored part-1 duration. + var part2 = (int)(completedAt - resumedAt).TotalSeconds; + var part1 = wallClock - part2; + if (part1 > 0) + { + var part1End = ResolvePart1End(events, resumedAt, ref flags); + if (part1End.HasValue) + { + var w1Start = part1End.Value.AddSeconds(-part1); + return new List + { + new Window(w1Start, part1End.Value), + new Window(resumedAt, completedAt), + }; + } + } + // part1 <= 0 (stored duration contradicts the resume stamp) or no usable part-1 + // anchor: fall through to a single derived window rather than inventing a split. + } + // WhiteGlove without a resolvable two-window split — the partition is still exact, + // but the WG semantics (pause exclusion boundaries) rest on no anchor. + flags |= TimeAttributionFlags.WhiteGloveAnchorsIncomplete; + } + + return new List { new Window(completedAt.AddSeconds(-wallClock), completedAt) }; + } + + /// + /// Part-1 end anchor: the whiteglove_part1_complete event; fallback: the last event + /// at/before ResumedAt (flagged — the boundary is then observational, not declarative). + /// An anchor after ResumedAt is clamped to ResumedAt (windows must not overlap). + /// + private static DateTime? ResolvePart1End( + List events, DateTime resumedAt, ref TimeAttributionFlags flags) + { + DateTime? part1End = null; + foreach (var evt in events) + { + if (evt.EventType == Constants.EventTypes.WhiteGlovePart1Complete) + part1End = evt.Timestamp; + } + + if (!part1End.HasValue) + { + flags |= TimeAttributionFlags.WhiteGloveAnchorsIncomplete; + foreach (var evt in events) + { + if (evt.Timestamp <= resumedAt && + (!part1End.HasValue || evt.Timestamp > part1End.Value)) + { + part1End = evt.Timestamp; + } + } + } + else if (part1End.Value > resumedAt) + { + flags |= TimeAttributionFlags.WhiteGloveAnchorsIncomplete; + part1End = resumedAt; + } + + return part1End; + } + + // ── segment spans ─────────────────────────────────────────────────────── + + private static string? MapPhaseToSegment(EnrollmentPhase phase) => phase switch + { + EnrollmentPhase.Start => TimeAttributionSegments.DevicePrep, + EnrollmentPhase.DevicePreparation => TimeAttributionSegments.DevicePrep, + EnrollmentPhase.DeviceSetup => TimeAttributionSegments.DevicePrep, + EnrollmentPhase.AppsDevice => TimeAttributionSegments.EspApps, + EnrollmentPhase.AccountSetup => TimeAttributionSegments.IdentityHello, + EnrollmentPhase.AppsUser => TimeAttributionSegments.UserEsp, + EnrollmentPhase.FinalizingSetup => TimeAttributionSegments.IdentityHello, + EnrollmentPhase.Complete => TimeAttributionSegments.DesktopHandoff, + // Failed: attribution ends — the post-failure tail stays unattributed rather than + // being relabeled into a segment no phase ever declared. + EnrollmentPhase.Failed => null, + _ => null, + }; + + /// + /// Anchor list in canonical order: phase-declaration events (Phase != Unknown) plus + /// desktop_arrived (Phase=Unknown by convention, but the spec-designated handoff + /// anchor). Consecutive same-bucket anchors collapse; anchors whose timestamp goes + /// backward vs. the previously accepted anchor are dropped with ClockSkewDropped. + /// + private static List BuildAnchors(List events, ref TimeAttributionFlags flags) + { + var anchors = new List(); + foreach (var evt in events) + { + string? bucket; + var isDeclaration = false; + if (evt.EventType == Constants.EventTypes.DesktopArrived) + { + bucket = TimeAttributionSegments.DesktopHandoff; + isDeclaration = true; + } + else if (evt.Phase != EnrollmentPhase.Unknown) + { + bucket = MapPhaseToSegment(evt.Phase); + isDeclaration = true; + } + else + { + bucket = null; + } + + if (!isDeclaration) continue; + + if (anchors.Count > 0) + { + var prev = anchors[anchors.Count - 1]; + if (evt.Timestamp < prev.Ts) + { + flags |= TimeAttributionFlags.ClockSkewDropped; + continue; + } + if (prev.Bucket == bucket) + continue; // same-phase re-declaration (re-registration, replay) — no boundary + } + + anchors.Add(new Anchor { Ts = evt.Timestamp, Bucket = bucket }); + } + return anchors; + } + + /// + /// Slices each window at the in-window anchors and buckets every slice by the phase context + /// active at its start. Context carries INTO window 0 from anchors at/before its start, but + /// never across the WhiteGlove pause into window 1+ (the resume gap makes no phase claim). + /// The leading gap of window 0 with no context is device_prep only when the first observed + /// anchor is still pre/at apps entry (the spec defines device_prep as session start → first + /// apps-phase entry); otherwise it stays unattributed. + /// + private static List BuildSegmentSpans(List windows, List anchors) + { + var spans = new List(); + + for (var w = 0; w < windows.Count; w++) + { + var window = windows[w]; + + string? context = null; + if (w == 0) + { + foreach (var a in anchors) + { + if (a.Ts <= window.Start) context = a.Bucket; + else break; + } + } + + var inWindow = anchors.Where(a => a.Ts > window.Start && a.Ts < window.End).ToList(); + + if (w == 0 && context == null) + { + var first = inWindow.FirstOrDefault(); + if (first != null && + (first.Bucket == TimeAttributionSegments.DevicePrep || + first.Bucket == TimeAttributionSegments.EspApps)) + { + context = TimeAttributionSegments.DevicePrep; + } + } + + var cursor = window.Start; + foreach (var anchor in inWindow) + { + AddSpan(spans, context, cursor, anchor.Ts); + cursor = anchor.Ts; + context = anchor.Bucket; + } + AddSpan(spans, context, cursor, window.End); + } + + return spans; + } + + private static void AddSpan(List spans, string? bucket, DateTime start, DateTime end) + { + if (bucket == null || end <= start) return; + var seconds = (int)Math.Floor((end - start).TotalSeconds); + if (seconds <= 0) return; // sub-second slivers land in the unattributed remainder + spans.Add(new TimeAttributionSpan { SegmentKey = bucket, StartUtc = start, EndUtc = end, Seconds = seconds }); + } + + // ── blocking-app intervals + occupancy ────────────────────────────────── + + private static (List Intervals, int MatchedCount) BuildBlockingAppIntervals( + List events, EspBlockingSets? sets, List windows, ref TimeAttributionFlags flags) + { + var intervals = new List(); + if (sets == null || sets.ListedCount == 0) + return (intervals, 0); + + // Fold per-app activity from EVENT timestamps (never payload timing — audit Q1). + var apps = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var evt in events) + { + var isStart = evt.EventType == Constants.EventTypes.AppInstallStart || + evt.EventType == Constants.EventTypes.AppDownloadStarted; + var isTerminal = evt.EventType == Constants.EventTypes.AppInstallComplete || + evt.EventType == Constants.EventTypes.AppInstallFailed || + evt.EventType == Constants.EventTypes.AppInstallSkipped; + if (!isStart && !isTerminal) continue; + if (evt.Data == null || !evt.Data.TryGetValue("appId", out var appIdObj)) continue; + + var appId = appIdObj?.ToString()?.Trim(); + if (string.IsNullOrEmpty(appId)) continue; + + if (!apps.TryGetValue(appId!, out var activity)) + { + activity = new AppActivity { AppId = appId! }; + apps[appId!] = activity; + } + if (string.IsNullOrEmpty(activity.AppName) && + evt.Data.TryGetValue("appName", out var nameObj)) + { + activity.AppName = nameObj?.ToString()?.Trim() ?? string.Empty; + } + + if (isStart && (!activity.FirstStart.HasValue || evt.Timestamp < activity.FirstStart.Value)) + activity.FirstStart = evt.Timestamp; + // LAST terminal wins — an IME retry (Error → retry → Installed) extends the + // occupancy to the final outcome instead of freezing at the first failure. + if (isTerminal && (!activity.LastTerminal.HasValue || evt.Timestamp > activity.LastTerminal.Value)) + activity.LastTerminal = evt.Timestamp; + } + + var matched = 0; + foreach (var activity in apps.Values) + { + if (!sets.Contains(activity.AppId)) continue; // absent ⇒ unknown, never "not blocking" + matched++; + + if (!activity.FirstStart.HasValue || !activity.LastTerminal.HasValue) + continue; // unobserved endpoint → no interval claim (attach window, audit §0.5) + if (activity.LastTerminal.Value < activity.FirstStart.Value) + { + flags |= TimeAttributionFlags.ClockSkewDropped; + continue; + } + + var clamped = ClampToWindows(activity.FirstStart.Value, activity.LastTerminal.Value, windows); + if (!clamped.HasValue) + { + flags |= TimeAttributionFlags.ClockSkewDropped; + continue; + } + + intervals.Add(new BlockingAppInterval + { + AppId = activity.AppId, + AppName = activity.AppName, + StartUtc = clamped.Value.Start, + EndUtc = clamped.Value.End, + Seconds = (int)Math.Floor((clamped.Value.End - clamped.Value.Start).TotalSeconds), + }); + } + + return (intervals, matched); + } + + /// Clips [start, end] to the windows' hull; null when it falls entirely outside observation. + private static (DateTime Start, DateTime End)? ClampToWindows(DateTime start, DateTime end, List windows) + { + var hullStart = windows[0].Start; + var hullEnd = windows[windows.Count - 1].End; + var s = start < hullStart ? hullStart : start; + var e = end > hullEnd ? hullEnd : end; + if (e < s) return null; + return (s, e); + } + + /// + /// Critical-path occupancy: overlap-merged union of the blocking intervals, intersected + /// with the esp_apps spans. Whole-second floor on the total (consistent with span math). + /// + internal static int ComputeOccupancySeconds( + List intervals, List espAppsSpans) + { + if (intervals.Count == 0 || espAppsSpans.Count == 0) return 0; + + var merged = new List<(DateTime Start, DateTime End)>(); + foreach (var iv in intervals.OrderBy(i => i.StartUtc)) + { + if (merged.Count > 0 && iv.StartUtc <= merged[merged.Count - 1].End) + { + var last = merged[merged.Count - 1]; + if (iv.EndUtc > last.End) + merged[merged.Count - 1] = (last.Start, iv.EndUtc); + } + else + { + merged.Add((iv.StartUtc, iv.EndUtc)); + } + } + + double total = 0; + foreach (var span in espAppsSpans) + { + foreach (var (ms, me) in merged) + { + var s = ms > span.StartUtc ? ms : span.StartUtc; + var e = me < span.EndUtc ? me : span.EndUtc; + if (e > s) total += (e - s).TotalSeconds; + } + } + return (int)Math.Floor(total); + } + + // ── reboot spans ──────────────────────────────────────────────────────── + + /// + /// One span per distinct observed boot: the event-stream gap bracketing lastBootUtc + /// (last event before the boot → first event at/after it). Fallback without a usable + /// payload: previous event → the detection event itself (which is genuinely post-boot — + /// usable as an observation bound, never as the boot moment). Spans are clipped to the + /// observation hull, so a WhiteGlove-pause reboot contributes nothing. + /// + private static List BuildRebootSpans( + List events, List windows, List spans) + { + var result = new List(); + var seenBoots = new HashSet(); + + for (var i = 0; i < events.Count; i++) + { + var evt = events[i]; + if (evt.EventType != Constants.EventTypes.SystemRebootDetected) continue; + + DateTime? lastBoot = null; + if (evt.Data != null && evt.Data.TryGetValue("lastBootUtc", out var lastBootObj)) + { + var raw = lastBootObj?.ToString(); + if (!string.IsNullOrEmpty(raw) && + DateTime.TryParse(raw, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) + { + lastBoot = parsed; + } + } + + DateTime? gapStart = null, gapEnd = null; + if (lastBoot.HasValue) + { + if (!seenBoots.Add(lastBoot.Value)) continue; // duplicate detection of the same boot + foreach (var e in events) + { + if (e.Timestamp < lastBoot.Value) + { + if (!gapStart.HasValue || e.Timestamp > gapStart.Value) gapStart = e.Timestamp; + } + else if (!gapEnd.HasValue || e.Timestamp < gapEnd.Value) + { + gapEnd = e.Timestamp; + } + } + } + else if (i > 0) + { + gapStart = events[i - 1].Timestamp; + gapEnd = evt.Timestamp; + } + + if (!gapStart.HasValue || !gapEnd.HasValue || gapEnd.Value <= gapStart.Value) continue; + + var clamped = ClampToWindows(gapStart.Value, gapEnd.Value, windows); + if (!clamped.HasValue) continue; + var seconds = (int)Math.Floor((clamped.Value.End - clamped.Value.Start).TotalSeconds); + if (seconds <= 0) continue; + + result.Add(new RebootSpan + { + StartUtc = clamped.Value.Start, + EndUtc = clamped.Value.End, + Seconds = seconds, + SegmentKey = SegmentAt(spans, clamped.Value.Start), + }); + } + + return result; + } + + private static string SegmentAt(List spans, DateTime ts) + { + foreach (var span in spans) + { + if (ts >= span.StartUtc && ts < span.EndUtc) + return span.SegmentKey; + } + return TimeAttributionSegments.Unattributed; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs index da86b6da..92889f6a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs @@ -53,6 +53,23 @@ namespace AutopilotMonitor.Functions.Services if (evt.Data != null) { + // F1 PR1 (audit Q3): the summaries dict — like the RowKey — is keyed by app NAME, + // but events carry the Intune app identity in `appId`. Adopt the first observed + // appId; if the same name later shows a DIFFERENT appId (device- + user-scope + // assignment, duplicate display names across rings), flag the row as a collision — + // its folded status/duration mixes two real apps and per-app fleet aggregates + // must exclude it. First-seen appId wins so the row stays deterministic. + if (evt.Data.TryGetValue("appId", out var appIdObj)) + { + var appId = appIdObj?.ToString()?.Trim(); + if (!string.IsNullOrEmpty(appId)) + { + if (string.IsNullOrEmpty(summary.AppId)) + summary.AppId = appId!; + else if (!string.Equals(summary.AppId, appId, StringComparison.OrdinalIgnoreCase)) + summary.AppIdCollision = true; + } + } if (evt.Data.TryGetValue("appVersion", out var appVersionObj)) { var appVersion = appVersionObj?.ToString(); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs index 4f4cee0a..825b0e4d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -1,5 +1,6 @@ using Azure; using Azure.Data.Tables; +using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; @@ -364,6 +365,114 @@ namespace AutopilotMonitor.Functions.Services if (!string.IsNullOrEmpty(existingDetection)) summary.DetectionResult = existingDetection; } + + // F1 PR1 — AppId identity across batches (audit Q3). First-seen appId wins + // (mirrors the in-batch fold rule); a later batch carrying a DIFFERENT appId + // under the same name proves the name-keyed row merges two apps → collision. + var existingAppId = existing.GetString("AppId"); + if (!string.IsNullOrEmpty(existingAppId)) + { + if (string.IsNullOrEmpty(summary.AppId)) + { + summary.AppId = existingAppId!; + } + else if (!string.Equals(summary.AppId, existingAppId, StringComparison.OrdinalIgnoreCase)) + { + summary.AppIdCollision = true; + summary.AppId = existingAppId!; + } + } + // Collision is one-way sticky: once any batch proved the identity mix, no + // later single-identity batch can unprove it. + if (!summary.AppIdCollision && (existing.GetBoolean("AppIdCollision") ?? false)) + summary.AppIdCollision = true; + + // EspBlocking is written only by the session-terminal resolution (positive + // evidence). A late app-event batch carries null → adopt the stored verdict + // so the in-memory summary matches what Merge-mode preserves on disk. + if (!summary.EspBlocking.HasValue) + { + var existingEspBlocking = existing.GetBoolean("EspBlocking"); + if (existingEspBlocking.HasValue) + summary.EspBlocking = existingEspBlocking; + } + } + + /// + /// Resolves for every app row of a session by + /// joining the rows' AppId against the session's LATEST esp_config_detected + /// tracking lists (F1 PR1; source-data audit Q2). Positive evidence only: listed rows are + /// merge-stamped EspBlocking=true; absent rows keep null (unknown) — never false. + /// Runs once per session at the terminal transition (TableSessionRepository seam — the + /// same funnel as the counter reconcile, covering ingest, admin marks, maintenance sweep + /// and rule-engine fails alike). Idempotent and fail-soft: any error is logged and + /// swallowed; app rows created by late batches after this ran simply stay unknown. + /// + /// Number of rows stamped (0 on no lists / nothing to stamp / error). + public async Task ResolveEspBlockingForSessionAsync(string tenantId, string sessionId) + { + try + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + // Latest emission that actually carries lists wins: the registry lists grow + // progressively, so later reads are supersets — but a later emission whose probe + // found no Diagnostics key (no espTracked* keys at all) must not erase earlier + // positive evidence. + var espEvents = await GetSessionEventsByTypeAsync( + tenantId, sessionId, Constants.EventTypes.EspConfigDetected, maxResults: 50); + EspBlockingSets? sets = null; + for (var i = espEvents.Count - 1; i >= 0 && sets == null; i--) + sets = EspBlockingSets.FromEventData(espEvents[i].Data); + if (sets == null || sets.ListedCount == 0) + return 0; + + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); + var filter = $"PartitionKey eq '{tenantId}' and RowKey ge '{sessionId}_' and RowKey lt '{sessionId}`'"; + var query = tableClient.QueryAsync( + filter: filter, + select: new[] { "PartitionKey", "RowKey", "AppId", "EspBlocking", "AppIdCollision" }); + + var stamped = 0; + await foreach (var row in query) + { + if (!ShouldStampEspBlocking(row, sets)) continue; + + var update = new TableEntity(row.PartitionKey, row.RowKey) + { + ["EspBlocking"] = true + }; + await tableClient.UpsertEntityAsync(update, TableUpdateMode.Merge); + stamped++; + } + + if (stamped > 0) + _logger.LogInformation( + "Session {SessionId}: stamped EspBlocking=true on {Count} app rows ({Listed} ids in blocking set)", + sessionId, stamped, sets.ListedCount); + return stamped; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "EspBlocking resolution failed for session {SessionId} (fail-soft)", sessionId); + return 0; + } + } + + /// + /// Pure per-row stamping predicate behind — + /// internal static so the join contract is pinned by unit tests: rows without an AppId and + /// collision rows (identity ambiguous) stay unknown; only positive membership stamps; an + /// already-stamped row is skipped (idempotency). + /// + internal static bool ShouldStampEspBlocking(TableEntity row, EspBlockingSets sets) + { + var appId = row.GetString("AppId"); + if (string.IsNullOrEmpty(appId)) return false; + if (row.GetBoolean("AppIdCollision") ?? false) return false; + if (row.GetBoolean("EspBlocking") == true) return false; + return sets.Contains(appId); } /// @@ -438,7 +547,7 @@ namespace AutopilotMonitor.Functions.Services "DurationSeconds", "DownloadBytes", "FailureCode", "DoDownloadMode", "DoTotalBytesDownloaded", "DoBytesFromPeers", "DoBytesFromHttp", "DoBytesFromLanPeers", "DoBytesFromGroupPeers", "DoBytesFromInternetPeers", - "DoBytesFromLinkLocalPeers", "DoBytesFromCacheServer" + "DoBytesFromLinkLocalPeers", "DoBytesFromCacheServer", "AppIdCollision" }; /// @@ -459,7 +568,8 @@ namespace AutopilotMonitor.Functions.Services { "PartitionKey", "RowKey", "TenantId", "SessionId", "AppName", "AppType", "AppVersion", "Status", "TerminalState", "StartedAt", "CompletedAt", "DurationSeconds", "DownloadBytes", - "AttemptNumber", "InstallerPhase", "FailureCode", "FailureMessage", "ExitCode", "DetectionResult" + "AttemptNumber", "InstallerPhase", "FailureCode", "FailureMessage", "ExitCode", "DetectionResult", + "AppId", "EspBlocking", "AppIdCollision" }; /// @@ -579,7 +689,11 @@ namespace AutopilotMonitor.Functions.Services AttemptNumber = entity.GetInt32("AttemptNumber") ?? 0, InstallerPhase = entity.GetString("InstallerPhase") ?? string.Empty, ExitCode = entity.GetInt32("ExitCode"), - DetectionResult = entity.GetString("DetectionResult") ?? string.Empty + DetectionResult = entity.GetString("DetectionResult") ?? string.Empty, + // F1 PR1 identity/blocking columns (absent on legacy rows → sentinels) + AppId = entity.GetString("AppId") ?? string.Empty, + EspBlocking = entity.GetBoolean("EspBlocking"), + AppIdCollision = entity.GetBoolean("AppIdCollision") ?? false }; } @@ -1421,6 +1535,19 @@ namespace AutopilotMonitor.Functions.Services entity["Status"] = summary.Status; if (!string.IsNullOrEmpty(summary.TerminalState)) entity["TerminalState"] = summary.TerminalState; + // F1 PR1 identity/blocking columns — all sentinel-gated so a batch that observed + // nothing about them cannot clobber a prior value under Merge-mode: + // AppId empty = not observed in this batch + // EspBlocking null = unknown (only the session-terminal resolution ever sets it, + // and only to true — positive evidence, never false) + // AppIdCollision false = never written; the flag is one-way sticky, so only true + // is persisted (Merge cannot un-set an absent column). + if (!string.IsNullOrEmpty(summary.AppId)) + entity["AppId"] = summary.AppId; + if (summary.EspBlocking.HasValue) + entity["EspBlocking"] = summary.EspBlocking.Value; + if (summary.AppIdCollision) + entity["AppIdCollision"] = true; if (summary.DurationSeconds > 0) entity["DurationSeconds"] = summary.DurationSeconds; if (summary.CompletedAt.HasValue) diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSummary.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSummary.cs index 438988fe..fc5850ab 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSummary.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSummary.cs @@ -12,6 +12,36 @@ namespace AutopilotMonitor.Shared.Models public string SessionId { get; set; } = string.Empty; public string TenantId { get; set; } = string.Empty; + /// + /// Intune app identity from the agent's app events (appId payload field) — + /// Win32/IME apps carry the Intune app GUID in lowercase dashed form + /// (AppPackageState.Id). Empty = sentinel: written before this column existed + /// (2026-07 F1 PR1) or the events carried no appId. The row stays name-keyed + /// (RowKey = {SessionId}_{AppName}); this column adds identity without a key migration. + /// + public string AppId { get; set; } = string.Empty; + + /// + /// Whether this app is in the ESP's own blocking/tracking set + /// (esp_config_detected lists, joined by at session-terminal + /// processing). Tri-state by design (source-data audit Q2): true = listed + /// (positive evidence), null = unknown (absent from the lists, lists never + /// observed, or row predates the column) — NEVER false: the lists are read + /// early/partially and MSI/PFN namespaces may not match, so absence is not evidence + /// of non-blocking. + /// + public bool? EspBlocking { get; set; } + + /// + /// True when the same app display name was observed with a DIFFERENT appId in + /// this session (source-data audit Q3): the name-keyed row then merges two distinct + /// apps (e.g. device- + user-scope assignment of "Company Portal") and its + /// status/duration mix is unattributable. Flagged rows are excluded from per-app + /// fleet aggregates (with a disclosed exclusion count) instead of attempting a RowKey + /// migration. Sticky once set. + /// + public bool AppIdCollision { get; set; } + /// /// Lifecycle status: Succeeded, Failed, InProgress, or empty. /// Empty (default) is a sentinel meaning "no status-relevant event observed in the current diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SessionTimeBreakdown.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SessionTimeBreakdown.cs new file mode 100644 index 00000000..3c764761 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SessionTimeBreakdown.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Canonical segment keys for the F1 time-attribution partition (insights spec §F1). + /// A segment may comprise multiple spans (e.g. identity_hello covers the AccountSetup + /// span AND the FinalizingSetup/Hello-wait span; WhiteGlove sessions split every segment + /// across two observation windows). + /// + public static class TimeAttributionSegments + { + /// Session start → first ESP apps-phase entry (Start/DevicePreparation/DeviceSetup phases). + public const string DevicePrep = "device_prep"; + + /// Device-scope ESP apps phase (EnrollmentPhase.AppsDevice spans). + public const string EspApps = "esp_apps"; + + /// Identity / sign-in / Windows Hello (AccountSetup + FinalizingSetup spans). + public const string IdentityHello = "identity_hello"; + + /// User-scope ESP apps phase (EnrollmentPhase.AppsUser spans), when present. + public const string UserEsp = "user_esp"; + + /// Last phase exit (desktop_arrived / Complete declaration) → terminal timestamp. + public const string DesktopHandoff = "desktop_handoff"; + + /// + /// Explicit remainder — time inside the observation window(s) no observed signal + /// accounts for. Reported, never redistributed (truthfulness rule 2: the partition + /// is exact and never normalized to 100 %). + /// + public const string Unattributed = "unattributed"; + } + + /// + /// Data-quality flags for a (truthfulness rule 7: + /// problems flag the record and exclude it from fleet aggregates with a disclosed count, + /// rather than silently skewing them). + /// + [Flags] + public enum TimeAttributionFlags + { + None = 0, + + /// At least one anchor/interval was dropped for going backward in time or falling outside the observation window. + ClockSkewDropped = 1, + + /// The agent started late (agent_late_start): early phases are underobserved; excluded from fleet segment medians. + PartialObservation = 2, + + /// No esp_config_detected tracking lists observed — per-app blocking membership is entirely unknown. + BlockingSetUnknown = 4, + + /// The tracking lists hit the 50-per-category emission cap — positive evidence is incomplete. + BlockingSetTruncated = 8, + + /// + /// WhiteGlove window boundaries rest on fallback anchors (no whiteglove_part1_complete + /// event, missing ResumedAt, or a derived part-1 length that contradicts the stored + /// combined duration) — the two-window split is best-effort rather than event-anchored. + /// + WhiteGloveAnchorsIncomplete = 16 + } + + /// One contiguous attributed span inside an observation window. + public class TimeAttributionSpan + { + /// Segment key — one of the constants (never "unattributed": the remainder is a scalar, not a span claim). + public string SegmentKey { get; set; } = string.Empty; + + public DateTime StartUtc { get; set; } + public DateTime EndUtc { get; set; } + + /// Whole seconds (floored). The sub-second dust lands in the unattributed remainder — never invented into a segment. + public int Seconds { get; set; } + } + + /// + /// Install interval of one ESP-blocking app, measured from EVENT timestamps + /// (first started/download event → last terminal event; source-data audit Q1 forbids the + /// agent payload timing, which freezes at the first terminal state across IME retries). + /// + public class BlockingAppInterval + { + public string AppId { get; set; } = string.Empty; + public string AppName { get; set; } = string.Empty; + public DateTime StartUtc { get; set; } + public DateTime EndUtc { get; set; } + public int Seconds { get; set; } + } + + /// + /// One observed reboot outage: the gap between the last pre-reboot event and the first + /// post-reboot event, located via the lastBootUtc payload (audit Q7: the + /// system_reboot_detected event itself is detection-time-stamped by the NEXT agent + /// run and is never used as the reboot moment). + /// + public class RebootSpan + { + public DateTime StartUtc { get; set; } + public DateTime EndUtc { get; set; } + public int Seconds { get; set; } + + /// Segment the reboot started in ( key, or "unattributed" when it began in an unattributed hole). + public string SegmentKey { get; set; } = string.Empty; + } + + /// + /// Per-session enrollment time attribution (F1, insights spec) — an exact partition of the + /// session's authoritative wall clock into named segments plus an explicit unattributed + /// remainder. Invariant (unit-enforced): sum of all span seconds + + /// == == the session's DurationSeconds (which excludes + /// the WhiteGlove pause by design — never CompletedAt − StartedAt, which diverges in 25 % of + /// terminal sessions). Computed once at session-terminal processing by + /// TimeAttributionCalculator; persisted in SessionTimeBreakdowns (F1 PR2). + /// + public class SessionTimeBreakdown + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// Attribution algorithm version — a definition change bumps this so aggregates never silently mix semantics (truthfulness rule 8). + public int AttributionVersion { get; set; } + + /// The session's authoritative DurationSeconds at compute time. + public int WallClockSeconds { get; set; } + + /// Attributed spans, ordered by StartUtc. Multiple spans may share a segment key. + public List Segments { get; set; } = new List(); + + /// Exact remainder: − sum of span seconds. + public int UnattributedSeconds { get; set; } + + /// Total observed reboot outage seconds. Cross-cutting annotation — reboots overlap the segments they occur in and are NOT part of the wall-clock partition. + public int RebootSeconds { get; set; } + + public List RebootSpans { get; set; } = new List(); + + /// + /// Install intervals of ESP-blocking apps (positive-evidence join against the latest + /// esp_config_detected lists), top 20 by duration. + /// carries the uncapped count of matched blocking apps (including those without a + /// measurable interval, e.g. unobserved start). + /// + public List BlockingApps { get; set; } = new List(); + + /// Uncapped count of apps matched against the blocking set (see ). + public int BlockingAppCount { get; set; } + + /// + /// Overlap-merged union of the blocking-app intervals clipped to the esp_apps spans — + /// the critical-path occupancy. esp_apps total − occupancy = in-phase wait (provider + /// stalls, settle, IME idle). Null when the blocking set is unknown (no lists observed): + /// unknown, not zero. + /// + public int? EspAppsOccupancySeconds { get; set; } + + public TimeAttributionFlags QualityFlags { get; set; } + + /// Convenience rollup: total seconds per segment key across all spans (unattributed NOT included — read ). + public Dictionary GetSegmentTotals() + { + var totals = new Dictionary(StringComparer.Ordinal); + foreach (var span in Segments) + { + totals.TryGetValue(span.SegmentKey, out var current); + totals[span.SegmentKey] = current + span.Seconds; + } + return totals; + } + } +}