mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+333
@@ -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<object>).</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)
|
||||
}
|
||||
}
|
||||
+7
@@ -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-<SID></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<object></c>
|
||||
/// via EventDataNormalizer) and test fixtures (<c>string[]</c> / <c>List<string></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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user