feat(insights): F1 schema + time attribution calculator (PR1)

Insights expansion PR1 (tasks/insights-expansion-spec.md §F1, audit §0.5):

- AppInstallSummary + AppId / EspBlocking (bool?, tri-state) / AppIdCollision:
  sentinel-gated columns + mapper + both projections. appId adopted from app
  events (first-seen wins); a second appId under the same display name flags
  the name-keyed row as a collision (audit Q3) — in-batch and cross-batch
  (sticky in the reconcile). Collision rows leave every per-app fleet
  aggregate (app metrics payload, apps list, app analytics) with a disclosed
  exclusion count; the fleet DO rollup and per-app session drill-down keep
  them.
- EspBlocking resolution at the session-terminal single-writer seam
  (TableSessionRepository.UpdateSessionStatusAsync, next to the counter
  reconcile — covers ingest, admin marks, maintenance sweep, rule engine):
  joins the session's app rows against the LATEST list-carrying
  esp_config_detected emission as a positive-evidence set (audit Q2) —
  listed => true, absent => stays unknown, never false. EspBlockingSets
  parser handles truncation via the uncapped espTracked*Count fields.
- TimeAttributionCalculator (pure, AttributionVersion 1) + SessionTimeBreakdown
  models: exact partition of the session's authoritative DurationSeconds into
  device_prep / esp_apps / identity_hello / user_esp / desktop_handoff spans
  plus an explicit unattributed remainder — invariant sum(spans)+unattributed
  == wall clock enforced in every test. WhiteGlove = two observation windows
  in ONE session (part1 anchored on whiteglove_part1_complete, pause never
  attributed). Per-app blocking intervals from EVENT timestamps (first start
  -> LAST terminal, audit Q1 retry freeze), occupancy = merged union clipped
  to esp_apps. Reboot spans from event gaps around lastBootUtc (audit Q7),
  deduped per boot. QualityFlags: ClockSkewDropped, PartialObservation,
  BlockingSetUnknown, BlockingSetTruncated, WhiteGloveAnchorsIncomplete.
  Persistence (SessionTimeBreakdowns) and surfaces follow in PR2/PR3.

