Files
AutopilotMonitor/docs/backend/time-attribution.md
T
Oliver Kieselbach 6de21e821a feat(insights): F1 surfaces — session lane, fleet panel, API routes, MCP tool (PR3)
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>
2026-07-26 18:33:16 +02:00

6.5 KiB
Raw Blame History

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
backend
metrics
time-attribution
insights
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] where part1End is anchored on the whiteglove_part1_complete event. The pause between the windows is never attributed. Fallback anchors set the WhiteGloveAnchorsIncomplete quality 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 or global, RK = {yyyy-MM-dd}|{class} and rolling30|{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; rolling30 rows 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 or breakdown: null (pre-feature / non-terminal / Incomplete = no lane).
  • GET api/metrics/time-attribution (MemberRead) / GET api/global/metrics/time-attribution (GlobalReadOrAdmin, optional tenantId) — { 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).