Insights expansion PR3 (tasks/insights-expansion-spec.md §F1 "Surfaces").
Completes F1: schema (PR1) -> persistence/aggregation (PR2) -> surfaces.
- Rolling range rows: the sweep now also writes "rolling30|{class}"
aggregate rows — range statistics over the window's actual sessions. A
median of per-day medians is not the range median, so the fleet panel
reads these and is deliberately fixed to "last 30 days"; the daily rows
stay in the payload for trends/MCP.
- Routes (all registered in EndpointAccessPolicyCatalog):
GET sessions/{sessionId}/time-attribution (MemberRead + tenant query
param, GA fallback via FindSessionTenantId; breakdown=null is SUCCESS —
a missing lane is a normal state, not an error), GET
metrics/time-attribution (MemberRead) and GET
global/metrics/time-attribution (GlobalReadOrAdmin + tenantId filter),
both returning { windowDays, classes (rolling), daily }.
- Web: TimeAttributionLane under the session-detail phase timeline —
proportional stacked bar of segment totals incl. the explicit
unattributed remainder (bar always sums to the session's authoritative
duration), ESP-blocking apps with critical-path occupancy, reboot badge
(cross-cutting, never a bar slice), quality-flag chips; fetched
fail-soft for terminal sessions only. Fleet Health gains the "Time
attribution" section: per-class stacked medians gated at n>=20
("insufficient data (n=...)" below), flagged/missing exclusions
disclosed, and a top-blocking-apps table with class-scoped rows and
"up to X (p75 Y)" what-if wording. Deliberate deviation: the spec's
per-app trend column is omitted — per-day app rows are too sparse under
the daily n>=5 gate; revisit once rolling history exists.
- MCP: get_time_attribution (one tool, session + fleet mode, role-aware
routing like get_app_install_metrics, registered for all roles) — the
privilege-leak-guard catalog snapshot extended deliberately.
- Docs: OKF concept doc backend/time-attribution.md (invariant, windows,
segment mapping, positive-evidence blocking, Q7 reboot spans,
persistence + surfaces) registered in index.md + log.md.
Backend 3382/3382 (+1 rolling-aggregate test), web tsc clean + vitest
627/627, MCP tsc clean + 455 passed. Post-deploy: web deploy +
MCP redeploy (new tool + docs corpus).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6.5 KiB
type, title, description, resource, tags, timestamp
| type | title | description | resource | tags | timestamp | ||||
|---|---|---|---|---|---|---|---|---|---|
| Concept | Enrollment Time Attribution (F1) | How a terminal session's wall clock is partitioned into named segments with an explicit unattributed remainder, how ESP-blocking apps and reboots are attributed, and where breakdowns and fleet aggregates are computed, stored and served. | src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs |
|
2026-07-26T20:00:00+02:00 |
Enrollment Time Attribution (F1)
Answers "where did the 47 minutes go?" per session and per fleet. Every number is traceable to observed events; a missing signal produces "unknown" or an explicit unattributed remainder — never a guess, and the partition is never normalized to 100 %.
Schema
Invariant
For every computed breakdown:
sum(span seconds) + UnattributedSeconds == WallClockSeconds == session DurationSeconds
DurationSeconds is the authoritative wall clock — for WhiteGlove it is part 1 + part 2
WITHOUT the pause, and re-terminal stamps move CompletedAt, so CompletedAt − StartedAt
is never used (it diverges in ~25 % of terminal sessions). Span seconds are floored;
sub-second dust lands in the unattributed remainder.
Observation windows
Windows are end-anchored on authoritative timestamps and constructed so their lengths sum
exactly to DurationSeconds:
- Non-WhiteGlove: one window
[CompletedAt − DurationSeconds, CompletedAt]. - WhiteGlove (ONE session row, two windows): part 2 =
[ResumedAt, CompletedAt]; part 1 =[part1End − (DurationSeconds − part2), part1End]wherepart1Endis anchored on thewhiteglove_part1_completeevent. The pause between the windows is never attributed. Fallback anchors set theWhiteGloveAnchorsIncompletequality flag.
Segments
Spans are sliced at phase-declaration events (only declaration events carry
Phase != Unknown) plus desktop_arrived, in canonical Sequence order; anchors whose
timestamp runs backward are dropped (ClockSkewDropped).
| EnrollmentPhase | Segment key |
|---|---|
| Start / DevicePreparation / DeviceSetup | device_prep |
| AppsDevice | esp_apps |
| AccountSetup / FinalizingSetup | identity_hello |
| AppsUser | user_esp |
Complete, desktop_arrived event |
desktop_handoff |
| Failed | attribution ends — tail stays unattributed |
ESP-blocking apps (positive evidence only)
Per-app intervals use EVENT timestamps (first started/download event → LAST terminal
event, covering IME retries) — never the agent payload timing, which freezes at the first
terminal transition. Blocking membership joins the app id against the session's latest
list-carrying esp_config_detected emission: listed ⇒ blocking; absent ⇒ unknown, never
false (BlockingSetUnknown / BlockingSetTruncated flags). Critical-path occupancy =
overlap-merged union of blocking intervals clipped to the esp_apps spans;
EspAppsOccupancySeconds is null (unknown) when no lists were observed.
The fleet what-if bound per app X is max(0, cpEnd − cpEndWithoutX) (latest interval end
with vs. without X; the idle gap before X counts — the ESP also waits for X to start).
It is an upper bound by construction; every consumer says "up to", never "you will save".
Reboots
system_reboot_detected is detection-time-stamped by the NEXT agent run; the real boot
moment is its lastBootUtc payload. Reboot spans are therefore the event-stream gap
bracketing lastBootUtc, deduped per boot, clipped to the observation windows, and carried
as a cross-cutting annotation (they overlap the segment they started in — never a slice of
the partition).
Persistence & compute
SessionTimeBreakdowns(PK = TenantId, RK = SessionId): spans, reboot spans, blocking intervals (top 20 + uncapped count), quality flags,AttributionVersion. Written once at the session-terminal single-writer seam (TableSessionRepository.UpdateSessionStatusAsync, Succeeded/Failed only — Incomplete deliberately has no duration). Deleted with its session (deletion-manifest step) and on tenant offboarding.- Self-healing 30-day maintenance sweep (2h tick): backfills missing breakdowns, folds late-terminating sessions into their StartedAt-date aggregates, and enforces version purity — a stale-version row whose events aged out counts as missing, never mixed.
TimeAttributionAggregates(PK = TenantId orglobal, RK ={yyyy-MM-dd}|{class}androlling30|{class}): per enrollment class (user_driven,whiteglove,self_deploying,device_preparation— never mixed) median/p75/p90 per segment over the fixed six-segment stack (incl.unattributed; a session without a segment contributes 0), plus top blocking apps (≥5 sessions per app row). Clean/flagged/missing counts are part of every row; rows exist below the ≥20 UI gate on purpose (the UI needs the n for "insufficient data (n=…)"). Daily rows retain 180 days;rolling30rows are refreshed whole (a median of per-day medians is not the range median — the rolling rows are the honest range statistics).
Surfaces
GET api/sessions/{sessionId}/time-attribution(MemberRead + tenant query param) — breakdown row orbreakdown: null(pre-feature / non-terminal / Incomplete = no lane).GET api/metrics/time-attribution(MemberRead) /GET api/global/metrics/time-attribution(GlobalReadOrAdmin, optionaltenantId) —{ windowDays, classes (rolling), daily }.- Web: attribution lane under the session-detail phase timeline; "Time attribution" section
on Fleet Health. MCP:
get_time_attribution(sessionId mode / fleet mode).
Examples
A 30-minute user-driven session: device_prep 5m → esp_apps 10m (occupancy 4m by one
blocking app) → identity_hello 8m → user_esp 5m → desktop_handoff 2m, unattributed 0.
A WhiteGlove session with a 2-day pause reports wall clock = part1 + part2 only; the resume
gap before the first part-2 phase declaration is unattributed, not guessed.
Citations
src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs— calculator + semantics (AttributionVersion 1).src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.TimeAttribution.cs— persistence.src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs— sweep + aggregation.src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs— golden fixtures pinning the invariant.tasks/insights-expansion-spec.md— F1 specification and source-data audit (§0.5).