Backend 3368/3368 (35 new tests: 19 calculator golden fixtures + 16
identity/blocking/aggregate-exclusion), solution build clean, web untouched
(new response fields are additive).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-26 17:29:36 +02:00
parent 4eac7d042c
commit e637317bdb
11 changed files with 1874 additions and 6 deletions
@@ -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;
/// <summary>
/// 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: <c>appId</c> is adopted from the app events; a second appId under the same
/// name flags the row (<see cref="AppInstallSummary.AppIdCollision"/>) — in-batch and
/// cross-batch. Store side: sentinel-gated columns + sticky collision. Join side:
/// <see cref="EspBlockingSets"/> is positive evidence only, and
/// <see cref="TableStorageService.ShouldStampEspBlocking"/> 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.
/// </summary>
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<string, AppInstallAggregationState> Aggregate(params EnrollmentEvent[] events)
{
var summaries = new Dictionary<string, AppInstallAggregationState>(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<string, object> { ["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<string, object> Lists(
object? win32 = null, object? userWin32 = null, object? msi = null, object? pfn = null,
long? win32Count = null)
{
var d = new Dictionary<string, object> { ["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<object> (EventDataNormalizer).
var sets = EspBlockingSets.FromEventData(Lists(
win32: new List<object> { IdA },
msi: new List<object> { "{11111111-2222-3333-4444-555555555555}" },
pfn: new List<object> { "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<object> { IdA }, win32Count: 60))!;
Assert.True(sets.IsTruncated);
}
[Fact]
public void Sets_UserScopeSubset_IsExposedForCoverageLabeling()
{
var sets = EspBlockingSets.FromEventData(Lists(
win32: new List<object> { IdA, IdB },
userWin32: new List<object> { 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<object> { 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<AppInstallSummary>
{
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<AppInstallSummary>
{
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<ISessionRepository>();
repo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((SessionSummary?)null);
var summaries = new List<AppInstallSummary>
{
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<ISessionRepository>();
repo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((SessionSummary?)null);
var summaries = new List<AppInstallSummary> { 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());
}
}
@@ -0,0 +1,459 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Golden fixtures for <see cref="TimeAttributionCalculator"/> (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).
/// </summary>
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<string, object>? data = null)
=> new()
{
EventType = eventType,
Timestamp = ts,
Sequence = ++_seq,
Phase = phase,
Data = data ?? new Dictionary<string, object>(),
};
private static Dictionary<string, object> AppData(string appId, string appName, string? state = null)
{
var d = new Dictionary<string, object> { ["appId"] = appId, ["appName"] = appName };
if (state != null) d["state"] = state;
return d;
}
/// <summary>Storage-shape blocking lists (EventDataNormalizer yields List&lt;object&gt;).</summary>
private static Dictionary<string, object> EspLists(long? win32Count = null, params string[] win32Ids)
=> new()
{
["espTrackedWin32AppIds"] = new List<object>(win32Ids),
["espTrackedUserWin32AppIds"] = new List<object>(),
["espTrackedMsiProductCodes"] = new List<object>(),
["espTrackedModernAppPfns"] = new List<object>(),
["espTrackedWin32Count"] = win32Count ?? win32Ids.Length,
["espTrackedMsiCount"] = 0L,
["espTrackedModernCount"] = 0L,
};
private static TimeAttributionInput Input(
List<EnrollmentEvent> 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,
};
/// <summary>The invariant every fixture must satisfy exactly (spec §0 rule 2).</summary>
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<EnrollmentEvent> 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<EnrollmentEvent>
{
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<EnrollmentEvent>
{
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<EnrollmentEvent>
{
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<EnrollmentEvent>
{
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<EnrollmentEvent>
{
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<EnrollmentEvent>
{
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<EnrollmentEvent>
{
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<EnrollmentEvent>
{
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<string, object> { ["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<EnrollmentEvent>
{
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<EnrollmentEvent>
{
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<EnrollmentEvent>
{
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<string, object> { ["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<string, object> { ["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<EnrollmentEvent>
{
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)
}
}
@@ -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;
}
@@ -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
{
@@ -0,0 +1,115 @@
using System.Collections;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Parsed ESP blocking/tracking lists from an <c>esp_config_detected</c> event payload
/// (agent emitter: <c>EspTrackingInfoProbe</c> → <c>DeviceInfoCollector.CollectEspConfiguration</c>).
///
/// 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 <c>S-&lt;SID&gt;</c> lists usually appear only after
/// sign-in, each list is capped at 50 entries (uncapped totals ride in the <c>espTracked*Count</c>
/// fields), and MSI ProductCodes / PFNs live in namespaces the IME <c>appId</c> 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.
/// </summary>
public sealed class EspBlockingSets
{
private readonly HashSet<string> _all;
private EspBlockingSets(
HashSet<string> all,
HashSet<string> userWin32AppIds,
int listedTotal,
bool isTruncated)
{
_all = all;
UserWin32AppIds = userWin32AppIds;
ListedCount = listedTotal;
IsTruncated = isTruncated;
}
/// <summary>Distinct ids across all four lists (Win32 device+user, MSI, PFN).</summary>
public int ListedCount { get; }
/// <summary>
/// True when any <c>espTracked*Count</c> exceeds its emitted list length — the 50-per-category
/// cap dropped entries, so even the positive evidence is incomplete for that category.
/// </summary>
public bool IsTruncated { get; }
/// <summary>
/// The user-scope Win32 subset (<c>espTrackedUserWin32AppIds</c>). 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.
/// </summary>
public IReadOnlyCollection<string> UserWin32AppIds { get; }
/// <summary>Positive-evidence membership test (case-insensitive, all four lists).</summary>
public bool Contains(string? appId)
=> !string.IsNullOrEmpty(appId) && _all.Contains(appId!);
/// <summary>
/// Parses the payload of one <c>esp_config_detected</c> event. Returns null when the payload
/// carries none of the <c>espTracked*</c> 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 (<c>List&lt;object&gt;</c>
/// via EventDataNormalizer) and test fixtures (<c>string[]</c> / <c>List&lt;string&gt;</c>).
/// </summary>
public static EspBlockingSets? FromEventData(IReadOnlyDictionary<string, object>? 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<string>(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<string>(userWin32 ?? new List<string>(), 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<string, object> data, string countKey, List<string>? 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<string>? ReadStringList(IReadOnlyDictionary<string, object> 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<string>();
foreach (var item in enumerable)
{
var s = item?.ToString()?.Trim();
if (!string.IsNullOrEmpty(s))
result.Add(s!);
}
return result;
}
}
@@ -58,7 +58,13 @@ public static class MetricsMath
{
var summaryList = summaries as IList<AppInstallSummary> ?? 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
@@ -0,0 +1,586 @@
using System.Globalization;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Inputs for <see cref="TimeAttributionCalculator.Compute"/> — the session facts the terminal
/// writer persisted plus the session's full event stream in canonical order.
/// </summary>
public sealed class TimeAttributionInput
{
public string TenantId { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
/// <summary>Session status string. Only "Succeeded" and "Failed" are computable (Incomplete deliberately carries no DurationSeconds).</summary>
public string Status { get; set; } = string.Empty;
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
/// <summary>
/// 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.
/// </summary>
public int? DurationSeconds { get; set; }
public bool IsPreProvisioned { get; set; }
/// <summary>WhiteGlove part-2 resume timestamp (session row), when present.</summary>
public DateTime? ResumedAt { get; set; }
/// <summary>Session events. Canonical order is Sequence ascending; the calculator re-sorts defensively.</summary>
public IReadOnlyList<EnrollmentEvent> Events { get; set; } = Array.Empty<EnrollmentEvent>();
}
/// <summary>
/// Deterministic, I/O-free computation of a session's <see cref="SessionTimeBreakdown"/>
/// (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:
/// <list type="bullet">
/// <item><b>Wall clock = session DurationSeconds.</b> 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 <c>whiteglove_part1_complete</c>.
/// The pause between the windows is never attributed.</item>
/// <item><b>Segments from phase-declaration events only</b> (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 / <c>desktop_arrived</c> → desktop_handoff. A Failed-phase declaration ends
/// attribution (the tail is unattributed, not relabeled). Anchors that go backward in canonical
/// order are dropped (<see cref="TimeAttributionFlags.ClockSkewDropped"/>).</item>
/// <item><b>Exact-partition invariant:</b> 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.</item>
/// <item><b>Per-app intervals use event timestamps</b> (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).</item>
/// <item><b>Blocking membership is positive evidence</b> from the LATEST
/// <c>esp_config_detected</c> lists: listed ⇒ blocking; absent ⇒ unknown, never false (audit Q2).
/// No lists ⇒ <see cref="TimeAttributionFlags.BlockingSetUnknown"/> and occupancy null.</item>
/// <item><b>Reboot spans from event gaps</b> around the <c>lastBootUtc</c> payload — the
/// <c>system_reboot_detected</c> 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.</item>
/// </list>
/// </summary>
public static class TimeAttributionCalculator
{
/// <summary>Bump on any semantic change so aggregates never mix definitions (truthfulness rule 8).</summary>
public const int CurrentVersion = 1;
/// <summary>Cap for the persisted per-app interval list (spec: top 20 by duration + uncapped count).</summary>
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;
/// <summary>Segment key, or null = attribution ends here (Failed-phase declaration).</summary>
public string? Bucket;
}
private sealed class AppActivity
{
public string AppId = string.Empty;
public string AppName = string.Empty;
public DateTime? FirstStart;
public DateTime? LastTerminal;
}
/// <summary>
/// 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).
/// </summary>
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 ─────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private static List<Window> BuildWindows(
TimeAttributionInput input, DateTime completedAt, int wallClock,
List<EnrollmentEvent> 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<Window>
{
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<Window> { new Window(completedAt.AddSeconds(-wallClock), completedAt) };
}
/// <summary>
/// Part-1 end anchor: the <c>whiteglove_part1_complete</c> 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).
/// </summary>
private static DateTime? ResolvePart1End(
List<EnrollmentEvent> 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,
};
/// <summary>
/// Anchor list in canonical order: phase-declaration events (Phase != Unknown) plus
/// <c>desktop_arrived</c> (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.
/// </summary>
private static List<Anchor> BuildAnchors(List<EnrollmentEvent> events, ref TimeAttributionFlags flags)
{
var anchors = new List<Anchor>();
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;
}
/// <summary>
/// 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.
/// </summary>
private static List<TimeAttributionSpan> BuildSegmentSpans(List<Window> windows, List<Anchor> anchors)
{
var spans = new List<TimeAttributionSpan>();
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<TimeAttributionSpan> 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<BlockingAppInterval> Intervals, int MatchedCount) BuildBlockingAppIntervals(
List<EnrollmentEvent> events, EspBlockingSets? sets, List<Window> windows, ref TimeAttributionFlags flags)
{
var intervals = new List<BlockingAppInterval>();
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<string, AppActivity>(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);
}
/// <summary>Clips [start, end] to the windows' hull; null when it falls entirely outside observation.</summary>
private static (DateTime Start, DateTime End)? ClampToWindows(DateTime start, DateTime end, List<Window> 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);
}
/// <summary>
/// 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).
/// </summary>
internal static int ComputeOccupancySeconds(
List<BlockingAppInterval> intervals, List<TimeAttributionSpan> 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 ────────────────────────────────────────────────────────
/// <summary>
/// One span per distinct observed boot: the event-stream gap bracketing <c>lastBootUtc</c>
/// (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.
/// </summary>
private static List<RebootSpan> BuildRebootSpans(
List<EnrollmentEvent> events, List<Window> windows, List<TimeAttributionSpan> spans)
{
var result = new List<RebootSpan>();
var seenBoots = new HashSet<DateTime>();
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<TimeAttributionSpan> spans, DateTime ts)
{
foreach (var span in spans)
{
if (ts >= span.StartUtc && ts < span.EndUtc)
return span.SegmentKey;
}
return TimeAttributionSegments.Unattributed;
}
}
@@ -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();
@@ -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;
}
}
/// <summary>
/// Resolves <see cref="AppInstallSummary.EspBlocking"/> for every app row of a session by
/// joining the rows' <c>AppId</c> against the session's LATEST <c>esp_config_detected</c>
/// tracking lists (F1 PR1; source-data audit Q2). Positive evidence only: listed rows are
/// merge-stamped <c>EspBlocking=true</c>; 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.
/// </summary>
/// <returns>Number of rows stamped (0 on no lists / nothing to stamp / error).</returns>
public async Task<int> 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<TableEntity>(
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;
}
}
/// <summary>
/// Pure per-row stamping predicate behind <see cref="ResolveEspBlockingForSessionAsync"/> —
/// 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).
/// </summary>
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);
}
/// <summary>
@@ -438,7 +547,7 @@ namespace AutopilotMonitor.Functions.Services
"DurationSeconds", "DownloadBytes", "FailureCode",
"DoDownloadMode", "DoTotalBytesDownloaded", "DoBytesFromPeers", "DoBytesFromHttp",
"DoBytesFromLanPeers", "DoBytesFromGroupPeers", "DoBytesFromInternetPeers",
"DoBytesFromLinkLocalPeers", "DoBytesFromCacheServer"
"DoBytesFromLinkLocalPeers", "DoBytesFromCacheServer", "AppIdCollision"
};
/// <summary>
@@ -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"
};
/// <summary>
@@ -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)
@@ -12,6 +12,36 @@ namespace AutopilotMonitor.Shared.Models
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Intune app identity from the agent's app events (<c>appId</c> payload field) —
/// Win32/IME apps carry the Intune app GUID in lowercase dashed form
/// (<c>AppPackageState.Id</c>). 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.
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// Whether this app is in the ESP's own blocking/tracking set
/// (<c>esp_config_detected</c> lists, joined by <see cref="AppId"/> at session-terminal
/// processing). Tri-state by design (source-data audit Q2): <c>true</c> = listed
/// (positive evidence), <c>null</c> = unknown (absent from the lists, lists never
/// observed, or row predates the column) — NEVER <c>false</c>: the lists are read
/// early/partially and MSI/PFN namespaces may not match, so absence is not evidence
/// of non-blocking.
/// </summary>
public bool? EspBlocking { get; set; }
/// <summary>
/// True when the same app display name was observed with a DIFFERENT <c>appId</c> 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.
/// </summary>
public bool AppIdCollision { get; set; }
/// <summary>
/// Lifecycle status: Succeeded, Failed, InProgress, or empty.
/// Empty (default) is a sentinel meaning "no status-relevant event observed in the current
@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// 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).
/// </summary>
public static class TimeAttributionSegments
{
/// <summary>Session start → first ESP apps-phase entry (Start/DevicePreparation/DeviceSetup phases).</summary>
public const string DevicePrep = "device_prep";
/// <summary>Device-scope ESP apps phase (EnrollmentPhase.AppsDevice spans).</summary>
public const string EspApps = "esp_apps";
/// <summary>Identity / sign-in / Windows Hello (AccountSetup + FinalizingSetup spans).</summary>
public const string IdentityHello = "identity_hello";
/// <summary>User-scope ESP apps phase (EnrollmentPhase.AppsUser spans), when present.</summary>
public const string UserEsp = "user_esp";
/// <summary>Last phase exit (desktop_arrived / Complete declaration) → terminal timestamp.</summary>
public const string DesktopHandoff = "desktop_handoff";
/// <summary>
/// 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 %).
/// </summary>
public const string Unattributed = "unattributed";
}
/// <summary>
/// Data-quality flags for a <see cref="SessionTimeBreakdown"/> (truthfulness rule 7:
/// problems flag the record and exclude it from fleet aggregates with a disclosed count,
/// rather than silently skewing them).
/// </summary>
[Flags]
public enum TimeAttributionFlags
{
None = 0,
/// <summary>At least one anchor/interval was dropped for going backward in time or falling outside the observation window.</summary>
ClockSkewDropped = 1,
/// <summary>The agent started late (<c>agent_late_start</c>): early phases are underobserved; excluded from fleet segment medians.</summary>
PartialObservation = 2,
/// <summary>No <c>esp_config_detected</c> tracking lists observed — per-app blocking membership is entirely unknown.</summary>
BlockingSetUnknown = 4,
/// <summary>The tracking lists hit the 50-per-category emission cap — positive evidence is incomplete.</summary>
BlockingSetTruncated = 8,
/// <summary>
/// WhiteGlove window boundaries rest on fallback anchors (no <c>whiteglove_part1_complete</c>
/// 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.
/// </summary>
WhiteGloveAnchorsIncomplete = 16
}
/// <summary>One contiguous attributed span inside an observation window.</summary>
public class TimeAttributionSpan
{
/// <summary>Segment key — one of the <see cref="TimeAttributionSegments"/> constants (never "unattributed": the remainder is a scalar, not a span claim).</summary>
public string SegmentKey { get; set; } = string.Empty;
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
/// <summary>Whole seconds (floored). The sub-second dust lands in the unattributed remainder — never invented into a segment.</summary>
public int Seconds { get; set; }
}
/// <summary>
/// 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).
/// </summary>
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; }
}
/// <summary>
/// One observed reboot outage: the gap between the last pre-reboot event and the first
/// post-reboot event, located via the <c>lastBootUtc</c> payload (audit Q7: the
/// <c>system_reboot_detected</c> event itself is detection-time-stamped by the NEXT agent
/// run and is never used as the reboot moment).
/// </summary>
public class RebootSpan
{
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
public int Seconds { get; set; }
/// <summary>Segment the reboot started in (<see cref="TimeAttributionSegments"/> key, or "unattributed" when it began in an unattributed hole).</summary>
public string SegmentKey { get; set; } = string.Empty;
}
/// <summary>
/// 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 + <see cref="UnattributedSeconds"/>
/// == <see cref="WallClockSeconds"/> == the session's <c>DurationSeconds</c> (which excludes
/// the WhiteGlove pause by design — never CompletedAt − StartedAt, which diverges in 25 % of
/// terminal sessions). Computed once at session-terminal processing by
/// <c>TimeAttributionCalculator</c>; persisted in <c>SessionTimeBreakdowns</c> (F1 PR2).
/// </summary>
public class SessionTimeBreakdown
{
public string TenantId { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
/// <summary>Attribution algorithm version — a definition change bumps this so aggregates never silently mix semantics (truthfulness rule 8).</summary>
public int AttributionVersion { get; set; }
/// <summary>The session's authoritative DurationSeconds at compute time.</summary>
public int WallClockSeconds { get; set; }
/// <summary>Attributed spans, ordered by StartUtc. Multiple spans may share a segment key.</summary>
public List<TimeAttributionSpan> Segments { get; set; } = new List<TimeAttributionSpan>();
/// <summary>Exact remainder: <see cref="WallClockSeconds"/> − sum of span seconds.</summary>
public int UnattributedSeconds { get; set; }
/// <summary>Total observed reboot outage seconds. Cross-cutting annotation — reboots overlap the segments they occur in and are NOT part of the wall-clock partition.</summary>
public int RebootSeconds { get; set; }
public List<RebootSpan> RebootSpans { get; set; } = new List<RebootSpan>();
/// <summary>
/// Install intervals of ESP-blocking apps (positive-evidence join against the latest
/// <c>esp_config_detected</c> lists), top 20 by duration. <see cref="BlockingAppCount"/>
/// carries the uncapped count of matched blocking apps (including those without a
/// measurable interval, e.g. unobserved start).
/// </summary>
public List<BlockingAppInterval> BlockingApps { get; set; } = new List<BlockingAppInterval>();
/// <summary>Uncapped count of apps matched against the blocking set (see <see cref="BlockingApps"/>).</summary>
public int BlockingAppCount { get; set; }
/// <summary>
/// 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.
/// </summary>
public int? EspAppsOccupancySeconds { get; set; }
public TimeAttributionFlags QualityFlags { get; set; }
/// <summary>Convenience rollup: total seconds per segment key across all spans (unattributed NOT included — read <see cref="UnattributedSeconds"/>).</summary>
public Dictionary<string, int> GetSegmentTotals()
{
var totals = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var span in Segments)
{
totals.TryGetValue(span.SegmentKey, out var current);
totals[span.SegmentKey] = current + span.Seconds;
}
return totals;
}
}
}