Compare commits

...

66 Commits

Author SHA1 Message Date
Oliver Kieselbach 20b43e5349 feat(agent): gather debug log — phase transitions + per-match regex traces
- New "phase" stage: OnPhaseChanged writes a transition marker line
  (phase change: Old -> New) so rule-level lines read against the current
  phase; only on real changes, written outside _scopeLock.
- LogParser traces each regex match: line number, matched text (<=200 chars)
  and capture groups (<=100 chars, same selection as event data), capped at
  10 lines per file per run; the per-file summary keeps the full count.
- Invalid-regex error line now includes the offending pattern string.

Tests: 4 new (transition dedup, match trace, cap, pattern in error);
agent suite 2137/2137 green. OKF doc + docs/log.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 20:07:52 +02:00
Oliver Kieselbach d0d1d28371 perf(backend): column projection for unmatched-software scan
GetUnmatchedSoftwareFunction runs a full cross-tenant scan of
SoftwareInventory on every request (including every pagination click) but
only reads 9 properties. GetAllSoftwareInventoryAsync now accepts an
optional $select projection and the function passes the consumed columns,
cutting transfer and entity materialization.

Server-side filtering on CpeUri is documented as impossible: rows
re-created by the cascade-delete 404 path carry no CpeUri property and
Table Storage cannot express "property missing" — those rows must still
count as unmatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:22:07 +02:00
Oliver Kieselbach 5191751ed2 perf(web): lazy admin context + instant shell for software mapping
AdminConfigProvider no longer eagerly fetches global/config, config/all
(46 KB) and preview/whitelist on every /admin/* entry. Sections that
consume adminConfig/tenants trigger the idempotent ensureAdminConfigLoaded/
ensureTenantsLoaded on mount; fetchTenants stays as the explicit post-save
refresh. Pages that only need the token (Software Mapping, Backups, ...)
now pay nothing.

/admin/software gets a route-level loading.tsx and all three software tabs
render a TableSkeleton instead of a centered spinner on initial load. The
Mapped/Ignored mount guards ("!loaded && !loading") could refetch endlessly
after a failed fetch; now fetch only on !loaded.

Also gitignore .playwright-mcp/ (Playwright MCP browser artifacts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:21:51 +02:00
Oliver Kieselbach 907118649b added playwright mcp 2026-07-29 16:00:17 +02:00
Oliver Kieselbach 26ecbf33d6 feat(web): instant-shell loading UX — table skeletons + route loading fallbacks
Perceived hangs on navigation (e.g. Distress Reports) had three causes:
missing loading.tsx fallbacks froze the old page until the new chunk
rendered, loading state initialised false flashed empty states for one
paint, and several pages blocked their entire render on the first fetch.

- New TableSkeleton/SectionSkeleton primitives (server-component-safe),
  loading-UX convention documented in TableSkeleton.tsx
- 20 new route-level loading.tsx fallbacks (admin areas + top-level pages)
- Distress/Session Reports + Feedback: loading init true, skeleton in the
  table slot; refreshes keep the populated table
- Dashboard: skeleton in the sessions slot instead of a blank gap
- Converted full-page loading early returns on audit (also fixes a latent
  auth hang: the spinner sat outside ProtectedRoute so a fresh tab never
  triggered the MSAL redirect), usage-metrics, fleet-health, sla and
  admin Usage Plans; deliberate gates (diagnosis, preview, CalculatingCard
  pages) left untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:17:48 +02:00
Oliver Kieselbach adb103fc9e fix(web): always render the diagnostics opt-in in the report modal
Hidden-when-unavailable read as missing. The block now always shows:
active checkbox when the session has an uploaded archive, disabled with
an explanatory hint when it does not. Submit semantics unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:11:04 +02:00
Oliver Kieselbach 26c8b80efa updated commands 2026-07-29 15:00:17 +02:00
Oliver Kieselbach 89c5c7da03 feat: richer, self-describing session report content
The report ZIP now captures everything an operator needs even after the
session is gone, and states its own completeness:

- session.csv: append the 15 newer Sessions fields that had drifted out
  of the export (failureSource, reconcileReason, adminMarkedAction,
  validatedBy, rebootCount, isHybridJoin, isSelfDeployingProfile,
  connectionType, geo*, avgApiLatencyMs, apiRequestCount, stalledAt,
  failureSnapshotJson)
- completeness signal: exportedEventCount / sessionEventCount /
  eventStreamActive in the request and report-metadata.json; the modal
  warns while event pages are still streaming
- includedDiagnostics records sourceBlobName/sourceDestination so a
  failed copy still points at the original archive
- tenantContext snapshot (fail-soft): upload mode/destination, gather-
  rule debug flag, custom gather/analyze rule IDs + UpdatedAt, IME
  pattern IDs — no secrets, no definitions
- robustness: 8 MB client-side screenshot cap (20 MB body cap headroom),
  attachments status in metadata instead of silently dropping invalid
  base64, SanitizeZipEntryName strips path components from customer-
  supplied file names (both report types)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:13:33 +02:00
Oliver Kieselbach 48308df4ff feat: opt-in preservation of session diagnostics archive with session reports
Session reports persist while sessions and their diagnostics blobs are
removed by user deletion or retention. A new checkbox in the Report
Session modal (shown only when a diagnostics ZIP was uploaded, default
checked) lets the submitter include the archive: the backend streams a
server-side copy into the durable session-reports container.

- SessionReportDiagnosticsArchiveCopier: fail-soft copy (never blocks the
  report), reuses the Hosted/CustomerSas read paths, re-verifies the
  hosted tenant prefix via ClassifyBlobName, honours the admin size cap
  and download timeout; outcome persisted as DiagnosticsCopyStatus
- Report row/API gain DiagnosticsBlobName + DiagnosticsCopyStatus
  (written only when requested, omitted for legacy rows)
- report-metadata.json records includedDiagnostics {requested, blobName, status}
- Admin reports UI: second Download Diagnostics button + failure status line
- Privacy page: explicit exception for the opt-in report copy (Last updated bump)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:53:12 +02:00
Oliver Kieselbach cefa57fde5 feat(web): track negotiated SignalR transport in telemetry
Long-poll 404s in dependency telemetry come from clients whose corporate
network blocks WebSockets. Emit the negotiated transport on connect and
reconnect so fallback sessions are attributable instead of mysterious.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:25:05 +02:00
Oliver Kieselbach 946d503be0 feat(mcp): emit RFC 9207 iss parameter in OAuth authorization response
MCP spec 2026-07-28 requires clients to validate the iss parameter
against the recorded issuer before redeeming the authorization code
(AS mix-up defense). The /oauth/callback redirect now carries
iss=<public base URL> — the same value the RFC 8414 metadata reports
as issuer — and the metadata advertises
authorization_response_iss_parameter_supported. Pre-9207 clients
ignore unknown response parameters (RFC 6749 s4.1.2), so nothing
regresses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:51:41 +02:00
Oliver Kieselbach f744ad8406 fix code review findings 2026-07-29 10:30:47 +02:00
Oliver Kieselbach 5e9876bc5f fix: hard page reloads on family routes — RSC .txt payloads were wildcard-rewritten
Client navigation to an exported route fetches its RSC payload
(<route>/index.txt). The legacy-path wildcards (/admin/backups/* etc.)
matched those .txt paths and served the detail HTML instead — the Next
router cannot parse HTML as a flight payload and falls back to a hard
full-page navigation, resetting all client state (visible as the sidebar
collapsing on every Backups/Customs-Archive navigation).

Exact self-rewrites for all nine exported index.txt files now precede the
wildcards; the swaConfig guard pins txt-before-wildcard for every family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:48:08 +02:00
Oliver Kieselbach ffe88a47c6 fix: /sla dead-ended on www — it is a portal page, not a public one
/sla sat in PUBLIC_PATH_PREFIXES only because SLA notifications
historically linked www…/sla. It is a fully authenticated page
(ProtectedRoute + token fetches), and since ProtectedRoute stands down on
the public host, www/sla waited forever on "Loading SLA metrics...".

Removed from the public prefixes: the nav stays on portal (no bounce),
old www…/sla notification links bounce to portal/sla where auth works,
and SlaBreachEvaluationService.DashboardUrl now links the portal host
directly for future notifications. /sla added to robots disallow.

Web tsc + 603/603, backend build clean + SLA suite 38/38.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:39:56 +02:00
Oliver Kieselbach 1f22b65d57 fix: /apps, /admin/backups, /admin/customs-archive list pages shadowed by detail rewrites
SWA's trailingSlash:auto normalizes /apps to /apps/, which the /apps/*
legacy wildcard matches — so the LIST pages were rewritten to the DETAIL
html (prod: /apps rendered the app-detail view with an empty name and hung
on "Aggregating app analytics"). Exact list-route rewrites now precede
each family wildcard; the swaConfig guard pins list-before-wildcard order
for all families.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:28:50 +02:00
Oliver Kieselbach 03afbf6ac6 fix: double sign-in on www→portal handover — silent SSO for app-triggered logins
Two causes of the reported "sign in on www, then sign in again on portal":
- Race: on www/<portal-path> ProtectedRoute fired loginRedirect BEFORE
  HostRoutingGuard's replace, signing the user in on the wrong origin.
  ProtectedRoute now stands down on the public host and lets the guard move
  the page.
- prompt "select_account" forced the account picker on EVERY loginRedirect,
  including the automatic portal-side re-auth where Entra's session cookie
  would complete silently. login({auto:true}) (ProtectedRoute) drops the
  prompt; user-clicked logins (LoginButton/PublicSiteNavbar, manual retry)
  keep the picker.

Net UX: sign in once — the portal-side re-auth is a silent Entra roundtrip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:26:23 +02:00
Oliver Kieselbach 36172b8cc5 fix: www↔portal redirect loop — MSAL auth is per-origin, guards must not fight it
Production incident after the static-export deploy: authenticated-on-www
users hit a hard ping-pong. Chain: AuthGate (www) pushed /dashboard →
HostRoutingGuard bounced to portal (no MSAL session there — auth state is
per-origin) → ProtectedRoute's login-failure fallback navigated to "/" →
guard bounced back to www → AuthGate pushed again.

Three-part fix:
- HostRoutingGuard: never bounce while the URL carries an MSAL auth
  response (#code=/#state= land on the ORIGIN ROOT because redirectUri has
  no path — a bounce destroys the sign-in), plus a sessionStorage bounce
  counter that stands down after 3 hops/15s as a generic loop breaker.
- ProtectedRoute: login failure no longer navigates to "/" (the loop's
  return edge) — fails in place with a manual "Sign in" retry UI.
- AuthGate: on the public host, hand over to the portal origin in ONE
  window.location.href navigation (getPortalLoginUrl pattern) instead of
  router.push + guard cascade.

tsc clean, vitest 603/603, export build 87 pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:16:32 +02:00
Oliver Kieselbach 8849a68b1c fix: og/twitter/apple-icon as static PNGs — extension-less exports served as octet-stream
The ImageResponse metadata routes export extension-less files and SWA
route-level Content-Type headers do not override the file server's type
(verified in prod: application/octet-stream + nosniff, which breaks social
preview crawlers). Replaced with the one-time-rendered PNGs as static
app/ metadata files — Next now emits /opengraph-image.png URLs and SWA
serves image/png by extension. Content-Type route entries dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:06:01 +02:00
Oliver Kieselbach cc195990ff fix: SWA deploy — drop invalid is_static_export input, dedupe trailing-slash routes
The pinned Azure/static-web-apps-deploy@v1 has no is_static_export input
(warning only — Oryx detected the export and validated out/ regardless).
The real failure was config validation: with "trailingSlash": "auto" SWA
normalizes /x and /x/ to the same route key, so the defensive
trailing-slash duplicates were rejected as duplicate rules. One entry per
route covers both forms under auto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:52:28 +02:00
Oliver Kieselbach 18b28260a7 feat: portal static export — eliminate the SWA SSR runtime (cold-start root fix)
output:'export' + trailingSlash: the SWA now serves plain static files; the
managed SSR runtime — the portal's last cold-start source ("Backend call
failure" when a cold start exceeded SWA's 45s limit) — no longer exists.

- Detail routes are query-string based (/sessions?id=, /sessions/inspector?id=,
  /diagnosis?id=, /apps/detail?name=, /admin/backups/detail?id=,
  /admin/customs-archive/detail?tenantId=&rowKey=). Single-definition builders
  in lib/routes.ts; all 21 link sites migrated, duplicate sessionHref helpers
  consolidated. Suspense boundaries added everywhere useSearchParams drives
  page identity (hard export-build errors otherwise).
- 9 [section] families prerender via server shells (generateStaticParams from
  the *NavSections registries, dynamicParams=false, client bodies extracted to
  SectionClient) — 37 pages; server redirect() index pages became
  ClientRedirect shells. robots/sitemap/og-image routes force-static.
- Legacy PATH-shaped deep links are preserved FOREVER (sent Teams/Slack/
  webhook notifications cannot be edited): SWA wildcard rewrites serve the
  family page and the globally mounted LegacyPathRedirect rewrites to the
  canonical query URL, preserving query + #event hashes (mapping pinned by
  lib/__tests__/legacyPathRedirect.test.ts).
- middleware.ts deleted → client-side HostRoutingGuard (prefix list moved to
  lib/hostRouting.ts). Legacy /go/[code] route + bootstrapValidation deleted —
  BootstrapSessions table verified free of active pre-cutover sessions.
- staticwebapp.config.json now carries production routing: 19 docs/marketing
  redirects (order-sensitive), legacy rewrites (exact before wildcard), 404
  override, all security headers incl. literalized prod CSP (no unsafe-eval),
  Content-Type routes for the extension-less og images. Guarded by the new
  utils/__tests__/swaConfig.guard.test.ts. Maintenance mode is now a
  first-routes-entry insert, never a whole-file swap (README rewritten).
- Backend notification deep links go through Constants.PortalSessionUrl
  (query shape, escaped, pinned); 3 producers migrated.
- deploy-web.yml uploads out/ with is_static_export.

Export build: 79 static pages, no go/ output. Web: tsc clean, vitest 53
files green (618 incl. new guards; 2 deleted legacy suites). Backend:
solution build clean, 3622/3622 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:44:32 +02:00
Oliver Kieselbach 6cbd507b4f fix: client-IP extraction was dead on Flex — RemoteIpAddress fallback + AFD-aware rate-limit key
Post-deploy verification of the go endpoint showed every anonymous caller
keyed as "unknown": Flex Consumption does not surface X-Forwarded-For to
the isolated worker, so ALL per-IP rate-limit buckets (bootstrap-validate,
distress, diag-ticket-download, stats/platform) had silently collapsed
into one shared bucket — confirmed by DistressReports.SourceIp being
"unknown" across the board.

- GetTrustedClientIp falls back to HttpContext.Connection.RemoteIpAddress
  (transport-derived, unspoofable; IPv4-mapped addresses normalized).
  Heals every existing caller without signature changes.
- New GetRateLimitClientIp for AFD-fronted endpoints: honors
  X-Azure-ClientIP only when X-Azure-FDID carries exactly our profile ID
  (FrontDoorProfileId app setting, set on autopilotmonitor-api-eu);
  fail-closed to GetTrustedClientIp on unset/mismatch/multi-value.
  Used by GetBootstrapScriptFunction (traffic arrives via
  go.autopilotmonitor.com, where the trusted hop is AFD egress).
- FdidMatches table tests incl. append/spoof cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:01:31 +02:00
Oliver Kieselbach 26bcab9054 feat: move OOBE bootstrap script endpoint into the backend (go.autopilotmonitor.com)
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.

- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
  hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
  pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
  invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
  expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
  raw string; golden test pins byte parity with the TS template
  (TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
  bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
  unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
  /api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
  legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
  removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
  section, index/log entries.

Backend tests 3611/3611, web vitest 651/651, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:42:56 +02:00
Oliver Kieselbach 10308664dd small UI fix in session list entry for started 2026-07-28 17:47:33 +02:00
Oliver Kieselbach 21b8e054df docs: correct portal-prefetch root cause (h2, server-side queue) + measured outcome
The HTTP/1.1 / six-connection-cap claim was wrong — it came from the
Schannel curl build that cannot speak HTTP/2. ALPN (openssl s_client)
and .NET HttpClient confirm the portal negotiates h2; the queue is
server-side in the SWA SSR runtime. Doc, index, log and the
GlobalSidebar comment now describe the real mechanism.

Adds the 2026-07-28 outcome section: Standard plan changed nothing
(stratified p50s match, Mann-Whitney p=0.81); prefetch={false} removed
the freezes (worst totalDuration 225s -> 4.9s) and also removed an
accidental keep-alive (~150ms added to cold navigations).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:52:19 +02:00
Oliver Kieselbach 249c20e15a feat: mark backfilled pre-agent events as history in timeline + agent summary event
Customer feedback: backfilled modern_deployment_error events (pre-agent
Autopilot event-log errors replayed at agent start) rendered as plain
Error rows with one identical emission timestamp — indistinguishable
from a live failure.

Web (EventTimeline.tsx, effective for already-stored events):
- "⟲ Backfilled" badge (slate, explanatory tooltip) on data.backfilled
- row time now shows the original event-log timeCreated with a
  "(reported …)" suffix; metadata block gains a "Recorded" line;
  raw mode gets a compact ⟲ marker
- severity deliberately NOT downgraded — unresolved pre-agent errors
  must stay visible in the Error filter

Agent (ModernDeploymentTracker):
- backfill scan buffers the capped record set, emits an Info summary
  event first (modern_deployment_log, backfillSummary: true +
  replayedCount/droppedOverCap/lookbackMinutes) announcing the block,
  then replays oldest-first so timeline sequence follows event-log
  chronology (cap semantics unchanged: newest 25 win)

No new EventType, no ConfigVersion bump. Tests: summary emission x2,
tracker subset 12/12; tsc + vitest 651/651 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:10:25 +02:00
Oliver Kieselbach 4ebd60fb1e feat: EnableGatherRuleDebugLog — per-tenant gather-rule debug trace (ConfigVersion 36)
Customers cannot tell why a gather rule produces nothing in the timeline
(e.g. a logparser rule with emit_mode on_change that never emits). New
per-tenant boolean EnableGatherRuleDebugLog (EnableImeMatchLog-style
plumbing end to end): when enabled, the agent writes
%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log with one
pipe-delimited line per evaluation decision across all previously silent
"produced nothing" exits — disabled rules, interval without
intervalSeconds, first-tick-after-one-full-interval, phase-scope skips
incl. currentPhase=Unknown, per-(rule, phase) dedup, empty collector
results (emitOnlyIfExists), on_change hash suppression (hash prefix +
streak), guard blocks, logparser per-file outcomes (position skips,
format=text hint when every line fails CMTrace parsing, maxLines and
wildcard caps), and full exception stacks the agent log drops.

- Backend: ConfigVersion 35 -> 36; TenantConfiguration/AgentConfigResponse
  field + TableConfigRepository Store+Map
- Agent: GatherRuleDebugLog writer (10 MB cap, one .old generation,
  failures swallowed, optional console echo), GatherRuleContext.DebugLog
  (null-safe no-op), trace points in executor + collectors; never writes
  under _scopeLock (reasons captured inside, written after release);
  merger bool->path projection with --gather-debug-log CLI override
  tolerance; deliberately NOT in the offline-cache strip lists;
  --run-gather-rules always traces; zero-rules delivery leaves a one-line
  file instead of nothing
- Side fix: GatherRuleExecutorHost now passes the logger into
  InformationalEventPost so gather-event emissions hit the DEBUG log
- Web: tenant settings toggle (Settings -> Agent) + admin tenant-config
  report rows
- Tests: merger on/off/override, writer format+rotation, executor debug
  lines, logparser debug lines (agent 2131, backend 3561, tsc green)
- Docs: docs/agent/gather-rule-debug-log.md + index/log entries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:59:30 +02:00
Oliver Kieselbach 22093d0824 feat(agent): diagnostics upload at WhiteGlove Part-1 seal (preprov, V1 parity)
Upload the diagnostics package as a pre-provisioning intermediate when
Part 1 seals (suffix "preprov"). No new config field: the existing mode
semantics carry the desired behaviour — Always uploads at the seal,
OnFailure skips (the seal terminates as Succeeded; a Part-1 failure goes
through the regular Failed terminal path, which already uploads), Off
stays off. The upload runs before the whiteglove_part1_complete emit so
diagnostics_* events keep a lower sequence than the Part-1/Part-2
boundary marker. Best-effort: a fast reseal-reboot just loses the
intermediate package. Backend needs nothing — ingest already stamps
diagnostics_uploaded independent of terminal state (proven live by the
on-demand collection path).

Also make the on-demand path honest: CreateAndUploadAsync now takes
bool? enrollmentSucceeded — null means "no outcome yet" (mid-session),
so the OnFailure gate only skips a KNOWN success and sessioninfo.txt
records "In Progress" instead of the former enrollmentSucceeded:false
workaround that stamped in-flight packages "Failed".

10 new tests (WG seal mode matrix, preprov suffix, event ordering vs the
Part-1 marker, unchanged Part-1 exit contract; nullable-outcome gate
matrix via HTTP-free sentinel, sessioninfo labels). Agent suite
2112/2112 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:54:14 +02:00
Oliver Kieselbach dff1532be5 feat: project ConnectionType (WiFi/Ethernet) onto SessionsIndex
The agent already reports connectionType in every network_interface_info
event, but it was only stored as a JSON blob in DeviceSnapshot — answering
"how many machines enroll over Ethernet vs WiFi?" required a slow full-table
scan via deviceProperties. Project it as a typed column instead (template:
the AvgApiLatencyMs chain), making it a cheap indexed one-call query.

- Ingest: TryExtractConnectionType — last network_interface_info emission of
  the batch wins (a device switching media mid-enrollment is relabeled);
  only exact "WiFi"/"Ethernet" accepted, fire-and-forget fail-soft writer
- Storage: UpdateSessionConnectionTypeAsync (Merge on Sessions + index
  mirror, tombstone-safe), BuildSessionIndexEntity superset entry,
  MapToSessionSummary, re-registration preserve in StoreSessionAsync
- Search: connectionType= filter with OData eq push-down on the index,
  client-side backstop on the non-OData paths, fields=connectionType
  projection, pagination fingerprint entry, raw endpoint parity
- MCP: connectionType z.enum param on search_sessions/query_raw_sessions,
  fields doc updated (server redeploy required)
- Web: optional sortable/filterable "Connection" column in the session list
- No backfill: legacy sessions lack the column and are excluded by the
  filter (intended, same semantics as RebootCount); the deviceProperties
  scan path still covers historical data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:25:17 +02:00
Oliver Kieselbach 8608ee6272 feat(agent): netsh WiFi fallback labels for all enrollment-geography languages
Derived the language set from actual enrollment geography
(get_geographic_metrics, 365d, 10k sessions across ~50 countries) and
documented the country->language mapping on the key arrays. Newly
covered: Finnish, Hungarian, Lithuanian, Latvian, Romanian, Croatian/
Bosnian, Slovak, Indonesian/Malay, Vietnamese, Thai, Arabic, Bulgarian
radio-type variant and Swedish "Radiotyp". New fi/id parser tests; the
unknown-language tests now use Greek (outside the geography set).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:18:34 +02:00
Oliver Kieselbach f762b932a2 feat(agent): CJK + Russian labels in netsh WiFi fallback parser
Extend the localized label sets with the remaining languages seen in
enrollments: Russian, Japanese, Chinese (Simplified + Traditional,
incl. the 信道/通道 channel variants) and Korean (incl. the two radio-
type spellings). SSID stays untranslated everywhere. New zh-CN and ko
parser tests; suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:14:07 +02:00
Oliver Kieselbach 173bfc40a9 feat(agent): multi-language label support in netsh WiFi fallback
The netsh fallback previously matched English labels only, silently
dropping fields on localized OS images. The parser now matches the
label variants of the languages commonly seen in enrollments
(EN/DE/FR/ES/PT/IT/NL/PL/CS/TR; Scandinavian locales reuse the EN/DE
spellings) and is split into a pure Parse(output) function covered by
unit tests (EN/DE/ES samples, "94 %" spacing, AP-BSSID collision,
unknown-language degradation - SSID itself is untranslated in all
locales). Unknown labels still fall through harmlessly; the native
WLAN API remains the primary path.

V2.Core suite 2095/2095 green; fallback live-verified against netsh
on dev hardware (identical values to the API path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:08:46 +02:00
Oliver Kieselbach 609d9c47af fix(agent): netsh fallback + step diagnostics for WiFi info collection
Field regression on 2.0.1383 (session f7c56065, HP ProBook 450 G7,
build 26200.8037): the native WLAN API path returned nothing in the
OOBE/SYSTEM context and the null path was silent, so wifi_signal_info
disappeared entirely - while netsh-based 2.0.1380/1382 sessions on the
same build kept reporting SSID/signal the same morning. Suspected (not
yet proven) cause: Wi-Fi location gating of WlanQueryInterface on
24H2+; inbox netsh is unaffected.

- WifiInfoReader: new TryGetCurrentConnection overload reporting WHY
  the read yielded nothing (native rc per step, interface state);
  callers log it at Debug so the agent log explains missing WiFi info.
- NetshWifiFallback (new): parses netsh wlan show interfaces ONLY when
  the WLAN API yields nothing on a WiFi-connected device; SystemPaths.
  Netsh restored for it.
- WifiConnectionInfo.SignalPercent is now int? (netsh fallback cannot
  always parse localized "Signal"); event payload emits the field only
  when present - same shape as before.

V2.Core suite 2085/2085 green; interactive positive path re-verified
(SSID/94%/802.11ax/ch116, empty diagnostics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:55:46 +02:00
Oliver Kieselbach 5496d575c1 style: two-line Started column (date over time) in session list
Narrows the column so more columns fit on one page; matches the
two-line style of Device/Model/Status cells.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:37:01 +02:00
Oliver Kieselbach 1c3d29f30b refactor(agent): read WiFi info via native WLAN API instead of netsh parsing
netsh wlan show interfaces output is localized: "Radio type"/"Channel"
(and on some languages even "Signal") never matched on non-English
images, silently dropping fields from wifi_signal_info and network
change snapshots. It also spawned a child process per collection.

Replace both netsh call sites with wlanapi.dll P/Invoke
(WlanQueryInterface: current_connection + channel_number):

- New WlanNativeMethods (raw interop) + WifiInfoReader (fail-soft,
  prefers the active NIC's interface GUID, falls back to the first
  connected WLAN interface; returns null on machines without WLAN).
- DeviceInfoCollector.CollectWiFiSignalInfo and
  NetworkChangeDetector.CollectWiFiInfo now use the reader; RadioType
  strings stay netsh-compatible ("802.11ax" ...), event fields
  (wifiSsid/wifiSignalPercent/wifiRadioType/wifiChannel) unchanged.
- SystemPaths.Netsh removed (no remaining references).

V2.Core suite 2085/2085 green incl. new WifiInfoReader tests; struct
marshaling verified against native sizeof (604/68/36/532).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:20:12 +02:00
Oliver Kieselbach bb4accfe96 feat: API Latency as optional column in dashboard session list
Off by default; sortable via avgApiLatencyMs. Data comes from the
SessionsIndex mirror the list already reads — no extra query or table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:11:20 +02:00
Oliver Kieselbach 0ec481b479 feat(agent): outcome-calibrated agent_late_start severity and phrasing
Fleet data (2026-07-28, agent 2.0.1380) showed the late-start detector was
outcome-blind: tenant 659c3a90 collected 12 Warnings with the post-mortem/
hung-script note on enrollments that succeeded — the bootstrap merely ran
late in the ESP queue and the agent observed the healthy final minutes live.

New pure helper ObservationCoverage.DescribeLateStart:
- Succeeded -> Info with a tail-only-coverage note (IME-replay hint,
  bootstrap-ordering review if recurring); no alarm framing.
- Failed/TimedOut -> Warning with the existing post-mortem/hung-script note.
- oobeStateAtAgentStart refines the message only: "completed" = pure
  post-mortem, "in_progress" = live tail observed; not_started/unavailable
  add no claim (value flips to not_started after mid-OOBE reboots and
  "completed" is routine on post-desktop restarts).

The coverage gate (10 min boot-to-start / 5 min uptime) is unchanged, and
the OOBE state is deliberately NOT a gate: the canonical hung-script
failure (f148976f) terminates on the still-shown ESP screen, i.e. with
OOBE reading in_progress.

8 new tests (outcome x oobe-state matrix); V2.Core suite 2067 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:53:17 +02:00
Oliver Kieselbach 3ec716a3ec feat: expose API latency to MCP field projection and tool docs
- SearchSessionsFunction fields whitelist += avgApiLatencyMs,
  apiRequestCount so search_sessions lean sweeps (e.g.
  fields=geoCountry,avgApiLatencyMs,apiRequestCount) can aggregate
  latency without full SessionSummary payloads
- search_sessions tool doc lists the new fields incl. the weighting
  hint; get_geographic_metrics doc names avgApiLatencyMs /
  apiLatencyVsGlobalPct as the backend-region distance metric

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:28:01 +02:00
Oliver Kieselbach b0bb7233e4 chore: drop Outbound IP row from Connectivity section
The outbound_ip trace event stays available for analysis (timeline,
MCP); only the UI row is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:20:35 +02:00
Oliver Kieselbach 6758dc59e4 refactor: move API latency into Device Details "Connectivity" section
The extra grid cell in the Session Info card forced a fourth row once
Device Validation was present. The latency reading is a "when needed"
detail, so it moves into the collapsed Device Details card as a new
Connectivity section instead — together with values that were collected
but never displayed anywhere: the estimated internet bandwidth
(network_bandwidth_estimate incl. DO-throttle hint), total telemetry
upload volume (agent_metrics_snapshot), and the outbound IP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:12:08 +02:00
Oliver Kieselbach ffd70e0383 feat: geographic API-latency view (per-location weighted avg by geo)
Surfaces the persisted per-session AvgApiLatencyMs in the geographic
views so operators can compare regions when deciding on new backend
locations (Asia/US):

- ComputeGeographicMetrics: per-location and global request-weighted
  latency (sum(avg*requests)/sum(requests)) + ApiLatencyVsGlobalPct;
  serves both the tenant and the GA cross-tenant endpoint
- GeoMetricsSessionProjection extended (column-projected scan would
  otherwise drop the new fields); equivalence test pins it
- Drilldown: LocationSessionRow carries the per-session values; sessions
  table + summary tile show them
- geographic-performance page: sortable "API Latency" column with
  absolute color buckets (<250 green .. >=800 red), global benchmark
- PlatformMetricsService: session AvgLatency now exact from cumulative
  counters when present (fallback: mean of interval averages)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:58:53 +02:00
Oliver Kieselbach c18154d76d feat: persist per-session agent-to-backend API latency (AvgApiLatencyMs)
The V2 agent already measures HTTP round-trip latency per request
(NetworkMetrics, cumulative _totalLatencyMs) but only emitted the
per-interval delta average. Operators deciding on new backend regions
(Asia/US) need the session-wide average, aggregatable by GeoCountry.
Measured baseline: Jakarta sessions average ~600-1050 ms client-side vs
94 ms server-side processing — the gap is pure network distance to GWC.

- Agent: agent_metrics_snapshot now carries cumulative net_total_latency_ms
  (total/requests = whole-session average; idempotent by construction)
- Ingest: last matching snapshot of a batch projects AvgApiLatencyMs +
  ApiRequestCount onto the Session row (fire-and-forget, fail-soft,
  last-write-wins; agents predating the field simply never match)
- SessionsIndex: both fields mirrored (merge + BuildSessionIndexEntity
  superset) so list/GA views can aggregate latency per geo
- Web: session detail shows "API Latency" when present

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:44:46 +02:00
Oliver Kieselbach f524473305 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-07-28 09:54:07 +02:00
Oliver Kieselbach 8fe420b902 updated commands 2026-07-28 09:53:57 +02:00
dependabot[bot] 2e8c2cdb2f chore(deps): bump @modelcontextprotocol/sdk (#110)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk).


Updates `@modelcontextprotocol/sdk` from 1.29.0 to 1.30.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.29.0...1.30.0)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/sdk"
  dependency-version: 1.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:44:13 +02:00
dependabot[bot] e04b75dd53 chore(deps): bump the web group (#111)
Bumps the web group in /src/Web/autopilot-monitor-web with 3 updates: [next](https://github.com/vercel/next.js), [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `next` from 15.5.21 to 15.5.22
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.21...v15.5.22)

Updates `@next/bundle-analyzer` from 16.2.11 to 16.2.12
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.12/packages/next-bundle-analyzer)

Updates `eslint-config-next` from 15.5.21 to 15.5.22
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.22/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.2.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 15.5.22
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:41:30 +02:00
Oliver Kieselbach d55b0ff864 test(backend): pin the cross-tenant redaction on metrics/ime-versions
GET metrics/ime-versions is MemberRead and the archive is one global
partition, so every tenant member reads the same platform-wide rows. The only
thing between an ordinary tenant user and another tenant's identifiers was an
inline projection with no test behind it.

There is no HTTP-level harness in this suite - nothing constructs an
HttpRequestData with a RequestContext - so the projection was unreachable from
a test while it lived inside Run. Extracted it to a static
BuildResponsePayload(versions, hasGlobalScope), matching the pattern
GetTpmPssUnsupportedFunction already uses for the same reason. Behaviour is
unchanged: the payload is returned as `object`, and System.Text.Json resolves
an `object` declared type via the runtime type, so both branches keep the wire
format they had inline. A test asserts that rather than trusting it.

10 tests, asserting on serialized JSON in camelCase (Program.cs's naming
policy) because a property dropped from an anonymous type is only a real
redaction once it is absent from the wire:

  - non-global: firstSeenTenantId / firstSeenSessionId absent, and their GUID
    VALUES absent anywhere in the payload (a model rename must not let the
    identifier survive under another key)
  - non-global: exactly {version, firstSeenAt, lastSeenAt, sessionCount}
  - non-global: sessionCount survives - the counts are platform-wide by
    decision, pinned so it cannot drift either way unnoticed
  - global (Admin or Reader): full entry including both identifiers
  - both scopes: array shape, empty archive, row order preserved

Mutation-checked both directions: forcing the global branch fails 3 tests,
dropping sessionCount from the projection fails 2. Full suite 3521 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:33:56 +02:00
Oliver Kieselbach 123ba0e55e fix(web): fleet-health crash in GA all-tenants scope - repeatDevices absent
The worker serializer omits null fields (WhenWritingNull), so the
aggregated global/metrics/device-journeys response carries no
repeatDevices at all. The strict `=== null` check missed undefined and
fell through to repeatDevices.length, crashing the page. Verified
against the deployed chunk: crash column 11464 is the `0===o.length`
read behind the `null===o` guard.

Loose `== null` check + optional DTO field; absent and null both render
the "select a tenant" hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 19:28:14 +02:00
Oliver Kieselbach c8175ff7b1 fix(mcp): drop npm from the runtime image, bump tar to 7.5.22
Defender for Cloud reports CVEs against the mcp container image that the
repo scanners cannot see. The subassessment evidence paths split them in two:

  /usr/local/lib/node_modules/npm/**  - the npm 10.9.8 that node:22-slim
  bundles, vendoring @sigstore/core 2.0.0, brace-expansion 2.0.2, tar 7.5.11
  (critical), sigstore, picomatch, ip-address, plus npm itself (high, no fix
  available). Present in all 20 images including the current one. Dependabot
  reads package-lock.json, so none of this is visible to it.

  /app/node_modules/**  - body-parser 2.2.2, fast-uri 3.1.2, hono, protobufjs,
  tar 7.5.16, all confined to images from 2026-07-21 and earlier and already
  fixed by 7df208fa/c3e9621a. Those findings age out with the old ACR tags.

npm is build-time only here: CMD is `node dist/index.js`, the HEALTHCHECK is
`node -e`, the container app spec overrides no command and probes over HTTP,
and src/ contains no child_process call. So remove npm rather than upgrade it
- npm 11.x still vendors @sigstore/core 3.1.0, itself inside the vulnerable
<= 3.2.0 range, and would clear nothing.

Separately, tar 7.5.20 -> 7.5.22 in our own tree (GHSA-r292-9mhp-454m, via
@huggingface/transformers -> onnxruntime-node). 7.5.22 satisfies the declared
^7.0.1, so no override is needed. fast-uri deliberately stays on 3.1.4: it is
patched against all four fast-uri advisories, and ajv 8.18.0 declares ^3.0.1,
so forcing 4.x would be an override against a major range.

npm audit --omit=dev: 0 vulnerabilities. tsc clean, 455 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:00:07 +02:00
Oliver Kieselbach 0efeb0f734 fix(backend): alert-webhook 500 on non-seekable body - bounded read replaces Length check
With ASP.NET Core integration the HttpRequestData body is a
forward-only stream whose Length property throws NotSupportedException
(verified in prod: 500 + System.NotSupportedException on the first
POST). The size cap is now enforced by reading at most cap+1 chars via
ReadBlockAsync instead of asking the stream for its length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:52:59 +02:00
Oliver Kieselbach 7b975d46b6 feat(backend): Azure Monitor alert webhook - platform alerts join the ops-event bus
New anonymous POST ops/alert-webhook receives Common Alert Schema
notifications from an Action Group webhook receiver and re-emits them
as ops events (category Platform, type AzureMonitorAlert), so Azure
Monitor alerts flow through the existing OpsEventService ->
OpsAlertDispatchService fan-out (Telegram/Teams/Slack + portal
ops-events list) like every internally raised operational event. The
delivery path is independent of the portal: a SWA outage cannot
suppress the notification; the action group's e-mail receiver stays as
the backend-independent fallback.

Security: action-group webhooks cannot send custom headers, so the
shared secret travels as ?secret= and is compared in constant time
(FixedTimeEquals) against the OpsAlertWebhookSecret app setting -
fail-closed while unset. 64 KB payload cap, defensive essentials-only
parsing (only alertRule is mandatory), severity mapped Sev0->Critical,
Sev1->Error, Sev2->Warning, Sev3/4->Info with unknown values falling
back to Info so schema drift can never escalate.

Dual-registered per convention: EndpointAccessPolicyCatalog entry
(PublicAnonymous), OPS_EVENT_TYPES in OpsAlertRulesSection (rule
wiring) and Platform category in OpsEventsSection (badge + filter).

Backend suite 3511 green (incl. new helper tests + catalog
completeness), web tsc + vitest 651 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:39:22 +02:00
Oliver Kieselbach 7680bdff7b fix(web): disable RSC prefetch on nav links - portal freeze root cause
Navbar + GlobalSidebar render the full navigation on every page, so the
App Router's default viewport prefetch fired one ?_rsc= request per
visible link against the SWA runtime (~10.4k requests / 14 days). That
origin serves document, chunks and RSC payloads over HTTP/1.1 (6
connections per origin); when the runtime queued the prefetches (p95
896ms, tail up to 234s), they held every socket and the portal froze
until the queue drained. Measured on the 2026-07-27 17:25 incident:
/admin/presence?_rsc= at 151s while the backend API answered in <2.2s.

All 16 nav links now set prefetch={false}. Every target route is a
client component behind ProtectedRoute that fetches its own data after
mount, so the prefetched RSC payload carried no benefit.

New OKF doc docs/web/portal-navigation-prefetch.md records the full
evidence chain and where browser telemetry lives (separate App Insights
resource, not reachable via MCP query_backend_logs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:27:24 +02:00
Oliver Kieselbach 93094653a2 fix(web): tenant-scope seed races on usage-metrics/sla - own-tenant stats shown for override
A GA with a persisted tenant override opening /usage-metrics (or /sla) saw
their OWN tenant's stats until a manual refresh. useGlobalAdminScope seeded
the persisted selection in a post-mount effect, so the page effect first saw
effectiveTenantId = own tenant and fired an own-tenant fetch immediately
followed by the override fetch; with raw authenticatedFetch (no latest-wins
guard, unlike useAuthenticatedFetch since 82934a37) the last-RESOLVED
response won.

- useGlobalAdminScope: seed the GA selection render-time (same pattern as
  the delegated seed) so consumers never observe a transient own-tenant
  effectiveTenantId - the wrong fetch no longer starts at all.
- usage-metrics/sla pages: latest-wins sequence guard in fetchMetrics so a
  superseded request can neither write data/notifications nor clear the
  newer request's loading state (covers rapid selector switches).

Verified: tsc 0 errors, vitest 651/651.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:08:12 +02:00
Oliver Kieselbach ba7d14b070 fix(insights): post-deploy MCP findings - projection drop, aggregate starvation, duration artifacts
Three defects found by measuring production after the 1.5.1012 deploy:

- AppMetricsProjection dropped TerminalState, so the PR0 skip
  classification never reached metrics/app (totalSkipped stuck at 0).
  Column added; equivalence fixture now populates TerminalState/AppId/
  EspBlocking/AppIdCollision and asserts them (proven: test fails
  without the projection fix).
- BuildTimeAttributionAggregates excluded breakdowns on ANY quality
  flag; 903/1000 carry BlockingSetUnknown, starving fleet stats
  (Clean=12 vs 331 excluded on the largest tenant). New
  TimeAttributionFlagQuality.DurationCriticalFlags mask - only
  ClockSkew/PartialObservation/WGAnchors exclude; blocking-set flags
  keep their measured spans (positive-evidence semantics unchanged).
- HasMeasuredDuration gets an upper plausibility bound (6h = default
  AgentMaxLifetimeMinutes): 490 rows carried back-stamped completions
  (up to 117 days; 60175s app rows in a 2697s session) that dominated
  slowestApps with six-figure averages. Read-side, heals old + new rows.

Backend 3484/3484 green; solution build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:50:55 +02:00
Oliver Kieselbach 7f7330e9e5 feat(web): auto-recover from post-deploy stale-chunk failures (one reload, loop-guarded)
A tab still running the previous deployment's bundle requests lazily-loaded
chunks (next/dynamic charts, route chunks) whose content-hashed filenames no
longer exist after the SWA swap - the user is stuck on "Loading chart..." or a
dead navigation until a manual refresh. Standard remedy: reload once so the
fresh document references the new bundle.

- utils/chunkReloadRecovery.ts: chunk-error detection (webpack ChunkLoadError +
  Chromium/Firefox/Safari dynamic-import variants) and a sessionStorage guard -
  at most one auto-reload per tab per 5 minutes; without working storage no
  auto-reload at all, so a reload loop is structurally impossible. Emits a
  ChunkReloadRecovery App Insights event before reloading.
- components/ChunkReloadRecovery.tsx (root layout): catches rejected dynamic
  imports via unhandledrejection and failed /_next/ script/link tags via a
  capture-phase error listener (third-party scripts never trigger a reload).
- app/error.tsx: a chunk error reaching the boundary reloads once instead of
  showing the error card; inside the guard window it falls through to the card.

Verified: tsc clean, vitest 651/651 (8 new tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:47:34 +02:00
Oliver Kieselbach 82934a3719 fix(web): tenant-aware unrestricted badge + latest-wins fetch guard on rules pages
GA override on gather-rules called GET /api/global/config/{tenantId}, a route
that does not exist in the backend; the swallowed 404 left the unrestricted
badge showing the GA's HOME tenant's mode for every foreign tenant. Both paths
now read the member-readable config/{tenantId}/feature-flags (MemberRead has a
global-scope bypass and the route is RouteParam-scoped), with a reset-on-switch
so a failed fetch never carries the previous tenant's mode over. The dead
globalConfig.tenant entry is removed from the URL register.

useAuthenticatedFetch now enforces latest-wins: the scope hook seeds a
persisted foreign tenant only after mount, so an own-tenant fetch races the
override fetch and the last-RESOLVED response used to win, leaving the rules
list on the home tenant while the selector showed the override. Stale requests
no longer write data/error/loading; the spinner is owned by the last-started
non-silent request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:26:01 +02:00
Oliver Kieselbach 372f0c7c98 feat(web): source pill for non-Intune install rows + per-source row key
Customers who additionally deploy Microsoft 365 Apps as their own Win32 app saw two
rows in Install Progress (the IME view and the Office C2R lifecycle) with nothing
telling them apart, which read as a duplicate.

- New lib/installProgress.ts: buildInstallItems() extracted from the component so the
  event-folding rules are unit-testable; component is now render-only.
- InstallItem gains `source` (ime | office-c2r | realmjoin) and a source-scoped `key`.
  Rows were keyed by app name alone, so a Win32 app named exactly "Microsoft 365 Apps"
  (= OfficeInstallDetector.AppName) collided with the C2R row and overwrote its state.
- Pill only for non-default sources: "Click-to-Run" / "RealmJoin", neutral grey so it
  does not compete with the state badge, with a tooltip explaining what the row measures.
  Plain Intune app rows stay unlabelled.
- Drops the "RJ: " name prefix, which broke sorting and truncation.

Verified: tsc clean, vitest 643/643 (16 new).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:24:53 +02:00
Oliver Kieselbach 8057b44e48 fix(agent): serialize ESP-config collects so a later emission never carries an older snapshot
Codex review (P2): the ESP-config refresh triggers each run on their own
Task.Run (as do CollectAll's startup sweep and CollectAtEnrollmentStart),
so two concurrent collects could interleave read-before-emit - the LATER
emission then carried the OLDER (smaller) registry snapshot. The backend
uses the latest list-bearing esp_config_detected emission as positive
blocking evidence, so an out-of-order emission would silently shrink it.

CollectEspConfiguration now runs its read->emit sequence inside a
dedicated lock covering ALL callers; a later emission always carries an
equal-or-newer snapshot. The race predates the Q2 refresh triggers (they
only raised its frequency).

Deterministic concurrency test: 12 parallel refreshes against a probe
whose list grows per read - the emission sequence must be exactly 1..12.

Verified: Agent V2 Core 2059/2059.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:52:56 +02:00
Oliver Kieselbach 7842d121a0 fix(insights): codex review findings - WG pause clamping, late-event healing, stale buckets, date windows, banner count
All six review findings verified against code and real; five fixed here,
the agent-side race lands in the companion agent commit.

[P1] WhiteGlove pause counted as time: ClampToWindows clipped intervals
against the HULL of both WG windows, but the excluded pause lies inside
the hull - a reboot gap or app interval straddling the pause counted the
entire pause (a ~2-day "reboot" in a 30-minute enrollment was possible).
Seconds are now the per-window intersection sum (chronological endpoints
kept); an interval wholly inside the pause makes no claim and no longer
raises ClockSkewDropped. What-if savings are additionally capped at the
session's WallClockSeconds - endpoint deltas can still straddle the
pause, and no removal can save more than the enrollment took.

[P1] Late-arriving events never updated breakdowns: the terminal-seam
compute is one-shot and the sweep only healed missing/version-stale rows.
SessionTimeBreakdown now stores EventCountAtCompute (0 = pre-fix row,
recomputed once); the sweep recomputes on EventCount drift and then also
re-runs ResolveEspBlockingForSessionAsync (newly exposed on
IMetricsRepository) so late esp_config_detected re-emits and app batches
reach both the breakdown and the blocking evidence.

[P2] Stale aggregate buckets: the sweeps now reconcile every partition
they wrote - buckets that existed but were not regenerated (sessions
deleted, class left the window) are deleted via new targeted deletes;
rolling30 rows additionally get a 48h ComputedAt freshness filter at
read time, so a fully quiet tenant stops serving year-old numbers as
"last 30 days".

[P2] Date windows included an extra calendar day: both inclusive range
ends meant days=30 returned 31 keys and days=1 summed yesterday AND
today. InclusiveWindowStart(today, days) = today-(days-1) in both
response builders; the sweep's rolling30 source is filtered to the same
30 calendar days (its query window stays one day wider for backfill).

[P3] Device-history banner counted later sessions as "previous
enrollments" when viewing an older session: selectPriorSessions
(deviceHistoryPrior.ts) anchors on the session's own chain entry (live
sessions: new sessionStartedAt prop), sessionId tie-break mirroring the
server chain sort.

Verified: solution build clean, Backend 3480/3480 (+6: three WG-pause
golden fixtures, window-start facts, RK builder/delete consistency,
EventCountAtCompute roundtrip), Web tsc + vitest 633/633 (+6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:52:39 +02:00
Oliver Kieselbach 4bc2435ec5 fix(agent): audit Q1/Q2/Q5 source fixes - timing re-arm, esp_config re-emits, serial trim
Q1 (AppInstallTiming): a terminal->active transition (IME retry path,
e.g. Error->Downloading or Skipped->Downloading) re-arms CompletedAtUtc
so the final terminal payload covers first-start -> last-terminal instead
of freezing at the first terminal state. Plus a negative-duration guard
in AppInstallTiming.DurationSeconds (inverted endpoints -> null, field
omitted) - observed in production as durationSeconds=-2342 on a
Skipped-then-retried user-scope app (session 66f1c5e1).

Q2 (esp_config_detected): the ESP blocking lists are read too early -
device lists grow progressively, user-scope S-<SID> lists appear only
after sign-in (88 of 89 prod sessions had an empty user list at last
emission). DeviceInfoHost now re-collects the ESP configuration as
phase-driven one-shots: apps sub-phase declaration (AppsDevice/AppsUser
phase_transition = first app activity) and AccountSetup detection.
The StartupEventGate keeps unchanged payloads silent (fingerprint covers
list contents), so only grown lists actually re-emit. F1 EspBlocking
resolution (latest list-bearing emission) benefits without backend change.

Q5 (HardwareInfo): WMI values (serial, manufacturer, model) trimmed at
the source via NormalizeWmiValue; whitespace-only collapses to the
"Unknown" sentinel the retry logic already treats as unresolved.

Attach-window investigation (11a3b67a): NO agent fix - hypothesis
disproved against two real agent logs (diag bundles db5ce007/2.0.1337,
4a9e9273/2.0.1379). "completed without started" first apps were already
installed (WinGet detection-only, zero install lines in AppWorkload.log);
pre-attach replay provably works (pattern-match log). PR0's
HasMeasuredDuration already handles the class server-side.
Duplicate app_install_completed (66f1c5e1): mechanism narrowed to an
in-process re-read of exactly one AppWorkload rotation window (<24h-old
lines, historic suppression not applicable) - documented as follow-up,
no fix without a bundle-bearing repro per lessons.md.

Verified: solution build clean, Agent V2 Core 2058/2058 (14 new tests),
DecisionCore 493/493. No ConfigVersion bump, no new event types,
decision rail untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:59:20 +02:00
Oliver Kieselbach 1bf37f6761 feat(insights): F3 regression radar - Wilson-gated rule hit-rate drift detection (PR6)
Analyze rules only (gather stats stay per-batch-deduped until Q6 is
fixed - never silently included). Per (tenant, rule) the radar compares
the trailing 7-day hit rate (FireCount/SessionsEvaluated from the daily
RuleStats rows) against the prior 28-day baseline and fires only when
the window has >=5 hit sessions and >=20 evaluated sessions, the rate
is >=2x baseline AND the Wilson 95% intervals are disjoint
(MetricsMath.WilsonInterval/RateIncreaseSeparated, hand-computed test
vectors). Suppressed for deleted rules, rules inside the CreatedAt
grace period (<35d), rules edited inside the window, empty baselines,
and the RuleRegressionRadarDisabled app-setting kill switch.

Alert episodes live as ruleregression|{ruleId} rows in the notification
tracker (third keyspace): AddEntity-409 dedup = one bell + one
RuleFrequencyRegression ops event (category Tenant, Warning, full
numbers, dual-registered in the web OPS_EVENT_TYPES) per episode;
numbers refresh while active without moving FirstNotifiedAt; episodes
re-arm under 1.5x baseline, on fire-stop, or via the tracker's 30d
retention prune (spec-intended reminder). On-fire dimension correlation
(osBuild/model/agentVersion/imeVersion; hit set from a RuleResults
range scan intersected with the window sessions) reports both shares
with the "correlated, not necessarily causal" wording contract or
honestly "no clear dimension concentration". Radar runs in the daily
maintenance pass after the rule-stats aggregation, anchored on
yesterday (whole days only).

Surfaces: regressions[] in the rule-stats response (MCP get_rule_stats
inherits it), tenant bell deep-linking to the rule card, and the
analyze-rules page gains a densified 30-day fire sparkline plus a
regression badge while an episode is active. OKF doc
backend/rule-regression-radar.md + index/log.

Backend 3474/3474 (27 new: Wilson vectors, every suppression branch,
window boundary dates, re-arm matrix, dimension gates, tracker
round-trips incl. tri-states, keyspace dedup/prune, wording pins),
web tsc + vitest 627/627, MCP tsc + 455 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:10:52 +02:00
Oliver Kieselbach 88da317bf4 feat(insights): F2 surfaces - device-history banner, fleet FTR panel, API routes, MCP tool (PR5)
Three read routes over the PR4 data: metrics/device-history
(MemberRead + tenant query param - one route serves the web banner AND
MCP, GA cross-tenant via ?tenantId= with a sessionId-based owner
fallback; history:null is a normal outcome), metrics/device-journeys
and global/metrics/device-journeys (days selector honored because FTR
counts are additive - window rate = ratio of summed daily rows; the
tenant-less global variant serves the "global" partition and discloses
repeatDevices:null). Attempt numbers are server-computed
(DeviceJourneyCalculator.ComputeAttemptNumber: chain position within
the journey; live sessions via a virtual non-successful attempt so the
redeploy and 30d-gap rules apply) - no consumer re-derives journey
semantics. Repeat-devices violator list: last journey >=2 attempts with
newest terminal ref in-window, attempts desc then recency, cap 10,
failure reason from the newest failed attempt (bounded point-reads).

Web: DeviceHistoryBanner on session detail ("Attempt N for this device
- View history", expandable terminal chain, durations strictly the
authoritative durationSeconds, WG/DevPrep/admin-marked badges) and the
Fleet Health "First-time-right" section (rate gated at >=20 completed
journeys with disclosed n, junk-serial exclusion note, Monday-UTC
weekly trend, attempt histogram, repeat devices) following the 7/30/90
selector. MCP: get_device_history (serial mode / fleet FTR mode,
role-aware routing, all roles - GA_FULL privilege snapshot extended
deliberately). Docs: OKF backend/device-journeys.md + index/log.

Backend 3447/3447 (14 new: attempt-number vectors incl. gap boundaries
and live sessions, additive sums, days clamping, repeat-device
gate/sort/cap), web tsc + vitest 627/627, MCP tsc + 455 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 08:38:17 +02:00
Oliver Kieselbach 1e6de47433 feat(insights): F2 journey grouping + DeviceHistories chains + daily FTR aggregates (PR4)
Device-key normalization (trim+casefold, junk-serial exclusion incl. the
agent's "Unknown" WMI sentinel), pure journey grouping (terminal set
Succeeded/Failed/Incomplete; journey ends at first success; >30d gap from
the previous attempt's CompletedAt starts a new journey; WG Pending stays
an open session, never an attempt), and the DeviceHistories table
(PK tenant, RK encoded normalized serial): terminal-only session-ref
chains capped at 20 with derived journey counts, written inline at the
session-terminal seam (all three terminal statuses, DeletionState-guarded)
and healed by a rolling 30d maintenance sweep. The sweep prunes chain refs
of deleted sessions tombstone-driven (SessionTombstones cover cascade AND
retention deletions), deletes emptied rows, and recomputes daily FTR
aggregates (DeviceJourneyAggregates, PK tenant/"global", RK date):
completed journeys, first-time-right count, attempt histogram and
junk-serial disclosure — additive counts, bucketed on the completing
success session's StartedAt date. Chain refs carry the authoritative
DurationSeconds verbatim (never CompletedAt-StartedAt) plus an
adminMarked flag. Both tables registered in Constants.All + offboarding
TenantPartitionTables; aggregates under the 180d retention block;
deliberately NO per-session deletion-manifest step for DeviceHistories.

Backend 3433/3433 (51 new tests: junk/normalization vectors, terminal
set, exact 30d gap boundaries, WG one-row-one-attempt, redeploy split,
deletion pruning, entity round-trips incl. row-key escaping, FTR
aggregation core), Agent 2044+493 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 08:14:13 +02:00
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
Oliver Kieselbach 55c898bb9c feat(insights): F1 breakdown persistence + daily attribution aggregates (PR2)
Insights expansion PR2 (tasks/insights-expansion-spec.md §F1 "Data & compute
changes"):

- SessionTimeBreakdowns table (PK=TenantId, RK=SessionId): span/reboot/
  blocking-app JSON columns, QualityFlags, AttributionVersion,
  EspAppsOccupancySeconds kept tri-state (absent column = unknown, never 0).
  Written once at the terminal single-writer seam (TableSessionRepository,
  next to the counter reconcile + EspBlocking resolution; Succeeded/Failed
  only — Incomplete deliberately stores no duration). Deleted with its
  session: new deletion-manifest step 16 (PkRkExact; tombstone moves to 19)
  plus tenant-offboarding partition wipe — no separate age sweep.
- Self-healing 30d maintenance sweep instead of a one-off backfill (rides
  the existing 2h tick + manual parity): backfills pre-deploy sessions,
  heals inline-compute misses, folds late-terminating sessions into their
  StartedAt-date aggregates, and enforces AttributionVersion purity — a
  stale-version breakdown whose events aged out counts as missing rather
  than mixing algorithm semantics (truthfulness rule 8). Skips sessions
  with a deletion cascade in flight.
- TimeAttributionAggregates table (PK=TenantId or "global",
  RK="{date}|{class}"): per enrollment class (whiteglove /
  device_preparation / self_deploying / user_driven — never mixed),
  median/p75/p90 per segment over the fixed six-segment stack (incl.
  unattributed; absent segment = honest 0), clean/flagged/missing counts
  disclosed (rule 7), rows written below the n>=20 UI gate on purpose
  (the UI needs the n — rule 4). 180d retention alongside UsageMetrics.
- What-if bound: WhatIfSavingSeconds implements the spec formula
  max(0, cpEnd - cpEndWithoutX) including the idle gap before X (the ESP
  waits for X to start, too); sole-interval fallback = own start. Per-app
  aggregate rows gate at >=5 sessions, top 20 by median interval, carry
  median/p75 of interval and saving ("up to" wording is the consumer
  contract).

Backend 3381/3381 (13 new tests: entity round-trips incl. tri-state,
aggregation bucketing/disclosure/gates, what-if vectors, class precedence,
manifest step-count pins 17/19), solution build clean, web untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:03:14 +02:00
Oliver Kieselbach e637317bdb 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>
2026-07-26 17:29:36 +02:00
Oliver Kieselbach 4eac7d042c fix(metrics): app duration/rate stats over real installs only — TerminalState column + out-of-order reconcile
Source-data audit (tasks/insights-expansion-spec.md §0.5) verified against
production: 17.9% of Succeeded AppInstallSummaries rows carry no duration —
78% are genuine skips (app_install_completed with payload state=Skipped,
"Update for X" WinGet policies not applicable) whose state the ingest
discarded, the rest are real installs whose start the agent never observed
(attach window). Averaging them as 0s understated avg install duration by
18% and distorted the slowest-apps ranking.

- AppInstallSummary.TerminalState (Installed/Skipped/Postponed/Error) from
  the closing event's payload state; sentinel-gated column + mapper +
  AppsDashboardProjection. Status stays "Succeeded" for compatibility.
- MetricsMath.IsSkipTerminalState/HasMeasuredDuration: durations computed
  over measured rows only; skips leave the failure/success rate (decision
  2026-07-26 — a skip is not an install attempt); per-app skipped/unmeasured
  counts + totals exposed; slowest ranking gates on measured installs.
  Applied consistently: app metrics payload, apps dashboard (list/analytics/
  time series/trend/versions/device models), SLA weekly/snapshot/breach.
- StoreAppInstallSummaryAsync: reconcile extracted to internal static
  ReconcileAppInstallSummaryWithExisting (test seam). Fixes Q4 (terminal
  batch before started batch left duration permanently unset) and hardens
  terminal status against late/replayed InProgress batches.
- Web: measured-only hints on app detail cards, skip suffix in apps list,
  fleet-health "measured installs" caption.

Backend 3333/3333 (20 new tests), web tsc clean, vitest 627/627.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:24:45 +02:00
384 changed files with 19549 additions and 2933 deletions
+6 -1
View File
@@ -52,7 +52,12 @@ jobs:
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: "src/Web/autopilot-monitor-web" # App source code path
api_location: "" # Api source code path - optional
output_location: "" # Built app content directory - optional
# Static export (next.config output:'export'): Oryx runs `npm run build`
# and uploads the exported out/ directory as plain static files — no
# managed SSR runtime is provisioned anymore. (No is_static_export
# input on this action version; Oryx detects the export from
# next.config + output_location.)
output_location: "out" # Built app content directory - optional
###### End of Repository/Build Configurations ######
close_pull_request_job:
+3
View File
@@ -148,3 +148,6 @@ plans/REFACTOR_AGENT_V2.md
scripts/Backup/setup-backup-container.ps1
docs/Introduction-AutopilotMonitor.pptx
docs/~$Introduction-AutopilotMonitor.pptx
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
.playwright-mcp/
+4
View File
@@ -3,6 +3,10 @@
"autopilot-monitor": {
"type": "http",
"url": "https://mcp.autopilotmonitor.com/mcp"
},
"playwright": {
"command": "cmd",
"args": ["/c", "npx", "@playwright/mcp@latest"]
}
}
}
+13
View File
@@ -119,6 +119,19 @@ normal IME deployment flow. Spam containment, in order:
payload; the event's own timestamp is the emission time, so the session's `StartedAt`
(which the backend aligns to the earliest event) is not dragged backwards.
Presentation (2026-07-28, customer feedback — an unannounced wall of pre-agent Error
events read like a live failure):
- The scan buffers the (capped) record set, first emits an **Info summary event**
(`modern_deployment_log`, payload `backfillSummary: true` + `replayedCount` /
`droppedOverCap` / `lookbackMinutes`) announcing the replay block, then replays the
records **oldest-first** so the timeline sequence follows event-log chronology.
- The web timeline (`EventTimeline.tsx`) badges `backfilled: true` events („⟲ Backfilled",
slate, explanatory tooltip) and renders the original `timeCreated` as the row time with a
"(reported …)" suffix; the expanded metadata block gains a `Recorded` line. Severity is
deliberately **not** downgraded — unresolved pre-agent errors must still surface in the
Error filter.
# Schema — Error codes (backend known-issue map)
Codes documented in the known-issues / troubleshooting-FAQ pages that appear in ESP/enrollment
+126
View File
@@ -0,0 +1,126 @@
---
type: Concept
title: Gather Rule Debug Log — the per-tenant evaluation trace for rules that produce nothing
description: EnableGatherRuleDebugLog (ConfigVersion 36) makes the agent write a local trace file explaining every gather-rule evaluation decision — scope skips, missing intervalSeconds, on_change suppression, empty collector results, logparser position/parse-mode/no-match outcomes — so customers can self-diagnose rules that never show up in the timeline.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs
tags:
- agent
- gather-rules
- diagnostics
- tenant-configuration
timestamp: 2026-07-28T00:00:00+02:00
---
# Gather Rule Debug Log
## Schema
### The problem
A gather rule that produces no timeline events is indistinguishable from a rule that
never ran. The pipeline has many *silent-nothing* exits — before this feature, none of
them were visible to the customer:
| # | Decision point | Where |
|---|---|---|
| 1 | Rule delivered but `Enabled=false` | `GatherRuleExecutor.UpdateRules` |
| 2 | `trigger=interval` without `intervalSeconds` — never runs | `UpdateRules` timer filter |
| 3 | Interval timer's first tick comes only after one **full** interval | `UpdateRules` timer creation |
| 4 | Out of phase scope (`activePhases` / `activeFromPhase` not latched); incl. `currentPhase=Unknown` before the first phase signal | `IsRuleInScopeLocked` |
| 5 | phase_change / phase_exit dedup — once per (rule, phase) | `OnPhaseChanged` |
| 6 | Collector returned an **empty** result (e.g. `emitOnlyIfExists=true` and target absent) | `ExecuteRule` |
| 7 | `emit_mode=on_change` — result hash unchanged, emit suppressed | `ShouldEmitOnChange` |
| 8 | Guard blocked the target (emits `security_warning`, but the rule yields no data) | `GatherRuleContext.EmitSecurityWarning` |
| 9 | logparser: no files matched, position tracker says nothing new, regex no-match, plain-text file parsed in default `cmtrace` mode, `maxLines` cap, wildcard capped at 20 files | `LogParserCollector` |
| 10 | Rule execution threw (agent log had only the message, no stack) | `ExecuteRule` catch |
### The setting
`TenantConfiguration.EnableGatherRuleDebugLog` (`bool?`, default null→false), served to the
agent as `AgentConfigResponse.EnableGatherRuleDebugLog` — **ConfigVersion 36**. Portal:
Settings → Agent → Settings, toggle "Gather Rule Debug Log", stored via the usual
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
tenant-config report.
`RemoteConfigMerger` projects the bool onto a runtime path (same pattern as
`EnableImeMatchLog`): `true` → `Constants.GatherRuleDebugLogPath` =
`%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`; `false` → null, unless a
`--gather-debug-log <path>` CLI override supplied a non-default path. The field is not
security-sensitive and deliberately **survives the offline config cache**.
### The trace file
`GatherRuleDebugLog` is an append-only writer held by `GatherRuleExecutor` and exposed
to collectors via `GatherRuleContext.DebugLog(ruleId, stage, message)` (null-safe no-op
when disabled — zero cost on the normal path). Line format:
```
2026-07-28T09:15:02.123Z | RULE-ID | stage | message
```
Stages: `config` (registration summary per rule, header, zero-rules note), `phase`
(enrollment-phase transition marker `phase change: Old -> New`, ruleId `-` — every
rule-level line below it reads against the new phase), `trigger` (timer scheduled,
phase/event fired, dedup), `scope` (skips with reason incl. current phase), `exec`
(execution start), `collector` (null/empty results with likely cause), `guard`
(allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
suppression with hash prefix + streak), `logparser` (per-file outcome: lines read,
position range, match count, parse failures, regex timeouts, mode; plus the
`format=text` hint when every line fails CMTrace parsing; plus one line **per regex
match** — line number, matched text, capture groups — capped at 10 per file per run),
`error` (full exception **with stack trace** — the agent log keeps only the message;
an invalid logparser regex is reported here including the offending pattern string).
Volume control: 10 MB cap with a single `.old` rotation generation (~20 MB worst case).
Every write failure is swallowed — tracing must never break gathering. The trace never
leaves the device on its own — but because it lives in the agent log folder
(`Constants.LogDirectory`), the diagnostics package (`DiagnosticsPackageService`,
`*.log` pattern, `AgentLogs/` section) picks it up automatically, so a customer-initiated
diagnostics upload hands the trace to support without manual copying.
### Semantics
* **No hot-reload** — the flag is consumed once when `DefaultComponentFactory` builds
the `GatherRuleExecutorHost` at session start (per-enrollment lifecycle, same as
`EnableImeMatchLog`). Enable it in the tenant, then the *next* enrollment writes the
trace.
* **Zero-rules case is traced too** — flag on + backend delivered no gather rules →
a one-shot standalone `config` line, so the file is never just absent.
* **`--run-gather-rules` always traces** (operator diagnostic mode; CLI override or
default path) and mirrors every line to the console under `--console`.
## Examples
A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
```
... | LP-01 | config | registered: trigger=interval, collector=logparser, target=C:\...\app.log, unscoped, emitMode=on_change, interval=300s
... | LP-01 | trigger | interval timer scheduled every 300s (first run after one full interval — no immediate execution)
... | - | phase | phase change: DeviceSetup -> AccountSetup
... | LP-01 | exec | executing: collector=logparser, target=C:\...\app.log
... | LP-01 | logparser | app.log: read 1000 lines from position 0->81234, matched 0, parseFailures=1000, regexTimeouts=0, mode=cmtrace
... | LP-01 | logparser | every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text
```
— the answer ("plain-text log parsed in CMTrace mode") is in the file.
A matching rule shows *how* it matched (capped at 10 per-match lines per file per run):
```
... | LP-02 | logparser | setupact.log: line 42 matched "Error 0x80070005" — groups: code="0x80070005"
... | LP-02 | logparser | setupact.log: read 500 lines from position 0->40123, matched 1, parseFailures=0, regexTimeouts=0, mode=text
```
and a broken regex names itself:
```
... | LP-03 | error | invalid regex pattern '([unclosed' — rule can never match: Invalid pattern '([unclosed' at offset 10. Not enough )'s.
```
## Citations
* Executor + trace points: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs`
* Writer: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs`
* Config projection: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs`
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
* Phase scoping / emit mode background: `../rules/gather-rule-phase-scoping.md`
+122
View File
@@ -0,0 +1,122 @@
---
type: Concept
title: Device Journeys & First-Time-Right (F2)
description: How a device's terminal enrollment attempts are grouped into journeys (device key, junk-serial exclusion, first-success end, 30-day gap), where the DeviceHistories chain and the daily FTR aggregates are computed, stored and pruned, and which surfaces serve them.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs
tags:
- backend
- metrics
- device-history
- first-time-right
- insights
timestamp: 2026-07-27T12:00:00+02:00
---
# Device Journeys & First-Time-Right (F2)
Answers "how many devices enrolled right on the first try?" and "show me every attempt
this device made". Session success rate hides wipe-and-retry loops — every retry that
eventually succeeds counts as a success; First-Time-Right (FTR) exposes the real pain.
All grouping semantics live server-side in `DeviceJourneyCalculator` (JourneyVersion 1);
no consumer re-derives them.
# Schema
## Device key
`(TenantId, normalized serial)` — normalization is trim + lower-case. Junk/placeholder
serials are excluded entirely (never a chain, never in FTR, disclosed as a per-day
exclusion count): `System Serial Number`, `To Be Filled By O.E.M.`, `Default string`,
`0`, `None`, `INVALID`, `Unknown` (the agent's WMI-failure sentinel), empty, or shorter
than 4 characters. A motherboard swap changes the serial and therefore the device key;
re-enrollment under a different tenant is a separate key by design.
## Journey
Terminal sessions of a device key ordered by StartedAt. The terminal set is
**Succeeded / Failed / Incomplete** — `Pending` (a sealed WhiteGlove part 1),
`AwaitingUser`, `Stalled` and `InProgress` are OPEN sessions and never attempts.
* A journey **ends** with the first terminal success; the next session starts a new
journey (redeployment).
* A gap of **more than 30 days** since the previous attempt's terminal moment
(`CompletedAt`, fallback StartedAt) starts a new journey even without a success
(device shelved/repurposed). Constant, not a setting — production gaps: median 4.9 h,
92 % < 7 d.
* WhiteGlove part 1 + part 2 share ONE session row, so a completed WG enrollment is ONE
attempt; a WG device still waiting for its user session leaves the journey open —
never a failed attempt.
* **Attempt count** = terminal sessions in the journey (`Failed` and `Incomplete` are
non-successful attempts).
* **FTR rate** = completed journeys with attempt count 1 ÷ all completed journeys.
Open and gap-abandoned journeys never enter numerator or denominator.
Durations shown for attempts are the sessions' authoritative `DurationSeconds` verbatim
(WhiteGlove pause excluded; `Incomplete` deliberately has none) — never
`CompletedAt − StartedAt`, which is later in ~25 % of terminal sessions.
## Persistence & compute
* `DeviceHistories` (PK = TenantId, RK = percent-encoded normalized serial): the chain of
the device's terminal session refs `{sessionId, startedAt, completedAt, status,
enrollmentType, isPreProvisioned, durationSeconds, adminMarked}` capped at the 20 most
recent, plus derived `CurrentJourneyAttempts` (the LAST journey's attempts — the
banner's "Attempt N"), `JourneyCount` (chain-scoped) and `JourneyVersion`. Written
inline at the session-terminal single-writer seam
(`TableSessionRepository.UpdateSessionStatusAsync`, all three terminal statuses,
DeletionState-guarded) so the session banner is fresh; healed by the maintenance sweep.
Deliberately NOT part of the per-session deletion manifest — the row aggregates many
sessions. Wiped on tenant offboarding.
* Sweep (`SweepDeviceJourneysAsync`, 30-day rolling window, 2h tick): (1) drops chain
refs of deleted sessions **tombstone-driven** — every session deletion (cascade and
retention both run through the cascade worker) leaves a `SessionTombstones` marker for
~7 days, far longer than the sweep cadence, and even an expired-but-unpruned marker is
valid deletion evidence; a chain left empty deletes its row. (2) Merges the window's
terminal sessions into their chains (backfill, inline-miss healing, re-terminal
reclassifications). (3) Recomputes the daily FTR aggregates idempotently.
* `DeviceJourneyAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}`): per day —
completed journeys, first-time-right count, attempt histogram, junk-serial exclusion
count, `JourneyVersion`. A journey buckets on the StartedAt date of its completing
success session (the platform's StartedAt-date convention; late terminals converge via
the sweep). All counts are **additive**, so a window rate is the ratio of summed daily
rows — there is no rolling-window row (unlike the median-based time-attribution
aggregates, which cannot merge). 180-day retention. Rows exist below the ≥20 UI gate
on purpose (the UI needs the n for "insufficient data (n=…)").
# Surfaces
* `GET api/metrics/device-history?serialNumber=&sessionId=` (MemberRead + tenant query
param — the same route serves the web banner and MCP; a Global Admin passes `tenantId`)
— the chain plus, with `sessionId`, that session's server-computed attempt number
(live sessions get their would-be position via a virtual non-successful attempt, so the
redeploy and gap rules apply). `history: null` = no recorded history.
* `GET api/metrics/device-journeys?days=` (MemberRead) /
`GET api/global/metrics/device-journeys?days=&tenantId=` (GlobalReadOrAdmin) —
`{ windowDays, totals, daily, repeatDevices }`. The days selector is honored because
counts are additive. Without `tenantId` the global variant serves the cross-tenant
aggregate rows and `repeatDevices: null` — a per-device list would require scanning
every tenant's partition.
* Repeat devices (violator list, capped 10): devices whose LAST journey took ≥2 attempts
and whose newest terminal session falls inside the window, ordered by attempts then
recency; the failure reason comes from the newest failed attempt (bounded point-reads).
* Web: "Attempt N for this device · View history" banner on session detail (expandable
chain); "First-time-right" section on Fleet Health (rate + weekly trend + attempt
histogram + repeat devices). MCP: `get_device_history` (serial mode / fleet FTR mode).
# Examples
A device fails ESP twice and succeeds on the third try within one week: one journey,
three attempts, completed — counts once in the FTR denominator, not in the numerator,
and the histogram's bucket `3` gains one. The same device redeployed a month later and
succeeding immediately starts journey 2 with attempt count 1 — first-time-right. A
device with `serialNumber = "Unknown"` (WMI failure) never gets a chain; its terminal
sessions surface only as that day's disclosed exclusion count.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs` — device key, junk list, grouping, attempt numbers (JourneyVersion 1).
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceHistory.cs` — persistence + inline seam entry.
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.DeviceJourneys.cs` — sweep, tombstone pruning, daily aggregation.
* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs` — read surfaces.
* `src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs` — table-driven grouping/aggregation/attempt-number tests.
* `tasks/insights-expansion-spec.md` — F2 specification and source-data audit (§0.5).
+107
View File
@@ -0,0 +1,107 @@
---
type: Concept
title: OOBE Bootstrap go-Code Flow — Always-200 Script Endpoint
description: The full lifecycle of the bootstrap short-code — created in the portal, typed as `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10, served by GetBootstrapScriptFunction as an always-HTTP-200 PowerShell script — including the two-line injection defense and the rate-limit story behind Front Door.
resource: src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs
tags:
- backend
- bootstrap
- security
- oobe
timestamp: 2026-07-28T00:00:00+02:00
---
# OOBE Bootstrap go-Code Flow
Bootstrap Sessions let a technician install the agent **before** Intune
enrollment: an admin creates a short-lived session in the portal (Settings →
Bootstrap Sessions), hands the technician a short URL, and the technician types
`irm https://go.autopilotmonitor.com/CODE | iex` into a Shift+F10 console
during OOBE. The URL is deliberately short because it is typed by hand on a
device that has no clipboard.
# Lifecycle
1. **Create** — `POST /api/bootstrap/sessions` (`BootstrapManagerOrGA`).
`BootstrapSessionService.CreateAsync` mints an unambiguous-charset short
code + a GUID token, validity clamped 1–168 h. The response's `bootstrapUrl`
is built from `Constants.BootstrapGoBaseUrl` — the single producer of the
absolute URL (pinned by `CreateBootstrapSessionUrlShapeTests`).
2. **Serve** — `GET /api/bootstrap/go/{code}` (`GetBootstrapScriptFunction`,
`PublicAnonymous`). Customers reach it through the Front Door custom domain
`go.autopilotmonitor.com`, whose `/*` route rewrites onto the API path.
Validation runs in-process (`ValidateCodeAsync` + the tenant's
`BootstrapTokenEnabled` gate) — no HTTP hop to the validate endpoint.
3. **Install** — the generated script downloads the agent ZIP from
`Constants.AgentDownloadBaseUrl`, extracts, and runs
`--install --no-auth --bootstrap-token … --tenant-id …`. The agent monitors
from OOBE start, switches to certificate auth after Intune enrollment, and
self-destructs on completion.
The legacy Next.js `/go/[code]` route (SSR on the SWA) stays alive only for
URLs issued before the migration and is removed with the static-export PR —
see the transition rule in [URL Registry](../url-registry.md).
# Always-200 contract
Every response — success, invalid format, unknown/expired code, disabled
feature, rate limit, internal error — is **HTTP 200 `text/plain`**. The
consumer is `irm | iex`: a non-2xx status would make `irm` throw an opaque
exception on the OOBE console, while a 200 error script surfaces the actual
message via `Write-Host 'ERROR: …'`. Consequences:
- Failures are invisible in status-code metrics; the compensating signal is the
`LogWarning` per rejection (code + client IP).
- Error messages are capped at 200 chars **before** quote-escaping and
single-quote-doubled — a hostile upstream message cannot break out of the
`Write-Host` literal.
A disabled tenant returns the **same generic message** as an unknown code — no
enumeration oracle for whether a code exists.
# Injection defense (two lines)
The script runs as SYSTEM during OOBE, so interpolation is treated as a
security boundary:
1. **`BootstrapScriptValueValidator`** — tenantId/token must be exact canonical
GUIDs, the download URL must be https on an allow-listed host
(`AgentDownloadBaseUrl`/`AgentBlobBaseUrl` hosts) with the strict
`/agent/….zip` path shape, expiry must be future and ≤ 14 days. No accepted
value can contain `$`, quotes, backticks, spaces, or newlines. Kept even
though values are produced in-process — defense-in-depth against tampered
table data.
2. **Single-quoted PS literals** — every interpolated value sits inside
single-quoted PowerShell strings, which do not expand `$()`, `$var`, or
backticks.
The template is a C# raw string with `__TOKEN__` placeholders substituted via
`Replace` (never `$`-interpolation — the script is full of PS `$vars`).
`OobeBootstrapScriptGeneratorTests` pins byte parity with the original
TypeScript template through a golden file, so the legacy route and the backend
serve identical scripts during the transition.
# Rate limiting behind Front Door
The endpoint self-limits per client IP (`bootstrap-script:{ip}`, 20/min via
`ClientIpExtractor.GetRateLimitClientIp`) — anonymous routes are skipped by
`UserRateLimitMiddleware` and must bring their own limiter. Because traffic
arrives through Front Door, the rightmost X-Forwarded-For hop resolves to AFD
egress IPs and buckets would collapse (the shared-bucket 429 incident class);
the AFD-aware extraction therefore honors `X-Azure-ClientIP` only when
`X-Azure-FDID` matches the `FrontDoorProfileId` app setting (fail-closed to
the trusted-hop path otherwise). Verified post-deploy 2026-07-29: real client
IPs are keyed, not AFD egress.
Front Door route caching is DISABLED for this route: the body inlines the
bearer token, so a cache hit would replay one requester's token to another.
`NoStoreCacheMiddleware` covers the `/api/bootstrap/go/` prefix.
# Citations
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs`
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs`
- `src/Backend/AutopilotMonitor.Functions/Security/BootstrapScriptValueValidator.cs`
- `src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs` — golden parity
- `src/Web/autopilot-monitor-web/app/go/[code]/route.ts` — legacy route (transition only)
- [URL Registry](../url-registry.md) — go-URL registry entries + transition rule
+112
View File
@@ -0,0 +1,112 @@
---
type: Concept
title: Rule Regression Radar (F3)
description: How the radar detects analyze rules whose hit rate suddenly rose (7-day window vs 28-day baseline, Wilson-separated), which suppressions keep it quiet, how alert episodes live in the notification tracker, and which surfaces show them.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/RuleRegressionRadar.cs
tags:
- backend
- rules
- regression-radar
- insights
timestamp: 2026-07-27T16:00:00+02:00
---
# Rule Regression Radar (F3)
Detects "this analyze rule suddenly fires much more often" before helpdesk volume does, and
says what the spike correlates with — with honest statistics and honest wording. Analyze
rules only in v1: their daily stats are audit-verified session-deduplicated
(`FireCount` ≈ distinct sessions with a hit, `SessionsEvaluated` the same-source
denominator). Gather rules are excluded until their per-batch stats dedup is fixed —
never silently included with bad math.
# Schema
## Signal & detection
Per (tenant, ruleId), daily hit rate = `FireCount ÷ SessionsEvaluated` from the
`RuleStats` rows — RATES, not counts (absolute counts spike with every rollout wave).
A regression fires only when ALL hold for the trailing 7-day window vs. the prior
28-day baseline:
1. window has ≥ 5 hit sessions AND ≥ 20 evaluated sessions;
2. window rate ≥ 2× baseline rate;
3. the Wilson 95 % intervals are disjoint in the increase direction
(`MetricsMath.WilsonInterval` / `RateIncreaseSeparated` — deterministic, unit-pinned),
so small-n noise never alerts.
## Suppression (false positives are trust damage)
* Rule entity missing (deleted) — never alerts.
* Rule younger than baseline + window (`CreatedAt` within 35 days — grace period for new
rules; the entity timestamp is the honest gate, a stats-row count would fail on
low-volume tenants whose quiet days simply have no rows).
* Rule edited inside the window (`UpdatedAt` ≥ window start) — edits legitimately change
hit rates.
* Empty baseline denominator (rule disabled throughout the baseline).
* Platform kill switch: app setting `RuleRegressionRadarDisabled=true` skips the radar
(fail-open — it only notifies, never mutates data).
## Alert episodes (tracker keyspace)
An episode lives as `ruleregression|{ruleId}` in the notification-tracker table
(the `tpmpss|` pattern): the row IS the dedup (bell + ops event fire exactly once per
episode), the badge state and the `regressions[]` payload — window/baseline counts,
rates, lift (null for a zero-baseline "new signal" — no finite lift is invented), window
dates, and the dimension concentration captured at first fire. Numbers refresh on every
radar pass while the episode stays active; `FirstNotifiedAt` never moves. The episode
closes (badge clears, dedup re-arms) when the window rate falls back under 1.5× baseline
or fires stop entirely; the tracker's 30-day retention sweep also re-arms, so a
month-old still-burning regression rings once more by design.
## Dimension correlation (on fire only)
Hit sessions (RuleResults scan: tenant partition range + RowKey = ruleId + DetectedAt in
window, intersected with the window's sessions) are compared against all window sessions
across `osBuild`, `model` (manufacturer + model), `agentVersion` and `imeVersion`. The
top value by lift is reported with BOTH shares — wording contract: "X % of affected
sessions are on … vs Y % of all sessions — correlated, not necessarily causal". No value
with ≥ 5 hit sessions at ≥ 2× lift → "no clear dimension concentration"; a correlation
failure also yields no claim. App-version correlation for `ANALYZE-APP-*` rules is a
follow-up (needs rule→app matching semantics), tracked with the gather-rule fix.
## Compute & alert path
Runs in the daily maintenance pass right after the rule-stats aggregation
(`RunRuleRegressionRadarAsync`), anchored on YESTERDAY — whole days only, a partial
today would understate the window rate. One 35-day stats read covers all tenants;
evaluation is the pure `RuleRegressionRadar` core. On a NEW episode:
1. `RuleFrequencyRegression` ops event (category Tenant, Warning) with the full numbers
— dual-registered in the web `OPS_EVENT_TYPES` catalog for alert-rule routing.
2. Tenant bell notification (type `rule_frequency_regression`, Admin audience) with the
same numbers and a deep link to the rule's card (`/analyze-rules#rule-card-{ruleId}`).
A sessions-list pre-filtered to rule hits needs a rule filter the session search does
not have yet — follow-up alongside the gather-rule work.
3. v1 channel: bell + ops event only. Provider fan-out (webhook/Telegram) follows once
alert quality is proven — a noisy first version burns trust permanently.
# Surfaces
* `GET api/metrics/rule-stats` (and the global variant with `tenantId`) carries
`regressions[]` — the active episodes. MCP `get_rule_stats` inherits the block.
* Analyze-rules page: per-rule 30-day fire-count sparkline (densified from the `trend`
rows the endpoint already served) and a red "↑ Regression" badge while an episode is
active, with the full numbers in the tooltip.
# Examples
A rule firing on 2 % of sessions for a month jumps to 15 % over a week (15/100 vs
10/500): lift 7.5×, Wilson-separated → one bell, one ops event, badge until the rate
falls back under 3 %. The same jump on 5/20 sessions with a 12 % baseline stays silent —
the intervals overlap, so the spike is not statistically real yet. A rule that never
fired in the baseline and now hits 15 % alerts as a "new signal" with no lift figure.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/RuleRegressionRadar.cs` — gates, suppression, re-arm, dimension concentration.
* `src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs` — Wilson interval + separation primitive.
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.RuleRegression.cs` — sweep, episode reconcile, bell/ops wiring.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs` — episode keyspace.
* `src/Backend/AutopilotMonitor.Functions.Tests/RuleRegressionRadarTests.cs` — hand-computed Wilson vectors + every suppression branch.
* `tasks/insights-expansion-spec.md` — F3 specification and source-data audit (§0.5).
+124
View File
@@ -0,0 +1,124 @@
---
type: Concept
title: Enrollment Time Attribution (F1)
description: 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.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs
tags:
- backend
- metrics
- time-attribution
- insights
timestamp: 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).
+9
View File
@@ -20,6 +20,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
@@ -32,12 +33,20 @@ https://docs.autopilotmonitor.com (separate repository).
# Backend
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
# MCP Server
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
# Web Portal
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link and the Static Web Apps SSR runtime queues them server-side into a multi-minute tail. Records the follow-up measurement (the Standard plan changed nothing; the prefetch storm was an accidental keep-alive), where browser telemetry actually lives, and why this curl build cannot be trusted for HTTP versions.
# Security & Identity
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
+26
View File
@@ -1,5 +1,27 @@
# Log
## 2026-07-29
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
* **Update**: `url-registry.md` (+ `web/portal-navigation-prefetch.md` context) — the portal is now a **full static export** (`output: 'export'`, `trailingSlash: true`): the SWA serves plain files, the managed SSR runtime — the portal's last cold-start source ("Backend call failure" > 45 s) — no longer exists. Detail routes moved from path params to query strings (`/sessions?id=`, `/sessions/inspector?id=`, `/diagnosis?id=`, `/apps/detail?name=`, `/admin/backups/detail?id=`, `/admin/customs-archive/detail?tenantId=&rowKey=`) with all 21 link sites going through the new single-definition builders in `lib/routes.ts`; the 9 `[section]` families prerender via `generateStaticParams` server shells (37 pages, `dynamicParams=false`), server `redirect()` index pages became `ClientRedirect` shells. Legacy PATH-shaped deep links (sent Teams/Slack/webhook notifications hold `/sessions/{id}` forever) are preserved permanently: SWA wildcard rewrites serve the family page and the globally mounted `LegacyPathRedirect` rewrites to the canonical query URL (hash + query preserved; mapping pinned by `lib/__tests__/legacyPathRedirect.test.ts`). `middleware.ts` host routing became the client-side `HostRoutingGuard` (prefix list moved to `lib/hostRouting.ts`); the legacy `/go/[code]` route + `bootstrapValidation.ts` are deleted (BootstrapSessions table verified free of pre-cutover active sessions). `staticwebapp.config.json` now carries the 19 docs/marketing redirects (order-sensitive), the legacy rewrites, `404.html` override and ALL security headers incl. the literalized prod CSP — guarded by the new `swaConfig.guard.test.ts`; maintenance mode changed from whole-file swap to a first-routes-entry insert (README rewritten). Backend notification deep links go through the new `Constants.PortalSessionUrl` (query shape, pinned). Deploy: `deploy-web.yml` uploads `out/` with `is_static_export: true`.
* **Creation**: Added `backend/oobe-bootstrap-go-flow.md` (and a `Bootstrap go-URL` section + web-registry update in `url-registry.md`) — the OOBE bootstrap script endpoint moved from the portal's Next.js `/go/[code]` SSR route into the Function App (`GET /api/bootstrap/go/{code}`, `GetBootstrapScriptFunction`), unblocking the portal's static export; the customer URL became **`go.autopilotmonitor.com/{code}`** (Front Door custom domain, `/*` route with origin-path rewrite, caching disabled — the script inlines the bearer token). New `Constants.BootstrapGoBaseUrl` (single producer via `CreateBootstrapSessionFunction`) + web `BOOTSTRAP_GO_URL` (display only); both guard host lists extended. Security invariants ported to C#: `BootstrapScriptValueValidator` (exact-GUID tenantId/token, https + host-allowlist + `/agent/….zip` path, expiry ≤ 14 d) and `OobeBootstrapScriptGenerator` (Replace-token raw-string template, single-quoted PS literals, 200-char error cap before quote-doubling; always-HTTP-200 text/plain contract for `irm | iex`). Endpoint is `PublicAnonymous` in the catalog, `NoStoreCacheMiddleware`-covered, self-rate-limited `bootstrap-script:{ip}` 20/min (rate-limit reply is also a 200 error script). Tests: golden byte-parity against the TS template, validator invariants incl. hostile-URL cases, format-gate table, and the previously missing BootstrapUrl shape pin. Transition: the legacy www route + `middleware.ts` `/go` prefix stay alive for pre-migration URLs (validity ≤ 168 h) and are removed in the static-export PR no earlier than 168 h after the last www URL was issued. Customer docs `network-endpoints.md` gained the `go.` host (device-facing, Bootstrap-Sessions-only).
* **Update**: `agent/autopilot-ztd-diagnostics.md` — presentation layer for the startup error backfill (customer feedback: an unannounced wall of pre-agent Error events with a single identical timestamp read like a live failure). Agent: the backfill scan now buffers the capped record set, emits an Info summary event first (`modern_deployment_log`, `backfillSummary: true` + `replayedCount`/`droppedOverCap`/`lookbackMinutes`), then replays oldest-first so the timeline sequence follows event-log chronology. Web: `EventTimeline.tsx` badges `backfilled: true` events („⟲ Backfilled", tooltip) and renders the original `timeCreated` as the row time with a "(reported …)" suffix plus a `Recorded` metadata line — works retroactively for already-stored events. Severity is deliberately not downgraded.
* **Creation**: Added `agent/gather-rule-debug-log.md` — the new per-tenant setting `EnableGatherRuleDebugLog` (ConfigVersion 35 → 36, portal toggle under Settings → Agent, `EnableImeMatchLog`-style bool→path projection in `RemoteConfigMerger`). When enabled, the agent writes `%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`: one pipe-delimited line per evaluation decision across ten previously silent "produced nothing" exits — disabled rules, `interval` without `intervalSeconds` (never runs), the first-tick-after-one-full-interval window, phase-scope skips incl. `currentPhase=Unknown`, per-(rule, phase) dedup, empty collector results (`emitOnlyIfExists`), `on_change` hash suppression (hash prefix + streak), guard blocks, logparser per-file outcomes (position skips, parse failures with a `format=text` hint when every line fails CMTrace parsing, `maxLines`/wildcard caps), and full exception stacks that the agent log drops. 10 MB cap + one `.old` generation; no hot-reload (per-enrollment lifecycle); flag survives the offline config cache; `--run-gather-rules` always traces and echoes under `--console`; zero-rules delivery leaves a one-line file instead of nothing. Side fix: `GatherRuleExecutorHost` now passes the logger into `InformationalEventPost`, so gather-event emissions appear in the agent DEBUG log.
* **Correction + Update**: `web/portal-navigation-prefetch.md`. The HTTP/1.1 claim in the 2026-07-27 entry below is **wrong** and has been struck from the document: `portal.autopilotmonitor.com` negotiates **h2**, verified by `openssl s_client -alpn h2,http/1.1` and a .NET `HttpClient` returning `HTTP/2.0`. The false reading came from the Windows/Schannel curl build in this environment, which has no HTTP/2 support and therefore always reports `1.1` — do not use it to determine HTTP versions. There is no six-connection cap in play; the queue is server-side in the SWA SSR runtime, which does not change the fix or the evidence, only the mechanism. Added an `Outcome` section with 20 h of post-change telemetry: the Free → Standard plan change (16:02:44 UTC) produced **no measurable latency gain** once stratified by the gap to the previous request (p50 Free → Standard: `<10s` 85 → 95 ms, `5-30min` 429 → 441 ms; tail rates 1.64 %/0.374 % over 2 s/10 s vs 6 and 1 events at n = 258 against 4.2 and 1.0 expected; `browserTimings` Mann-Whitney z = −0.24, p = 0.81). The freezes were removed by `prefetch={false}` (16:34:13 UTC): worst `browserTimings.totalDuration` 225,277 → 4,856 ms. The prefetch storm was also an **accidental keep-alive** — requests under 10 s apart fell from 85 % to 46 %, and on the identical never-cacheable `/sessions/{id}` route the median rose 87 → 211 ms and p90 465 → 1,480 ms, with one 11,441 ms abandoned request (`resultCode 0`) after 517 minutes idle. Synthetic bursts warm the runtime and must not be used to benchmark this (round 1 p50 288 ms vs rounds 4–25 p50 113 ms).
## 2026-07-27
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). *(The original entry attributed the freeze to HTTP/1.1 and a six-connection-per-origin cap — see the 2026-07-28 correction; the queue is server-side.)* When the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the navigation the user wanted was stuck behind that queue. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
* **Creation**: Added `backend/rule-regression-radar.md` — F3 regression radar (analyze rules only; gather rules wait for their per-batch stats dedup fix): per (tenant, rule) daily hit rate from `RuleStats`, firing only when the trailing 7-day window has ≥5 hit sessions and ≥20 evaluated sessions, the rate is ≥2× the prior 28-day baseline AND the Wilson 95 % intervals are disjoint (deterministic `MetricsMath` primitive with hand-computed test vectors). Suppressed for deleted rules, rules younger than baseline+window (`CreatedAt` grace), rules edited inside the window (`UpdatedAt`), empty baselines, and the `RuleRegressionRadarDisabled` app-setting kill switch. Alert episodes live as `ruleregression|{ruleId}` tracker rows (one bell + one `RuleFrequencyRegression` ops event per episode; numbers refreshed while active; re-armed under 1.5× baseline, on fire-stop, or by the 30-day tracker retention). On-fire dimension correlation over osBuild/model/agentVersion/imeVersion with the "correlated, not necessarily causal" wording contract. Surfaces: `regressions[]` in the rule-stats response (MCP inherits), the analyze-rules page's 30-day sparkline and regression badge.
* **Creation**: Added `backend/device-journeys.md` — F2 device history / First-Time-Right: the device key (trim + case-fold serial, junk/placeholder exclusion incl. the agent's `Unknown` WMI sentinel), journey grouping over the terminal set Succeeded/Failed/Incomplete (first success ends a journey, a >30-day gap from the previous attempt's CompletedAt starts one, WhiteGlove's single session row is ONE attempt, open sessions are never attempts), the `DeviceHistories` chain (cap 20, written inline at the terminal seam, healed by the sweep, deleted-session refs pruned tombstone-driven, deliberately not in the per-session deletion manifest) and the additive `DeviceJourneyAggregates` daily rows (window rate = ratio of sums — no rolling row needed, 180-day retention). Surfaces: the session-detail "Attempt N" banner with server-computed attempt numbers (virtual-attempt rule for live sessions), the Fleet Health First-time-right section (≥20-journey gate, attempt histogram, repeat-devices violator list), and the MCP `get_device_history` tool (serial mode / fleet FTR mode).
## 2026-07-25
* **Update**: `agent/secureboot-ca2023-detection.md` — review follow-up on 90012d9 hardened the privilege scope, the repo's only privilege-adjustment code and therefore the precedent for any future one. The process token moved from a raw `IntPtr` to a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR owns it from the moment `OpenProcessToken` returns and the critical finalizer releases it even when `Dispose` never runs — previously an async exception between the successful open and the caller's `using` leaked the handle *with the privilege enabled and no owner left to restore it*. `Dispose` became idempotent and now releases the handle in a `finally`, so a throwing restore cannot strand it; the restore obligation is recorded immediately after the successful `AdjustTokenPrivileges` with nothing throwable in between. The whole enable → read → restore sequence runs under a static gate because the privilege is adjusted on the *process* token: a concurrent reader would see "already enabled", correctly skip its own restore, and then lose the privilege mid-read when the first reader disposed, reporting `privilege_denied` on a machine that holds it.
@@ -52,6 +74,10 @@
* **Creation**: Added `agent/endpoint-migration.md` — config-channel agent re-home (`MigrateToApiBaseUrl` on `GET agent/config`, ConfigVersion 33): backend or per-tenant API-URL moves without DNS indirection (impossible due to mTLS/Flex-TLS). Live-fetch-only with cache strip, `AgentEndpointMigrationRules` allowlist validated on both sides, one hop, kill-switch wins. Built as the lesson from the 2026-07-18 WEU→GWC cutover, which hard-cut all in-flight agents.
## 2026-07-26
* **Creation**: Added `backend/time-attribution.md` — the F1 enrollment time attribution: exact-partition invariant against the session's authoritative DurationSeconds, two-window WhiteGlove model, segment mapping from phase-declaration events, positive-evidence ESP-blocking join with "up to" what-if bounds, reboot spans from event gaps around `lastBootUtc`, and the SessionTimeBreakdowns / TimeAttributionAggregates tables with their terminal-seam + maintenance-sweep compute paths and read surfaces (web lane, fleet panel, MCP `get_time_attribution`).
## 2026-07-17
* **Creation**: Added `agent/build-counter-blob.md` — agent build numbers now come from a shared public counter blob (`buildcounter-v{N}.txt`, reserve-before-build with ETag-CAS) so local `build.ps1` and CI `build-agent.yml` builds can never mint the same number. Documents the CI parity fixes (SummaryDialog.exe.config in the ZIP, EXE-vs-manifest version check, hash-oracle update gated on stable cutover) and the Sigstore provenance attestation on CI-built ZIPs.
+37 -1
View File
@@ -21,7 +21,7 @@ literal appears nowhere else.
| Component | Registry | Guard test |
|---|---|---|
| C# (Shared/Backend/Agent) | `AutopilotMonitor.Shared.Constants` (`*BaseUrl`) | `HardcodedUrlGuardTests` |
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `BOOTSTRAP_GO_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
| MCP | `src/config.ts` (`API_BASE_URL`, `ENTRA_LOGIN_BASE_URL`, `DOCS_BASE_URL`) | `src/__tests__/hardcoded-urls.guard.test.ts` |
Derived registries build on these instead of repeating hosts:
@@ -76,6 +76,42 @@ Deliberately still on the legacy account: the `HealthCheckService` /
`build-agent.yml` — they exist precisely to keep already-deployed customer
bootstrap scripts working until the customer migration completes.
# Bootstrap go-URL (2026-07-28)
The OOBE bootstrap short-URL moved from `www.autopilotmonitor.com/go/{code}`
(Next.js route, SSR) to **`go.autopilotmonitor.com/{code}`** — a Front Door
custom domain whose `/*` route rewrites onto `/api/bootstrap/go/{code}` on the
Function App (`GetBootstrapScriptFunction`). Registry entries:
`Constants.BootstrapGoBaseUrl` (C#, the single producer via
`CreateBootstrapSessionFunction`) and `BOOTSTRAP_GO_URL` (web, display-only in
the sessions list); `go.autopilotmonitor.com` is on both guards' enforced-host
lists. `CreateBootstrapSessionUrlShapeTests` pins the produced URL as an
independent oracle — before this, no test guarded the bootstrap URL shape at
all.
Front Door route caching for `bootstrap-go` is DISABLED — same hazard class as
the `/agent/*` route above, sharpened: the script body inlines the bearer
token, so a cache hit would replay one requester's token to another.
`NoStoreCacheMiddleware` additionally stamps `no-store` via the
`/api/bootstrap/go/` prefix.
Transition COMPLETED with the static-export PR (2026-07-29): the legacy
Next.js `/go/[code]` route, `utils/bootstrapValidation.ts`, and
`middleware.ts` are deleted — verified safe because the BootstrapSessions
table held zero active sessions predating the URL cutover (all expired
2026-03). The backend is now the only script producer;
`OobeBootstrapScriptGeneratorTests`' golden file remains as the pin of the
shipped template.
Portal detail routes are query-string based since the same PR
(`/sessions?id=…` etc., builders in `lib/routes.ts`, notification producer
`Constants.PortalSessionUrl`). Legacy PATH-shaped links in sent
Teams/Slack/webhook notifications are rewritten forever by SWA wildcard
rewrites + `components/LegacyPathRedirect.tsx`; `staticwebapp.config.json`
carries the production redirects/headers and is guarded by
`utils/__tests__/swaConfig.guard.test.ts` (registry-backed hosts, no
unsafe-eval, route order).
# Citations
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — C# registry
+126
View File
@@ -0,0 +1,126 @@
---
type: Concept
title: Portal Navigation Prefetch & the SWA Runtime Bottleneck
description: Why every navigation Link in the portal sets prefetch={false} — the App Router's viewport prefetch fires one RSC request per visible link, and the Static Web Apps SSR runtime queues them server-side into a multi-minute latency tail. Also records what the follow-up measurement showed about the Standard SKU and the keep-alive side effect.
resource: src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
tags:
- web
- performance
- nextjs
- static-web-apps
- observability
timestamp: 2026-07-27T18:00:00+02:00
---
# Context
The portal is a Next.js 15 App Router application hosted on Azure Static Web Apps. Every page under
the authenticated shell is a client component behind `ProtectedRoute` that fetches its own data from
the Function App (`autopilotmonitor-api-eu`) after mount. The RSC payload a route returns on
navigation therefore carries no page data — only the shell.
`GlobalSidebar` and `Navbar` render the full navigation on every page. With the App Router's default
prefetch behaviour, each visible `<Link>` triggers a background `GET /<route>?_rsc=<hash>` as soon as
it enters the viewport.
# Mechanism
Two properties combine into a portal-wide freeze:
1. **Fan-out.** The navigation is fully rendered on each page load, so a single navigation issues one
RSC request per visible link — measured at ~10,400 requests over 14 days.
2. **A heavy latency tail on the SSR runtime.** RSC responses are prerendered and edge-cached
(`x-nextjs-cache: HIT`, `s-maxage=31536000`), so the median is fast. On a cache miss or cold start
the Static Web Apps managed runtime queues them **server-side**, and the queue drains all at once.
The navigation the user wants is then stuck behind that queue and the tab shows a loading spinner or a
blank page until it drains. Nothing fails — the requests eventually return `200`, which is why the
failure leaves no error trace anywhere.
> **Correction (2026-07-28).** The original version of this document named a third property: that
> `portal.autopilotmonitor.com` negotiates HTTP/1.1 and the prefetches exhaust the six-connection
> browser cap. **That is wrong.** It came from `curl -w '%{http_version}'` on a Windows build of curl
> whose Schannel backend has no HTTP/2 support, so it can only ever report `1.1`. ALPN says otherwise:
> `openssl s_client -alpn h2,http/1.1` negotiates **h2** on the portal host, confirmed by a .NET
> `HttpClient` request returning `HTTP/2.0`. There is no six-connection cap and connection starvation
> was never the mechanism — the queue is server-side. Verify HTTP versions with `openssl s_client
> -alpn` or `HttpClient`, never with this curl build.
# Evidence
Incident of 2026-07-27, 17:25–17:29 CEST. Browser-side dependencies during the freeze:
| Request | Duration | Status |
| --- | --- | --- |
| `/admin/presence?_rsc=…` | 151,461 ms | 200 |
| `/gather-rules?_rsc=…` | 52,283 ms | 200 |
| `/progress?_rsc=…` | 37,878 ms | 200 |
| `/analyze-rules?_rsc=…` | 37,559 ms | 200 |
| `/audit?_rsc=…` | 37,539 ms | 200 |
| Backend API, same window | 1,600–2,200 ms | 200 |
The three ~37.5 s responses complete within 340 ms of each other — a queue draining, not independent
slow requests. The corresponding `browserTimings` row for `/geographic-performance` reports
`networkDuration` = 16 ms against `processingDuration` = 225,095 ms: the network delivered the
document immediately and the browser then waited nearly four minutes for the `load` event.
Fourteen-day distribution of RSC prefetches: n = 10,428, p50 = 91 ms, p95 = 896 ms, max = 234,230 ms,
38 over 10 s, 21 over 30 s.
# Outcome, measured 2026-07-28
Two changes landed within half an hour of each other on 2026-07-27: the SWA plan went Free → Standard
at 16:02:44 UTC, and `prefetch={false}` went live at 16:34:13 UTC. Twenty hours of production
telemetry separate their effects.
**The Standard plan produced no measurable latency gain.** Latency is governed almost entirely by the
gap to the previous request — the SSR runtime cools down — so raw percentiles only measure the traffic
mix. Stratified by that gap, the two eras are indistinguishable (p50, Free → Standard): `<10s`
85 → 95 ms · `10-60s` 100 → 117 ms · `1-5min` 295 → 310 ms · `5-30min` 429 → 441 ms. Tail rates match
too: Free ran 1.64 % over 2 s and 0.374 % over 10 s (n = 10,163); Standard produced 6 and 1 events at
n = 258 against 4.2 and 1.0 expected. `browserTimings.totalDuration` by Mann-Whitney: n = 436 vs 48,
z = −0.24, **p = 0.81**.
**The prefetch fix is what removed the freezes** — worst `browserTimings.totalDuration` fell from
225,277 ms to 4,856 ms, and the 30-second events stopped.
**It also removed an accidental keep-alive.** The prefetch storm kept the SSR runtime warm: 85 % of
requests used to arrive less than 10 s apart, now 46 % do. On an identical never-cacheable route
(`/sessions/{id}`) the median rose from 87 ms to 211 ms and p90 from 465 ms to 1,480 ms. After 517
minutes idle, one request took 11,441 ms and was abandoned (`resultCode 0`). The trade — minute-long
freezes gone, ~150 ms added to a cold navigation — is worth it, but it is a trade.
Do not benchmark this with a synthetic burst. Firing requests in quick succession warms the runtime
and measures the best case: in a 25-round `curl --parallel-max 6` run, round 1 came in at p50 288 ms
and rounds 4–25 at p50 113 ms.
> **Endgame (2026-07-29).** The cold-start class was eliminated at the root: the portal became a
> full static export (`output: 'export'`) — the SWA-managed SSR runtime no longer exists, so there
> is nothing left to cool down. One escalation drove this: on 2026-07-28 a cold start exceeded the
> SWA's 45-second function limit and the landing page served a hard "Backend call failure" 500.
> The availability test that bridged the gap as a keep-alive stays as plain uptime monitoring.
# Diagnosing this class of problem
The backend Application Insights resource is the wrong place to look — a freeze appears there as an
*absence* of requests, never as an error. Browser telemetry lives in a **separate** Application
Insights resource addressed by `AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`, which the MCP
`query_backend_logs` tool does not reach. Query it through
`.claude/commands/scripts/query-appinsights.sh` with that ID exported, and read `dependencies`
(client-side fetches, including RSC prefetches) and `browserTimings` (the network/processing split of
a document load).
# Contract
Navigation `<Link>` elements in `GlobalSidebar` and `Navbar` set `prefetch={false}`. The prefetched
RSC payload buys nothing for these routes, so the only remaining cost of a click is the route's JS
chunk, which the browser caches after first use.
Do not remove the prop to "speed up navigation" without first re-measuring the tail in the web
Application Insights resource — the prefetch is cheap only while the runtime is warm.
# Citations
* `src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx` — nav link rendering, rationale comment
* `src/Web/autopilot-monitor-web/components/Navbar.tsx` — top-bar and notification links
* [SWA and Oryx node pinning](../architecture.md) — hosting model for the web app
@@ -285,6 +285,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(@"C:\Temp\custom-ime.log", agent.ImeMatchLogPath);
}
[Fact]
public void Merge_maps_enable_gather_rule_debug_log_true_to_default_path()
{
var agent = NewAgentConfig();
agent.GatherRuleDebugLogPath = null;
var remote = FullRemote();
remote.EnableGatherRuleDebugLog = true;
RemoteConfigMerger.Merge(agent, remote);
Assert.False(string.IsNullOrEmpty(agent.GatherRuleDebugLogPath));
Assert.Equal(
Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath),
agent.GatherRuleDebugLogPath);
}
[Fact]
public void Merge_maps_enable_gather_rule_debug_log_false_clears_path()
{
var agent = NewAgentConfig();
agent.GatherRuleDebugLogPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
var remote = FullRemote();
remote.EnableGatherRuleDebugLog = false;
RemoteConfigMerger.Merge(agent, remote);
Assert.Null(agent.GatherRuleDebugLogPath);
}
[Fact]
public void Merge_preserves_custom_gather_rule_debug_log_path_when_remote_disables()
{
// A dev-time --gather-debug-log <custom> override seeds a non-default path on the
// AgentConfiguration. When the tenant remote flips EnableGatherRuleDebugLog off we
// keep the custom path rather than clobbering a deliberate dev override.
var agent = NewAgentConfig();
agent.GatherRuleDebugLogPath = @"C:\Temp\custom-gather.log";
var remote = FullRemote();
remote.EnableGatherRuleDebugLog = false;
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(@"C:\Temp\custom-gather.log", agent.GatherRuleDebugLogPath);
}
// ============================================================= Null / partial tolerance
[Fact]
@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
using DeviceInfoCollector = AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector;
namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo
{
/// <summary>
/// Audit Q2 — <see cref="DeviceInfoCollector.RefreshEspConfiguration"/>
/// re-reads the ESP blocking lists and re-emits <c>esp_config_detected</c> exactly when the
/// payload changed: the registry lists grow progressively (one timestamped subkey per CSP
/// status write) and the user-scope lists appear only after sign-in, so the early emissions
/// are structurally partial. The StartupEventGate keeps an unchanged re-read silent.
/// Probe overrides are static slots → serialized with the other probe-driven suites.
/// </summary>
[Collection("SerialThreading")]
public sealed class DeviceInfoCollectorEspRefreshTests
{
private static readonly DateTime T0 = new DateTime(2026, 7, 27, 10, 0, 0, DateTimeKind.Utc);
private const string DeviceApp1 = "11111111-1111-1111-1111-111111111111";
private const string DeviceApp2 = "22222222-2222-2222-2222-222222222222";
private const string UserApp1 = "33333333-3333-3333-3333-333333333333";
private static EspTrackingInfoSnapshot Snapshot(string[] deviceApps, string[] userApps)
{
var all = deviceApps.Concat(userApps).Distinct().ToArray();
return new EspTrackingInfoSnapshot(
msiProductCodes: Array.Empty<string>(),
modernAppPfns: Array.Empty<string>(),
win32AppIds: all,
userWin32AppIds: userApps,
msiCount: 0,
modernCount: 0,
win32Count: all.Length);
}
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> EspConfigEvents(FakeSignalIngressSink ingress) =>
ingress.Posted
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == SharedEventTypes.EspConfigDetected)
.ToList();
private static IReadOnlyList<string> TypedList(FakeSignalIngressSink.PostedSignal signal, string key)
{
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(signal.TypedPayload);
return Assert.IsAssignableFrom<IReadOnlyList<string>>(data[key]);
}
[Fact]
public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var ingress = new FakeSignalIngressSink();
var clock = new VirtualClock(T0);
var collector = new DeviceInfoCollector(
"session-1", "tenant-1",
new InformationalEventPost(ingress, clock), logger,
signalIngress: null, clock: clock,
startupGate: new StartupEventGate(tmp.Path, logger));
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
_ => new EspFirstSyncSnapshot(
skipUser: false, skipDevice: false,
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
// Early read (agent start / DeviceSetup detection): partial device list, empty user list.
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1 }, Array.Empty<string>())))
{
collector.CollectEspConfiguration();
}
var afterEarly = EspConfigEvents(ingress);
Assert.Single(afterEarly);
Assert.Empty(TypedList(afterEarly[0], "espTrackedUserWin32AppIds"));
// Apps sub-phase opened: the device list grew and sign-in populated the user list.
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1, DeviceApp2 }, new[] { UserApp1 })))
{
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsUser);
}
var afterGrowth = EspConfigEvents(ingress);
Assert.Equal(2, afterGrowth.Count);
Assert.Contains(DeviceApp2, TypedList(afterGrowth[1], "espTrackedWin32AppIds"));
Assert.Equal(new[] { UserApp1 }, TypedList(afterGrowth[1], "espTrackedUserWin32AppIds"));
// A later trigger with an UNCHANGED registry state re-reads but emits nothing —
// the StartupEventGate suppresses the identical payload.
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1, DeviceApp2 }, new[] { UserApp1 })))
{
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAccountSetup);
}
Assert.Equal(2, EspConfigEvents(ingress).Count);
}
[Fact]
public async Task Concurrent_refreshes_never_emit_an_older_snapshot_after_a_newer_one()
{
// Codex review: the refresh triggers each run on their own Task.Run — without the
// collector-side lock, two collects can interleave read-before-emit and the LATER
// emission carries the OLDER (smaller) list. The backend keeps the latest
// list-bearing emission as positive evidence, so that would silently shrink it.
// The probe returns a strictly growing list per read; with the read→emit critical
// section, emission order must equal read order — sizes strictly ascending.
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var ingress = new FakeSignalIngressSink();
var clock = new VirtualClock(T0);
var collector = new DeviceInfoCollector(
"session-1", "tenant-1",
new InformationalEventPost(ingress, clock), logger,
signalIngress: null, clock: clock,
startupGate: new StartupEventGate(tmp.Path, logger));
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
_ => new EspFirstSyncSnapshot(
skipUser: false, skipDevice: false,
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
var readCounter = 0;
using var tracking = new EspTrackingInfoProbe.ScopedOverride(_ =>
{
var n = Interlocked.Increment(ref readCounter);
var ids = Enumerable.Range(1, n)
.Select(i => $"{i:d8}-1111-2222-3333-444444444444")
.ToArray();
return Snapshot(ids, Array.Empty<string>());
});
var tasks = Enumerable.Range(0, 12)
.Select(i => Task.Run(() => collector.RefreshEspConfiguration($"stress-{i}")))
.ToArray();
await Task.WhenAll(tasks);
var sizes = EspConfigEvents(ingress)
.Select(e => TypedList(e, "espTrackedWin32AppIds").Count)
.ToList();
// Every refresh saw a strictly larger list (gate never suppresses) and each
// read→emit is atomic, so the emission sequence is exactly 1..12.
Assert.Equal(Enumerable.Range(1, 12), sizes);
}
}
}
@@ -0,0 +1,104 @@
using System;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// Writer unit tests for <see cref="GatherRuleDebugLog"/>: line format, lazy directory
/// creation, size-cap rotation, and the null-writer no-op path on the context.
/// </summary>
public sealed class GatherRuleDebugLogTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly AgentLogger _logger;
public GatherRuleDebugLogTests()
{
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
}
public void Dispose() => _tmp.Dispose();
private string LogPath => Path.Combine(_tmp.Path, "sub", "gather_rules_debug.log");
[Fact]
public void Write_produces_pipe_delimited_line_and_creates_directory_lazily()
{
var log = new GatherRuleDebugLog(LogPath, _logger);
Assert.False(File.Exists(LogPath)); // lazy — nothing until the first write
log.Write("RULE-1", GatherRuleDebugLog.StageScope, "skipped: out of scope");
var lines = File.ReadAllLines(LogPath);
Assert.Equal(2, lines.Length); // header + entry
Assert.Contains("| - | config | -- gather rule debug log started --", lines[0]);
var parts = lines[1].Split(new[] { " | " }, StringSplitOptions.None);
Assert.Equal(4, parts.Length);
Assert.Equal("RULE-1", parts[1]);
Assert.Equal("scope", parts[2]);
Assert.Equal("skipped: out of scope", parts[3]);
}
[Fact]
public void Write_flattens_newlines_except_for_error_stage()
{
var log = new GatherRuleDebugLog(LogPath, _logger);
log.Write("RULE-1", GatherRuleDebugLog.StageCollector, "line1\r\nline2");
log.Write("RULE-1", GatherRuleDebugLog.StageError, "boom\r\n at Frame()");
var content = File.ReadAllText(LogPath);
Assert.Contains("line1 line2", content);
Assert.Contains(" at Frame()", content); // error stage keeps the stack multi-line
}
[Fact]
public void Write_null_ruleId_renders_dash()
{
var log = new GatherRuleDebugLog(LogPath, _logger);
log.Write(null, GatherRuleDebugLog.StageConfig, "no rules delivered");
Assert.Contains(" | - | config | no rules delivered", File.ReadAllText(LogPath));
}
[Fact]
public void Write_rotates_once_when_file_exceeds_cap()
{
var log = new GatherRuleDebugLog(LogPath, _logger, maxBytes: 512);
for (int i = 0; i < 20; i++)
log.Write("RULE-1", GatherRuleDebugLog.StageExec, new string('x', 100));
var oldPath = Path.Combine(Path.GetDirectoryName(LogPath), "gather_rules_debug.old.log");
Assert.True(File.Exists(oldPath), "expected one .old rotation generation");
Assert.True(File.Exists(LogPath));
Assert.Contains("-- rotated", File.ReadAllText(LogPath) + File.ReadAllText(oldPath));
// Never more than one .old generation
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(LogPath), "*.old.log"));
}
[Fact]
public void WriteStandalone_writes_single_config_line()
{
GatherRuleDebugLog.WriteStandalone(LogPath, "no gather rules delivered by backend — nothing to execute", _logger);
var lines = File.ReadAllLines(LogPath);
Assert.Contains(lines, l => l.Contains("no gather rules delivered by backend"));
}
[Fact]
public void Context_DebugLog_is_noop_without_writer()
{
var context = new GatherRuleContext(
_logger, "sess", "tenant", _ => { }, null, new LogFilePositionTracker());
context.DebugLog("RULE-1", GatherRuleDebugLog.StageScope, "should not throw, should not write");
Assert.False(File.Exists(LogPath));
}
}
}
@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// Debug-trace coverage of <see cref="GatherRuleExecutor"/> (EnableGatherRuleDebugLog):
/// every "silently produced nothing" outcome must leave an explanatory line in the trace
/// file — registration summary, missing interval, scope skips, on_change suppression,
/// empty collector results.
/// </summary>
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
public sealed class GatherRuleExecutorDebugLogTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly GatherRuleExecutor _executor;
private readonly string _debugLogPath;
private const string AbsentPath =
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000";
public GatherRuleExecutorDebugLogTests()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
_executor = new GatherRuleExecutor(
"sess", "tenant", _ => { }, logger,
debugLogPath: _debugLogPath)
{
UnrestrictedMode = true
};
}
public void Dispose()
{
_executor.Dispose();
_tmp.Dispose();
}
private string TraceContent() => File.Exists(_debugLogPath) ? File.ReadAllText(_debugLogPath) : string.Empty;
private static GatherRule Rule(string id, Action<GatherRule>? mutate = null)
{
var rule = new GatherRule
{
RuleId = id,
Title = id,
CollectorType = "registry",
Target = AbsentPath,
Trigger = "startup",
OutputEventType = "gather_test",
Enabled = true,
};
mutate?.Invoke(rule);
return rule;
}
[Fact]
public void UpdateRules_writes_registration_summary_per_rule()
{
_executor.UpdateRules(new List<GatherRule>
{
Rule("GATHER-REG-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.EmitMode = "on_change"; }),
Rule("GATHER-REG-002", r => r.Enabled = false),
});
_executor.WaitForStartupRules(30);
var trace = TraceContent();
Assert.Contains("GATHER-REG-001 | config | registered: trigger=interval, collector=registry", trace);
Assert.Contains("emitMode=on_change", trace);
Assert.Contains("interval=3600s", trace);
Assert.Contains("GATHER-REG-002 | config | rule disabled — will never run", trace);
Assert.Contains("interval timer scheduled every 3600s (first run after one full interval", trace);
}
[Fact]
public void UpdateRules_flags_interval_rule_without_intervalSeconds()
{
_executor.UpdateRules(new List<GatherRule>
{
Rule("GATHER-NOINT-001", r => { r.Trigger = "interval"; r.IntervalSeconds = null; }),
});
Assert.Contains("GATHER-NOINT-001 | error | trigger=interval but intervalSeconds is missing — rule will NEVER run",
TraceContent());
}
[Fact]
public void ScopedRule_skip_traces_unknown_phase_reason()
{
var rule = Rule("GATHER-SCOPE-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.ActivePhases = new List<string> { "DeviceSetup" }; });
_executor.UpdateRules(new List<GatherRule> { rule });
// Synchronous internal check mirrors the timer-tick gate; the reason must name Unknown.
string reason;
Assert.False(_executor.IsRuleInScope(rule, out reason));
Assert.Contains("currentPhase=Unknown", reason);
}
[Fact]
public void ScopedStartupRule_deferral_is_traced()
{
_executor.UpdateRules(new List<GatherRule>
{
Rule("GATHER-DEFER-001", r => r.ActivePhases = new List<string> { "DeviceSetup" }),
});
Assert.Contains("GATHER-DEFER-001 | scope | startup rule deferred — currentPhase=Unknown", TraceContent());
}
[Fact]
public void OnChange_suppression_and_reemission_are_traced()
{
var rule = Rule("GATHER-OC-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.EmitMode = "on_change"; });
_executor.UpdateRules(new List<GatherRule> { rule });
string hashPrefix;
int streak;
var first = new Dictionary<string, object> { ["exists"] = false };
Assert.True(_executor.ShouldEmitOnChange(rule, first, out hashPrefix, out streak));
Assert.Equal(0, streak);
Assert.False(string.IsNullOrEmpty(hashPrefix));
Assert.False(_executor.ShouldEmitOnChange(rule, new Dictionary<string, object> { ["exists"] = false }, out hashPrefix, out streak));
Assert.Equal(1, streak);
Assert.False(_executor.ShouldEmitOnChange(rule, new Dictionary<string, object> { ["exists"] = false }, out hashPrefix, out streak));
Assert.Equal(2, streak);
var changed = new Dictionary<string, object> { ["exists"] = true };
Assert.True(_executor.ShouldEmitOnChange(rule, changed, out hashPrefix, out streak));
Assert.Equal(2, streak); // the streak that just ended
Assert.Equal(2, changed["suppressedPolls"]);
}
[Fact]
public void PhaseChange_writes_transition_line()
{
_executor.UpdateRules(new List<GatherRule> { Rule("GATHER-PHASE-001") });
_executor.WaitForStartupRules(30);
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // repeat — no extra line
var trace = TraceContent();
Assert.Contains("- | phase | phase change: Unknown -> DeviceSetup (first phase signal", trace);
Assert.Contains("- | phase | phase change: DeviceSetup -> AccountSetup", trace);
// A repeated signal for the same phase must not produce a second transition line.
var marker = "phase change: DeviceSetup -> AccountSetup";
Assert.Equal(trace.IndexOf(marker), trace.LastIndexOf(marker));
}
[Fact]
public void EmptyCollectorResult_is_traced_via_startup_execution()
{
_executor.UpdateRules(new List<GatherRule>
{
Rule("GATHER-EMPTY-001", r => r.Parameters = new Dictionary<string, string> { ["emitOnlyIfExists"] = "true" }),
});
Assert.True(_executor.WaitForStartupRules(30));
var trace = TraceContent();
Assert.Contains("GATHER-EMPTY-001 | exec | executing: collector=registry", trace);
Assert.Contains("GATHER-EMPTY-001 | collector | registry key/value not found", trace);
Assert.Contains("GATHER-EMPTY-001 | collector | collector returned EMPTY result — nothing emitted", trace);
}
}
}
@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// Debug-trace coverage of <see cref="LogParserCollector"/>: the classic
/// "rule delivers nothing" cases must be explained in the trace — zero matches,
/// position-tracker skips, and plain-text logs parsed in the default cmtrace mode.
/// </summary>
public sealed class LogParserCollectorDebugLogTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
private readonly GatherRuleContext _context;
private readonly string _debugLogPath;
// %TEMP% lives under C:\Users (hard-blocked) — parse targets sit beside the
// test assembly instead, admitted via UnrestrictedMode.
private readonly string _outsideUsers = Path.Combine(
AppContext.BaseDirectory, "gather-debug-tests-" + Guid.NewGuid().ToString("N"));
public LogParserCollectorDebugLogTests()
{
Directory.CreateDirectory(_outsideUsers);
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
_context = new GatherRuleContext(
logger, "sess", "tenant",
evt => _events.Add(evt),
null,
new LogFilePositionTracker(),
new GatherRuleDebugLog(_debugLogPath, logger))
{
UnrestrictedMode = true
};
}
public void Dispose()
{
_tmp.Dispose();
try { Directory.Delete(_outsideUsers, recursive: true); } catch { /* best effort */ }
}
private string TraceContent() => File.Exists(_debugLogPath) ? File.ReadAllText(_debugLogPath) : string.Empty;
private static GatherRule Rule(string target, string pattern, string? format = null) => new GatherRule
{
RuleId = "GATHER-LP-DEBUG",
Title = "logparser debug test",
CollectorType = "logparser",
Target = target,
Parameters = format == null
? new Dictionary<string, string> { ["pattern"] = pattern }
: new Dictionary<string, string> { ["pattern"] = pattern, ["format"] = format },
Trigger = "startup",
OutputEventType = "logparser_debug_test",
};
[Fact]
public void ZeroMatches_writes_per_file_outcome_line()
{
var path = Path.Combine(_outsideUsers, "app.log");
File.WriteAllText(path, "nothing interesting\nstill nothing\n");
new LogParserCollector().Execute(Rule(path, "WILL_NEVER_MATCH_[0-9]+", format: "text"), _context);
Assert.Empty(_events);
var trace = TraceContent();
Assert.Contains("app.log: read 2 lines", trace);
Assert.Contains("matched 0", trace);
Assert.Contains("mode=text", trace);
}
[Fact]
public void SecondRun_without_new_content_traces_position_skip()
{
var path = Path.Combine(_outsideUsers, "twice.log");
File.WriteAllText(path, "one line\n");
var rule = Rule(path, "one", format: "text");
var collector = new LogParserCollector();
collector.Execute(rule, _context); // reads the file, advances the position
collector.Execute(rule, _context); // nothing new
Assert.Contains("twice.log: no new content (position", TraceContent());
}
[Fact]
public void PlainTextFile_in_default_cmtrace_mode_gets_format_hint()
{
var path = Path.Combine(_outsideUsers, "plain.log");
File.WriteAllText(path, "plain line one\nplain line two\n");
new LogParserCollector().Execute(Rule(path, "plain"), _context); // default = cmtrace mode
Assert.Empty(_events);
var trace = TraceContent();
Assert.Contains("parseFailures=2", trace);
Assert.Contains("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text", trace);
}
[Fact]
public void Match_traces_line_number_matched_text_and_groups()
{
var path = Path.Combine(_outsideUsers, "match.log");
File.WriteAllText(path, "boring line\nError 0x80070005 in setup\n");
new LogParserCollector().Execute(
Rule(path, @"Error (?<code>0x[0-9A-Fa-f]+)", format: "text"), _context);
Assert.Single(_events);
var trace = TraceContent();
Assert.Contains("match.log: line 2 matched \"Error 0x80070005\"", trace);
Assert.Contains("groups: code=\"0x80070005\"", trace);
}
[Fact]
public void MatchTraces_are_capped_per_file()
{
var path = Path.Combine(_outsideUsers, "many.log");
var sb = new System.Text.StringBuilder();
for (var i = 1; i <= 15; i++)
sb.Append("hit number ").Append(i).Append('\n');
File.WriteAllText(path, sb.ToString());
new LogParserCollector().Execute(Rule(path, "hit", format: "text"), _context);
Assert.Equal(15, _events.Count); // all matches still emit events
var trace = TraceContent();
Assert.Contains("many.log: line 10 matched", trace);
Assert.Contains("further matches not traced individually", trace);
Assert.DoesNotContain("many.log: line 11 matched", trace);
Assert.Contains("matched 15", trace); // summary still carries the full count
}
[Fact]
public void InvalidRegex_traces_the_pattern_itself()
{
var path = Path.Combine(_outsideUsers, "irrelevant.log");
File.WriteAllText(path, "content\n");
new LogParserCollector().Execute(Rule(path, "([unclosed", format: "text"), _context);
Assert.Empty(_events);
var trace = TraceContent();
Assert.Contains("invalid regex pattern '([unclosed'", trace);
Assert.Contains("rule can never match", trace);
}
[Fact]
public void MissingFile_traces_no_files_matched()
{
var path = Path.Combine(_outsideUsers, "does-not-exist.log");
new LogParserCollector().Execute(Rule(path, "x", format: "text"), _context);
Assert.Contains("no files matched target", TraceContent());
}
}
}
@@ -0,0 +1,209 @@
#nullable enable
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
{
/// <summary>
/// Tests for the pure netsh-output parser of the WiFi fallback path. netsh labels follow
/// the OS UI language — the parser must extract SSID/signal/radio/channel from the label
/// variants of the languages commonly seen in enrollments, and degrade gracefully (fields
/// absent, never throw) on unrecognized ones.
/// </summary>
public sealed class NetshWifiFallbackTests
{
[Fact]
public void Parse_english_output_extracts_all_fields()
{
const string output = @"
There is 1 interface on the system:
Name : Wi-Fi
Description : Intel(R) Wi-Fi 6 AX201 160MHz
GUID : 754b6c07-5ae9-49d5-ba6c-4fbd6e38bbf6
State : connected
SSID : contoso-corp
AP BSSID : 3c:37:12:53:c8:44
Band : 5 GHz
Channel : 116
Radio type : 802.11ax
Signal : 94%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-corp", r!.Ssid);
Assert.Equal(94, r.SignalPercent);
Assert.Equal("802.11ax", r.RadioType);
Assert.Equal(116, r.Channel);
}
[Fact]
public void Parse_german_output_extracts_all_fields()
{
const string output = @"
Es ist 1 Schnittstelle auf dem System vorhanden:
Name : WLAN
Beschreibung : Intel(R) Wi-Fi 6 AX201 160MHz
Status : Verbunden
SSID : fabrikam-wlan
Kanal : 44
Funktyp : 802.11ac
Signal : 87%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("fabrikam-wlan", r!.Ssid);
Assert.Equal(87, r.SignalPercent);
Assert.Equal("802.11ac", r.RadioType);
Assert.Equal(44, r.Channel);
}
[Fact]
public void Parse_spanish_output_extracts_signal_and_channel()
{
const string output = @"
Nombre : Wi-Fi
SSID : contoso-es
Canal : 6
Tipo de radio : 802.11n
Señal : 72%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-es", r!.Ssid);
Assert.Equal(72, r.SignalPercent);
Assert.Equal("802.11n", r.RadioType);
Assert.Equal(6, r.Channel);
}
[Fact]
public void Parse_chinese_simplified_output_extracts_all_fields()
{
const string output = @"
名称 : WLAN
SSID : contoso-cn
信道 : 149
无线电类型 : 802.11ac
信号 : 68%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-cn", r!.Ssid);
Assert.Equal(68, r.SignalPercent);
Assert.Equal("802.11ac", r.RadioType);
Assert.Equal(149, r.Channel);
}
[Fact]
public void Parse_korean_output_extracts_all_fields()
{
const string output = @"
이름 : Wi-Fi
SSID : contoso-kr
채널 : 36
라디오 종류 : 802.11ax
신호 : 91%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-kr", r!.Ssid);
Assert.Equal(91, r.SignalPercent);
Assert.Equal("802.11ax", r.RadioType);
Assert.Equal(36, r.Channel);
}
[Fact]
public void Parse_signal_with_space_before_percent_is_parsed()
{
var r = NetshWifiFallback.Parse(" Signal : 81 %\r\n SSID : x\r\n");
Assert.NotNull(r);
Assert.Equal(81, r!.SignalPercent);
}
[Fact]
public void Parse_ap_bssid_does_not_overwrite_ssid()
{
const string output = @"
SSID : contoso-corp
AP BSSID : aa:bb:cc:dd:ee:ff
";
var r = NetshWifiFallback.Parse(output);
Assert.Equal("contoso-corp", r!.Ssid);
}
[Fact]
public void Parse_finnish_output_extracts_all_fields()
{
const string output = @"
Nimi : Wi-Fi
SSID : contoso-fi
Kanava : 100
Radiotyyppi : 802.11ax
Signaali : 79%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-fi", r!.Ssid);
Assert.Equal(79, r.SignalPercent);
Assert.Equal("802.11ax", r.RadioType);
Assert.Equal(100, r.Channel);
}
[Fact]
public void Parse_indonesian_output_extracts_all_fields()
{
const string output = @"
Nama : Wi-Fi
SSID : contoso-id
Saluran : 11
Jenis radio : 802.11n
Sinyal : 64%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-id", r!.Ssid);
Assert.Equal(64, r.SignalPercent);
Assert.Equal("802.11n", r.RadioType);
Assert.Equal(11, r.Channel);
}
[Fact]
public void Parse_unknown_language_without_ssid_returns_null()
{
// Greek is not in the enrollment-geography label set -> nothing to report.
var r = NetshWifiFallback.Parse(" Κατάσταση : συνδεδεμένο\r\n Σήμα : 90%\r\n");
Assert.Null(r);
}
[Fact]
public void Parse_unknown_language_still_yields_ssid()
{
// SSID is untranslated in all locales — partial result beats none.
var r = NetshWifiFallback.Parse(" SSID : contoso-gr\r\n Σήμα : 90%\r\n");
Assert.NotNull(r);
Assert.Equal("contoso-gr", r!.Ssid);
Assert.Null(r.SignalPercent);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Der WLAN-AutoKonfig-Dienst (wlansvc) wird nicht ausgeführt.")]
public void Parse_empty_or_error_output_returns_null(string? output)
{
Assert.Null(NetshWifiFallback.Parse(output));
}
}
}
@@ -0,0 +1,98 @@
#nullable enable
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
{
/// <summary>
/// Tests for the native-WLAN-API WiFi reader. The WLAN state of the test machine is
/// unknown (CI runners/VMs have no WLAN service), so the live path only pins the
/// fail-soft contract; the pure helpers (SSID decode, PHY-type mapping) are pinned
/// exactly — their output feeds wifi_signal_info event payloads and must stay
/// consistent with the strings netsh historically reported.
/// </summary>
public sealed class WifiInfoReaderTests
{
[Fact]
public void TryGetCurrentConnection_never_throws_and_returns_sane_values()
{
var result = WifiInfoReader.TryGetCurrentConnection();
if (result != null)
{
Assert.True(result.SignalPercent.HasValue, "native API path must always carry signal quality");
Assert.InRange(result.SignalPercent.Value, 0, 100);
if (result.Channel.HasValue)
Assert.True(result.Channel.Value > 0, "channel must be positive when present");
}
}
// ----- DecodeSsid ---------------------------------------------------
[Fact]
public void DecodeSsid_decodes_utf8_bytes()
{
var raw = new byte[32];
var ssid = Encoding.UTF8.GetBytes("Contoso-Büro");
ssid.CopyTo(raw, 0);
Assert.Equal("Contoso-Büro", WifiInfoReader.DecodeSsid(raw, (uint)ssid.Length));
}
[Fact]
public void DecodeSsid_returns_null_for_empty_or_missing()
{
Assert.Null(WifiInfoReader.DecodeSsid(null, 5));
Assert.Null(WifiInfoReader.DecodeSsid(new byte[32], 0));
}
[Fact]
public void DecodeSsid_clamps_length_to_buffer_size()
{
var raw = Encoding.UTF8.GetBytes("abc");
// A corrupt native length beyond the buffer must not throw.
Assert.Equal("abc", WifiInfoReader.DecodeSsid(raw, 200));
}
[Fact]
public void DecodeSsid_uses_only_declared_length()
{
var raw = new byte[32];
Encoding.UTF8.GetBytes("HomeNet-Rest-Ignored").CopyTo(raw, 0);
Assert.Equal("HomeNet", WifiInfoReader.DecodeSsid(raw, 7));
}
// ----- PhyTypeToRadioType -------------------------------------------
// int parameters (cast inside): DOT11_PHY_TYPE is internal and may not appear in a
// public test method signature (CS0051).
[Theory]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ofdm, "802.11a")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_hrdsss, "802.11b")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_erp, "802.11g")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ht, "802.11n")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_vht, "802.11ac")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dmg, "802.11ad")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_he, "802.11ax")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_eht, "802.11be")]
public void PhyTypeToRadioType_maps_known_generations(int phyType, string expected)
{
Assert.Equal(expected, WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
}
[Theory]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_unknown)]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_fhss)]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dsss)]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_irbaseband)]
[InlineData(9999)]
public void PhyTypeToRadioType_returns_null_for_legacy_or_unknown(int phyType)
{
Assert.Null(WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
}
}
}
@@ -226,6 +226,95 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
return archive.Entries.Select(e => e.FullName).ToArray();
}
private static string ZipEntryText(byte[] zipBytes, string entryName)
{
using var ms = new MemoryStream(zipBytes);
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
var entry = archive.GetEntry(entryName);
Assert.NotNull(entry);
using var reader = new StreamReader(entry!.Open(), Encoding.UTF8);
return reader.ReadToEnd();
}
// ── Nullable outcome (on-demand mid-session) — gate + sessioninfo semantics ──
/// <summary>
/// Sentinel subclass: throws before any HTTP so gate-pass-through is observable as a
/// non-null error result (a skip returns null BEFORE the archive is ever built).
/// </summary>
private sealed class ThrowOnBuildService : DiagnosticsPackageService
{
public ThrowOnBuildService(AgentConfiguration cfg, AgentLogger logger, BackendApiClient api)
: base(cfg, logger, api) { }
internal override byte[] BuildArchiveBytes(bool? enrollmentSucceeded) =>
throw new System.InvalidOperationException("gate-passed-sentinel");
}
private static DiagnosticsPackageService BuildGateProbe(AgentLogger logger, string mode)
{
var cfg = Cfg();
cfg.DiagnosticsUploadEnabled = true;
cfg.DiagnosticsUploadMode = mode;
var apiClient = new BackendApiClient(
httpClient: new System.Net.Http.HttpClient(),
baseUrl: "http://localhost",
manufacturer: string.Empty,
model: string.Empty,
serialNumber: string.Empty,
useBootstrapTokenAuth: false,
bootstrapToken: null,
agentVersion: "0.0.0",
logger: logger);
return new ThrowOnBuildService(cfg, logger, apiClient);
}
[Fact]
public async System.Threading.Tasks.Task CreateAndUploadAsync_null_outcome_passes_OnFailure_gate()
{
// On-demand mid-session (no outcome yet): the OnFailure gate must NOT skip —
// there is no known success to skip on. Reaching the archive build (sentinel
// throw → error result) proves the gate was passed; a skip would return null.
using var rig = new Rig();
var result = await BuildGateProbe(rig.Logger, "OnFailure")
.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: "server-requested");
Assert.NotNull(result);
Assert.Equal("gate-passed-sentinel", result!.ErrorCode);
}
[Fact]
public async System.Threading.Tasks.Task CreateAndUploadAsync_known_success_still_skipped_by_OnFailure_gate()
{
using var rig = new Rig();
var result = await BuildGateProbe(rig.Logger, "OnFailure")
.CreateAndUploadAsync(enrollmentSucceeded: true);
Assert.Null(result);
}
[Fact]
public async System.Threading.Tasks.Task CreateAndUploadAsync_null_outcome_still_blocked_by_mode_Off()
{
using var rig = new Rig();
var result = await BuildGateProbe(rig.Logger, "Off")
.CreateAndUploadAsync(enrollmentSucceeded: null);
Assert.Null(result);
}
[Theory]
[InlineData(true, "Succeeded")]
[InlineData(false, "Failed")]
[InlineData(null, "In Progress")]
public void BuildArchiveBytes_sessioninfo_reflects_nullable_outcome(bool? outcome, string expected)
{
using var rig = new Rig();
var bytes = rig.Build().BuildArchiveBytes(outcome);
var sessionInfo = ZipEntryText(bytes, "sessioninfo.txt");
Assert.Contains($"Enrollment Result: {expected}", sessionInfo);
}
// ── BuildBlobUploadUrl — destination-aware URL construction ─────────────────
[Theory]
@@ -112,6 +112,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.Equal(false, data["backfilled"]);
}
[Fact]
public void ErrorBackfillSummary_EmitsInfoLogEvent_AnnouncingTheReplayBlock()
{
_tracker.EmitErrorBackfillSummary(replayedCount: 6, droppedOverCap: 0);
var posted = _sink.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent);
Assert.Equal("modern_deployment_log", posted.Payload![SignalPayloadKeys.EventType]);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(posted.TypedPayload);
Assert.Equal(true, data["backfillSummary"]);
Assert.Equal(6, data["replayedCount"]);
Assert.Equal(0, data["droppedOverCap"]);
Assert.Equal(240, data["lookbackMinutes"]);
var message = (string)posted.Payload[SignalPayloadKeys.Message];
Assert.Contains("Replaying 6 error event(s)", message);
Assert.Contains("before the agent started", message);
Assert.DoesNotContain("cap", message);
}
[Fact]
public void ErrorBackfillSummary_MentionsDroppedRecords_WhenCapWasHit()
{
_tracker.EmitErrorBackfillSummary(replayedCount: 25, droppedOverCap: 7);
var posted = _sink.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent);
var message = (string)posted.Payload![SignalPayloadKeys.Message];
Assert.Contains("7 older record(s) beyond the 25-record cap", message);
}
[Fact]
public void BackfilledHarmlessId_StillRunsThroughRollupSuppression()
{
@@ -37,5 +37,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
Assert.False(DeviceInfoHost.IsEndTrigger(DecisionSignalKind.EspPhaseChanged, null));
Assert.False(DeviceInfoHost.IsEndTrigger(DecisionSignalKind.InformationalEvent, Phase("FinalizingSetup")));
}
// Audit Q2 — the payload shape the adapter's apps sub-phase declaration produces on the
// ingress rail (InformationalEventPost: eventType + declared phase enum name).
private static IReadOnlyDictionary<string, string> SubPhaseDeclaration(string declaredPhase) =>
new Dictionary<string, string>
{
{ SignalPayloadKeys.EventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition },
{ AutopilotMonitor.Agent.V2.Core.Telemetry.Events.EventTimelineEmitter.PhaseParamKey, declaredPhase },
};
[Fact]
public void Apps_subphase_declarations_classify_as_esp_config_refresh_triggers()
{
Assert.Equal(DeviceInfoHost.EspConfigTriggerAppsDevice,
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("AppsDevice")));
Assert.Equal(DeviceInfoHost.EspConfigTriggerAppsUser,
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("AppsUser")));
// Non-apps phase declarations (e.g. the reducer's Finalizing entry) never classify.
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("FinalizingSetup")));
// A phase_transition without a declared phase never classifies.
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent,
new Dictionary<string, string> { { SignalPayloadKeys.EventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition } }));
}
[Fact]
public void AccountSetup_phase_classifies_as_the_user_setup_refresh_trigger()
{
Assert.Equal(DeviceInfoHost.EspConfigTriggerAccountSetup,
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, Phase("AccountSetup")));
// DeviceSetup is the enrollment-start trigger, not an ESP-config refresh.
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, Phase("DeviceSetup")));
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, null));
// Unrelated informational events never classify.
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent,
new Dictionary<string, string> { { SignalPayloadKeys.EventType, "esp_config_detected" } }));
}
}
}
@@ -0,0 +1,33 @@
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// Audit Q5 — WMI hardware values are trimmed at the source. BIOS serials become device
/// identity keys downstream (F2 device history joins on TenantId + serial), so padding
/// must never leave the agent; whitespace-only values collapse to the "Unknown" sentinel
/// the retry logic already treats as unresolved.
/// </summary>
public sealed class HardwareInfoNormalizeTests
{
[Theory]
[InlineData(" PF60H941 ", "PF60H941")]
[InlineData("PF60H941", "PF60H941")]
[InlineData("\tLENOVO \r\n", "LENOVO")]
[InlineData("Unknown", "Unknown")]
public void Trims_whitespace_at_the_source(string raw, string expected)
=> Assert.Equal(expected, HardwareInfo.NormalizeWmiValue(raw));
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t\r\n")]
public void Empty_and_whitespace_only_collapse_to_the_Unknown_sentinel(string raw)
=> Assert.Equal("Unknown", HardwareInfo.NormalizeWmiValue(raw));
[Fact]
public void Null_collapses_to_the_Unknown_sentinel()
=> Assert.Equal("Unknown", HardwareInfo.NormalizeWmiValue(null));
}
}
@@ -243,5 +243,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Equal(firstStamp, adapter.AppTimings["app-P"].StartedAtUtc);
}
[Fact]
public void Terminal_to_active_retry_rearms_CompletedAt_and_final_terminal_covers_total_occupancy()
{
// Audit Q1: the IME retry path (Error -> Downloading -> Installed is permitted by
// AppPackageState.UpdateState). Pre-fix, CompletedAt froze at the first Error, so
// the final Installed payload carried the first failure's completedAt/duration.
using var f = new ImeLogTrackerAdapterFixture(T0);
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState("app-R", 0);
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Installing);
f.Clock.Advance(TimeSpan.FromSeconds(5));
adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Error);
f.Clock.Advance(TimeSpan.FromSeconds(10));
adapter.TriggerAppStateFromTest(app, AppInstallationState.Error, AppInstallationState.Downloading);
// Re-armed while active again: the retry's download event carries no completedAt,
// and the FIRST start stamp is kept.
Assert.Null(adapter.AppTimings["app-R"].CompletedAtUtc);
var retryDownload = f.InfoEvent(SharedEventTypes.AppDownloadStarted);
Assert.False(retryDownload.Payload!.ContainsKey("completedAt"));
Assert.False(retryDownload.Payload.ContainsKey("durationSeconds"));
Assert.Equal(T0.ToString("o"), retryDownload.Payload["startedAt"]);
f.Clock.Advance(TimeSpan.FromSeconds(20));
adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installed);
var timing = adapter.AppTimings["app-R"];
Assert.Equal(T0, timing.StartedAtUtc);
Assert.Equal(T0.AddSeconds(35), timing.CompletedAtUtc);
Assert.Equal(35.0, timing.DurationSeconds);
var completed = Assert.Single(f.InfoEvents(SharedEventTypes.AppInstallComplete));
Assert.Equal("35.00", completed.Payload!["durationSeconds"]);
Assert.Equal(T0.AddSeconds(35).ToString("o"), completed.Payload["completedAt"]);
}
[Fact]
public void Skipped_then_retried_app_emits_positive_duration_from_retry_start()
{
// Production regression (session 66f1c5e1, audit Q1): a user-scope app evaluated
// Skipped during DeviceSetup (terminal, no start observed), retried after sign-in
// (Skipped -> Downloading is permitted), Installed later. Pre-fix payload:
// completedAt frozen at the early Skip + startedAt from the retry ->
// durationSeconds=-2342. Post-fix: retry re-arms, duration covers retry-start ->
// final terminal.
using var f = new ImeLogTrackerAdapterFixture(T0);
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState("app-S", 0);
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Skipped);
Assert.Null(adapter.AppTimings["app-S"].StartedAtUtc);
Assert.Equal(T0, adapter.AppTimings["app-S"].CompletedAtUtc);
f.Clock.Advance(TimeSpan.FromSeconds(60));
adapter.TriggerAppStateFromTest(app, AppInstallationState.Skipped, AppInstallationState.Downloading);
f.Clock.Advance(TimeSpan.FromSeconds(30));
adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installed);
var timing = adapter.AppTimings["app-S"];
Assert.Equal(T0.AddSeconds(60), timing.StartedAtUtc);
Assert.Equal(T0.AddSeconds(90), timing.CompletedAtUtc);
Assert.Equal(30.0, timing.DurationSeconds);
var completedEvents = f.InfoEvents(SharedEventTypes.AppInstallComplete);
Assert.Equal(2, completedEvents.Count);
Assert.Equal("30.00", completedEvents[1].Payload!["durationSeconds"]);
}
[Fact]
public void Inverted_endpoints_yield_null_DurationSeconds()
{
// Negative guard (audit Q1): endpoints stamped from out-of-order source lines must
// never surface as a negative durationSeconds (observed -2342s in production). The
// payload emitter omits the field when DurationSeconds is null.
var inverted = new AppInstallTiming(startedAtUtc: T0.AddSeconds(100), completedAtUtc: T0);
Assert.Null(inverted.DurationSeconds);
var wellOrdered = new AppInstallTiming(startedAtUtc: T0, completedAtUtc: T0.AddSeconds(100));
Assert.Equal(100.0, wellOrdered.DurationSeconds);
}
}
}
@@ -511,6 +511,88 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
$"network_bandwidth_estimate (@{bandwidthIdx}) must precede whiteglove_part1_complete (@{part1CompleteIdx}) so the straggler stays in Part 1.");
}
// ===================================================== WG Part-1 preprov diagnostics
// V1 parity (restored 2026-07-28): the seal exit uploads the diagnostics package as a
// pre-provisioning intermediate under the SAME mode gating as the terminal path — the
// seal counts as a success, so Always uploads, OnFailure skips (a Part-1 failure
// terminates via the regular Failed path, which uploads), Off does nothing.
[Theory]
[InlineData("Always", true, 1)]
[InlineData("OnFailure", true, 0)]
[InlineData("Off", true, 0)]
[InlineData("Always", false, 0)]
public void Handle_whiteglove_part1_diagnostics_upload_respects_mode(
string mode, bool enabled, int expected)
{
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
var cfg = rig.BuildConfig(diagEnabled: enabled, diagMode: mode);
rig.Build(cfg).Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
Assert.Equal(expected, rig.DiagnosticsUploads);
}
[Fact]
public void Handle_whiteglove_part1_diagnostics_upload_uses_preprov_suffix_and_succeeded_flag()
{
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
rig.Build(cfg).Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
Assert.Equal(1, rig.DiagnosticsUploads);
Assert.Equal("preprov", rig.LastDiagnosticsSuffix);
Assert.Equal(true, rig.LastDiagnosticsSucceededFlag);
}
[Fact]
public void Handle_whiteglove_part1_diagnostics_events_precede_part1_complete_marker()
{
// whiteglove_part1_complete is the authoritative Part-1/Part-2 boundary for the
// web split (computeWhiteGloveSplitSequence) — diagnostics_collecting/uploaded
// must sequence BEFORE it or they get mis-filed into the resumed Part-2 block.
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-preprov.zip" };
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
rig.Build(cfg).Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
var emitted = rig.EmittedEventTypes.ToList();
var collectingIdx = emitted.IndexOf("diagnostics_collecting");
var uploadedIdx = emitted.IndexOf("diagnostics_uploaded");
var part1CompleteIdx = emitted.IndexOf(Constants.EventTypes.WhiteGlovePart1Complete);
Assert.True(collectingIdx >= 0, "diagnostics_collecting must have been emitted.");
Assert.True(uploadedIdx >= 0, "diagnostics_uploaded must have been emitted.");
Assert.True(part1CompleteIdx >= 0, "whiteglove_part1_complete must have been emitted.");
Assert.True(uploadedIdx < part1CompleteIdx,
$"diagnostics_uploaded (@{uploadedIdx}) must precede whiteglove_part1_complete (@{part1CompleteIdx}) so the upload stays in Part 1.");
}
[Fact]
public void Handle_whiteglove_part1_still_skips_cleanup_and_marker_when_diagnostics_enabled()
{
// The preprov upload must not change the Part-1 exit contract: no self-destruct,
// no enrollment-complete.marker, whiteglove.complete still written.
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
rig.Build(cfg).Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
Assert.Equal(0, rig.CleanupService.Invocations);
Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
Assert.True(rig.SessionPersistence.IsWhiteGloveResume());
}
[Fact]
public void Handle_standalone_reboot_fires_when_reboot_enabled_and_no_self_destruct()
{
@@ -1,5 +1,7 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Termination;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
@@ -70,5 +72,82 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
Assert.Equal(0, bootToStart, 0);
Assert.Equal(10, uptime, 0);
}
// --- DescribeLateStart: outcome-calibrated severity + phrasing (fleet 2026-07-28) ---
private static void Describe(
EnrollmentTerminationOutcome outcome, string oobeState,
out EventSeverity severity, out string message, out string note)
=> ObservationCoverage.DescribeLateStart(
outcome, oobeState, bootToStartSeconds: 34 * 60, uptimeSeconds: 30,
out severity, out message, out note);
[Fact]
public void Failed_outcome_is_a_warning_with_the_hung_script_post_mortem_note()
{
Describe(EnrollmentTerminationOutcome.Failed, "in_progress",
out var severity, out var message, out var note);
Assert.Equal(EventSeverity.Warning, severity);
Assert.Contains("low coverage of the enrollment window", message);
Assert.Contains("post-mortem", note);
Assert.Contains("hung ahead of the bootstrap", note);
}
[Fact]
public void TimedOut_outcome_is_a_warning_like_a_failure()
{
Describe(EnrollmentTerminationOutcome.TimedOut, "in_progress",
out var severity, out _, out var note);
Assert.Equal(EventSeverity.Warning, severity);
Assert.Contains("post-mortem", note);
}
[Fact]
public void Succeeded_outcome_is_an_info_about_tail_only_coverage_without_alarm_framing()
{
// The 659c3a90 shape: healthy enrollment, bootstrap merely ran late in the ESP queue.
Describe(EnrollmentTerminationOutcome.Succeeded, "in_progress",
out var severity, out _, out var note);
Assert.Equal(EventSeverity.Info, severity);
Assert.Contains("succeeded", note);
Assert.Contains("IME log replay", note);
Assert.DoesNotContain("post-mortem", note);
Assert.DoesNotContain("hung", note);
}
[Fact]
public void Oobe_completed_at_start_phrases_a_pure_post_mortem()
{
Describe(EnrollmentTerminationOutcome.Failed, "completed",
out _, out var message, out _);
Assert.Contains("OOBE was already completed when the agent started", message);
}
[Fact]
public void Oobe_in_progress_at_start_phrases_the_live_tail()
{
Describe(EnrollmentTerminationOutcome.Failed, "in_progress",
out _, out var message, out _);
Assert.Contains("OOBE was still in progress at agent start", message);
Assert.Contains("final 30s live", message);
}
[Theory]
[InlineData("not_started")] // flips in after a mid-OOBE reboot — not interpretable
[InlineData("unavailable")] // WinRT contract absent / read failed
[InlineData("unknown_3")] // unexpected enum value
public void Uninterpretable_oobe_states_add_no_claim_to_the_message(string oobeState)
{
Describe(EnrollmentTerminationOutcome.Failed, oobeState,
out _, out var message, out _);
Assert.DoesNotContain("OOBE", message);
Assert.EndsWith("low coverage of the enrollment window.", message);
}
}
}
@@ -121,6 +121,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// </summary>
public string ImeMatchLogPath { get; set; }
/// <summary>
/// Optional path to write a gather-rule evaluation trace (for debugging).
/// If empty, no trace is written. Set from remote config (EnableGatherRuleDebugLog)
/// or the --gather-debug-log CLI override.
/// </summary>
public string GatherRuleDebugLogPath { get; set; }
/// <summary>
/// Path to directory with real IME log files for log replay.
/// When set, the agent replays these logs with time compression instead of reading live logs.
@@ -135,6 +135,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
}
}
// ---------------- GatherRuleDebugLog: same bool→path projection as ImeMatchLog
// above. `true` expands Constants.GatherRuleDebugLogPath, `false`
// nulls the runtime path unless a --gather-debug-log CLI override
// supplied a non-default path.
if (remoteConfig.EnableGatherRuleDebugLog)
{
agentConfig.GatherRuleDebugLogPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
}
else
{
var defaultGatherDebugPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
if (string.IsNullOrEmpty(agentConfig.GatherRuleDebugLogPath)
|| string.Equals(agentConfig.GatherRuleDebugLogPath, defaultGatherDebugPath, StringComparison.OrdinalIgnoreCase))
{
agentConfig.GatherRuleDebugLogPath = null;
}
}
// ---------------- Upload interval / batch — direct, V1 parity (no > 0 gate so a
// misconfigured tenant can in theory break uploads, but this
// surfaces the config bug instead of silently masking it).
@@ -327,6 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
KeepLogFile = false,
EnableGeoLocation = true,
EnableImeMatchLog = false,
EnableGatherRuleDebugLog = false,
Collectors = CollectorConfiguration.CreateDefault(),
GatherRules = new System.Collections.Generic.List<GatherRule>(),
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
@@ -10,9 +10,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// the <c>app_tracking_summary</c> terminal event.
/// <para>
/// <b>StartedAtUtc</b> is recorded on the first lifecycle transition into Downloading /
/// Installing / InProgress. <b>CompletedAtUtc</b> is recorded on the first transition to a
/// terminal state (Installed / Skipped / Postponed / Error). Both are "set-once" — subsequent
/// identical-state events do not overwrite an earlier stamp.
/// Installing / InProgress and never overwritten. <b>CompletedAtUtc</b> is recorded on a
/// transition to a terminal state (Installed / Skipped / Postponed / Error) and re-armed
/// to <c>null</c> when the app goes terminal → active again (IME retry / re-evaluation,
/// audit Q1) — so the final terminal event carries first-start → last-terminal.
/// </para>
/// </summary>
public sealed class AppInstallTiming
@@ -30,10 +31,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// <summary>
/// Elapsed time from first Downloading/Installing transition to terminal state, in
/// seconds. <c>null</c> when either endpoint is missing (e.g. app still installing,
/// or the adapter missed the start event because it came online mid-install).
/// or the adapter missed the start event because it came online mid-install) — and
/// <c>null</c> when the endpoints are inverted (CompletedAt before StartedAt: log lines
/// from different source files can be processed out of chronological order; observed in
/// production as durationSeconds=-2342 on a Skipped-then-retried user-scope app,
/// audit Q1). A negative duration is never a real measurement, so it is not emitted.
/// </summary>
public double? DurationSeconds =>
StartedAtUtc.HasValue && CompletedAtUtc.HasValue
StartedAtUtc.HasValue && CompletedAtUtc.HasValue && CompletedAtUtc.Value >= StartedAtUtc.Value
? (CompletedAtUtc.Value - StartedAtUtc.Value).TotalSeconds
: (double?)null;
}
@@ -631,10 +631,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// the regular <see cref="ProcessRecord"/> pipeline with isBackfill=true, so they arrive
/// as modern_deployment_error events carrying backfilled=true plus the original
/// timeCreated; the event's own timestamp is emission time, so session StartedAt is not
/// dragged backwards. Newest-first, capped, watermark-deduped across restarts.
/// dragged backwards. The scan reads newest-first (the cap keeps the newest records),
/// but replay is buffered so an Info summary event announces the block BEFORE the errors
/// hit the timeline (customer feedback 2026-07-28: an unannounced wall of pre-agent
/// Error events reads like a live failure) and the records emit oldest-first, giving the
/// timeline sequence the original event-log chronology. Watermark-deduped across restarts.
/// </summary>
private void BackfillAutopilotErrors()
{
var buffered = new List<EventRecord>();
try
{
long watermark;
@@ -649,7 +654,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
ReverseDirection = true
};
int processed = 0, skippedWatermark = 0, droppedOverCap = 0;
int skippedWatermark = 0, droppedOverCap = 0;
long maxSeen = watermark;
using (var reader = new EventLogReader(query))
@@ -657,37 +662,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
EventRecord record;
while ((record = reader.ReadEvent()) != null)
{
using (record)
var recordId = record.RecordId ?? 0;
if (recordId > maxSeen) maxSeen = recordId;
if (recordId != 0 && recordId <= watermark)
{
var recordId = record.RecordId ?? 0;
if (recordId > maxSeen) maxSeen = recordId;
if (recordId != 0 && recordId <= watermark)
{
skippedWatermark++;
continue;
}
if (processed >= ErrorBackfillMaxRecords)
{
droppedOverCap++;
continue;
}
processed++;
ProcessRecord(record, "Autopilot", AutopilotChannel, isBackfill: true);
skippedWatermark++;
record.Dispose();
continue;
}
if (buffered.Count >= ErrorBackfillMaxRecords)
{
droppedOverCap++;
record.Dispose();
continue;
}
buffered.Add(record);
}
}
if (maxSeen > watermark)
AdvanceAutopilotErrorWatermark(maxSeen);
if (processed == 0 && droppedOverCap == 0)
if (buffered.Count == 0 && droppedOverCap == 0)
{
_logger.Debug($"ModernDeployment error backfill: nothing new (skippedByWatermark={skippedWatermark})");
}
else
_logger.Info($"ModernDeployment error backfill: forwarded {processed} record(s) " +
{
EmitErrorBackfillSummary(buffered.Count, droppedOverCap);
// Scan buffered newest-first — replay in reverse so the emitted sequence
// follows the original event-log chronology.
for (var i = buffered.Count - 1; i >= 0; i--)
{
ProcessRecord(buffered[i], "Autopilot", AutopilotChannel, isBackfill: true);
}
_logger.Info($"ModernDeployment error backfill: forwarded {buffered.Count} record(s) " +
$"(skippedByWatermark={skippedWatermark}, droppedOverCap={droppedOverCap})");
}
}
catch (EventLogNotFoundException)
{
@@ -701,6 +717,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
_logger.Warning($"ModernDeployment error backfill failed: {ex.Message}");
}
finally
{
foreach (var record in buffered)
{
try { record.Dispose(); } catch { }
}
}
}
/// <summary>
/// Announces the error-backfill block on the session timeline BEFORE the replayed
/// errors, so pre-agent Error events are readable as history rather than a live
/// failure. The web timeline additionally badges each replayed event via its
/// backfilled=true payload marker; this summary carries backfillSummary=true instead —
/// it is a live emission, not a replayed record.
/// </summary>
internal void EmitErrorBackfillSummary(int replayedCount, int droppedOverCap)
{
var message = $"[Autopilot] Replaying {replayedCount} error event(s) recorded in the Autopilot event log " +
$"before the agent started (lookback {ErrorBackfillLookbackMinutes / 60}h). " +
"These predate monitoring — if enrollment progressed afterwards, they were already resolved by a retry.";
if (droppedOverCap > 0)
{
message += $" {droppedOverCap} older record(s) beyond the {ErrorBackfillMaxRecords}-record cap were not replayed.";
}
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.ModernDeploymentLog,
Severity = EventSeverity.Info,
Source = "ModernDeploymentWatcher",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = new Dictionary<string, object>
{
{ "backfillSummary", true },
{ "replayedCount", replayedCount },
{ "droppedOverCap", droppedOverCap },
{ "lookbackMinutes", ErrorBackfillLookbackMinutes }
}
});
}
/// <summary>
@@ -0,0 +1,221 @@
using System;
using System.Diagnostics;
using AutopilotMonitor.Agent.V2.Core.Security;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Fallback WiFi reader via <c>netsh wlan show interfaces</c> output parsing, used ONLY
/// when <see cref="WifiInfoReader"/> (native WLAN API) yields nothing on a WiFi-connected
/// device. Field evidence 2026-07-28: the WLAN API can return nothing in the OOBE/SYSTEM
/// context on builds where inbox netsh still reports the connection — until that is fully
/// root-caused (suspect: Wi-Fi location gating, 24H2+), netsh stays as the safety net.
/// <para>
/// netsh labels follow the OS UI language, so the parser matches the label variants of
/// every language implied by the enrollment geography seen to date (see the comment on
/// the key arrays for the country→language derivation). Unknown-language labels fall
/// through harmlessly — the affected field is simply absent, same as before. "SSID"
/// itself is untranslated in all known locales.
/// </para>
/// </summary>
internal static class NetshWifiFallback
{
private const int NetshTimeoutMs = 5000;
// Label variants per field, by OS UI language. Matched case-insensitively against the
// full text left of the colon, so acronym-suffixed keys ("AP BSSID") never collide.
// Language set derived from enrollment geography (get_geographic_metrics, 365d):
// US/GB/AU/CA/IE/SG/IN/ZA/NG/PH en · DE/AT/CH de · BE/NL nl · BE/FR/CH fr · DK da ·
// SE sv · PL pl · TR tr · CN zh-CN · HK zh-TW · ES/MX/PE/PY es · CZ cs · SK sk ·
// IT/CH it · ID id · MY ms · FI fi · LT lt · LV lv · HU hu · BR/PT pt · JP ja ·
// KR ko · TH th · VN vi · RO/MD ro · BG bg · BA/HR hr · AE/SA/EG/JO ar ·
// MN/GE/AZ usually en/ru images → ru.
private static readonly string[] SignalKeys =
{
"Signal", // en, de, fr, sv, da, nb, hr
"Señal", // es
"Sinal", // pt
"Segnale", // it
"Signaal", // nl
"Sygnał", // pl
"Signál", // cs, sk
"Sinyal", // tr, id
"Isyarat", // ms
"Signaali", // fi
"Signalas", // lt
"Signāls", // lv
"Jel", // hu
"Semnal", // ro
"Tín hiệu", // vi
"สัญญาณ", // th
"الإشارة", // ar
"إشارة", // ar (variant without article)
"Сигнал", // ru, bg
"シグナル", // ja
"信号", // zh-CN
"訊號", // zh-TW
"신호", // ko
};
private static readonly string[] RadioTypeKeys =
{
"Radio type", // en
"Funktyp", // de
"Type de radio", // fr
"Tipo de radio", // es
"Tipo de rádio", // pt
"Tipo di radio", // it
"Radiotype", // nl, da
"Radiotyp", // sv
"Typ radia", // pl
"Typ rádia", // cs, sk
"Radyo türü", // tr
"Jenis radio", // id, ms
"Radiotyyppi", // fi
"Radijo tipas", // lt
"Radio tips", // lv
"Rádió típusa", // hu
"Tip radio", // ro
"Vrsta radija", // hr, bs
"Loại radio", // vi
"ชนิดวิทยุ", // th
"نوع الراديو", // ar
"Тип радио", // ru
"Тип на радиото", // bg
"無線の種類", // ja
"无线电类型", // zh-CN
"無線電類型", // zh-TW
"라디오 종류", // ko
"무선 종류", // ko (variant)
};
private static readonly string[] ChannelKeys =
{
"Channel", // en
"Kanal", // de, sv, da, nb, tr, hr
"Canal", // fr, es, pt, ro
"Canale", // it
"Kanaal", // nl
"Kanał", // pl
"Kanál", // cs, sk
"Saluran", // id, ms
"Kanava", // fi
"Kanalas", // lt
"Kanāls", // lv
"Csatorna", // hu
"Kênh", // vi
"ช่องสัญญาณ", // th
"ช่อง", // th (variant)
"القناة", // ar
"قناة", // ar (variant without article)
"Канал", // ru, bg
"チャネル", // ja
"信道", // zh-CN
"通道", // zh-TW / zh-CN (variant)
"채널", // ko
};
/// <summary>
/// Runs <c>netsh wlan show interfaces</c> and parses its output. Returns a
/// <see cref="WifiConnectionInfo"/> with the fields that could be parsed, or null when
/// nothing was found (no WLAN, netsh failure, timeout, or unrecognized localized
/// labels). Never throws.
/// </summary>
internal static WifiConnectionInfo TryRead()
{
try
{
var psi = new ProcessStartInfo
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(NetshTimeoutMs))
{
try { process.Kill(); } catch { }
return null;
}
}
return Parse(output);
}
catch
{
return null;
}
}
/// <summary>
/// Pure parser over the netsh output text. Internal for direct unit-testing via
/// InternalsVisibleTo (TryRead itself spawns a process and stays untested).
/// </summary>
internal static WifiConnectionInfo Parse(string output)
{
if (string.IsNullOrEmpty(output))
return null;
string ssid = null, radioType = null;
int? signal = null, channel = null;
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase))
{
ssid = value;
}
else if (MatchesAny(key, SignalKeys))
{
// Value formats seen: "94%", "94% " (trailing space), "94 %".
if (int.TryParse(value.TrimEnd('%', ' '), out var sig))
signal = sig;
}
else if (MatchesAny(key, RadioTypeKeys))
{
radioType = value;
}
else if (MatchesAny(key, ChannelKeys))
{
if (int.TryParse(value, out var ch))
channel = ch;
}
}
if (ssid == null && signal == null && radioType == null && channel == null)
return null;
return new WifiConnectionInfo
{
Ssid = ssid,
SignalPercent = signal,
RadioType = radioType,
Channel = channel,
};
}
private static bool MatchesAny(string key, string[] candidates)
{
foreach (var candidate in candidates)
{
if (key.Equals(candidate, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}
}
@@ -0,0 +1,234 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Current WiFi connection details as read from the native WLAN API.
/// </summary>
public sealed class WifiConnectionInfo
{
/// <summary>SSID of the connected network; null when the AP broadcasts an empty SSID.</summary>
public string Ssid { get; internal set; }
/// <summary>
/// Signal quality 0–100 (same semantics as the netsh "Signal" percentage). Always set
/// on the native WLAN API path; null only when the netsh fallback could not parse it.
/// </summary>
public int? SignalPercent { get; internal set; }
/// <summary>Human-readable PHY generation ("802.11ax" …); null for exotic/unknown PHYs.</summary>
public string RadioType { get; internal set; }
/// <summary>Current 802.11 channel number; null when the channel query fails.</summary>
public int? Channel { get; internal set; }
}
/// <summary>
/// Fail-soft reader over <see cref="WlanNativeMethods"/>: returns the current WiFi
/// connection or null — never throws, never blocks on a child process. Machines without
/// the WLAN AutoConfig service (VMs, wired-only devices) simply return null.
/// </summary>
public static class WifiInfoReader
{
/// <summary>
/// Reads the current WiFi connection. When <paramref name="preferredInterfaceId"/> is
/// given (the adapter GUID from <c>NetworkInterface.Id</c>), that interface is queried
/// first; otherwise — or when it is not connected — the first connected WLAN interface
/// wins. Returns null when no interface has an active connection.
/// </summary>
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId = null)
=> TryGetCurrentConnection(preferredInterfaceId, out _);
/// <summary>
/// Same as <see cref="TryGetCurrentConnection(Guid?)"/> but reports WHY the read
/// yielded nothing via <paramref name="diagnostics"/> (native return codes per step).
/// The WLAN API can fail in ways that are invisible from the outside (e.g. per-process
/// access gating on newer Windows builds) — callers log this at Debug so field issues
/// are explainable from the agent log instead of a silent missing event.
/// </summary>
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId, out string diagnostics)
{
var diag = new StringBuilder();
var clientHandle = IntPtr.Zero;
try
{
var rc = WlanOpenHandle(WLAN_CLIENT_VERSION, IntPtr.Zero, out _, out clientHandle);
if (rc != ERROR_SUCCESS)
{
diagnostics = $"WlanOpenHandle rc={rc}";
return null;
}
var interfaceList = IntPtr.Zero;
try
{
rc = WlanEnumInterfaces(clientHandle, IntPtr.Zero, out interfaceList);
if (rc != ERROR_SUCCESS)
{
diagnostics = $"WlanEnumInterfaces rc={rc}";
return null;
}
var count = (uint)Marshal.ReadInt32(interfaceList);
if (count == 0)
{
diagnostics = "no WLAN interfaces";
return null;
}
var infoSize = Marshal.SizeOf(typeof(WLAN_INTERFACE_INFO));
// List header: DWORD dwNumberOfItems + DWORD dwIndex, then the info array.
var firstEntry = new IntPtr(interfaceList.ToInt64() + 8);
WifiConnectionInfo fallback = null;
for (var i = 0; i < count; i++)
{
var entryPtr = new IntPtr(firstEntry.ToInt64() + i * infoSize);
var info = (WLAN_INTERFACE_INFO)Marshal.PtrToStructure(entryPtr, typeof(WLAN_INTERFACE_INFO));
var connection = QueryCurrentConnection(clientHandle, info.InterfaceGuid, diag);
if (connection == null)
continue;
if (preferredInterfaceId.HasValue && info.InterfaceGuid == preferredInterfaceId.Value)
{
diagnostics = null;
return connection;
}
if (fallback == null)
fallback = connection;
}
diagnostics = fallback == null ? diag.ToString() : null;
return fallback;
}
finally
{
if (interfaceList != IntPtr.Zero)
WlanFreeMemory(interfaceList);
}
}
catch (Exception ex)
{
diagnostics = $"exception {ex.GetType().Name}: {ex.Message}";
return null;
}
finally
{
if (clientHandle != IntPtr.Zero)
WlanCloseHandle(clientHandle, IntPtr.Zero);
}
}
private static WifiConnectionInfo QueryCurrentConnection(IntPtr clientHandle, Guid interfaceGuid, StringBuilder diag)
{
var dataPtr = IntPtr.Zero;
try
{
var rc = WlanQueryInterface(clientHandle, ref interfaceGuid,
WLAN_INTF_OPCODE.wlan_intf_opcode_current_connection,
IntPtr.Zero, out _, out dataPtr, IntPtr.Zero);
if (rc != ERROR_SUCCESS)
{
diag.Append($"[{interfaceGuid:D}] query(current_connection) rc={rc}; ");
return null;
}
var attributes = (WLAN_CONNECTION_ATTRIBUTES)Marshal.PtrToStructure(
dataPtr, typeof(WLAN_CONNECTION_ATTRIBUTES));
if (attributes.isState != WLAN_INTERFACE_STATE.wlan_interface_state_connected)
{
diag.Append($"[{interfaceGuid:D}] state={attributes.isState}; ");
return null;
}
var assoc = attributes.wlanAssociationAttributes;
return new WifiConnectionInfo
{
Ssid = DecodeSsid(assoc.dot11Ssid.ucSSID, assoc.dot11Ssid.uSSIDLength),
SignalPercent = (int)Math.Min(assoc.wlanSignalQuality, 100),
RadioType = PhyTypeToRadioType(assoc.dot11PhyType),
Channel = QueryChannel(clientHandle, interfaceGuid),
};
}
finally
{
if (dataPtr != IntPtr.Zero)
WlanFreeMemory(dataPtr);
}
}
private static int? QueryChannel(IntPtr clientHandle, Guid interfaceGuid)
{
var dataPtr = IntPtr.Zero;
try
{
if (WlanQueryInterface(clientHandle, ref interfaceGuid,
WLAN_INTF_OPCODE.wlan_intf_opcode_channel_number,
IntPtr.Zero, out var dataSize, out dataPtr, IntPtr.Zero) != ERROR_SUCCESS)
return null;
if (dataSize < sizeof(uint) || dataPtr == IntPtr.Zero)
return null;
return Marshal.ReadInt32(dataPtr);
}
catch
{
return null;
}
finally
{
if (dataPtr != IntPtr.Zero)
WlanFreeMemory(dataPtr);
}
}
/// <summary>
/// Decodes a raw DOT11_SSID (UTF-8 by convention) into a string; null for empty SSIDs.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static string DecodeSsid(byte[] raw, uint length)
{
if (raw == null || length == 0)
return null;
var byteCount = (int)Math.Min(length, (uint)raw.Length);
try
{
var ssid = Encoding.UTF8.GetString(raw, 0, byteCount);
return string.IsNullOrEmpty(ssid) ? null : ssid;
}
catch
{
return null;
}
}
/// <summary>
/// Maps a DOT11 PHY type to the same strings netsh historically reported as
/// "Radio type" — keeps event payloads consistent across the netsh→WLAN-API switch.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static string PhyTypeToRadioType(DOT11_PHY_TYPE phyType)
{
switch (phyType)
{
case DOT11_PHY_TYPE.dot11_phy_type_ofdm: return "802.11a";
case DOT11_PHY_TYPE.dot11_phy_type_hrdsss: return "802.11b";
case DOT11_PHY_TYPE.dot11_phy_type_erp: return "802.11g";
case DOT11_PHY_TYPE.dot11_phy_type_ht: return "802.11n";
case DOT11_PHY_TYPE.dot11_phy_type_vht: return "802.11ac";
case DOT11_PHY_TYPE.dot11_phy_type_dmg: return "802.11ad";
case DOT11_PHY_TYPE.dot11_phy_type_he: return "802.11ax";
case DOT11_PHY_TYPE.dot11_phy_type_eht: return "802.11be";
default: return null;
}
}
}
}
@@ -0,0 +1,142 @@
using System;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Native WLAN API interop (<c>wlanapi.dll</c>) used to read the current WiFi connection
/// (SSID, signal quality, PHY type, channel) without spawning <c>netsh</c>.
/// <para>
/// The WLAN API is language-neutral — unlike parsing <c>netsh wlan show interfaces</c>
/// output, whose labels follow the OS UI language and silently drop fields on
/// non-English images. On machines without the WLAN AutoConfig service (VMs, servers,
/// wired-only devices) <c>WlanOpenHandle</c>/<c>WlanEnumInterfaces</c> fail with a
/// non-zero error code; callers treat that as "no WiFi" (see <see cref="WifiInfoReader"/>).
/// </para>
/// </summary>
internal static class WlanNativeMethods
{
internal const uint ERROR_SUCCESS = 0;
/// <summary>Client version 2 = Vista and later (the only relevant range).</summary>
internal const uint WLAN_CLIENT_VERSION = 2;
internal enum WLAN_INTF_OPCODE : uint
{
wlan_intf_opcode_current_connection = 7,
wlan_intf_opcode_channel_number = 8,
}
internal enum WLAN_INTERFACE_STATE
{
wlan_interface_state_not_ready = 0,
wlan_interface_state_connected = 1,
wlan_interface_state_ad_hoc_network_formed = 2,
wlan_interface_state_disconnecting = 3,
wlan_interface_state_disconnected = 4,
wlan_interface_state_associating = 5,
wlan_interface_state_discovering = 6,
wlan_interface_state_authenticating = 7,
}
internal enum DOT11_PHY_TYPE : uint
{
dot11_phy_type_unknown = 0,
dot11_phy_type_fhss = 1,
dot11_phy_type_dsss = 2,
dot11_phy_type_irbaseband = 3,
dot11_phy_type_ofdm = 4, // 802.11a
dot11_phy_type_hrdsss = 5, // 802.11b
dot11_phy_type_erp = 6, // 802.11g
dot11_phy_type_ht = 7, // 802.11n
dot11_phy_type_vht = 8, // 802.11ac
dot11_phy_type_dmg = 9, // 802.11ad
dot11_phy_type_he = 10, // 802.11ax
dot11_phy_type_eht = 11, // 802.11be
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct WLAN_INTERFACE_INFO
{
public Guid InterfaceGuid;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string strInterfaceDescription;
public WLAN_INTERFACE_STATE isState;
}
[StructLayout(LayoutKind.Sequential)]
internal struct DOT11_SSID
{
public uint uSSIDLength;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
public byte[] ucSSID;
}
[StructLayout(LayoutKind.Sequential)]
internal struct WLAN_ASSOCIATION_ATTRIBUTES
{
public DOT11_SSID dot11Ssid;
public int dot11BssType;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)]
public byte[] dot11Bssid;
public DOT11_PHY_TYPE dot11PhyType;
public uint uDot11PhyIndex;
public uint wlanSignalQuality; // 0..100, same semantics as netsh "Signal"
public uint ulRxRate;
public uint ulTxRate;
}
[StructLayout(LayoutKind.Sequential)]
internal struct WLAN_SECURITY_ATTRIBUTES
{
[MarshalAs(UnmanagedType.Bool)]
public bool bSecurityEnabled;
[MarshalAs(UnmanagedType.Bool)]
public bool bOneXEnabled;
public int dot11AuthAlgorithm;
public int dot11CipherAlgorithm;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct WLAN_CONNECTION_ATTRIBUTES
{
public WLAN_INTERFACE_STATE isState;
public int wlanConnectionMode;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string strProfileName;
public WLAN_ASSOCIATION_ATTRIBUTES wlanAssociationAttributes;
public WLAN_SECURITY_ATTRIBUTES wlanSecurityAttributes;
}
[DllImport("wlanapi.dll")]
internal static extern uint WlanOpenHandle(
uint dwClientVersion,
IntPtr pReserved,
out uint pdwNegotiatedVersion,
out IntPtr phClientHandle);
[DllImport("wlanapi.dll")]
internal static extern uint WlanCloseHandle(
IntPtr hClientHandle,
IntPtr pReserved);
[DllImport("wlanapi.dll")]
internal static extern uint WlanEnumInterfaces(
IntPtr hClientHandle,
IntPtr pReserved,
out IntPtr ppInterfaceList);
[DllImport("wlanapi.dll")]
internal static extern uint WlanQueryInterface(
IntPtr hClientHandle,
[In] ref Guid pInterfaceGuid,
WLAN_INTF_OPCODE OpCode,
IntPtr pReserved,
out uint pdwDataSize,
out IntPtr ppData,
IntPtr pWlanOpcodeValueType);
[DllImport("wlanapi.dll")]
internal static extern void WlanFreeMemory(IntPtr pMemory);
}
}
@@ -218,15 +218,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// This method is non-fatal: all exceptions are caught and logged.
/// </summary>
/// <param name="enrollmentSucceeded">
/// True for a successful enrollment (affects OnFailure mode check and sessioninfo.txt content).
/// Pass true for WhiteGlove pre-provisioning (it succeeded up to this point).
/// True for a successful enrollment, false for a failed one (drives the OnFailure mode
/// check and sessioninfo.txt content). Pass true for WhiteGlove pre-provisioning (it
/// succeeded up to this point). Pass <b>null</b> when the session has no outcome yet
/// (on-demand server-requested collection mid-enrollment): the OnFailure gate does not
/// apply — there is no success to skip on — and sessioninfo.txt records "In Progress".
/// </param>
/// <param name="fileNameSuffix">
/// Optional suffix inserted before the .zip extension.
/// Example: "preprov" → AgentDiagnostics-{sessionId}-{timestamp}-preprov.zip
/// Null (default) → AgentDiagnostics-{sessionId}-{timestamp}.zip
/// </param>
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool enrollmentSucceeded, string fileNameSuffix = null)
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool? enrollmentSucceeded, string fileNameSuffix = null)
{
try
{
@@ -244,13 +247,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
return null;
}
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
// Null outcome (on-demand mid-session) passes through: the OnFailure gate only
// skips a KNOWN success — an in-flight session has nothing to skip on.
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded == true)
{
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
return null;
}
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={(enrollmentSucceeded.HasValue ? enrollmentSucceeded.ToString() : "n/a")}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
// Build ZIP in memory
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
@@ -343,7 +348,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// Builds the diagnostics ZIP body in-memory. Extracted from CreateAndUploadAsync so
// tests can assert archive contents without going through the upload path.
internal virtual byte[] BuildArchiveBytes(bool enrollmentSucceeded)
internal virtual byte[] BuildArchiveBytes(bool? enrollmentSucceeded)
{
var tracker = new BudgetTracker(Budget);
using (var ms = new MemoryStream())
@@ -414,14 +419,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded)
private void AddSessionInfo(ZipArchive archive, bool? enrollmentSucceeded)
{
var sb = new StringBuilder();
sb.AppendLine($"Session ID: {_configuration.SessionId}");
sb.AppendLine($"Tenant ID: {_configuration.TenantId}");
sb.AppendLine($"Device Name: {Environment.MachineName}");
sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}");
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}");
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded == null ? "In Progress" : enrollmentSucceeded.Value ? "Succeeded" : "Failed")}");
// Hardware info via existing DeviceInfoProvider (WMI)
sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}");
@@ -706,10 +706,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
/// the desktop via "Continue anyway".
/// </para>
/// </summary>
// Serializes the ESP-config read+emit across ALL callers (CollectAll on the startup
// ThreadPool task, CollectAtEnrollmentStart and RefreshEspConfiguration on their own
// Task.Run) — without it, two concurrent collects can interleave read-before-emit and
// the LATER emission can carry the OLDER (smaller) registry snapshot. The backend keeps
// the latest list-bearing emission as positive blocking evidence, so an out-of-order
// emission would silently shrink it (Codex review). Inside the lock each collect is
// read→emit atomic: a later emission always carries an equal-or-newer snapshot.
private readonly object _espConfigCollectLock = new object();
// Internal (not private) as a test seam: unlike CollectAll/CollectAtEnrollmentStart this
// method touches no live system state once both probes are overridden, so tests can
// drive the esp_config_detected event surface in isolation (InternalsVisibleTo).
internal (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration()
{
lock (_espConfigCollectLock)
{
return CollectEspConfigurationLocked();
}
}
private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfigurationLocked()
{
bool? skipUser = null;
bool? skipDevice = null;
@@ -798,7 +815,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
/// Collects active network interface details (name, type, link speed, MAC) using
/// System.Net.NetworkInformation — instant, no process spawn.
/// If the active interface is WiFi, fires off a separate async task to collect
/// WiFi signal info via netsh (fire-and-forget, never blocks).
/// WiFi signal info via the native WLAN API (fire-and-forget, never blocks).
/// </summary>
private void CollectActiveNetworkInterfaceInfo()
{
@@ -841,7 +858,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
// Fire-and-forget WiFi signal collection — separate event, never blocks
if (isWifi)
{
Task.Run(() => CollectWiFiSignalInfo());
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
Task.Run(() => CollectWiFiSignalInfo(nicGuid));
}
}
catch (Exception ex)
@@ -882,78 +900,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
/// <summary>
/// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
/// never blocks other collection. VMs without WiFi service simply get no event.
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
/// language-neutral, no process spawn) and emits a separate wifi_signal_info event.
/// Designed to run as fire-and-forget via Task.Run — never blocks other collection.
/// VMs without WiFi service simply get no event.
/// </summary>
private void CollectWiFiSignalInfo()
private void CollectWiFiSignalInfo(Guid? interfaceGuid)
{
try
{
var psi = new ProcessStartInfo
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
if (wifi == null)
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
process.WaitForExit(5000);
_logger.Debug($"EnrollmentTracker: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
wifi = NetshWifiFallback.TryRead();
if (wifi == null)
return;
}
if (string.IsNullOrEmpty(output))
return;
var data = new Dictionary<string, object>();
if (wifi.Ssid != null)
data["wifiSsid"] = wifi.Ssid;
if (wifi.SignalPercent.HasValue)
data["wifiSignalPercent"] = wifi.SignalPercent.Value;
if (wifi.RadioType != null)
data["wifiRadioType"] = wifi.RadioType;
if (wifi.Channel.HasValue)
data["wifiChannel"] = wifi.Channel.Value;
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var message = wifi.Ssid != null ? $"WiFi: {wifi.Ssid}" : "WiFi signal info";
if (wifi.SignalPercent.HasValue)
message += $", Signal: {wifi.SignalPercent.Value}%";
if (wifi.RadioType != null)
message += $" ({wifi.RadioType})";
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
{
data["wifiSsid"] = value;
}
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value.TrimEnd('%'), out var signal))
data["wifiSignalPercent"] = signal;
}
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
{
data["wifiRadioType"] = value;
}
else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value, out var channel))
data["wifiChannel"] = channel;
}
}
if (data.Count > 0)
{
var message = data.ContainsKey("wifiSsid")
? $"WiFi: {data["wifiSsid"]}"
: "WiFi signal info";
if (data.ContainsKey("wifiSignalPercent"))
message += $", Signal: {data["wifiSignalPercent"]}%";
if (data.ContainsKey("wifiRadioType"))
message += $" ({data["wifiRadioType"]})";
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
}
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
}
catch (Exception ex)
{
@@ -114,6 +114,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
CollectTpmStatus();
}
/// <summary>
/// Re-reads the ESP configuration (skip flags + ESPTrackingInfo blocking lists) and
/// re-emits <c>esp_config_detected</c> when the payload changed. Audit Q2: the earlier
/// emissions (agent start, DeviceSetup detection) fire before the registry lists are
/// complete — the ESP writes one timestamped subkey per CSP status write, so the
/// device-scope list grows progressively and the user-scope <c>S-&lt;SID&gt;</c> lists
/// appear only after sign-in. Called by <see cref="Orchestration.DeviceInfoHost"/> as
/// phase-driven one-shots (apps sub-phase opened, AccountSetup detected) — never on a
/// timer. The StartupEventGate keeps an unchanged payload silent, so a re-collect that
/// found nothing new emits nothing.
/// </summary>
public void RefreshEspConfiguration(string trigger)
{
_logger.Info($"EnrollmentTracker: re-collecting ESP configuration (trigger={trigger})");
CollectEspConfiguration();
}
/// <summary>
/// Re-collects device info that may change during enrollment (e.g. BitLocker enabled via policy).
/// Called at enrollment complete / FinalizingSetup transition to capture final state.
@@ -25,14 +25,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, "empty target — nothing to parse");
return null;
}
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
if (filePath == null)
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
"target contains %LOGGED_ON_USER_PROFILE% but no user is logged on — skipped this run");
return null; // Token present but no user logged on — skip silently
}
// Apply IME log path override if set. The override comes from the local
// --ime-log-path flag only (never from remote config), so the operator
@@ -66,6 +73,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
string.IsNullOrEmpty(patternStr))
{
context.Logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
"missing 'pattern' parameter — rule can never match");
return null;
}
@@ -93,6 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
$"invalid regex pattern '{patternStr}' — rule can never match: {ex.Message}");
return null;
}
@@ -101,6 +112,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
if (resolvedPaths.Count == 0)
{
context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"no files matched target {filePath}" + (HasWildcard(Path.GetFileName(filePath)) ? " (wildcard)" : "") + " — nothing to parse");
return null;
}
@@ -120,6 +133,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
return null;
}
// Per-match trace lines are capped per file per run — the per-file summary line
// still carries the full match count, this only bounds the per-match detail.
private const int MaxTracedMatchesPerFile = 10;
/// <summary>
/// Writes one debug-trace line per regex match: the line number, the matched text,
/// and every capture group that will land in the emitted event (same "0" exclusion
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
/// </summary>
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
int lineNumber, Match match, Regex pattern, int matchNumber)
{
if (matchNumber > MaxTracedMatchesPerFile)
{
if (matchNumber == MaxTracedMatchesPerFile + 1)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{fileName}: further matches not traced individually (cap {MaxTracedMatchesPerFile}/file/run) — see the per-file summary for the total");
return;
}
var sb = new StringBuilder();
sb.Append(fileName).Append(": line ").Append(lineNumber)
.Append(" matched \"").Append(TruncateMessage(match.Value, 200)).Append('"');
var first = true;
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (!group.Success) continue;
sb.Append(first ? " — groups: " : ", ");
first = false;
sb.Append(groupName).Append("=\"").Append(TruncateMessage(group.Value, 100)).Append('"');
}
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, sb.ToString());
}
private static bool HasWildcard(string fileNamePart)
=> !string.IsNullOrEmpty(fileNamePart) &&
(fileNamePart.Contains("*") || fileNamePart.Contains("?"));
@@ -144,7 +195,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
try
{
// Return matched files sorted by last write time (newest first), capped at 20
return Directory.GetFiles(directory, fileNamePart)
var allMatches = Directory.GetFiles(directory, fileNamePart);
if (allMatches.Length > 20)
context.DebugLog(ruleId, GatherRuleDebugLog.StageLogParser,
$"wildcard matched {allMatches.Length} files — capped to the 20 newest");
return allMatches
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
.Take(20)
.ToList();
@@ -152,6 +207,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {ruleId}: wildcard expansion failed for {filePath}: {ex.Message}");
context.DebugLog(ruleId, GatherRuleDebugLog.StageError, $"wildcard expansion failed for {filePath}: {ex.Message}");
return new List<string>();
}
}
@@ -168,10 +224,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
// Nothing new to read
if (startPosition >= fileInfo.Length)
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{Path.GetFileName(filePath)}: no new content (position {startPosition} >= length {fileInfo.Length}) — trackPosition=true, nothing re-read");
return;
}
int matchCount = 0;
int linesRead = 0;
int parseFailCount = 0;
int timeoutCount = 0;
long endPosition = startPosition;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
@@ -195,6 +257,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
}
catch (RegexMatchTimeoutException)
{
timeoutCount++;
continue;
}
if (!match.Success)
@@ -233,13 +296,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
});
matchCount++;
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
}
else
{
// CMTrace mode: parse line as CMTrace format, match regex against message
CmTraceLogEntry entry;
if (!CmTraceLogParser.TryParseLine(line, out entry))
{
parseFailCount++;
continue;
}
Match match;
try
@@ -248,6 +315,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
}
catch (RegexMatchTimeoutException)
{
timeoutCount++;
continue;
}
if (!match.Success)
@@ -288,6 +356,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
});
matchCount++;
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
}
}
@@ -301,10 +370,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
if (matchCount > 0)
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
// Per-file outcome — always written so a "matched 0" run is visible too.
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{Path.GetFileName(filePath)}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}");
if (!isTextMode && linesRead > 0 && parseFailCount == linesRead)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
"every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
if (linesRead == maxLines)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"stopped at maxLines={maxLines} — remaining content is deferred to the next run");
}
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"failed reading {filePath}: {ex}");
}
}
@@ -113,7 +113,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
// Suppress the "not found" event when the rule only wants a positive signal.
if (emitOnlyIfExists)
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
$"registry key/value not found: {path} — emitOnlyIfExists=true, empty result");
return new Dictionary<string, object>(); // empty → executor emits nothing
}
data["exists"] = false;
data["path"] = path;
@@ -22,13 +22,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public LogFilePositionTracker FilePositionTracker { get; }
public bool UnrestrictedMode { get; set; }
/// <summary>
/// Optional gather-rule debug trace writer (EnableGatherRuleDebugLog). Null when
/// tracing is disabled — use <see cref="DebugLog"/> for null-safe writes.
/// </summary>
public GatherRuleDebugLog DebugWriter { get; }
public GatherRuleContext(
AgentLogger logger,
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
string imeLogPathOverride,
LogFilePositionTracker filePositionTracker)
LogFilePositionTracker filePositionTracker,
GatherRuleDebugLog debugLog = null)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
@@ -36,8 +43,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
ImeLogPathOverride = imeLogPathOverride;
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
DebugWriter = debugLog;
}
/// <summary>
/// Null-safe write to the gather-rule debug trace. No-op when tracing is disabled.
/// </summary>
public void DebugLog(string ruleId, string stage, string message) => DebugWriter?.Write(ruleId, stage, message);
/// <summary>
/// Emits a security_warning event and returns an empty result dictionary.
/// Called when a gather rule targets a path/query not on the allowlist.
@@ -45,6 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public Dictionary<string, object> EmitSecurityWarning(GatherRule rule, string collectorType, string target)
{
Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageGuard,
$"{collectorType} target blocked by allowlist guard: {target} — security_warning emitted, rule produced no data");
var data = new Dictionary<string, object>
{
@@ -0,0 +1,116 @@
using System;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
/// <summary>
/// Append-only trace file for gather-rule evaluation, enabled per tenant via
/// EnableGatherRuleDebugLog (or --gather-debug-log). Explains every evaluation
/// decision — including the outcomes that produce no event — so customers can
/// diagnose rules that never show up in the timeline. Every write failure is
/// swallowed: tracing must never break gathering.
/// </summary>
public sealed class GatherRuleDebugLog
{
public const string StageConfig = "config";
public const string StagePhase = "phase";
public const string StageTrigger = "trigger";
public const string StageScope = "scope";
public const string StageExec = "exec";
public const string StageCollector = "collector";
public const string StageGuard = "guard";
public const string StageEmit = "emit";
public const string StageSuppress = "suppress";
public const string StageLogParser = "logparser";
public const string StageError = "error";
private const long DefaultMaxBytes = 10 * 1024 * 1024;
private readonly string _path;
private readonly long _maxBytes;
private readonly Action<string> _echo;
private readonly object _lock = new object();
private bool _headerWritten;
public GatherRuleDebugLog(string path, AgentLogger logger, Action<string> echo = null, long maxBytes = DefaultMaxBytes)
{
if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path));
_path = path;
_maxBytes = maxBytes;
_echo = echo;
logger?.Info($"GatherRuleDebugLog: enabled -> {path}");
}
/// <summary>
/// Writes one trace line: <c>{utc} | {ruleId} | {stage} | {message}</c>.
/// Newlines in <paramref name="message"/> are flattened except for the error
/// stage, where exception stack traces stay multi-line.
/// </summary>
public void Write(string ruleId, string stage, string message)
{
try
{
if (stage != StageError && message != null && message.IndexOf('\n') >= 0)
message = message.Replace("\r", "").Replace('\n', ' ');
var line = $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | {ruleId ?? "-"} | {stage} | {message}";
lock (_lock)
{
EnsureFileReadyLocked();
File.AppendAllText(_path, line + Environment.NewLine);
}
_echo?.Invoke(line);
}
catch { }
}
/// <summary>
/// One-shot helper for callers that have no executor (e.g. the flag is on but the
/// backend delivered zero gather rules) — the customer's trace file should explain
/// that too instead of silently not existing.
/// </summary>
public static void WriteStandalone(string path, string message, AgentLogger logger)
{
if (string.IsNullOrEmpty(path)) return;
try
{
new GatherRuleDebugLog(path, logger).Write(null, StageConfig, message);
}
catch { }
}
// Lazy on first write: create the directory and emit a header line. Before every
// append, rotate when the file exceeds maxBytes — keeping exactly one .old
// generation (worst case ~2x maxBytes on disk).
private void EnsureFileReadyLocked()
{
if (!_headerWritten)
{
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
}
var info = new FileInfo(_path);
if (info.Exists && info.Length > _maxBytes)
{
var oldPath = Path.ChangeExtension(_path, null) + ".old.log";
if (File.Exists(oldPath))
File.Delete(oldPath);
File.Move(_path, oldPath);
File.AppendAllText(_path,
$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | - | {StageConfig} | -- rotated (previous file exceeded {_maxBytes} bytes) --" + Environment.NewLine);
}
if (!_headerWritten)
{
File.AppendAllText(_path,
$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | - | {StageConfig} | -- gather rule debug log started --" + Environment.NewLine);
_headerWritten = true;
}
}
}
}
@@ -22,6 +22,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public class GatherRuleExecutor : IDisposable
{
private readonly AgentLogger _logger;
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
private readonly GatherRuleContext _context;
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
@@ -61,7 +62,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
}
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger, string imeLogPathOverride = null)
AgentLogger logger, string imeLogPathOverride = null, string debugLogPath = null,
Action<string> debugEcho = null)
{
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
@@ -69,9 +71,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
if (logger == null) throw new ArgumentNullException(nameof(logger));
_logger = logger;
_debug = string.IsNullOrEmpty(debugLogPath) ? null : new GatherRuleDebugLog(debugLogPath, logger, debugEcho);
var filePositionTracker = new LogFilePositionTracker();
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker);
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker, _debug);
// Register all collector strategies
var collectorList = new IGatherRuleCollector[]
@@ -96,6 +99,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
_collectors["command"] = _collectors["command_allowlisted"];
}
/// <summary>
/// Null-safe write to the gather-rule debug trace. Never call while holding
/// <see cref="_scopeLock"/> — capture the message under the lock, write after release.
/// </summary>
private void DebugLog(string ruleId, string stage, string message) => _debug?.Write(ruleId, stage, message);
/// <summary>
/// Updates the active rules and starts/stops execution accordingly
/// </summary>
@@ -111,11 +120,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
_activeRules = rules.Where(r => r.Enabled).ToList();
// Registration summary for the debug trace: one line per delivered rule so the
// customer can see exactly what the agent received and how it will be triggered.
if (_debug != null)
{
foreach (var rule in rules)
{
if (!rule.Enabled)
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageConfig, "rule disabled — will never run");
continue;
}
var scope = rule.ActivePhases != null && rule.ActivePhases.Count > 0
? $"activePhases=[{string.Join(",", rule.ActivePhases)}]"
: !string.IsNullOrEmpty(rule.ActiveFromPhase)
? $"activeFromPhase={rule.ActiveFromPhase}"
: "unscoped";
DebugLog(rule.RuleId, GatherRuleDebugLog.StageConfig,
$"registered: trigger={rule.Trigger}" +
(string.IsNullOrEmpty(rule.TriggerPhase) ? "" : $" triggerPhase={rule.TriggerPhase}") +
(string.IsNullOrEmpty(rule.TriggerEventType) ? "" : $" triggerEventType={rule.TriggerEventType}") +
$", collector={rule.CollectorType}, target={rule.Target}, {scope}" +
$", emitMode={(string.IsNullOrEmpty(rule.EmitMode) ? "always" : rule.EmitMode)}" +
(rule.IntervalSeconds.HasValue ? $", interval={rule.IntervalSeconds}s" : ""));
if (rule.Trigger == "interval" && !rule.IntervalSeconds.HasValue)
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
"trigger=interval but intervalSeconds is missing — rule will NEVER run");
}
}
// Execute startup rules — track completion via CountdownEvent so callers can wait.
// Scoped startup rules that are not yet in scope are deferred: they run once from
// OnPhaseChanged when their scope activates (same _startupRulesExecuted dedup — a
// rule runs either here or there, never both). Unscoped rules behave as before.
List<GatherRule> pendingStartup;
var deferredTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
lock (_scopeLock)
{
// A config refresh may deliver new from-phase rules after their phase was
@@ -124,7 +165,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
pendingStartup = _activeRules
.Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId))
.Where(r => IsRuleInScopeLocked(r))
.Where(r =>
{
string reason;
var inScope = IsRuleInScopeLocked(r, out reason);
if (!inScope && deferredTraces != null)
deferredTraces.Add(new KeyValuePair<string, string>(r.RuleId,
$"startup rule deferred — {reason}; runs once when its scope activates"));
return inScope;
})
.ToList();
foreach (var rule in pendingStartup)
@@ -133,6 +182,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
}
}
if (deferredTraces != null)
{
foreach (var trace in deferredTraces)
DebugLog(trace.Key, GatherRuleDebugLog.StageScope, trace.Value);
}
if (pendingStartup.Count > 0)
{
_startupRulesLatch?.Dispose();
@@ -158,8 +213,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
var timer = new Timer(
_ =>
{
if (!IsRuleInScope(intervalRule))
string skipReason;
if (!IsRuleInScope(intervalRule, out skipReason))
{
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageScope, $"interval tick skipped: {skipReason}");
return;
}
ExecuteRule(intervalRule);
},
null,
@@ -168,6 +227,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
);
_intervalTimers[intervalRule.RuleId] = timer;
_logger.Info($" Interval rule {intervalRule.RuleId} scheduled every {intervalRule.IntervalSeconds}s");
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageTrigger,
$"interval timer scheduled every {intervalRule.IntervalSeconds}s (first run after one full interval — no immediate execution)");
}
_logger.Info($"GatherRuleExecutor: {_activeRules.Count(r => r.Trigger == "startup")} startup, " +
@@ -187,9 +248,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
// phase that failed — that snapshot is the whole point at a failure boundary.
List<GatherRule> exitRules = null;
EnrollmentPhase previousPhase;
var exitTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
lock (_scopeLock)
{
var previousPhase = _currentPhase;
previousPhase = _currentPhase;
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
{
var previousPhaseName = previousPhase.ToString();
@@ -204,12 +267,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// Scope gate before the dedup add — an out-of-scope pass must not consume
// the once-per-(rule, phase) slot. IsRuleInScopeLocked still sees the old phase.
if (!IsRuleInScopeLocked(rule)) continue;
string scopeReason;
if (!IsRuleInScopeLocked(rule, out scopeReason))
{
if (exitTraces != null)
exitTraces.Add(new KeyValuePair<string, string>(rule.RuleId,
$"phase_exit of {previousPhaseName} skipped: {scopeReason}"));
continue;
}
// "exit:" prefix keeps this key space disjoint from the phase_change keys.
if (!_phaseRulesExecuted.Add($"exit:{rule.RuleId}|{previousPhaseName}"))
{
_logger.Debug($"Phase-exit rule {rule.RuleId} already executed for exit of {previousPhaseName}, skipping");
if (exitTraces != null)
exitTraces.Add(new KeyValuePair<string, string>(rule.RuleId,
$"phase_exit already executed for exit of {previousPhaseName} — once per (rule, phase)"));
continue;
}
@@ -219,6 +292,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
}
}
// Transition marker first, so every rule-level line below reads against it.
// Written outside _scopeLock like all trace lines.
if (previousPhase != newPhase)
DebugLog(null, GatherRuleDebugLog.StagePhase,
$"phase change: {previousPhase} -> {phaseName}" +
(previousPhase == EnrollmentPhase.Unknown
? " (first phase signal — scoped rules now evaluate against this phase)"
: ""));
if (exitTraces != null)
{
foreach (var trace in exitTraces)
DebugLog(trace.Key, GatherRuleDebugLog.StageScope, trace.Value);
}
if (exitRules != null)
{
foreach (var rule in exitRules)
@@ -252,6 +340,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
var deferredRule = rule;
_logger.Info($"Phase {phaseName} activated deferred startup rule {deferredRule.RuleId}");
DebugLog(deferredRule.RuleId, GatherRuleDebugLog.StageTrigger,
$"phase {phaseName} activated deferred startup rule");
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(deferredRule));
}
@@ -262,18 +352,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
// Scope gate BEFORE the dedup add — an out-of-scope pass must not consume
// the once-per-(rule, phase) execution slot.
if (!IsRuleInScope(rule))
string scopeReason;
if (!IsRuleInScope(rule, out scopeReason))
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageScope,
$"phase_change to {phaseName} skipped: {scopeReason}");
continue;
}
// Deduplicate: only fire once per (ruleId, phase) combination
var deduplicationKey = $"{rule.RuleId}|{phaseName}";
if (!_phaseRulesExecuted.Add(deduplicationKey))
{
_logger.Debug($"Phase rule {rule.RuleId} already executed for phase {phaseName}, skipping");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger,
$"phase_change already executed for phase {phaseName} — once per (rule, phase)");
continue;
}
_logger.Info($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"phase_change to {phaseName} fired");
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
}
}
@@ -288,10 +386,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
if (string.Equals(rule.TriggerEventType, eventType, StringComparison.OrdinalIgnoreCase))
{
if (!IsRuleInScope(rule))
string scopeReason;
if (!IsRuleInScope(rule, out scopeReason))
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageScope,
$"on_event {eventType} skipped: {scopeReason}");
continue;
}
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"on_event {eventType} fired");
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
}
}
@@ -302,6 +406,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
try
{
_logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageExec, $"executing: collector={rule.CollectorType}, target={rule.Target}");
var collectorType = rule.CollectorType?.ToLower();
@@ -309,6 +414,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
if (collectorType == null || !_collectors.TryGetValue(collectorType, out collector))
{
_logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"unknown collector type '{rule.CollectorType}' — nothing emitted");
return;
}
@@ -316,7 +422,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// LogParser (and potentially others) emit events directly and return null
if (result == null)
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
collectorType == "logparser"
? "logparser emits per-match events directly — see logparser lines above for the per-file outcome"
: "collector returned null — nothing emitted");
return;
}
if (result.Count == 0)
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
"collector returned EMPTY result — nothing emitted (typical causes: target not found with emitOnlyIfExists, or guard block — see guard line if present)");
}
if (result.Count > 0)
{
@@ -325,8 +443,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// empty result never reaches this point, so an emitOnlyIfExists miss neither
// emits nor updates the hash — composing to "one event on appearance, then
// only on change".
if (IsOnChangeMode(rule) && !ShouldEmitOnChange(rule, result))
return;
if (IsOnChangeMode(rule))
{
string hashPrefix;
int suppressedStreak;
if (!ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak))
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageSuppress,
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}");
return;
}
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
$"result changed (hash {hashPrefix})" + (suppressedStreak > 0 ? $" after {suppressedStreak} suppressed polls" : ""));
}
result["ruleId"] = rule.RuleId;
result["ruleTitle"] = rule.Title;
@@ -357,11 +486,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
Message = $"Gather: {rule.Title}",
Data = result
});
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
$"emitted {eventType} (severity={severity}, {result.Count} fields)");
}
}
catch (Exception ex)
{
_logger.Warning($"Gather rule {rule.RuleId} failed: {ex.Message}");
// Full exception incl. stack trace only in the debug trace — the agent log stays terse.
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"rule execution failed: {ex}");
}
}
@@ -375,15 +508,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// Internal so the scope matrix is testable synchronously (no timer/ThreadPool races).
internal bool IsRuleInScope(GatherRule rule)
{
string reason;
return IsRuleInScope(rule, out reason);
}
/// <summary>
/// Scope gate with a human-readable skip reason for the debug trace. The reason is
/// captured under the lock but must be WRITTEN outside it (never trace under _scopeLock).
/// </summary>
internal bool IsRuleInScope(GatherRule rule, out string reason)
{
lock (_scopeLock)
{
return IsRuleInScopeLocked(rule);
return IsRuleInScopeLocked(rule, out reason);
}
}
private bool IsRuleInScopeLocked(GatherRule rule)
{
string reason;
return IsRuleInScopeLocked(rule, out reason);
}
private bool IsRuleInScopeLocked(GatherRule rule, out string reason)
{
reason = null;
if (!HasPhaseScope(rule))
return true; // unscoped — legacy behavior
@@ -400,7 +551,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// No phase signal yet (or scope tokens that never match, e.g. "Unknown"):
// scoped rules stay inactive.
if (_currentPhase == EnrollmentPhase.Unknown)
{
reason = "currentPhase=Unknown — scoped rules stay inactive until the first phase signal";
return false;
}
var phaseName = _currentPhase.ToString();
foreach (var phase in rule.ActivePhases)
@@ -408,10 +562,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
if (string.Equals(phase, phaseName, StringComparison.OrdinalIgnoreCase))
return true;
}
reason = $"out of scope (currentPhase={phaseName}, activePhases=[{string.Join(",", rule.ActivePhases)}])";
return false;
}
return _fromPhaseLatched.Contains(rule.RuleId);
if (_fromPhaseLatched.Contains(rule.RuleId))
return true;
reason = $"activeFromPhase={rule.ActiveFromPhase} not latched yet (currentPhase={_currentPhase})";
return false;
}
/// <summary>
@@ -457,8 +616,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
/// Internal so the state machine is testable synchronously (no timer/ThreadPool races).
/// </summary>
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result)
{
string hashPrefix;
int suppressedStreak;
return ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak);
}
/// <summary>
/// Overload exposing the hash prefix and suppression streak for the debug trace:
/// on suppress, <paramref name="suppressedStreak"/> is the updated streak count;
/// on emit, it is the streak that just ended (0 when none).
/// </summary>
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
out string hashPrefix, out int suppressedStreak)
{
var hash = ComputeCanonicalHash(result);
hashPrefix = hash.Length > 8 ? hash.Substring(0, 8) : hash;
suppressedStreak = 0;
bool emit;
lock (_scopeLock)
{
@@ -469,6 +643,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
_suppressed[rule.RuleId] = _suppressed.TryGetValue(rule.RuleId, out streak)
? (streak.Count + 1, streak.SinceUtc)
: (1, DateTime.UtcNow);
suppressedStreak = _suppressed[rule.RuleId].Count;
emit = false;
}
else
@@ -479,6 +654,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
result["suppressedPolls"] = streak.Count;
result["suppressedSinceUtc"] = streak.SinceUtc.ToString("o", CultureInfo.InvariantCulture);
suppressedStreak = streak.Count;
}
_suppressed.Remove(rule.RuleId);
emit = true;
@@ -77,11 +77,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
protected override void Collect()
{
// Full path writes 18 keys: agent_version + process metrics (5: cpu, ws, private,
// threads, handles) + spool stats (4) + network delta (8: requests, failures,
// bytes_up/down, avg_latency, total_up/down/requests). cap=18 → HashHelpers.GetPrime(18)=23
// buckets → no resize on the 18th key (cap=16 would land at 17 buckets and resize).
var data = new Dictionary<string, object>(capacity: 18, StringComparer.Ordinal)
// Full path writes 19 keys: agent_version + process metrics (5: cpu, ws, private,
// threads, handles) + spool stats (4) + network delta (9: requests, failures,
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=19 →
// HashHelpers.GetPrime(19)=23 buckets → no resize on the 19th key.
var data = new Dictionary<string, object>(capacity: 19, StringComparer.Ordinal)
{
{ "agent_version", _agentVersion }
};
@@ -164,6 +164,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
data["net_total_bytes_up"] = currentNet.TotalBytesUp;
data["net_total_bytes_down"] = currentNet.TotalBytesDown;
data["net_total_requests"] = currentNet.RequestCount;
// Cumulative latency sum: total/requests = session-wide average HTTP
// round-trip. The backend projects that average onto the Session row
// (AvgApiLatencyMs) — cumulative counters make the write idempotent.
data["net_total_latency_ms"] = currentNet.TotalLatencyMs;
}
catch (Exception ex)
{
@@ -8,8 +8,8 @@ using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -37,7 +37,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
private const int DebounceDelayMs = 3000;
private const int ConnectivityCheckDelayMs = 5000;
private const int ConnectivityCheckTimeoutMs = 8000;
private const int WifiCollectTimeoutMs = 3000;
private const string Source = "NetworkChangeDetector";
/// <summary>
@@ -374,7 +373,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Collect WiFi SSID synchronously (needed for before/after comparison)
if (isWifi)
{
var wifiInfo = CollectWiFiInfo();
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
var wifiInfo = CollectWiFiInfo(nicGuid);
snapshot.WifiSsid = wifiInfo.ssid;
snapshot.WifiSignalPercent = wifiInfo.signal;
snapshot.WifiRadioType = wifiInfo.radioType;
@@ -390,73 +390,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
}
/// <summary>
/// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces.
/// Synchronous with timeout — needed before emitting the event.
/// Collects WiFi SSID, signal, and radio type via the native WLAN API
/// (<see cref="WifiInfoReader"/> — language-neutral, no process spawn).
/// Synchronous — needed before emitting the event.
/// </summary>
private (string ssid, int? signal, string radioType) CollectWiFiInfo()
private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid)
{
string ssid = null;
int? signal = null;
string radioType = null;
try
{
var psi = new ProcessStartInfo
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
if (wifi == null)
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(WifiCollectTimeoutMs))
{
try { process.Kill(); } catch { }
_logger.Debug("NetworkChangeDetector: netsh timed out");
return (null, null, null);
}
_logger.Debug($"NetworkChangeDetector: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
wifi = NetshWifiFallback.TryRead();
}
if (string.IsNullOrEmpty(output))
return (null, null, null);
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
{
ssid = value;
}
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value.TrimEnd('%'), out var sig))
signal = sig;
}
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
{
radioType = value;
}
}
if (wifi != null)
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
}
catch (Exception ex)
{
_logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}");
}
return (ssid, signal, radioType);
return (null, null, null);
}
// -------------------------------------------------------------------
@@ -483,7 +483,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
logger: logger,
rules: _remoteConfig.GatherRules,
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
unrestrictedMode: _agentConfig.UnrestrictedMode));
unrestrictedMode: _agentConfig.UnrestrictedMode,
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath));
}
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
{
// Debug trace is on but the backend delivered zero gather rules — the trace
// file must say so instead of silently not existing.
Monitoring.Telemetry.Gather.GatherRuleDebugLog.WriteStandalone(
_agentConfig.GatherRuleDebugLogPath,
"no gather rules delivered by backend — nothing to execute", logger);
}
return new CollectorSurfaces(hosts, imeLogHost, espAndHelloHost, aadJoinHost);
@@ -36,6 +36,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// <item><c>CollectAtEnd</c> — once, on <c>FinalizingSetup</c> or desktop arrival
/// (whichever comes first; desktop arrival also covers no-ESP / WDP v2). Re-fetches
/// BitLocker (commonly enabled via policy DURING enrollment) + the active NIC.</item>
/// <item><c>RefreshEspConfiguration</c> — audit Q2, one-shot per trigger: the ESP
/// blocking lists in the registry grow progressively (device scope) and the user-scope
/// lists appear only after sign-in, so the early emissions are structurally partial.
/// Re-collected when the apps sub-phase opens (first app activity in DeviceSetup /
/// AccountSetup — the adapter's <c>phase_transition</c> declaration) and when the
/// AccountSetup ESP phase itself is detected. Phase-driven one-shots, never a timer
/// (no-heartbeat policy).</item>
/// </list>
/// <para>
/// Duplicate-event cost is zero: every re-collected event runs through the collector's
@@ -46,6 +53,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
public string Name => "DeviceInfoCollector";
// ESP-config refresh trigger labels (audit Q2) — also the values passed to
// DeviceInfoCollector.RefreshEspConfiguration for the agent-log line.
internal const string EspConfigTriggerAppsDevice = "apps_phase_device";
internal const string EspConfigTriggerAppsUser = "apps_phase_user";
internal const string EspConfigTriggerAccountSetup = "account_setup_detected";
private readonly Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector _collector;
private readonly AgentLogger _logger;
@@ -58,6 +71,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private int _endCollected;
private int _disposed;
// Audit Q2 — fire-once latches per ESP-config refresh trigger. Guarded by _espConfigLock
// instead of three Interlocked ints so the membership test + add stays one atomic step.
private readonly object _espConfigLock = new object();
private readonly HashSet<string> _espConfigRefreshesFired = new HashSet<string>(StringComparer.Ordinal);
public DeviceInfoHost(
string sessionId,
string tenantId,
@@ -100,6 +118,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private void OnSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
{
HandleEspConfigRefreshTrigger(kind, payload);
var startTrigger = IsEnrollmentStartTrigger(kind, payload);
var endTrigger = IsEndTrigger(kind, payload);
if (!startTrigger && !endTrigger) return;
@@ -111,10 +131,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
var runEndCollect = endTrigger && Interlocked.Exchange(ref _endCollected, 1) == 0;
if (!runStartRefresh && !runEndCollect) return;
// Both one-shots done → nothing left to observe.
if (Volatile.Read(ref _enrollmentStartCollected) == 1 && Volatile.Read(ref _endCollected) == 1)
Unsubscribe();
var trigger = kind == DecisionSignalKind.DesktopArrived ? "desktop_arrived" : "esp_phase_changed";
_logger.Info($"DeviceInfoHost: trigger '{trigger}' — scheduling re-collect (enrollmentStart={runStartRefresh}, end={runEndCollect}).");
@@ -134,6 +150,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
});
}
/// <summary>
/// Audit Q2 — ESP-config refresh one-shots. Each trigger label fires at most once per
/// agent run; the collector's StartupEventGate keeps an unchanged payload silent, so
/// the worst case is one silent registry re-read per trigger. The subscription stays
/// armed for the host's lifetime (unlike the start/end pair there is no "all triggers
/// seen" point that is guaranteed to arrive — SkipUser enrollments never enter
/// AccountSetup); the per-signal cost is a few dictionary lookups.
/// </summary>
private void HandleEspConfigRefreshTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
{
var trigger = ClassifyEspConfigRefreshTrigger(kind, payload);
if (trigger == null) return;
lock (_espConfigLock)
{
if (!_espConfigRefreshesFired.Add(trigger)) return;
}
_logger.Info($"DeviceInfoHost: ESP-config refresh trigger '{trigger}' — scheduling re-collect.");
Task.Run(() =>
{
try { _collector.RefreshEspConfiguration(trigger); }
catch (Exception ex) { _logger.Warning($"DeviceInfoHost: ESP-config refresh threw: {ex.Message}"); }
});
}
/// <summary>First DeviceSetup phase signal — MDM enrollment has populated the registry surface.</summary>
internal static bool IsEnrollmentStartTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
{
@@ -156,6 +198,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
&& string.Equals(phase, nameof(EnrollmentPhase.FinalizingSetup), StringComparison.Ordinal);
}
/// <summary>
/// Audit Q2 — maps a posted signal to an ESP-config refresh trigger label, or
/// <c>null</c> when the signal is not one. Two trigger families:
/// <list type="bullet">
/// <item>The adapter's apps sub-phase declaration (<c>phase_transition</c>
/// InformationalEvent with phase AppsDevice/AppsUser — fired on the first app
/// activity in an ESP phase): by then IME is actively processing the blocking set,
/// so the device-scope ESPTrackingInfo lists are as complete as they get.</item>
/// <item>The AccountSetup ESP phase (UserSetup detection): sign-in has happened, so
/// the user-scope <c>S-&lt;SID&gt;</c> lists exist now — production showed 88 of 89
/// sessions with an EMPTY user list at the last pre-fix emission.</item>
/// </list>
/// </summary>
internal static string? ClassifyEspConfigRefreshTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
{
if (payload == null) return null;
if (kind == DecisionSignalKind.InformationalEvent
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
&& string.Equals(eventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition, StringComparison.Ordinal)
&& payload.TryGetValue(Telemetry.Events.EventTimelineEmitter.PhaseParamKey, out var declaredPhase))
{
if (string.Equals(declaredPhase, nameof(EnrollmentPhase.AppsDevice), StringComparison.Ordinal))
return EspConfigTriggerAppsDevice;
if (string.Equals(declaredPhase, nameof(EnrollmentPhase.AppsUser), StringComparison.Ordinal))
return EspConfigTriggerAppsUser;
return null;
}
if (kind == DecisionSignalKind.EspPhaseChanged
&& payload.TryGetValue(SignalPayloadKeys.EspPhase, out var espPhase)
&& string.Equals(espPhase, nameof(EnrollmentPhase.AccountSetup), StringComparison.Ordinal))
{
return EspConfigTriggerAccountSetup;
}
return null;
}
public void Stop() => Unsubscribe();
public void Dispose()
@@ -39,7 +39,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
AgentLogger logger,
List<GatherRule> rules,
string? imeLogPathOverride,
bool unrestrictedMode = false)
bool unrestrictedMode = false,
string? gatherDebugLogPath = null)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
@@ -55,9 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// session mode we wrap post.Emit so every session-mode gather event still
// flows through the InformationalEvent ingress pipe before hitting the
// telemetry spool — Rail-A semantics for ordering / replay determinism.
var post = new InformationalEventPost(ingress, clock);
var post = new InformationalEventPost(ingress, clock, logger);
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride);
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
debugLogPath: gatherDebugLogPath);
_observableIngress = ingress as SignalIngress;
}
@@ -93,7 +93,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
{
using (obj)
{
manufacturer = obj["Manufacturer"]?.ToString() ?? Unknown;
manufacturer = NormalizeWmiValue(obj["Manufacturer"]?.ToString());
if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0)
{
using (var lenovoSearcher = new ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct"))
@@ -103,7 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
{
using (lenovoObj)
{
model = lenovoObj["Version"]?.ToString() ?? Unknown;
model = NormalizeWmiValue(lenovoObj["Version"]?.ToString());
}
break;
}
@@ -111,7 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
else
{
model = obj["Model"]?.ToString() ?? Unknown;
model = NormalizeWmiValue(obj["Model"]?.ToString());
}
}
}
@@ -132,7 +132,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
{
using (obj)
{
serialNumber = obj["SerialNumber"]?.ToString() ?? Unknown;
serialNumber = NormalizeWmiValue(obj["SerialNumber"]?.ToString());
}
}
}
@@ -154,5 +154,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
private static bool IsResolved(string value)
=> !string.IsNullOrWhiteSpace(value)
&& !string.Equals(value, Unknown, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Trims a raw WMI string value at the source (audit Q5: BIOS serials are used as device
/// identity keys downstream — F2 device history joins on them — so padding must never
/// leave the agent). Whitespace-only and empty values collapse to the "Unknown" sentinel,
/// matching the null fallback so the retry logic treats them as unresolved.
/// </summary>
internal static string NormalizeWmiValue(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return Unknown;
return raw.Trim();
}
}
}
@@ -132,7 +132,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
// Plan §5 Fix 4c — per-app install-lifecycle timing captured from state transitions.
// StartedAtUtc on first Downloading/Installing/InProgress transition (set-once);
// CompletedAtUtc on first terminal transition (Installed/Skipped/Postponed/Error).
// CompletedAtUtc on terminal transition (Installed/Skipped/Postponed/Error), re-armed
// to null on a terminal→active transition (IME retry, audit Q1) so the final terminal
// stamp reflects the last outcome instead of freezing at the first one.
// Surfaced via <see cref="AppTimings"/> to peripheral consumers (FinalStatusBuilder,
// app_tracking_summary emission in EnrollmentTerminationHandler).
private readonly Dictionary<string, (DateTime? Started, DateTime? Completed)> _appTimings =
@@ -691,10 +693,20 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
}
/// <summary>
/// Plan §5 Fix 4c — set-once per-app install-lifecycle timing. Called for every
/// state transition, no-op for duplicate stamps. Returns the up-to-date
/// Plan §5 Fix 4c — per-app install-lifecycle timing. Called for every state
/// transition, no-op for duplicate stamps. Returns the up-to-date
/// <see cref="AppInstallTiming"/> so the emitter can inline the values on the
/// current event's payload without a second dictionary lookup.
/// <para>
/// Audit Q1: a terminal→active transition (the IME retry path — <see
/// cref="AppPackageState.UpdateState"/> permits e.g. Error→Downloading and
/// Skipped→Downloading) re-arms <c>Completed</c> to <c>null</c>, so the NEXT terminal
/// transition stamps a fresh CompletedAt. StartedAt keeps its first value — the final
/// terminal payload then covers first-start → last-terminal (total occupancy across
/// attempts) instead of freezing at the first failure. Observed pre-fix: a user-scope
/// app Skipped early in DeviceSetup, retried after sign-in, emitted its final
/// Installed event with the stale early CompletedAt (durationSeconds=-2342).
/// </para>
/// </summary>
private AppInstallTiming UpdateAppTiming(string appId, AppInstallationState newState, DateTime now)
{
@@ -702,13 +714,20 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
_appTimings.TryGetValue(appId, out var current);
// Terminal→active (retry): the earlier terminal stamp no longer describes this
// app's outcome — re-arm so the next terminal transition stamps fresh.
if (current.Completed != null && IsLifecycleActive(newState))
{
current.Completed = null;
}
// First lifecycle-active transition → record StartedAt.
if (current.Started == null && IsLifecycleActive(newState))
{
current.Started = now;
}
// First terminal transition → record CompletedAt.
// Terminal transition → record CompletedAt (fresh after a re-arm).
if (current.Completed == null && IsCompletedState(newState))
{
current.Completed = now;
@@ -39,8 +39,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
/// <see cref="SessionStage.WhiteGloveSealed"/> (Part-1 exit, session resumes Part 2).</item>
/// <item>WhiteGlove Part-1 path: stop peripheral collectors first (so their stop-time
/// stragglers — e.g. <c>network_bandwidth_estimate</c> — sequence BEFORE the marker),
/// then emit <c>whiteglove_part1_complete</c>, drain spool, and write
/// <c>whiteglove.complete</c> marker via <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
/// upload the diagnostics package as a pre-provisioning intermediate (suffix
/// <c>preprov</c>, same mode gating as the terminal path — the seal counts as a
/// success, so <c>OnFailure</c> skips), then emit <c>whiteglove_part1_complete</c>,
/// drain spool, and write <c>whiteglove.complete</c> marker via
/// <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
/// so Part-2 resume is detected on the next boot.</item>
/// <item>Signal the caller-owned shutdown <see cref="ManualResetEventSlim"/>.</item>
/// </list>
@@ -241,6 +244,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
// the full Stop() later is a no-op for the hosts.
StopPeripheralCollectorsBestEffort();
// V1 parity (restored 2026-07-28) — upload the diagnostics package as a
// pre-provisioning intermediate. Between reseal and user OOBE the device
// may sit boxed for weeks, and if Part 2 never uploads (never runs, or
// OnFailure + success) the Part-1 forensics would be lost to self-destruct.
// No dedicated flag: the existing mode semantics apply — Always uploads,
// OnFailure skips (the seal is a success; a Part-1 failure terminates via
// the regular Failed path below, which uploads), Off does nothing. Must run
// BEFORE the whiteglove_part1_complete emit so diagnostics_* events keep a
// lower sequence than the Part-1/Part-2 boundary marker. Best-effort: a
// reseal-reboot racing the upload just loses the intermediate package.
RunUploadDiagnosticsWithEvents(args, suffixOverride: "preprov");
EmitEventSafe(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
@@ -623,12 +638,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
}
/// <summary>
/// Emits a one-shot <c>agent_late_start</c> Warning when the agent had low observation
/// Emits a one-shot <c>agent_late_start</c> event when the agent had low observation
/// coverage: it started a long time after device boot AND lived only briefly before this
/// terminal outcome (gate in <see cref="ObservationCoverage"/>). Such a session looks like a
/// normal multi-minute failure in the list (StartedAt is back-dated to the earliest replayed
/// IME-log event) but the agent actually observed only the end-state — so the operator should
/// read the diagnosis as a post-mortem reconstruction, not live evidence. No state mutation.
/// IME-log event) but the agent actually observed only the end-state. Severity and phrasing
/// are outcome-calibrated via <see cref="ObservationCoverage.DescribeLateStart"/>: a failure
/// stays a Warning with the post-mortem/hung-script note, a success is an Info about
/// tail-only coverage (fleet 2026-07-28: tenant 659c3a90 collected 12 Warnings on healthy
/// enrollments whose bootstrap merely ran late). No state mutation.
/// </summary>
private void MaybeEmitAgentLateStart(EnrollmentTerminatedEventArgs args)
{
@@ -642,21 +660,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
out var bootToStartSeconds, out var uptimeSeconds))
return;
ObservationCoverage.DescribeLateStart(
args.Outcome, _oobeStateAtAgentStart, bootToStartSeconds, uptimeSeconds,
out var severity, out var message, out var note);
_logger.Info(
$"EnrollmentTerminationHandler: low observation coverage — agent started " +
$"{bootToStartSeconds / 60.0:F1} min after boot and lived {uptimeSeconds:F0}s; " +
$"emitting agent_late_start.");
$"emitting agent_late_start ({severity}, outcome {args.Outcome}).");
_post.Emit(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = Constants.EventTypes.AgentLateStart,
Severity = EventSeverity.Warning,
Severity = severity,
Source = "EnrollmentTerminationHandler",
Phase = EnrollmentPhase.Unknown,
Message = $"Agent started {bootToStartSeconds / 60.0:F0} min after boot and observed only " +
$"{uptimeSeconds:F0}s before terminating ({args.Outcome}) — low coverage of the enrollment window.",
Message = message,
Data = new Dictionary<string, object>
{
{ "bootToAgentStartSeconds", Math.Round(bootToStartSeconds, 1) },
@@ -665,7 +686,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
{ "agentStartUtc", _agentStartTimeUtc.ToString("o") },
{ "oobeStateAtAgentStart", _oobeStateAtAgentStart },
{ "outcome", args.Outcome.ToString() },
{ "note", "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, so the agent observed the already-decided end-state rather than the failure window. Treat the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of the bootstrap (see script_timeout_suspected)." },
{ "note", note },
},
ImmediateUpload = true,
});
@@ -771,7 +792,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
StringComparison.Ordinal);
}
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args)
/// <param name="args">Termination payload — outcome drives the OnFailure gate.</param>
/// <param name="suffixOverride">
/// Blob-name suffix override. Null (terminal path) derives "success"/"failure" from
/// the outcome; the WhiteGlove Part-1 seal passes "preprov" so the intermediate
/// package is distinguishable from the later Part-2 terminal upload.
/// </param>
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args, string suffixOverride = null)
{
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
@@ -807,7 +834,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
DiagnosticsUploadResult result = null;
try
{
var suffix = enrollmentSucceeded ? "success" : "failure";
var suffix = suffixOverride ?? (enrollmentSucceeded ? "success" : "failure");
result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult();
}
catch (Exception ex)
@@ -1,6 +1,8 @@
#nullable enable
using System;
using System.Runtime.InteropServices;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Termination
{
@@ -76,5 +78,57 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
uptimeSeconds = Math.Max(0, uptime.TotalSeconds);
return bootToStart >= LateStartBootThreshold && uptime <= LowCoverageUptime;
}
/// <summary>
/// Severity + operator-facing phrasing for the <c>agent_late_start</c> event, calibrated
/// by fleet data (2026-07-28): the gate alone is outcome-blind and attached a
/// "post-mortem / check for a hung script" Warning to sessions that in fact succeeded —
/// there the late bootstrap is deployment hygiene, not an alarm. The OOBE state refines
/// the phrasing but must never gate: the canonical hung-script failure terminates on the
/// still-shown ESP screen, i.e. with OOBE reading <c>in_progress</c>.
/// </summary>
/// <param name="outcome">Terminal outcome the enrollment reached.</param>
/// <param name="oobeStateAtAgentStart"><see cref="Monitoring.Interop.OobeStateReader"/>
/// sample taken at THIS process's start. Only <c>completed</c> / <c>in_progress</c> are
/// interpreted; <c>not_started</c> flips in after mid-OOBE reboots and <c>completed</c> is
/// routine for post-desktop restarts, so no claim is phrased from the other values.</param>
/// <param name="bootToStartSeconds">From <see cref="IsLowObservationCoverage"/>.</param>
/// <param name="uptimeSeconds">From <see cref="IsLowObservationCoverage"/>.</param>
internal static void DescribeLateStart(
EnrollmentTerminationOutcome outcome,
string oobeStateAtAgentStart,
double bootToStartSeconds,
double uptimeSeconds,
out EventSeverity severity,
out string message,
out string note)
{
var succeeded = outcome == EnrollmentTerminationOutcome.Succeeded;
severity = succeeded ? EventSeverity.Info : EventSeverity.Warning;
message = $"Agent started {bootToStartSeconds / 60.0:F0} min after boot and observed only " +
$"{uptimeSeconds:F0}s before terminating ({outcome}) — low coverage of the enrollment window.";
switch (oobeStateAtAgentStart)
{
case "completed":
message += " OOBE was already completed when the agent started — the diagnosis is a " +
"post-mortem reconstruction, not live observation.";
break;
case "in_progress":
message += $" OOBE was still in progress at agent start — the agent did observe the " +
$"final {uptimeSeconds:F0}s live.";
break;
}
note = succeeded
? "The agent's bootstrap (an Intune platform script) ran late in the enrollment, so only the " +
"final stretch was observed live; earlier timeline entries are reconstructed from IME log " +
"replay. The enrollment itself succeeded. If this recurs across sessions, review which " +
"platform/remediation scripts run ahead of the bootstrap (see script_timeout_suspected)."
: "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, " +
"so the agent observed the already-decided end-state rather than the failure window. Treat " +
"the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of " +
"the bootstrap (see script_timeout_suspected).";
}
}
}
@@ -221,8 +221,18 @@ namespace AutopilotMonitor.Agent.V2
},
});
// Step 4 — run the executor and wait.
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride))
// Step 4 — run the executor and wait. Diagnostic mode always writes the
// gather-rule debug trace (CLI override or default path) and mirrors it
// to the console in --console mode.
var gatherDebugLogPath = !string.IsNullOrEmpty(config.GatherRuleDebugLogPath)
? config.GatherRuleDebugLogPath
: Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
logger.Info($"--run-gather-rules: debug trace -> {gatherDebugLogPath}");
if (consoleMode) Console.WriteLine($"Debug trace: {gatherDebugLogPath}");
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride,
debugLogPath: gatherDebugLogPath,
debugEcho: consoleMode ? (Action<string>)(line => Console.WriteLine($" {line}")) : null))
{
// Diagnostic mode has no enrollment phase context — scoped rules
// (activePhases / activeFromPhase) execute unconditionally here.
@@ -303,6 +303,7 @@ namespace AutopilotMonitor.Agent.V2
var apiBaseUrl = GetArgValue(args, "--api-url", "--backend-api") ?? Constants.ApiBaseUrl;
var imeLogPathOverride = GetArgValue(args, "--ime-log-path");
var imeMatchLogPath = GetArgValue(args, "--ime-match-log");
var gatherDebugLogPath = GetArgValue(args, "--gather-debug-log");
// Dev / test — IME log replay (V1 compat mode). Feeds ImeLogTracker.SimulationMode
// + SpeedFactor so recorded raw IME logs are replayed at an accelerated rate; signal
@@ -373,6 +374,7 @@ namespace AutopilotMonitor.Agent.V2
EnableGeoLocation = !disableGeoLocation,
ImeLogPathOverride = imeLogPathOverride,
ImeMatchLogPath = imeMatchLogPath,
GatherRuleDebugLogPath = gatherDebugLogPath,
KeepLogFile = keepLogFile,
SelfDestructOnComplete = !noCleanup,
CommandLineArgs = FormatArgsForLog(args),
@@ -485,6 +487,7 @@ namespace AutopilotMonitor.Agent.V2
Console.Out.WriteLine(" --api-url <URL> Override backend API base URL (alias: --backend-api)");
Console.Out.WriteLine(" --ime-log-path <PATH> Override IME logs directory");
Console.Out.WriteLine(" --ime-match-log <PATH> Write matched IME log lines to file (debug)");
Console.Out.WriteLine(" --gather-debug-log <PATH> Write gather-rule evaluation trace to file (debug)");
Console.Out.WriteLine();
Console.Out.WriteLine("Dev / Test:");
Console.Out.WriteLine(" --replay-log-dir <PATH> Replay IME logs from this directory (simulation mode)");
@@ -46,8 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
},
// On-demand request mid-enrollment — the session has no outcome yet. Null keeps
// sessioninfo.txt honest ("In Progress") and passes the OnFailure gate by
// semantics (there is no success to skip on) instead of the former
// enrollmentSucceeded:false workaround that stamped in-flight packages "Failed".
uploadDiagnosticsAsync: async (suffix) =>
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix),
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: suffix),
onTerminateRequested: action => OnTerminateRequested(
action, agentConfig, orchestrator, terminationHandler, shutdown, shutdownComplete, logger),
post: post);
@@ -189,7 +189,7 @@ public class AnalyzeRuleNotifyTests
var alert = NotificationAlertBuilder.BuildRuleFiredAlert(
result, "DESKTOP-CONTOSO1", "SN-1234",
"https://portal.autopilotmonitor.com/sessions/abc");
"https://portal.autopilotmonitor.com/sessions?id=abc");
Assert.Equal("analyze_rule_fired", alert.EventType);
Assert.Contains("Proxy blocks enrollment endpoints", alert.Title);
@@ -197,7 +197,7 @@ public class AnalyzeRuleNotifyTests
Assert.Contains(alert.Facts, f => f.Name == "Serial" && f.Value == "SN-1234");
Assert.Contains(alert.Facts, f => f.Name == "Rule" && f.Value.Contains("ANALYZE-NET-001"));
Assert.Contains(alert.Facts, f => f.Name == "Confidence" && f.Value == "85%");
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions/abc");
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions?id=abc");
Assert.Contains(alert.Sections, s => s.Text.Contains("could not reach"));
}
@@ -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,201 @@
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// PR0 (2026-07-26, source-data audit §0.5 of tasks/insights-expansion-spec.md).
///
/// Ingest side: the agent emits <c>app_install_completed</c> for EVERY terminal transition —
/// including Skipped/Postponed no-ops (V1 wire parity; production: 78 % of zero-duration rows
/// were skips). The payload's <c>state</c> field must be persisted as
/// <see cref="AppInstallSummary.TerminalState"/> so metrics can exclude skips from durations
/// and rates.
///
/// Store side: <see cref="TableStorageService.ReconcileAppInstallSummaryWithExisting"/> pins the
/// cross-batch contract — earliest StartedAt wins, terminal status is sticky, and the Q4
/// out-of-order case (terminal batch first, started batch later) recomputes the duration.
/// </summary>
public class AppInstallTerminalStateAndReconcileTests
{
private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc);
// ── ingest: TerminalState fold ──────────────────────────────────────────
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, DateTime ts, string? state = null, string appName = "App")
{
var evt = new EnrollmentEvent
{
EventType = eventType,
Timestamp = ts,
Data = new Dictionary<string, object> { ["appName"] = appName },
};
if (state != null) evt.Data["state"] = state;
return evt;
}
[Theory]
[InlineData("Installed")]
[InlineData("Skipped")]
[InlineData("Postponed")]
public void Completed_PersistsPayloadState(string state)
{
var s = Aggregate(AppEvent("app_install_completed", T0, state))["App"].Summary;
Assert.Equal("Succeeded", s.Status); // backward-compatible status
Assert.Equal(state, s.TerminalState); // new precision column
}
[Fact]
public void Completed_WithoutState_LeavesSentinelEmpty_NeverGuessed()
{
var s = Aggregate(AppEvent("app_install_completed", T0))["App"].Summary;
Assert.Equal(string.Empty, s.TerminalState);
}
[Fact]
public void Completed_WithNonTerminalOrGarbageState_LeavesSentinelEmpty()
{
// A payload state outside the terminal set (bug or drift) must not be persisted.
var s = Aggregate(AppEvent("app_install_completed", T0, "Downloading"))["App"].Summary;
Assert.Equal(string.Empty, s.TerminalState);
}
[Fact]
public void Failed_SetsError()
{
var s = Aggregate(AppEvent("app_install_failed", T0))["App"].Summary;
Assert.Equal("Failed", s.Status);
Assert.Equal("Error", s.TerminalState);
}
[Fact]
public void FailedThenCompleted_RetrySucceeds_LastTerminalWins()
{
// IME retry path (audit Q1): Error → retry → Installed. The later completed event is
// the authoritative outcome for Status AND TerminalState.
var s = Aggregate(
AppEvent("app_install_failed", T0),
AppEvent("app_install_completed", T0.AddMinutes(10), "Installed"))["App"].Summary;
Assert.Equal("Succeeded", s.Status);
Assert.Equal("Installed", s.TerminalState);
}
[Fact]
public void SkippedEvent_SetsSkipped_ButNeverOverridesStrongerTerminal()
{
var fresh = Aggregate(AppEvent("app_install_skipped", T0))["App"].Summary;
Assert.Equal("Succeeded", fresh.Status);
Assert.Equal("Skipped", fresh.TerminalState);
var afterFailure = Aggregate(
AppEvent("app_install_failed", T0),
AppEvent("app_install_skipped", T0.AddSeconds(5)))["App"].Summary;
Assert.Equal("Error", afterFailure.TerminalState);
}
// ── store: cross-batch reconcile ────────────────────────────────────────
[Fact]
public void Reconcile_Q4_OutOfOrder_RecomputesDurationFromStoredCompletedAt()
{
// Terminal batch landed first: row has CompletedAt but no usable duration.
var existing = new TableEntity("tenant", "session_App")
{
["Status"] = "Succeeded",
["StartedAt"] = new DateTimeOffset(T0.AddMinutes(5)), // late (completed-event) stamp
["CompletedAt"] = new DateTimeOffset(T0.AddMinutes(5)),
};
// Started batch arrives now with the true (earlier) start.
var summary = new AppInstallSummary
{
AppName = "App", SessionId = "session", TenantId = "tenant",
Status = "InProgress", StartedAt = T0,
};
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
Assert.Equal(T0, summary.StartedAt); // earlier start kept
Assert.Equal(T0.AddMinutes(5), summary.CompletedAt); // endpoint adopted from the row
Assert.Equal(300, summary.DurationSeconds); // recomputed — was permanently unset before PR0
Assert.Equal("Succeeded", summary.Status); // sticky terminal (below)
}
[Fact]
public void Reconcile_TerminalStatusIsSticky_AgainstLateInProgressBatch()
{
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "Failed" };
var summary = new AppInstallSummary { AppName = "App", Status = "InProgress", StartedAt = T0 };
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
Assert.Equal("Failed", summary.Status);
}
[Fact]
public void Reconcile_TerminalToTerminal_StaysWithIncomingEvidence()
{
// Failed row + succeeded batch (successful retry) → the new terminal wins.
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "Failed" };
var summary = new AppInstallSummary { AppName = "App", Status = "Succeeded", StartedAt = T0 };
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
Assert.Equal("Succeeded", summary.Status);
}
[Fact]
public void Reconcile_Q4Guard_DoesNotFireWithoutStoredCompletedAt()
{
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "InProgress" };
var summary = new AppInstallSummary { AppName = "App", Status = "InProgress", StartedAt = T0 };
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
Assert.Null(summary.CompletedAt);
Assert.Equal(0, summary.DurationSeconds);
}
[Fact]
public void Reconcile_Q4Guard_IgnoresStoredCompletedAtBeforeIncomingStart()
{
// Negative interval (clock mess) must not produce a negative duration.
var existing = new TableEntity("tenant", "session_App")
{
["Status"] = "Succeeded",
["CompletedAt"] = new DateTimeOffset(T0.AddMinutes(-10)),
};
var summary = new AppInstallSummary { AppName = "App", Status = "InProgress", StartedAt = T0 };
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
Assert.Null(summary.CompletedAt);
Assert.Equal(0, summary.DurationSeconds);
}
// ── entity builder: sentinel gating for the new column ─────────────────
[Fact]
public void EntityBuilder_TerminalState_IsSentinelGated()
{
var withState = new AppInstallSummary { AppName = "App", TerminalState = "Skipped" };
var entity = TableStorageService.BuildAppInstallSummaryEntity(withState, "rk");
Assert.Equal("Skipped", entity.GetString("TerminalState"));
var withoutState = new AppInstallSummary { AppName = "App" };
var empty = TableStorageService.BuildAppInstallSummaryEntity(withoutState, "rk");
// Column absent → Merge-mode preserves a prior batch's terminal state.
Assert.False(empty.ContainsKey("TerminalState"));
}
}
@@ -162,6 +162,58 @@ public class AppsAnalyticsFailureRateTests
Assert.Equal(50d, todayBucket.GetProperty("failureRate").GetDouble());
}
// ── PR0 (2026-07-26): skip / unmeasured classification ──────────────────
[Fact]
public void List_Skips_LeaveRateAndDurations_AndAreReportedSeparately()
{
var skip = A("Succeeded");
skip.TerminalState = "Skipped";
skip.DurationSeconds = 0;
var unmeasured = A("Succeeded");
unmeasured.TerminalState = "Installed";
unmeasured.DurationSeconds = 0;
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
var root = JsonSerializer.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
Assert.Equal(4, app.GetProperty("totalInstalls").GetInt32());
Assert.Equal(2, app.GetProperty("succeeded").GetInt32()); // real install + unmeasured
Assert.Equal(1, app.GetProperty("skipped").GetInt32());
Assert.Equal(1, app.GetProperty("unmeasured").GetInt32());
// 1 / (1 + 2) — the skip is not an attempt.
Assert.Equal(33.3d, app.GetProperty("failureRate").GetDouble());
// avg over the single measured row (30s) — the zero must not drag it to 15.
Assert.Equal(30d, app.GetProperty("avgDurationSeconds").GetDouble());
}
[Fact]
public async Task Analytics_Summary_CarriesSkippedAndUnmeasured_AndP95OverMeasuredOnly()
{
var skip = A("Succeeded");
skip.TerminalState = "Skipped";
skip.DurationSeconds = 0;
var unmeasured = A("Succeeded");
unmeasured.TerminalState = "Installed";
unmeasured.DurationSeconds = 0;
var measured = A("Succeeded");
measured.TerminalState = "Installed";
measured.DurationSeconds = 100;
var root = await BuildAnalyticsAsync(new List<AppInstallSummary> { skip, unmeasured, measured });
var summary = root.GetProperty("summary");
Assert.Equal(2, summary.GetProperty("succeeded").GetInt32());
Assert.Equal(1, summary.GetProperty("skipped").GetInt32());
Assert.Equal(1, summary.GetProperty("unmeasured").GetInt32());
Assert.Equal(100d, summary.GetProperty("avgDurationSeconds").GetDouble());
Assert.Equal(100, summary.GetProperty("p95DurationSeconds").GetInt32());
Assert.Equal(0d, summary.GetProperty("failureRate").GetDouble());
}
[Fact]
public async Task Analytics_DeviceModelBreakdown_RatesAndSampleFloor_UseFinishedInstalls()
{
@@ -34,6 +34,14 @@ public class AppsProjectionEquivalenceTests
["AppType"] = "Win32",
["AppVersion"] = "2.1.0",
["Status"] = "Failed",
// PR0/PR1 classification columns. The fixture MUST populate every column a Build*
// method reads: a column absent here passes the equivalence assert trivially (both
// sides map to default) — exactly how the missing TerminalState projection slipped
// through and zeroed totalSkipped in production (2026-07-27).
["TerminalState"] = "Skipped",
["AppId"] = "9e8d7c6b-5a49-4832-9182-736451234567",
["EspBlocking"] = true,
["AppIdCollision"] = true,
["StartedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-6)),
["CompletedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-5)),
["DurationSeconds"] = 340,
@@ -83,6 +91,9 @@ public class AppsProjectionEquivalenceTests
Assert.Equal(fromFull.AppName, fromProjected.AppName);
Assert.Equal(fromFull.Status, fromProjected.Status);
// IsSkipTerminalState / skip classification input — see the fixture comment.
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
Assert.Equal(fromFull.AppIdCollision, fromProjected.AppIdCollision);
Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt);
Assert.Equal(fromFull.DurationSeconds, fromProjected.DurationSeconds);
Assert.Equal(fromFull.DownloadBytes, fromProjected.DownloadBytes);
@@ -122,6 +133,10 @@ public class AppsProjectionEquivalenceTests
Assert.Equal(fromFull.FailureMessage, fromProjected.FailureMessage);
Assert.Equal(fromFull.ExitCode, fromProjected.ExitCode);
Assert.Equal(fromFull.DetectionResult, fromProjected.DetectionResult);
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
Assert.Equal(fromFull.AppId, fromProjected.AppId);
Assert.Equal(fromFull.EspBlocking, fromProjected.EspBlocking);
Assert.Equal(fromFull.AppIdCollision, fromProjected.AppIdCollision);
}
[Fact]
@@ -30,6 +30,9 @@
<None Update="device-cert-sample.pem">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="TestData\oobe-bootstrap-golden.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,172 @@
using AutopilotMonitor.Functions.Functions.Infrastructure;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the pure helpers of <see cref="AzureMonitorAlertWebhookFunction"/> — the anonymous,
/// secret-gated bridge that turns Azure Monitor Common Alert Schema notifications into ops
/// events. Load-bearing bits: the secret gate is fail-closed (an unset app setting rejects
/// everything), severity mapping can never escalate on schema drift (unknown → Info), and
/// parsing is defensive (only the alert rule name is mandatory).
/// </summary>
public class AzureMonitorAlertWebhookFunctionTests
{
// ── SecretMatches ──────────────────────────────────────────────────────
[Fact]
public void SecretMatches_accepts_equal_secrets()
{
Assert.True(AzureMonitorAlertWebhookFunction.SecretMatches("s3cret-value", "s3cret-value"));
}
[Theory]
[InlineData("wrong", "right")]
[InlineData("right-but-longer", "right")]
[InlineData("", "configured")]
[InlineData(null, "configured")]
public void SecretMatches_rejects_mismatch_or_missing_provided(string? provided, string configured)
{
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(provided, configured));
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void SecretMatches_fails_closed_when_not_configured(string? configured)
{
// While the app setting is unset, NOTHING matches — not even an empty provided value.
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("anything", configured));
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("", configured));
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(null, configured));
}
// ── MapSeverity ────────────────────────────────────────────────────────
[Theory]
[InlineData("Sev0", OpsEventSeverity.Critical)]
[InlineData("Sev1", OpsEventSeverity.Error)]
[InlineData("Sev2", OpsEventSeverity.Warning)]
[InlineData("Sev3", OpsEventSeverity.Info)]
[InlineData("Sev4", OpsEventSeverity.Info)]
public void MapSeverity_maps_azure_scale_to_ops_scale(string azure, string expected)
{
Assert.Equal(expected, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Sev5")]
[InlineData("Critical")] // someone posting the ops scale directly must not pass through
public void MapSeverity_falls_back_to_info_on_unknown_values(string? azure)
{
Assert.Equal(OpsEventSeverity.Info, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
}
// ── TryParse ───────────────────────────────────────────────────────────
private const string FullPayload = """
{
"schemaId": "azureMonitorCommonAlertSchema",
"data": {
"essentials": {
"alertId": "/subscriptions/xxx/providers/Microsoft.AlertsManagement/alerts/abc",
"alertRule": "Portal RSC prefetch tail",
"severity": "Sev2",
"signalType": "Log",
"monitorCondition": "Fired",
"monitoringService": "Log Alerts V2",
"alertTargetIDs": [
"/subscriptions/xxx/resourcegroups/rg-autopilotmonitor-prd-gwc/providers/microsoft.insights/components/autopilotmonitorweb"
],
"description": "RSC prefetches exceeding 10s",
"firedDateTime": "2026-07-27T15:30:00.000Z"
},
"alertContext": {
"condition": {
"windowSize": "PT1H",
"allOf": [
{
"searchQuery": "dependencies | where name contains '_rsc='",
"metricValue": 18.0,
"operator": "GreaterThan",
"threshold": "0"
}
]
}
}
}
}
""";
[Fact]
public void TryParse_extracts_essentials_and_metric_value()
{
var alert = AzureMonitorAlertWebhookFunction.TryParse(FullPayload);
Assert.NotNull(alert);
Assert.Equal("Portal RSC prefetch tail", alert!.AlertRule);
Assert.Equal("Sev2", alert.Severity);
Assert.Equal("Fired", alert.MonitorCondition);
Assert.Equal("RSC prefetches exceeding 10s", alert.Description);
Assert.Equal("Log Alerts V2", alert.MonitoringService);
// No targetResourceName in the payload → resource name segment of the first target ID.
Assert.Equal("autopilotmonitorweb", alert.TargetResource);
Assert.Equal(18.0, alert.MetricValue);
}
[Fact]
public void TryParse_prefers_explicit_target_resource_name()
{
const string payload = """
{"data":{"essentials":{"alertRule":"r","targetResourceName":"explicit-name",
"alertTargetIDs":["/sub/x/other-name"]}}}
""";
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
Assert.Equal("explicit-name", alert!.TargetResource);
}
[Fact]
public void TryParse_minimal_payload_needs_only_the_alert_rule()
{
var alert = AzureMonitorAlertWebhookFunction.TryParse(
"""{"data":{"essentials":{"alertRule":"minimal"}}}""");
Assert.NotNull(alert);
Assert.Equal("minimal", alert!.AlertRule);
Assert.Null(alert.Severity);
Assert.Equal("Fired", alert.MonitorCondition); // default when absent
Assert.Null(alert.Description);
Assert.Null(alert.MetricValue);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json at all")]
[InlineData("{}")] // no data
[InlineData("""{"data":{}}""")] // no essentials
[InlineData("""{"data":{"essentials":{}}}""")] // no alertRule
[InlineData("""{"data":{"essentials":{"alertRule":""}}}""")] // empty alertRule
[InlineData("""{"data":{"essentials":{"alertRule":123}}}""")] // wrong type
public void TryParse_rejects_unusable_payloads(string? body)
{
Assert.Null(AzureMonitorAlertWebhookFunction.TryParse(body!));
}
[Fact]
public void TryParse_tolerates_malformed_alert_context()
{
// alertContext with unexpected shapes must not break essentials extraction.
const string payload = """
{"data":{"essentials":{"alertRule":"r","severity":"Sev0"},
"alertContext":{"condition":{"allOf":"not-an-array"}}}}
""";
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
Assert.NotNull(alert);
Assert.Equal("Sev0", alert!.Severity);
Assert.Null(alert.MetricValue);
}
}
@@ -0,0 +1,120 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the injection-defense invariants of <see cref="BootstrapScriptValueValidator"/>,
/// ported from the portal's utils/bootstrapValidation.ts (and its vitest suite). The
/// validated values are interpolated into a PowerShell script that runs as SYSTEM
/// during OOBE — any relaxation here widens the injection surface.
/// </summary>
public class BootstrapScriptValueValidatorTests
{
private const string GoodTenant = "11111111-1111-1111-1111-111111111111";
private const string GoodToken = "22222222-2222-2222-2222-222222222222";
private const string GoodUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
private static DateTime FutureExpiry => DateTime.UtcNow.AddHours(24);
private static bool Validate(
string tenantId = GoodTenant,
string token = GoodToken,
string url = GoodUrl,
DateTime? expiresAt = null,
BootstrapScriptValueValidator.ValidationFailure? expectedFailure = null)
{
var ok = BootstrapScriptValueValidator.TryValidate(
tenantId, token, url, expiresAt ?? FutureExpiry, out _, out var failure);
if (!ok && expectedFailure.HasValue)
Assert.Equal(expectedFailure.Value, failure);
return ok;
}
[Fact]
public void Accepts_valid_values()
{
Assert.True(Validate());
}
// ── tenantId / token: exact canonical GUIDs only ──
[Theory]
[InlineData("")]
[InlineData("not-a-guid")]
[InlineData("11111111111111111111111111111111")] // "N" format — no hyphens
[InlineData("{11111111-1111-1111-1111-111111111111}")] // "B" format
[InlineData(" 11111111-1111-1111-1111-111111111111")] // leading space (TryParseExact would trim)
[InlineData("11111111-1111-1111-1111-11111111111$")]
public void Rejects_non_canonical_tenant_ids(string tenantId)
{
Assert.False(Validate(tenantId: tenantId,
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.TenantId));
}
[Fact]
public void Rejects_non_guid_token()
{
Assert.False(Validate(token: "'; $(calc); '",
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.Token));
}
// ── agentDownloadUrl: https + host allow-list + strict path ──
[Theory]
[InlineData("https://evil.example.com/x.zip'; $(calc); '")] // hostile payload from the vitest suite
[InlineData("https://evil.example.com/agent/AutopilotMonitor-Agent.zip")]
[InlineData("http://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")] // not https
[InlineData("https://download.autopilotmonitor.com:8443/agent/AutopilotMonitor-Agent.zip")] // explicit port
[InlineData("https://user:pw@download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")] // userinfo
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip?x=1")] // query
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip#f")] // fragment
[InlineData("https://download.autopilotmonitor.com/other/AutopilotMonitor-Agent.zip")] // wrong prefix
[InlineData("https://download.autopilotmonitor.com/agent/.hidden.zip")] // leading dot
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.exe")] // not .zip
[InlineData("")]
public void Rejects_disallowed_download_urls(string url)
{
Assert.False(Validate(url: url,
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.AgentDownloadUrl));
}
[Theory]
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")]
[InlineData("https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip")] // legacy blob host
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-v2.zip")]
public void Accepts_allowlisted_download_urls(string url)
{
Assert.True(Validate(url: url));
}
[Fact]
public void Rejects_filename_longer_than_80_chars()
{
// First char + 80 more before ".zip" exceeds the {0,79} tail bound.
var tooLong = "https://download.autopilotmonitor.com/agent/a" + new string('b', 80) + ".zip";
Assert.False(Validate(url: tooLong,
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.AgentDownloadUrl));
var atLimit = "https://download.autopilotmonitor.com/agent/a" + new string('b', 75) + ".zip";
Assert.True(Validate(url: atLimit));
}
// ── expiresAt: strictly future, at most 14 days out ──
[Fact]
public void Rejects_past_expiry()
{
Assert.False(Validate(expiresAt: DateTime.UtcNow.AddMinutes(-1),
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.ExpiresAt));
}
[Fact]
public void Rejects_expiry_beyond_14_days()
{
Assert.False(Validate(expiresAt: DateTime.UtcNow.AddDays(15),
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.ExpiresAt));
}
[Fact]
public void Accepts_expiry_within_the_window()
{
Assert.True(Validate(expiresAt: DateTime.UtcNow.AddDays(7)));
}
}
@@ -0,0 +1,48 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Cover for the ConnectionType predicate used on every search path that does NOT push the
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
/// unpaged scan. Sessions that predate the projection (null ConnectionType) must never match
/// a set filter; an unset filter matches everything.
/// </summary>
public class ConnectionTypeFilterTests
{
private static SessionSummary Session(string? connectionType) =>
new() { SessionId = "s", TenantId = "t", ConnectionType = connectionType };
[Theory]
[InlineData(null)]
[InlineData("WiFi")]
[InlineData("Ethernet")]
public void NoFilter_AlwaysMatches(string? connectionType)
{
var filter = new SessionSearchFilter();
Assert.True(TableStorageService.MatchesConnectionType(Session(connectionType), filter));
}
[Theory]
[InlineData("Ethernet", true)]
[InlineData("ethernet", true)] // case-insensitive, matching the other string filters
[InlineData("WiFi", false)]
[InlineData(null, false)] // legacy session without the projected column
public void EthernetFilter_MatchesOnlyEthernet(string? connectionType, bool expected)
{
var filter = new SessionSearchFilter { ConnectionType = "Ethernet" };
Assert.Equal(expected, TableStorageService.MatchesConnectionType(Session(connectionType), filter));
}
[Theory]
[InlineData("WiFi", true)]
[InlineData("Ethernet", false)]
[InlineData(null, false)]
public void WiFiFilter_MatchesOnlyWiFi(string? connectionType, bool expected)
{
var filter = new SessionSearchFilter { ConnectionType = "WiFi" };
Assert.Equal(expected, TableStorageService.MatchesConnectionType(Session(connectionType), filter));
}
}
@@ -75,8 +75,9 @@ public class DeletionManifestBuilderTests
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
Assert.Equal(11, c["signalsByKind"]);
// 15 cascade tables + tombstone = 16 steps; no inventory steps when no contributions row.
Assert.Equal(16, manifest.Steps.Count);
// 16 cascade tables (incl. SessionTimeBreakdowns, F1 PR2) + tombstone = 17 steps;
// no inventory steps when no contributions row.
Assert.Equal(17, manifest.Steps.Count);
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
}
@@ -102,7 +103,8 @@ public class DeletionManifestBuilderTests
.WithSessionsByStage(1, propValue: "stage_marker")
.WithDeadEndsByReason(1, propValue: "deadend_marker")
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
.WithSignalsByKind(1, propValue: "signalkind_marker"));
.WithSignalsByKind(1, propValue: "signalkind_marker")
.WithSessionTimeBreakdown(propValue: "breakdown_marker"));
var manifest = await NewBuilder(reader).BuildAsync(
TenantId, SessionId, "admin_delete",
@@ -145,8 +147,8 @@ public class DeletionManifestBuilderTests
new DeletionRetentionContext { TenantRetentionDays = 90 });
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
// Without a contributions row, neither inventory step is emitted → exactly 15 cascade-table steps.
Assert.Equal(15, nonTombstoneSteps.Count);
// Without a contributions row, neither inventory step is emitted → exactly 16 cascade-table steps.
Assert.Equal(16, nonTombstoneSteps.Count);
foreach (var step in nonTombstoneSteps)
{
Assert.Equal(0, step.RowCount);
@@ -175,8 +177,8 @@ public class DeletionManifestBuilderTests
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
new DeletionRetentionContext { TenantRetentionDays = 90 });
// Expect: 15 cascade + 2 inventory + 1 tombstone = 18 steps.
Assert.Equal(18, manifest.Steps.Count);
// Expect: 16 cascade + 2 inventory + 1 tombstone = 19 steps.
Assert.Equal(19, manifest.Steps.Count);
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
@@ -640,6 +642,7 @@ public class DeletionManifestBuilderTests
private TableEntity? _vulnerabilityReport;
private TableEntity? _deviceSnapshot;
private TableEntity? _eventSessionIndex;
private TableEntity? _sessionTimeBreakdown;
private TableEntity? _sessionInventoryContributions;
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
@@ -719,6 +722,16 @@ public class DeletionManifestBuilderTests
return this;
}
public ReaderSeed WithSessionTimeBreakdown(string propValue = "tab")
{
_sessionTimeBreakdown = new TableEntity(TenantId, SessionId)
{
["Marker"] = propValue,
};
_sessionTimeBreakdown.ETag = new ETag("0xTAB");
return this;
}
public ReaderSeed WithEventTypeIndex(int n, string propValue = "eti")
{
// PK = {tenantId}_{eventType}, RK ends with _{sessionId}.
@@ -796,6 +809,8 @@ public class DeletionManifestBuilderTests
.ReturnsAsync(_deviceSnapshot);
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.EventSessionIndex, TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(_eventSessionIndex);
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionTimeBreakdowns, TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(_sessionTimeBreakdown);
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(_sessionInventoryContributions);
@@ -0,0 +1,809 @@
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// F2 PR4 (insights spec §F2): device-key normalization (junk serials), terminal-only chain
/// maintenance (upsert, cap, deletion-ref pruning), journey grouping (first-success end,
/// 30-day gap, Incomplete attempts, WG-Pending stays open), the pure daily FTR aggregation
/// core (<see cref="MaintenanceService.BuildDeviceJourneyAggregates"/>) and the persistence
/// round-trips (table-storage-serialization rule — every property must survive Store→Map).
/// </summary>
public class DeviceJourneyAndFtrTests
{
private static readonly DateTime T0 = new(2026, 7, 20, 10, 0, 0, DateTimeKind.Utc);
private const string TenantA = "aaaaaaaa-0000-0000-0000-000000000001";
private const string TenantB = "bbbbbbbb-0000-0000-0000-000000000002";
private static SessionSummary Session(
string sessionId,
SessionStatus status = SessionStatus.Succeeded,
DateTime? startedAt = null,
DateTime? completedAt = null,
int? durationSeconds = 1800,
string? serial = "PF4X1ABC",
string tenantId = TenantA,
bool wg = false,
string enrollmentType = "v1",
string? adminMarkedAction = null)
=> new()
{
TenantId = tenantId,
SessionId = sessionId,
SerialNumber = serial!,
Status = status,
StartedAt = startedAt ?? T0,
CompletedAt = completedAt ?? (startedAt ?? T0).AddSeconds(durationSeconds ?? 0),
DurationSeconds = durationSeconds,
IsPreProvisioned = wg,
EnrollmentType = enrollmentType,
AdminMarkedAction = adminMarkedAction,
Manufacturer = "Contoso",
Model = "Laptop 5",
};
private static DeviceSessionRef Ref(
string sessionId, SessionStatus status, DateTime startedAt, DateTime? completedAt = null)
=> new()
{
SessionId = sessionId,
Status = status.ToString(),
StartedAt = startedAt,
CompletedAt = completedAt ?? startedAt.AddMinutes(30),
};
// ── serial normalization / junk exclusion ───────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("abc")] // shorter than 4 chars
[InlineData("0")]
[InlineData("None")]
[InlineData("INVALID")]
[InlineData("Unknown")] // the agent's WMI-failure sentinel (audit Q5)
[InlineData("unknown")]
[InlineData("System Serial Number")]
[InlineData("TO BE FILLED BY O.E.M.")]
[InlineData("Default String")]
[InlineData(" Default string ")] // junk match happens after trim + case-fold
public void NormalizeSerial_JunkAndPlaceholders_YieldNull(string? raw)
{
Assert.Null(DeviceJourneyCalculator.NormalizeSerial(raw));
}
[Theory]
[InlineData("PF4X1ABC", "pf4x1abc")]
[InlineData(" PF4X1ABC ", "pf4x1abc")] // trim
[InlineData("pf4x1abc", "pf4x1abc")]
[InlineData("1234", "1234")] // exactly 4 chars is a valid identity
public void NormalizeSerial_RealSerials_TrimAndCaseFold(string raw, string expected)
{
Assert.Equal(expected, DeviceJourneyCalculator.NormalizeSerial(raw));
}
// ── terminal set / ref building ─────────────────────────────────────────
[Theory]
[InlineData(SessionStatus.Succeeded, true)]
[InlineData(SessionStatus.Failed, true)]
[InlineData(SessionStatus.Incomplete, true)]
[InlineData(SessionStatus.InProgress, false)]
[InlineData(SessionStatus.Pending, false)] // WG part 1 sealed — an OPEN session
[InlineData(SessionStatus.Stalled, false)]
[InlineData(SessionStatus.AwaitingUser, false)]
[InlineData(SessionStatus.Unknown, false)]
public void IsTerminal_MatchesTheF2TerminalSet(SessionStatus status, bool expected)
{
Assert.Equal(expected, DeviceJourneyCalculator.IsTerminal(status));
}
[Fact]
public void BuildSessionRef_MapsAllFields_DurationIsAuthoritativeDurationSeconds()
{
// CompletedAt deliberately diverges from StartedAt + DurationSeconds (25 % of terminal
// sessions in production) — the ref must carry DurationSeconds verbatim.
var session = Session("s-1", startedAt: T0, completedAt: T0.AddHours(5), durationSeconds: 1800,
wg: true, enrollmentType: "v1", adminMarkedAction: "Succeeded");
var reference = DeviceJourneyCalculator.BuildSessionRef(session)!;
Assert.Equal("s-1", reference.SessionId);
Assert.Equal(T0, reference.StartedAt);
Assert.Equal(T0.AddHours(5), reference.CompletedAt);
Assert.Equal("Succeeded", reference.Status);
Assert.Equal("v1", reference.EnrollmentType);
Assert.True(reference.IsPreProvisioned);
Assert.Equal(1800, reference.DurationSeconds);
Assert.True(reference.AdminMarked);
}
[Fact]
public void BuildSessionRef_WhiteGlovePending_YieldsNull_NeverAnAttempt()
{
var pending = Session("s-1", status: SessionStatus.Pending, wg: true, durationSeconds: null);
Assert.Null(DeviceJourneyCalculator.BuildSessionRef(pending));
}
[Fact]
public void BuildSessionRef_Incomplete_KeepsNullDuration()
{
var incomplete = Session("s-1", status: SessionStatus.Incomplete, durationSeconds: null);
var reference = DeviceJourneyCalculator.BuildSessionRef(incomplete)!;
Assert.Equal("Incomplete", reference.Status);
Assert.Null(reference.DurationSeconds);
Assert.False(reference.AdminMarked);
}
// ── chain maintenance ───────────────────────────────────────────────────
[Fact]
public void MergeChain_UpsertsBySessionId_ReclassificationReplacesTheEntry()
{
var existing = new List<DeviceSessionRef> { Ref("s-1", SessionStatus.Incomplete, T0) };
var update = Ref("s-1", SessionStatus.Succeeded, T0);
var merged = DeviceJourneyCalculator.MergeChain(existing, new[] { update });
var entry = Assert.Single(merged);
Assert.Equal("Succeeded", entry.Status);
}
[Fact]
public void MergeChain_OrdersByStartedAt_TiebreakSessionId_AndCapsToMostRecent()
{
var refs = new List<DeviceSessionRef>();
for (var i = 0; i < 25; i++)
refs.Add(Ref($"s-{i:D2}", SessionStatus.Failed, T0.AddDays(i)));
// Same StartedAt as s-24 — the ordinal sessionId tiebreak must keep the order stable.
refs.Add(Ref("s-00-twin", SessionStatus.Failed, T0.AddDays(24)));
var merged = DeviceJourneyCalculator.MergeChain(null, refs);
Assert.Equal(20, merged.Count); // cap 20 most recent
Assert.Equal("s-06", merged[0].SessionId); // s-00..s-05 dropped (oldest)
Assert.Equal(T0.AddDays(24), merged[^1].StartedAt);
Assert.Equal("s-24", merged[^1].SessionId); // "s-00-twin" < "s-24" ordinal → sorts first
Assert.Equal("s-00-twin", merged[^2].SessionId);
}
[Fact]
public void MergeChain_DropsNonTerminalRefs_Defensively()
{
var corrupt = new List<DeviceSessionRef>
{
Ref("s-1", SessionStatus.Succeeded, T0),
new() { SessionId = "s-2", Status = "Pending", StartedAt = T0.AddDays(1) },
new() { SessionId = "s-3", Status = "garbage", StartedAt = T0.AddDays(2) },
};
var merged = DeviceJourneyCalculator.MergeChain(corrupt, Array.Empty<DeviceSessionRef>());
Assert.Equal("s-1", Assert.Single(merged).SessionId);
}
[Fact]
public void RemoveSessionRefs_DropsDeletedSessions_EmptyChainSignalsRowDeletion()
{
var chain = new List<DeviceSessionRef>
{
Ref("s-1", SessionStatus.Failed, T0),
Ref("s-2", SessionStatus.Succeeded, T0.AddDays(1)),
};
var partial = DeviceJourneyCalculator.RemoveSessionRefs(
chain, new HashSet<string>(StringComparer.Ordinal) { "s-1" });
Assert.Equal("s-2", Assert.Single(partial).SessionId);
var empty = DeviceJourneyCalculator.RemoveSessionRefs(
chain, new HashSet<string>(StringComparer.Ordinal) { "s-1", "s-2" });
Assert.Empty(empty);
}
// ── journey grouping ────────────────────────────────────────────────────
[Fact]
public void GroupJourneys_SingleSuccess_IsOneCompletedFirstTimeRightJourney()
{
var journeys = DeviceJourneyCalculator.GroupJourneys(
new[] { Ref("s-1", SessionStatus.Succeeded, T0) });
var journey = Assert.Single(journeys);
Assert.True(journey.Completed);
Assert.Single(journey.Attempts);
Assert.Equal("s-1", journey.CompletingRef!.SessionId);
}
[Fact]
public void GroupJourneys_FailAndIncompleteAreAttempts_JourneyEndsWithFirstSuccess()
{
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
{
Ref("s-1", SessionStatus.Failed, T0),
Ref("s-2", SessionStatus.Incomplete, T0.AddHours(2)),
Ref("s-3", SessionStatus.Succeeded, T0.AddHours(4)),
});
var journey = Assert.Single(journeys);
Assert.True(journey.Completed);
Assert.Equal(3, journey.Attempts.Count); // Incomplete is a non-successful attempt
}
[Fact]
public void GroupJourneys_SessionAfterSuccess_StartsANewJourney_Redeployment()
{
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
{
Ref("s-1", SessionStatus.Succeeded, T0),
Ref("s-2", SessionStatus.Failed, T0.AddDays(2)),
});
Assert.Equal(2, journeys.Count);
Assert.True(journeys[0].Completed);
Assert.False(journeys[1].Completed); // open — no failed verdict, just not done yet
Assert.Null(journeys[1].CompletingRef);
}
[Fact]
public void GroupJourneys_FailuresOnly_JourneyStaysOpen_NeverCompleted()
{
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
{
Ref("s-1", SessionStatus.Failed, T0),
Ref("s-2", SessionStatus.Failed, T0.AddDays(1)),
});
var journey = Assert.Single(journeys);
Assert.False(journey.Completed);
Assert.Equal(2, journey.Attempts.Count);
}
[Fact]
public void GroupJourneys_GapOver30Days_StartsANewJourney_AbandonedStaysUncompleted()
{
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
{
Ref("s-1", SessionStatus.Failed, T0, completedAt: T0.AddHours(1)),
Ref("s-2", SessionStatus.Failed, T0.AddHours(1).AddDays(31)),
Ref("s-3", SessionStatus.Succeeded, T0.AddHours(1).AddDays(31).AddHours(3)),
});
Assert.Equal(2, journeys.Count);
Assert.False(journeys[0].Completed); // shelved/abandoned — never counts for FTR
Assert.Single(journeys[0].Attempts);
Assert.True(journeys[1].Completed);
Assert.Equal(2, journeys[1].Attempts.Count);
}
[Fact]
public void GroupJourneys_GapBoundary_Exactly30DaysStaysOneJourney_StrictlyMoreSplits()
{
var prevEnd = T0.AddHours(1);
var exactly30 = DeviceJourneyCalculator.GroupJourneys(new[]
{
Ref("s-1", SessionStatus.Failed, T0, completedAt: prevEnd),
Ref("s-2", SessionStatus.Failed, prevEnd.AddDays(30)),
});
Assert.Single(exactly30); // spec: "> 30 days" — the boundary itself does not split
var over30 = DeviceJourneyCalculator.GroupJourneys(new[]
{
Ref("s-1", SessionStatus.Failed, T0, completedAt: prevEnd),
Ref("s-2", SessionStatus.Failed, prevEnd.AddDays(30).AddHours(1)),
});
Assert.Equal(2, over30.Count);
}
[Fact]
public void GroupJourneys_GapMeasuredFromCompletedAt_NotStartedAt()
{
// Previous attempt STARTED 40 days before the next one but went terminal only 10 days
// before it — the gap is 10 days, one journey. StartedAt-based measuring would split.
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
{
Ref("s-1", SessionStatus.Failed, T0.AddDays(-40), completedAt: T0.AddDays(-10)),
Ref("s-2", SessionStatus.Succeeded, T0),
});
var journey = Assert.Single(journeys);
Assert.Equal(2, journey.Attempts.Count);
}
[Fact]
public void GroupJourneys_WhiteGloveIsOneSessionRow_OneAttempt_NoStitching()
{
// WG part 1 + part 2 share one session row (second-sweep finding): a completed WG
// enrollment is exactly one ref and one first-time-right attempt.
var wg = Session("s-1", wg: true, durationSeconds: 5400, completedAt: T0.AddDays(6));
var reference = DeviceJourneyCalculator.BuildSessionRef(wg)!;
var journeys = DeviceJourneyCalculator.GroupJourneys(new[] { reference });
var journey = Assert.Single(journeys);
Assert.True(journey.Completed);
Assert.Single(journey.Attempts);
Assert.Equal(5400, journey.Attempts[0].DurationSeconds); // pause-free combined duration, verbatim
}
[Fact]
public void Derive_CurrentJourneyAttempts_IsTheLastJourneys_OpenOrCompleted()
{
var (journeyCount, currentAttempts) = DeviceJourneyCalculator.Derive(new[]
{
Ref("s-1", SessionStatus.Succeeded, T0),
Ref("s-2", SessionStatus.Failed, T0.AddDays(1)),
Ref("s-3", SessionStatus.Failed, T0.AddDays(2)),
});
Assert.Equal(2, journeyCount);
Assert.Equal(2, currentAttempts); // the open redeployment journey has 2 attempts so far
Assert.Equal((0, 0), DeviceJourneyCalculator.Derive(Array.Empty<DeviceSessionRef>()));
}
// ── history row assembly (display precedence) ───────────────────────────
[Fact]
public void BuildDeviceHistoryRow_DisplayFields_FollowTheNewestChainEntry()
{
var newest = Session("s-2", serial: " PF4X1ABC ", startedAt: T0.AddDays(1));
var chain = new List<DeviceSessionRef>
{
Ref("s-1", SessionStatus.Failed, T0),
Ref("s-2", SessionStatus.Succeeded, T0.AddDays(1)),
};
var row = TableStorageService.BuildDeviceHistoryRow(TenantA, "pf4x1abc", chain, existing: null, newest);
Assert.Equal("PF4X1ABC", row.SerialNumber); // trimmed original casing
Assert.Equal("Contoso", row.Manufacturer);
Assert.Equal("Laptop 5", row.Model);
Assert.Equal(2, row.CurrentJourneyAttempts);
Assert.Equal(1, row.JourneyCount);
Assert.Equal(DeviceJourneyCalculator.CurrentVersion, row.JourneyVersion);
}
[Fact]
public void BuildDeviceHistoryRow_BackfillingAnOlderSession_KeepsExistingDisplayFields()
{
var older = Session("s-1", serial: "pf4x1abc", startedAt: T0);
older.Model = "Laptop 4"; // stale model from the older enrollment
var chain = new List<DeviceSessionRef>
{
Ref("s-1", SessionStatus.Failed, T0),
Ref("s-2", SessionStatus.Succeeded, T0.AddDays(1)),
};
var existing = new DeviceHistory
{
TenantId = TenantA, SerialKey = "pf4x1abc",
SerialNumber = "PF4X1ABC", Manufacturer = "Contoso", Model = "Laptop 5",
};
var row = TableStorageService.BuildDeviceHistoryRow(TenantA, "pf4x1abc", chain, existing, older);
Assert.Equal("PF4X1ABC", row.SerialNumber); // not regressed to the older session's data
Assert.Equal("Laptop 5", row.Model);
}
// ── persistence round-trips ─────────────────────────────────────────────
[Fact]
public void DeviceHistoryEntity_RoundTripsAllFields_IncludingChainJson()
{
var history = new DeviceHistory
{
TenantId = TenantA,
SerialKey = "pf4x1abc",
SerialNumber = "PF4X1ABC",
Manufacturer = "Contoso",
Model = "Laptop 5",
CurrentJourneyAttempts = 2,
JourneyCount = 3,
JourneyVersion = 1,
LastUpdated = T0,
Chain = new List<DeviceSessionRef>
{
new()
{
SessionId = "s-1", StartedAt = T0, CompletedAt = T0.AddHours(5),
Status = "Succeeded", EnrollmentType = "v2", IsPreProvisioned = true,
DurationSeconds = 1800, AdminMarked = true,
},
new()
{
SessionId = "s-2", StartedAt = T0.AddDays(1), CompletedAt = null,
Status = "Incomplete", EnrollmentType = "v1", DurationSeconds = null,
},
},
};
var mapped = TableStorageService.MapToDeviceHistory(
TableStorageService.BuildDeviceHistoryEntity(history));
Assert.Equal(TenantA, mapped.TenantId);
Assert.Equal("pf4x1abc", mapped.SerialKey);
Assert.Equal("PF4X1ABC", mapped.SerialNumber);
Assert.Equal("Contoso", mapped.Manufacturer);
Assert.Equal("Laptop 5", mapped.Model);
Assert.Equal(2, mapped.CurrentJourneyAttempts);
Assert.Equal(3, mapped.JourneyCount);
Assert.Equal(1, mapped.JourneyVersion);
Assert.Equal(T0, mapped.LastUpdated);
Assert.Equal(2, mapped.Chain.Count);
var first = mapped.Chain[0];
Assert.Equal("s-1", first.SessionId);
Assert.Equal(T0, first.StartedAt);
Assert.Equal(T0.AddHours(5), first.CompletedAt);
Assert.Equal("Succeeded", first.Status);
Assert.Equal("v2", first.EnrollmentType);
Assert.True(first.IsPreProvisioned);
Assert.Equal(1800, first.DurationSeconds);
Assert.True(first.AdminMarked);
var second = mapped.Chain[1];
Assert.Null(second.CompletedAt); // Incomplete: no terminal timestamp requirement
Assert.Null(second.DurationSeconds); // Incomplete: deliberately no duration — stays null
Assert.False(second.AdminMarked);
}
[Fact]
public void DeviceHistoryEntity_RowKeyEncoding_MakesForbiddenKeyCharsSafe_AndRoundTrips()
{
// '/', '\', '#', '?' are forbidden in Table Storage keys — a defensive encoding keeps a
// pathological serial writable, and the mapper restores the exact normalized serial.
var weird = "ab/cd\\ef#1?x";
var history = new DeviceHistory { TenantId = TenantA, SerialKey = weird };
var entity = TableStorageService.BuildDeviceHistoryEntity(history);
Assert.All(new[] { "/", "\\", "#", "?" }, c => Assert.DoesNotContain(c, entity.RowKey));
Assert.Equal(weird, TableStorageService.MapToDeviceHistory(entity).SerialKey);
// Ordinary serials stay human-readable.
Assert.Equal("pf4x1abc", TableStorageService.SerialRowKey("pf4x1abc"));
}
[Fact]
public void DeviceJourneyAggregateEntity_RoundTripsAllFields_RowKeyIsTheDate()
{
var aggregate = new DeviceJourneyDailyAggregate
{
TenantId = TenantA,
Date = "2026-07-20",
JourneyVersion = 1,
CompletedJourneyCount = 23,
FirstTimeRightCount = 19,
ExcludedSessionCount = 2,
ComputedAt = T0,
AttemptHistogram = new List<DeviceJourneyAttemptBucket>
{
new() { Attempts = 1, JourneyCount = 19 },
new() { Attempts = 2, JourneyCount = 3 },
new() { Attempts = 4, JourneyCount = 1 },
},
};
var entity = TableStorageService.BuildDeviceJourneyAggregateEntity(aggregate);
Assert.Equal("2026-07-20", entity.RowKey);
var mapped = TableStorageService.MapToDeviceJourneyAggregate(entity);
Assert.Equal(TenantA, mapped.TenantId);
Assert.Equal("2026-07-20", mapped.Date);
Assert.Equal(1, mapped.JourneyVersion);
Assert.Equal(23, mapped.CompletedJourneyCount);
Assert.Equal(19, mapped.FirstTimeRightCount);
Assert.Equal(2, mapped.ExcludedSessionCount);
Assert.Equal(T0, mapped.ComputedAt);
Assert.Equal(3, mapped.AttemptHistogram.Count);
Assert.Equal(1, mapped.AttemptHistogram[0].Attempts);
Assert.Equal(19, mapped.AttemptHistogram[0].JourneyCount);
Assert.Equal(4, mapped.AttemptHistogram[2].Attempts);
}
// ── daily FTR aggregation core ──────────────────────────────────────────
private static readonly DateTime WindowStart = T0.Date.AddDays(-30);
private static readonly DateTime WindowEnd = T0.Date.AddDays(1);
private static DeviceHistory History(string tenantId, params DeviceSessionRef[] chain)
=> new() { TenantId = tenantId, SerialKey = Guid.NewGuid().ToString("N"), Chain = chain.ToList() };
[Fact]
public void FtrAggregates_BucketByCompletingSessionsStartDate_AndMirrorGlobal()
{
var histories = new List<DeviceHistory>
{
// Tenant A device 1: fail → success (2 attempts, completes on T0 date).
History(TenantA,
Ref("a1-1", SessionStatus.Failed, T0.AddDays(-1)),
Ref("a1-2", SessionStatus.Succeeded, T0)),
// Tenant A device 2: first-time right on the same date.
History(TenantA, Ref("a2-1", SessionStatus.Succeeded, T0.AddHours(2))),
// Tenant B device: first-time right on a different date.
History(TenantB, Ref("b1-1", SessionStatus.Succeeded, T0.AddDays(-3))),
};
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
histories, Array.Empty<SessionSummary>(), WindowStart, WindowEnd, T0);
// Tenant A (1 date) + Tenant B (1 date) + global (2 dates) = 4 rows.
Assert.Equal(4, rows.Count);
var tenantARow = rows.Single(r => r.TenantId == TenantA);
Assert.Equal(T0.ToString("yyyy-MM-dd"), tenantARow.Date);
Assert.Equal(2, tenantARow.CompletedJourneyCount);
Assert.Equal(1, tenantARow.FirstTimeRightCount);
Assert.Equal(DeviceJourneyCalculator.CurrentVersion, tenantARow.JourneyVersion);
Assert.Equal(2, tenantARow.AttemptHistogram.Count);
Assert.Equal(1, tenantARow.AttemptHistogram[0].Attempts);
Assert.Equal(1, tenantARow.AttemptHistogram[0].JourneyCount);
Assert.Equal(2, tenantARow.AttemptHistogram[1].Attempts);
var globalOnT0 = rows.Single(r => r.TenantId == "global" && r.Date == T0.ToString("yyyy-MM-dd"));
Assert.Equal(2, globalOnT0.CompletedJourneyCount); // tenant B's journey sits on its own date
}
[Fact]
public void FtrAggregates_OpenAndAbandonedJourneys_NeverCount()
{
var histories = new List<DeviceHistory>
{
// Open journey: failures only (e.g. a WG device still waiting for its user session
// has, at most, prior failed attempts in the chain — the Pending row itself never
// becomes a ref).
History(TenantA, Ref("a1-1", SessionStatus.Failed, T0)),
// Abandoned journey (31d gap, no success) followed by a completed journey.
History(TenantA,
Ref("a2-1", SessionStatus.Failed, T0.AddDays(-20), completedAt: T0.AddDays(-20).AddHours(1)),
Ref("a2-2", SessionStatus.Succeeded, T0.AddDays(11).AddHours(2))),
};
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
histories, Array.Empty<SessionSummary>(), WindowStart, T0.Date.AddDays(12), T0);
// Only the completed journey produced rows: tenant + global on one date.
Assert.Equal(2, rows.Count);
var tenantRow = rows.Single(r => r.TenantId == TenantA);
Assert.Equal(1, tenantRow.CompletedJourneyCount);
Assert.Equal(1, tenantRow.FirstTimeRightCount); // the abandoned attempt belongs to a DIFFERENT journey
Assert.Equal(T0.AddDays(11).ToString("yyyy-MM-dd"), tenantRow.Date);
}
[Fact]
public void FtrAggregates_JourneysCompletedOutsideTheWindow_AreSkipped()
{
var histories = new List<DeviceHistory>
{
History(TenantA, Ref("a1-1", SessionStatus.Succeeded, WindowStart.AddDays(-1))),
};
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
histories, Array.Empty<SessionSummary>(), WindowStart, WindowEnd, T0);
Assert.Empty(rows);
}
[Fact]
public void FtrAggregates_JunkSerialExclusions_ProduceDisclosureRows()
{
var excluded = new List<SessionSummary>
{
Session("x-1", serial: "Unknown", startedAt: T0),
Session("x-2", serial: "Unknown", startedAt: T0.AddHours(1)),
Session("x-3", serial: "Unknown", startedAt: WindowStart.AddDays(-2)), // outside window — ignored
};
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
Array.Empty<DeviceHistory>(), excluded, WindowStart, WindowEnd, T0);
Assert.Equal(2, rows.Count); // tenant + global disclosure rows, no journey claim
var tenantRow = rows.Single(r => r.TenantId == TenantA);
Assert.Equal(0, tenantRow.CompletedJourneyCount);
Assert.Equal(0, tenantRow.FirstTimeRightCount);
Assert.Empty(tenantRow.AttemptHistogram);
Assert.Equal(2, tenantRow.ExcludedSessionCount);
Assert.Equal(2, rows.Single(r => r.TenantId == "global").ExcludedSessionCount);
}
[Fact]
public void FtrAggregates_TwoCompletedJourneysOfOneDevice_SameDay_BothCount()
{
// Redeploy on the same day: success closes journey 1, the next success closes journey 2.
var histories = new List<DeviceHistory>
{
History(TenantA,
Ref("a1-1", SessionStatus.Succeeded, T0),
Ref("a1-2", SessionStatus.Succeeded, T0.AddHours(3))),
};
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
histories, Array.Empty<SessionSummary>(), WindowStart, WindowEnd, T0);
var tenantRow = rows.Single(r => r.TenantId == TenantA);
Assert.Equal(2, tenantRow.CompletedJourneyCount);
Assert.Equal(2, tenantRow.FirstTimeRightCount);
}
// ── attempt number (session banner, PR5) ────────────────────────────────
[Fact]
public void ComputeAttemptNumber_TerminalSessionInChain_ReturnsPositionWithinItsJourney()
{
var chain = new[]
{
Ref("s-1", SessionStatus.Failed, T0),
Ref("s-2", SessionStatus.Succeeded, T0.AddHours(2)),
Ref("s-3", SessionStatus.Failed, T0.AddDays(5)), // redeployment → new journey
};
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-1", T0));
Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddHours(2)));
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-3", T0.AddDays(5)));
}
[Fact]
public void ComputeAttemptNumber_LiveSession_ContinuesTheOpenJourney()
{
var chain = new[] { Ref("s-1", SessionStatus.Failed, T0) };
// s-2 is still InProgress — not a chain ref; the virtual-attempt rule places it.
Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddDays(1)));
}
[Fact]
public void ComputeAttemptNumber_LiveSession_AfterCompletedJourney_IsAttemptOne()
{
var chain = new[] { Ref("s-1", SessionStatus.Succeeded, T0) };
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddDays(2)));
}
[Fact]
public void ComputeAttemptNumber_LiveSession_AfterThirtyDayGap_IsAttemptOne()
{
var prevEnd = T0.AddHours(1);
var chain = new[] { Ref("s-1", SessionStatus.Failed, T0, completedAt: prevEnd) };
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", prevEnd.AddDays(31)));
// Inside the gap boundary the open journey continues instead.
Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-3", prevEnd.AddDays(29)));
}
[Fact]
public void ComputeAttemptNumber_EmptyChain_YieldsNull_NoGuessedPosition()
{
Assert.Null(DeviceJourneyCalculator.ComputeAttemptNumber(
Array.Empty<DeviceSessionRef>(), "s-1", T0));
}
// ── fleet response helpers (PR5) ────────────────────────────────────────
[Fact]
public void SumAggregates_SumsAdditiveCounts_AndMergesHistograms()
{
var daily = new List<DeviceJourneyDailyAggregate>
{
new()
{
CompletedJourneyCount = 2, FirstTimeRightCount = 1, ExcludedSessionCount = 1,
AttemptHistogram = new List<DeviceJourneyAttemptBucket>
{
new() { Attempts = 1, JourneyCount = 1 },
new() { Attempts = 2, JourneyCount = 1 },
},
},
new()
{
CompletedJourneyCount = 1, FirstTimeRightCount = 1, ExcludedSessionCount = 0,
AttemptHistogram = new List<DeviceJourneyAttemptBucket> { new() { Attempts = 1, JourneyCount = 1 } },
},
};
var totals = DeviceJourneyMetricsResponse.SumAggregates(daily);
Assert.Equal(3, totals.CompletedJourneys);
Assert.Equal(2, totals.FirstTimeRight);
Assert.Equal(66.7, totals.FtrRatePct);
Assert.Equal(1, totals.ExcludedSessions);
Assert.Equal(2, totals.AttemptHistogram.Count);
Assert.Equal(2, totals.AttemptHistogram.Single(b => b.Attempts == 1).JourneyCount);
Assert.Equal(1, totals.AttemptHistogram.Single(b => b.Attempts == 2).JourneyCount);
}
[Fact]
public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero()
{
var totals = DeviceJourneyMetricsResponse.SumAggregates(new List<DeviceJourneyDailyAggregate>
{
new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 },
});
Assert.Equal(0, totals.CompletedJourneys);
Assert.Null(totals.FtrRatePct);
Assert.Equal(3, totals.ExcludedSessions);
}
[Theory]
[InlineData(null, 30)] // default window
[InlineData("garbage", 30)]
[InlineData("7", 7)]
[InlineData("0", 1)] // clamped low
[InlineData("999", 180)] // clamped to aggregate retention
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
{
Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw));
}
[Fact]
public void InclusiveWindowStart_YieldsExactlyNCalendarDays()
{
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
// days=1 summed yesterday AND today.
var today = new DateTime(2026, 7, 27);
Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1));
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30));
}
private static DeviceHistory RepeaterHistory(
string serial, int attempts, params DeviceSessionRef[] chain)
=> new()
{
TenantId = TenantA,
SerialKey = serial,
SerialNumber = serial.ToUpperInvariant(),
Chain = chain.ToList(),
CurrentJourneyAttempts = attempts,
JourneyCount = 1,
};
[Fact]
public void SelectRepeatDevices_FiltersGate_WindowsOnNewestRef_AndPicksNewestFailure()
{
var histories = new List<DeviceHistory>
{
// Single attempt — not a repeat device.
RepeaterHistory("clean-1", attempts: 1, Ref("c1", SessionStatus.Succeeded, T0)),
// Repeat device, active in window: newest failed ref carries the reason lookup.
RepeaterHistory("hot-1", attempts: 3,
Ref("h1", SessionStatus.Failed, T0.AddDays(-2)),
Ref("h2", SessionStatus.Failed, T0.AddDays(-1)),
Ref("h3", SessionStatus.Incomplete, T0)),
// Repeat device, but its pain is OLDER than the window — current lists only.
RepeaterHistory("old-1", attempts: 4,
Ref("o1", SessionStatus.Failed, T0.AddDays(-40))),
// Lower attempt count → sorts after hot-1.
RepeaterHistory("warm-1", attempts: 2,
Ref("w1", SessionStatus.Failed, T0.AddDays(-3)),
Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))),
};
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
Assert.Equal(2, selected.Count);
Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc
Assert.Equal("h3", selected[0].Newest.SessionId);
Assert.Equal("h2", selected[0].NewestFailed!.SessionId); // last FAILED, not last ref
Assert.Equal("warm-1", selected[1].History.SerialKey);
}
[Fact]
public void SelectRepeatDevices_CapsAtTen_ByAttemptsThenRecency()
{
var histories = new List<DeviceHistory>();
for (var i = 0; i < 12; i++)
{
histories.Add(RepeaterHistory($"dev-{i:D2}", attempts: 2 + i,
Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i))));
}
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
Assert.Equal(10, selected.Count);
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-00");
Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-01");
}
}
@@ -120,6 +120,77 @@ public class DistressValidationTests
Assert.Equal("203.0.113.5", ClientIpExtractor.ExtractTrustedHop("10.0.0.1, 203.0.113.5, "));
}
// =========================================================================
// ClientIpExtractor.GetHeaderFromBindingDataJson — Flex Consumption strips the
// X-Forwarded-* family from the proxied HTTP request; the host passes the
// ORIGINAL header set as trigger metadata (BindingData["Headers"], JSON object).
// Malformed metadata must fail soft (null), never throw into request handling.
// =========================================================================
[Fact]
public void GetHeaderFromBindingDataJson_XffPresent_ReturnsValue()
{
var json = """{"Host":"example.com","X-Forwarded-For":"1.1.1.1, 203.0.113.5:443"}""";
Assert.Equal(
"1.1.1.1, 203.0.113.5:443",
ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
}
[Fact]
public void GetHeaderFromBindingDataJson_HeaderNameCaseInsensitive()
{
var json = """{"x-forwarded-for":"203.0.113.5"}""";
Assert.Equal(
"203.0.113.5",
ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not json")]
[InlineData("[]")] // JSON but not an object
[InlineData("""{"Host":"example.com"}""")] // header absent
[InlineData("""{"X-Forwarded-For":42}""")] // non-string value
public void GetHeaderFromBindingDataJson_MissingOrMalformed_ReturnsNull(string? json)
{
Assert.Null(ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
}
// =========================================================================
// ClientIpExtractor.RemoteIpToString — transport-level fallback. On Flex the
// worker's TCP peer is the host's LOCAL proxy: loopback means "not a client"
// and must map to "unknown", never be stored as a forensic SourceIp
// (production 2026-07-29: distress reports carried SourceIp 127.0.0.1).
// =========================================================================
[Fact]
public void RemoteIpToString_Null_ReturnsUnknown()
{
Assert.Equal("unknown", ClientIpExtractor.RemoteIpToString(null));
}
[Theory]
[InlineData("127.0.0.1")]
[InlineData("::1")]
[InlineData("::ffff:127.0.0.1")] // IPv4-mapped loopback
public void RemoteIpToString_Loopback_ReturnsUnknown(string ip)
{
Assert.Equal("unknown", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse(ip)));
}
[Fact]
public void RemoteIpToString_PublicIpv4_ReturnsIp()
{
Assert.Equal("203.0.113.5", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse("203.0.113.5")));
}
[Fact]
public void RemoteIpToString_Ipv4MappedIpv6_FoldsToIpv4()
{
Assert.Equal("203.0.113.5", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse("::ffff:203.0.113.5")));
}
// =========================================================================
// Sanitize — Control character stripping and truncation
// =========================================================================
@@ -50,6 +50,8 @@ public class GeoMetricsProjectionEquivalenceTests
["GeoCity"] = "Frankfurt",
["GeoLoc"] = "50.11,8.68",
["IsPreProvisioned"] = false,
["AvgApiLatencyMs"] = 217.4,
["ApiRequestCount"] = 270,
// Noise the geo aggregation never reads.
["SerialNumber"] = "SN-FULL",
["Manufacturer"] = "Contoso",
@@ -76,6 +78,8 @@ public class GeoMetricsProjectionEquivalenceTests
Assert.Equal(fromFull.GeoRegion, fromProjected.GeoRegion);
Assert.Equal(fromFull.GeoCity, fromProjected.GeoCity);
Assert.Equal(fromFull.GeoLoc, fromProjected.GeoLoc);
Assert.Equal(fromFull.AvgApiLatencyMs, fromProjected.AvgApiLatencyMs);
Assert.Equal(fromFull.ApiRequestCount, fromProjected.ApiRequestCount);
// The grouping key must be identical too — it is composed from the Geo* fields.
Assert.Equal(
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the API-latency aggregation of
/// <see cref="GetGeographicMetricsFunction.ComputeGeographicMetrics"/> (2026-07-28): both the
/// per-location and the global figure are REQUEST-WEIGHTED averages —
/// Σ(sessionAvg·requests)/Σ(requests) — not averages of session averages, so a long session
/// with many uploads counts proportionally. Sessions without latency data (agents predating
/// <c>AvgApiLatencyMs</c>) drop out of the weighting entirely.
/// </summary>
public class GeographicMetricsApiLatencyTests
{
private static SessionSummary S(string country, string city, double? avgLatencyMs, int? requestCount)
=> new()
{
TenantId = "00000000-0000-0000-0000-000000000fe0",
SessionId = Guid.NewGuid().ToString(),
Status = SessionStatus.Succeeded,
StartedAt = DateTime.UtcNow.AddHours(-2),
GeoCountry = country,
GeoRegion = country,
GeoCity = city,
GeoLoc = "0,0",
AvgApiLatencyMs = avgLatencyMs,
ApiRequestCount = requestCount,
};
[Fact]
public void LocationLatency_IsRequestWeighted_NotAverageOfAverages()
{
var sessions = new List<SessionSummary>
{
S("ID", "Jakarta", 1000, 300),
S("ID", "Jakarta", 500, 100),
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
var loc = Assert.Single(result.Locations);
// (1000·300 + 500·100) / 400 = 875 — an unweighted mean would say 750.
Assert.Equal(875d, loc.AvgApiLatencyMs);
Assert.Equal(2, loc.ApiLatencySessionCount);
}
[Fact]
public void SessionsWithoutLatencyData_DropOutOfWeighting_ButStayInSessionCount()
{
var sessions = new List<SessionSummary>
{
S("DE", "Frankfurt", 200, 100),
S("DE", "Frankfurt", avgLatencyMs: null, requestCount: null), // pre-feature agent
S("DE", "Frankfurt", avgLatencyMs: 0, requestCount: 50), // zero = no signal
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
var loc = Assert.Single(result.Locations);
Assert.Equal(3, loc.SessionCount);
Assert.Equal(1, loc.ApiLatencySessionCount);
Assert.Equal(200d, loc.AvgApiLatencyMs);
}
[Fact]
public void NoLatencyDataAnywhere_YieldsZeroes_NotDivisionByZero()
{
var sessions = new List<SessionSummary> { S("DE", "Frankfurt", null, null) };
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
var loc = Assert.Single(result.Locations);
Assert.Equal(0d, loc.AvgApiLatencyMs);
Assert.Equal(0, loc.ApiLatencySessionCount);
Assert.Equal(0d, loc.ApiLatencyVsGlobalPct);
Assert.Equal(0d, result.GlobalAverages.AvgApiLatencyMs);
}
[Fact]
public void GlobalLatency_WeightsAcrossLocations_AndVsGlobalPctCompares()
{
var sessions = new List<SessionSummary>
{
S("DE", "Frankfurt", 200, 100),
S("ID", "Jakarta", 800, 100),
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
// Equal request weights → global = (200 + 800) / 2 = 500.
Assert.Equal(500d, result.GlobalAverages.AvgApiLatencyMs);
var de = result.Locations.Single(l => l.Country == "DE");
var id = result.Locations.Single(l => l.Country == "ID");
Assert.Equal(-60d, de.ApiLatencyVsGlobalPct); // 200 vs 500
Assert.Equal(60d, id.ApiLatencyVsGlobalPct); // 800 vs 500
}
[Fact]
public void LocationWithoutData_KeepsZeroPct_EvenWhenGlobalHasData()
{
var sessions = new List<SessionSummary>
{
S("DE", "Frankfurt", 200, 100),
S("FR", "Paris", null, null),
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
var fr = result.Locations.Single(l => l.Country == "FR");
Assert.Equal(0d, fr.AvgApiLatencyMs);
Assert.Equal(0d, fr.ApiLatencyVsGlobalPct); // no data ≠ "0 ms fast"
}
}
@@ -0,0 +1,153 @@
using AutopilotMonitor.Functions.Functions.Bootstrap;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Seam tests for <c>GET /api/bootstrap/go/{code}</c> (GetBootstrapScriptFunction) —
/// the OOBE bootstrap script endpoint reached by customers as
/// <c>irm https://go.autopilotmonitor.com/{code} | iex</c>. Following the project
/// convention (see AppInsightsQueryFunctionTests), the HTTP entrypoint itself is not
/// faked; the compilable seams — code-format gate, policy-catalog registration, and
/// the no-store cache classification — are pinned here. Script generation and value
/// validation have their own suites.
/// </summary>
public class GetBootstrapScriptFunctionTests
{
private const string Route = "/api/bootstrap/go/abcd";
// ── Code-format gate: 4-10 alphanumeric, checked before any service call.
// Table ported from the vitest suite of the original Next.js route. ──
[Theory]
[InlineData("")]
[InlineData("abc")] // too short
[InlineData("abcdefghijk")] // too long
[InlineData("ab cd")]
[InlineData("../etc")]
[InlineData("ab%2Fcd")]
[InlineData("abc$")]
[InlineData("ab'cd")]
[InlineData("ab.cd")]
public void Rejects_invalid_code_formats(string code)
{
Assert.False(GetBootstrapScriptFunction.IsValidCodeFormat(code));
}
[Theory]
[InlineData("abcd")]
[InlineData("ABC123")]
[InlineData("a1b2c3d4e5")]
public void Accepts_valid_code_formats(string code)
{
Assert.True(GetBootstrapScriptFunction.IsValidCodeFormat(code));
}
// ── Catalog registration: anonymous (OOBE devices have no JWT, no cert),
// JWT-exempt via the catalog-derived rule. Unregistered routes fail closed,
// so this pins reachability. ──
[Fact]
public void Route_is_registered_as_PublicAnonymous()
{
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", Route);
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.PublicAnonymous, entry!.Policy);
}
[Fact]
public void Route_is_jwt_exempt()
{
Assert.True(AuthenticationMiddleware.SkipsJwtValidation("GET", Route));
}
// ── The script body inlines the bearer token — must never be cached. ──
[Fact]
public void Route_is_classified_no_store()
{
Assert.True(NoStoreCacheMiddleware.IsSensitive(Route));
}
// ── AFD-aware rate-limit extraction: X-Azure-ClientIP is honored only when
// X-Azure-FDID carries EXACTLY our profile ID. Multi-value headers mean
// someone appended to what AFD set — untrusted, fail closed. ──
private const string Fdid = "3490f60d-3639-495f-902e-681b9324d2ba";
[Theory]
[InlineData(new[] { Fdid }, true)]
[InlineData(new[] { "3490F60D-3639-495F-902E-681B9324D2BA" }, true)] // case-insensitive
[InlineData(new[] { " " + Fdid + " " }, true)] // trimmed
[InlineData(new[] { "other-profile-id" }, false)]
[InlineData(new[] { Fdid, Fdid }, false)] // two headers
[InlineData(new[] { Fdid + "," + Fdid }, false)] // comma-appended
[InlineData(new[] { "spoofed," + Fdid }, false)]
[InlineData(new string[0], false)]
[InlineData(new[] { "" }, false)]
public void FdidMatches_requires_exactly_our_profile_id(string[] headerValues, bool expected)
{
Assert.Equal(expected, ClientIpExtractor.FdidMatches(headerValues, Fdid));
}
}
/// <summary>
/// Pins the customer-facing bootstrap URL produced by CreateBootstrapSessionFunction —
/// previously unguarded, which made a silent origin change possible. The literal here
/// is a deliberate independent oracle per the url-registry doctrine (test files are
/// excluded from HardcodedUrlGuardTests).
/// </summary>
public class CreateBootstrapSessionUrlShapeTests
{
[Fact]
public void BootstrapGoBaseUrl_is_the_go_domain()
{
Assert.Equal("https://go.autopilotmonitor.com", Constants.BootstrapGoBaseUrl);
}
// ── Notification session deep link (Teams/Slack/webhook "Open session"): the
// query-string shape the static-export portal understands. Literal is a
// deliberate independent oracle; the legacy /sessions/{id} path shape is
// rewritten client-side by the portal forever. ──
[Fact]
public void PortalSessionUrl_is_the_query_string_shape()
{
Assert.Equal(
"https://portal.autopilotmonitor.com/sessions?id=abc-123",
Constants.PortalSessionUrl("abc-123"));
}
[Fact]
public void PortalSessionUrl_escapes_the_session_id()
{
Assert.Equal(
"https://portal.autopilotmonitor.com/sessions?id=a%26b%3Dc",
Constants.PortalSessionUrl("a&b=c"));
}
[Fact]
public void CreateBootstrapSession_builds_the_url_from_the_go_constant()
{
// Source-level pin (the URL expression sits inside the HTTP entrypoint with no
// injectable seam): the create function must derive BootstrapUrl from
// BootstrapGoBaseUrl + "/{shortCode}" — not from WebsiteBaseUrl, and without a
// path segment between host and code.
var repoRoot = FindRepoRoot();
var source = File.ReadAllText(Path.Combine(
repoRoot, "src", "Backend", "AutopilotMonitor.Functions",
"Functions", "Bootstrap", "CreateBootstrapSessionFunction.cs"));
Assert.Contains("$\"{Constants.BootstrapGoBaseUrl}/{session.ShortCode}\"", source);
Assert.DoesNotContain("WebsiteBaseUrl}/go/", source);
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -0,0 +1,189 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Guards the cross-tenant redaction on GET metrics/ime-versions.
///
/// The route is <c>MemberRead</c> (EndpointAccessPolicyCatalog) and the archive itself is a
/// single global partition — every tenant member reads the SAME platform-wide rows. The only
/// thing standing between an ordinary tenant user and another tenant's identifiers is the
/// projection in <see cref="GetImeVersionHistoryFunction.BuildResponsePayload"/>. That made it
/// worth pinning: the redaction had no test at all, and a boundary nobody asserts is one a
/// later refactor can open without a single failure.
///
/// The JSON tests are the ones that matter — a property dropped from an anonymous type is only
/// a real redaction once it is also absent from the wire, and the wire is what the customer's
/// MCP client receives. camelCase mirrors Program.cs's PropertyNamingPolicy.
/// </summary>
public class GetImeVersionHistoryRedactionTests
{
private static readonly DateTime First = new(2026, 7, 8, 3, 18, 13, DateTimeKind.Utc);
private static readonly DateTime Last = new(2026, 7, 27, 16, 59, 34, DateTimeKind.Utc);
private static readonly JsonSerializerOptions CamelCase =
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
// =========================================================================
// Non-global caller (ordinary tenant member) — the redaction
// =========================================================================
[Fact]
public void NonGlobalCaller_DropsForeignTenantAndSessionIds()
{
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
Assert.DoesNotContain("firstSeenTenantId", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("firstSeenSessionId", json, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The values, not just the property names: a rename of the model property must not let the
/// identifier survive under a different key. Asserting on the GUIDs catches that.
/// </summary>
[Fact]
public void NonGlobalCaller_ForeignIdentifierValuesAppearNowhereInThePayload()
{
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
Assert.DoesNotContain("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("baae0453-6d19-47b5-aa58-8bed5a572fc8", json, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void NonGlobalCaller_ExposesExactlyTheFourAllowedProperties()
{
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, MakeEntry()));
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
Assert.Equal(new[] { "firstSeenAt", "lastSeenAt", "sessionCount", "version" }, keys);
}
/// <summary>
/// SessionCount stays on purpose — it is a platform-wide total, and the route exists so any
/// tenant member can track Microsoft's IME rollout. Pinned so that "the counts are global"
/// stays a decision someone made rather than something that quietly drifts either way.
/// </summary>
[Fact]
public void NonGlobalCaller_KeepsVersionDatesAndGlobalSessionCount()
{
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, MakeEntry()));
var row = doc.RootElement[0];
Assert.Equal("1.103.101.0", row.GetProperty("version").GetString());
Assert.Equal(First, row.GetProperty("firstSeenAt").GetDateTime());
Assert.Equal(Last, row.GetProperty("lastSeenAt").GetDateTime());
Assert.Equal(3109, row.GetProperty("sessionCount").GetInt32());
}
// =========================================================================
// Global caller (Global Admin or Global Reader) — full archive
// =========================================================================
[Fact]
public void GlobalCaller_KeepsTenantAndSessionIds()
{
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, MakeEntry()));
var row = doc.RootElement[0];
Assert.Equal("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", row.GetProperty("firstSeenTenantId").GetString());
Assert.Equal("baae0453-6d19-47b5-aa58-8bed5a572fc8", row.GetProperty("firstSeenSessionId").GetString());
}
[Fact]
public void GlobalCaller_ExposesTheFullEntry()
{
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, MakeEntry()));
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
Assert.Equal(
new[] { "firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt", "sessionCount", "version" },
keys);
}
// =========================================================================
// Shape-preserving properties of the projection
// =========================================================================
[Fact]
public void BothScopes_EmitAJsonArray_EvenForASingleRow()
{
foreach (var scope in new[] { true, false })
{
using var doc = JsonDocument.Parse(SerializeFor(scope, MakeEntry()));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
}
}
[Fact]
public void BothScopes_EmptyArchive_SerializesAsEmptyArray()
{
foreach (var scope in new[] { true, false })
{
using var doc = JsonDocument.Parse(SerializeFor(scope));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
}
/// <summary>
/// The repository orders by FirstSeenAt descending; the projection must not reshuffle, or a
/// redacted caller would see a different sequence than a Global Admin for the same archive.
/// </summary>
[Fact]
public void NonGlobalCaller_PreservesRowOrderAndCount()
{
var newest = MakeEntry("1.103.101.0", First);
var older = MakeEntry("1.101.111.0", First.AddDays(-48));
var oldest = MakeEntry("1.99.101.0", First.AddDays(-131));
using var doc = JsonDocument.Parse(SerializeFor(false, newest, older, oldest));
Assert.Equal(3, doc.RootElement.GetArrayLength());
Assert.Equal("1.103.101.0", doc.RootElement[0].GetProperty("version").GetString());
Assert.Equal("1.101.111.0", doc.RootElement[1].GetProperty("version").GetString());
Assert.Equal("1.99.101.0", doc.RootElement[2].GetProperty("version").GetString());
}
/// <summary>
/// BuildResponsePayload returns `object`; the branch that keeps everything hands back the
/// entries themselves. System.Text.Json resolves an `object` declared type via the runtime
/// type, which is what keeps the wire format identical to the pre-extraction inline code —
/// assert it rather than trust it, since a regression here would silently emit "{}" rows.
/// </summary>
[Fact]
public void GlobalPayload_SerializedAsObject_StillEmitsPopulatedRows()
{
var payload = GetImeVersionHistoryFunction.BuildResponsePayload([MakeEntry()], hasGlobalScope: true);
var json = JsonSerializer.Serialize(payload, CamelCase);
Assert.Contains("1.103.101.0", json, StringComparison.Ordinal);
Assert.NotEqual("[{}]", json);
}
// =========================================================================
// Helpers
// =========================================================================
private static string SerializeFor(bool hasGlobalScope, params ImeVersionHistoryEntry[] versions) =>
JsonSerializer.Serialize(
GetImeVersionHistoryFunction.BuildResponsePayload(versions, hasGlobalScope),
CamelCase);
private static ImeVersionHistoryEntry MakeEntry(string version = "1.103.101.0", DateTime? firstSeenAt = null) =>
new()
{
Version = version,
FirstSeenAt = firstSeenAt ?? First,
FirstSeenSessionId = "baae0453-6d19-47b5-aa58-8bed5a572fc8",
FirstSeenTenantId = "57f34dd1-6a42-47f9-9bc0-b921fa6caa30",
LastSeenAt = Last,
SessionCount = 3109,
};
}
@@ -26,6 +26,7 @@ public class HardcodedUrlGuardTests
"www.autopilotmonitor.com",
"docs.autopilotmonitor.com",
"download.autopilotmonitor.com",
"go.autopilotmonitor.com",
"mcp.autopilotmonitor.com",
"autopilotmonitor-api-eu.azurewebsites.net",
"autopilotmonitor.blob.core.windows.net",
@@ -135,8 +135,64 @@ public class HardwareRejectionNotificationTrackerDedupTests
Assert.Equal(2, harness.Store.Count);
}
// ── F3 rule regression: one bell per episode, keyspaces stay independent ──────
[Fact]
public async Task RuleRegression_SecondRegister_SameRule_ReturnsFalse_OneBellPerEpisode()
{
var harness = new Harness();
var alert = new AutopilotMonitor.Shared.Models.RuleRegressionAlert
{
TenantId = TenantId, RuleId = "ANALYZE-NET-001",
FirstNotifiedAt = DateTime.UtcNow, LastEvaluatedAt = DateTime.UtcNow,
};
Assert.True(await harness.Sut.TryRegisterRuleRegressionAsync(TenantId, alert));
Assert.False(await harness.Sut.TryRegisterRuleRegressionAsync(TenantId, alert));
var entity = Assert.Single(harness.Store.Values);
Assert.Equal("ruleregression|analyze-net-001", entity.RowKey);
Assert.NotNull(entity.GetDateTime("FirstNotifiedAt")); // the retention sweep depends on it
}
[Fact]
public async Task RuleRegression_DeleteReArms_AndDoesNotTouchOtherKeyspaces()
{
var harness = new Harness();
var alert = new AutopilotMonitor.Shared.Models.RuleRegressionAlert
{
TenantId = TenantId, RuleId = "ANALYZE-NET-001",
FirstNotifiedAt = DateTime.UtcNow, LastEvaluatedAt = DateTime.UtcNow,
};
Assert.True(await harness.Sut.TryRegisterRuleRegressionAsync(TenantId, alert));
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
await harness.Sut.DeleteRuleRegressionAsync(TenantId, "ANALYZE-NET-001");
// The episode re-arms; the unrelated tpmpss row survives.
Assert.Equal("tpmpss|s4sq8685", Assert.Single(harness.Store.Values).RowKey);
Assert.True(await harness.Sut.TryRegisterRuleRegressionAsync(TenantId, alert));
}
// ── Retention sweep ───────────────────────────────────────────────────────────
[Fact]
public async Task DeleteOlderThanAsync_PrunesRuleRegressionRows_ReArmingLongBurningEpisodes()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterRuleRegressionAsync(TenantId,
new AutopilotMonitor.Shared.Models.RuleRegressionAlert
{
TenantId = TenantId, RuleId = "ANALYZE-NET-001",
FirstNotifiedAt = DateTime.UtcNow, LastEvaluatedAt = DateTime.UtcNow,
}));
harness.AgeRows(TimeSpan.FromDays(31));
// Spec §F3: retention cleanup re-arms — a month-old still-burning regression rings again.
Assert.Equal(1, await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30)));
Assert.Empty(harness.Store);
}
[Fact]
public async Task DeleteOlderThanAsync_PrunesTpmPssRows_AndReArmsThatDevicesBell()
{
@@ -0,0 +1,165 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the per-session API-latency projection (2026-07-28): the V2 agent emits cumulative
/// counters <c>net_total_latency_ms</c> / <c>net_total_requests</c> in every
/// <c>agent_metrics_snapshot</c>; ingest derives the session-wide average from the LAST
/// snapshot of the batch and persists it via <c>UpdateSessionNetworkLatencyAsync</c>
/// (last-write-wins on cumulative counters → replay-idempotent).
/// Wire-format note: the ingest body is parsed with Newtonsoft into
/// <c>Dictionary&lt;string, object&gt;</c>, so integer JSON numbers arrive as boxed
/// <c>long</c> — fixtures mirror that, not the agent's in-memory types.
/// </summary>
public class IngestApiLatencyProjectionTests
{
private static EnrollmentEvent Snapshot(object? totalLatencyMs, object? totalRequests)
{
var evt = new EnrollmentEvent
{
EventType = "agent_metrics_snapshot",
Data = new Dictionary<string, object>
{
["agent_cpu_percent"] = 3.62,
["net_requests"] = 50L,
["net_avg_latency_ms"] = 513.0,
},
};
if (totalLatencyMs != null) evt.Data["net_total_latency_ms"] = totalLatencyMs;
if (totalRequests != null) evt.Data["net_total_requests"] = totalRequests;
return evt;
}
[Fact]
public void CumulativeCounters_YieldSessionAverage()
{
var events = new List<EnrollmentEvent> { Snapshot(51_300L, 100L) };
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
Assert.Equal(513.0, avg);
Assert.Equal(100, count);
}
[Fact]
public void AverageIsRounded_ToOneDecimal()
{
var events = new List<EnrollmentEvent> { Snapshot(1_000L, 3L) };
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out _));
Assert.Equal(333.3, avg);
}
[Fact]
public void LastSnapshotOfBatch_Wins()
{
// Cumulative counters grow monotonically — the last snapshot carries the most
// complete session average.
var events = new List<EnrollmentEvent>
{
Snapshot(51_300L, 100L),
new EnrollmentEvent { EventType = "download_progress" },
Snapshot(120_000L, 200L),
};
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
Assert.Equal(600.0, avg);
Assert.Equal(200, count);
}
[Fact]
public void OldAgentSnapshot_WithoutTotalLatencyField_DoesNotMatch()
{
// Agents predating net_total_latency_ms emit net_total_requests only.
var events = new List<EnrollmentEvent> { Snapshot(totalLatencyMs: null, totalRequests: 100L) };
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
}
[Fact]
public void SnapshotWithLatencyButMissingRequests_DoesNotMatch()
{
var events = new List<EnrollmentEvent> { Snapshot(51_300L, totalRequests: null) };
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
}
[Fact]
public void ZeroRequests_DoesNotMatch()
{
// No requests yet (first snapshot before the first upload completes) — no division.
var events = new List<EnrollmentEvent> { Snapshot(0L, 0L) };
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
}
[Fact]
public void MatchingLastSnapshot_SkipsOlderAgentSnapshotAfterIt()
{
// A field-less (old-schema) snapshot later in the batch must not shadow an earlier
// one that carries the counters — the predicate selects the last MATCHING snapshot.
var events = new List<EnrollmentEvent>
{
Snapshot(51_300L, 100L),
Snapshot(totalLatencyMs: null, totalRequests: 150L),
};
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
Assert.Equal(513.0, avg);
Assert.Equal(100, count);
}
[Fact]
public void BatchWithoutSnapshots_DoesNotMatch()
{
var events = new List<EnrollmentEvent>
{
new EnrollmentEvent { EventType = "esp_phase_changed" },
new EnrollmentEvent { EventType = "app_install_started" },
};
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
}
// -------- TryGetDouble coercion ladder (mirrors PlatformMetricsService.GetDouble) --------
[Theory]
[InlineData(513.5)] // double — decimal JSON number
[InlineData(513)] // int — in-memory constructed events (tests, V1 paths)
[InlineData(513L)] // long — Newtonsoft wire form for integer JSON numbers
[InlineData(513f)] // float
public void TryGetDouble_CoercesBoxedNumericPrimitives(object boxed)
{
var data = new Dictionary<string, object> { ["k"] = boxed };
Assert.True(EventIngestProcessor.TryGetDouble(data, "k", out var value));
Assert.Equal(Convert.ToDouble(boxed), value);
}
[Fact]
public void TryGetDouble_ParsesStrings_InvariantCulture()
{
var data = new Dictionary<string, object> { ["k"] = "513.5" };
Assert.True(EventIngestProcessor.TryGetDouble(data, "k", out var value));
Assert.Equal(513.5, value);
}
[Theory]
[InlineData("not-a-number")]
[InlineData("")]
public void TryGetDouble_RejectsUnparseableStrings(string raw)
{
var data = new Dictionary<string, object> { ["k"] = raw };
Assert.False(EventIngestProcessor.TryGetDouble(data, "k", out _));
}
[Fact]
public void TryGetDouble_MissingKey_ReturnsFalse()
{
Assert.False(EventIngestProcessor.TryGetDouble(new Dictionary<string, object>(), "k", out _));
}
}
@@ -0,0 +1,124 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the per-session connection-type projection (2026-07-28): the agent stamps
/// <c>connectionType</c> ("WiFi"/"Ethernet") into every <c>network_interface_info</c>
/// emission (initial collect + re-collect on change); ingest takes the LAST matching
/// event of the batch and persists it via <c>UpdateSessionConnectionTypeAsync</c>
/// (last-write-wins → a device that switches media mid-enrollment is relabeled).
/// The no-NIC payload ({"status":"no_active_interface"}) carries no connectionType
/// and must never match.
/// </summary>
public class IngestConnectionTypeProjectionTests
{
private static EnrollmentEvent NicInfo(string? connectionType)
{
var evt = new EnrollmentEvent
{
EventType = "network_interface_info",
Data = new Dictionary<string, object>
{
["adapterName"] = "Ethernet 2",
["linkSpeedMbps"] = 1000L,
},
};
if (connectionType != null) evt.Data["connectionType"] = connectionType;
return evt;
}
[Fact]
public void EthernetEmission_IsExtracted()
{
var events = new List<EnrollmentEvent> { NicInfo("Ethernet") };
Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type));
Assert.Equal("Ethernet", type);
}
[Fact]
public void LastEmissionOfBatch_Wins()
{
// Media switch mid-batch (dock removed → WiFi) — the most recent state wins.
var events = new List<EnrollmentEvent>
{
NicInfo("Ethernet"),
new EnrollmentEvent { EventType = "download_progress" },
NicInfo("WiFi"),
};
Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type));
Assert.Equal("WiFi", type);
}
[Fact]
public void NoActiveInterfacePayload_DoesNotMatch()
{
// The no-NIC emission carries only {"status":"no_active_interface"}.
var events = new List<EnrollmentEvent>
{
new EnrollmentEvent
{
EventType = "network_interface_info",
Data = new Dictionary<string, object> { ["status"] = "no_active_interface" },
},
};
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
}
[Fact]
public void NoActiveInterfaceAfterValidEmission_DoesNotShadowIt()
{
// The predicate selects the last event that CARRIES connectionType.
var events = new List<EnrollmentEvent>
{
NicInfo("WiFi"),
new EnrollmentEvent
{
EventType = "network_interface_info",
Data = new Dictionary<string, object> { ["status"] = "no_active_interface" },
},
};
Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type));
Assert.Equal("WiFi", type);
}
[Theory]
[InlineData("Cellular")]
[InlineData("wifi")] // agent emits exact casing — anything else is unexpected
[InlineData("")]
public void UnexpectedValues_AreDroppedDefensively(string raw)
{
var events = new List<EnrollmentEvent> { NicInfo(raw) };
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
}
[Fact]
public void BatchWithoutNicEvents_DoesNotMatch()
{
var events = new List<EnrollmentEvent>
{
new EnrollmentEvent { EventType = "esp_phase_changed" },
new EnrollmentEvent { EventType = "agent_metrics_snapshot" },
};
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
}
[Fact]
public void NullData_DoesNotMatch()
{
var events = new List<EnrollmentEvent>
{
new EnrollmentEvent { EventType = "network_interface_info", Data = null! },
};
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
}
}
@@ -137,6 +137,120 @@ public class MetricsMathAppPayloadTests
Assert.Equal(50d, app.GetProperty("failureRate").GetDouble());
}
// ── PR0 (2026-07-26): skip/unmeasured classification ────────────────────
[Fact]
public void Skips_LeaveTheFailureRate_AndAreReportedSeparately()
{
// 1 Failed + 1 real install + 3 skips ("Update for X" not applicable).
// Rate must be 1/(1+1) = 50%, NOT 1/(1+4) = 20% — skips are not attempts.
var summaries = new List<AppInstallSummary>
{
new() { AppName = "App A", Status = "Failed", TerminalState = "Error" },
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 30 },
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Skipped" },
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Skipped" },
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Postponed" },
};
var root = Build(summaries);
var app = root.GetProperty("topFailingApps")[0];
Assert.Equal(50d, app.GetProperty("failureRate").GetDouble());
Assert.Equal(1, app.GetProperty("succeeded").GetInt32());
Assert.Equal(3, app.GetProperty("skipped").GetInt32());
Assert.Equal(0, app.GetProperty("unmeasured").GetInt32());
Assert.Equal(3, root.GetProperty("totalSkipped").GetInt32());
}
[Fact]
public void ZeroDurations_AreExcludedFromAverages_AndCountedAsUnmeasured()
{
// Two measured installs (100s, 200s) + one whose start was never observed (audit §0.5:
// agent attach window — duration UNKNOWN). avg must be 150, not (100+200+0)/3 = 100.
var summaries = new List<AppInstallSummary>
{
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 100 },
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 200 },
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 0 },
};
var root = Build(summaries);
Assert.Equal(1, root.GetProperty("totalUnmeasured").GetInt32());
// Rebuild with a third measured row to clear the sample floor and read the average.
summaries.Add(new AppInstallSummary { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 150 });
var slowest = Build(summaries).GetProperty("slowestApps");
Assert.Equal(1, slowest.GetArrayLength());
Assert.Equal(150d, slowest[0].GetProperty("avgDurationSeconds").GetDouble());
Assert.Equal(1, slowest[0].GetProperty("unmeasured").GetInt32());
}
[Fact]
public void LegacyZeroDurationRows_StayInRate_ButNeverInDurations()
{
// Rows written before TerminalState existed: cannot be classified as skip vs unmeasured.
// They keep the legacy rate behavior (counted as succeeded) and are excluded from
// durations (counted as unmeasured — duration genuinely not measured).
var summaries = new List<AppInstallSummary>
{
new() { AppName = "App A", Status = "Failed" },
new() { AppName = "App A", Status = "Succeeded", DurationSeconds = 0 }, // legacy, no TerminalState
new() { AppName = "App A", Status = "Succeeded", DurationSeconds = 60, TerminalState = "Installed" },
};
var app = Build(summaries).GetProperty("topFailingApps")[0];
Assert.Equal(2, app.GetProperty("succeeded").GetInt32()); // legacy row stays in the rate
Assert.Equal(33.3d, app.GetProperty("failureRate").GetDouble());
Assert.Equal(1, app.GetProperty("unmeasured").GetInt32());
Assert.Equal(0, app.GetProperty("skipped").GetInt32());
}
[Fact]
public void ImplausibleDurations_AreExcludedFromAverages_AndCountedAsUnmeasured()
{
// A duration beyond the agent's max observation lifetime is a back-stamped emission
// artifact (CompletedAt pinned at session end — production: 60 175 s app rows inside a
// 2 697 s session), not a slow install. It must fall out of the averages exactly like
// an unobserved start. Boundary: exactly 6 h is still plausible/measured.
var summaries = new List<AppInstallSummary>
{
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 100 },
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 200 },
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed",
DurationSeconds = MetricsMath.MaxPlausibleInstallDurationSeconds }, // boundary: measured
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed",
DurationSeconds = MetricsMath.MaxPlausibleInstallDurationSeconds + 1 }, // artifact: unmeasured
};
var root = Build(summaries);
Assert.Equal(1, root.GetProperty("totalUnmeasured").GetInt32());
var slowest = root.GetProperty("slowestApps");
Assert.Equal(1, slowest.GetArrayLength());
Assert.Equal(3, slowest[0].GetProperty("measuredInstalls").GetInt32());
// avg over {100, 200, 21600} — the 21601 artifact contributes nothing.
Assert.Equal(7300d, slowest[0].GetProperty("avgDurationSeconds").GetDouble());
Assert.Equal(MetricsMath.MaxPlausibleInstallDurationSeconds, slowest[0].GetProperty("maxDurationSeconds").GetInt32());
}
[Fact]
public void SlowestApps_GateOnMeasuredInstalls_NotOnSucceededCount()
{
// 3 succeeded but only 2 measured → below the floor; an app must not rank "fast"
// (or at all) on a handful of unobserved zeros.
var summaries = new List<AppInstallSummary>
{
new() { AppName = "Mostly Unmeasured", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 5 },
new() { AppName = "Mostly Unmeasured", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 5 },
new() { AppName = "Mostly Unmeasured", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 0 },
};
var slowest = Build(summaries).GetProperty("slowestApps");
Assert.Equal(0, slowest.GetArrayLength());
}
[Fact]
public void SlowestApps_DropsAppsBelowSampleFloor()
{
@@ -125,5 +125,6 @@ public class OffboardingSessionEnumeratorTests
public Task<bool> IsSessionIndexEmptyAsync() => throw new NotImplementedException();
public Task<List<OrphanedEventSession>> GetOrphanedEventSessionsAsync(TimeSpan gracePeriod) => throw new NotImplementedException();
public Task DeleteEventSessionIndexEntryAsync(string tenantId, string sessionId) => throw new NotImplementedException();
public Task<List<(string TenantId, string SessionId)>> GetAllSessionTombstoneKeysAsync() => throw new NotImplementedException();
}
}
@@ -0,0 +1,112 @@
using AutopilotMonitor.Functions.Functions.Bootstrap;
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the OOBE bootstrap script generator, ported from the portal's
/// app/go/[code]/route.ts. The golden test asserts byte parity (modulo line
/// endings) with the original TypeScript template output — the reference file
/// TestData/oobe-bootstrap-golden.ps1 was captured by evaluating the TS template
/// literal with the same fixed inputs. Ports the behavioral cases of the vitest
/// suite app/go/[code]/__tests__/route.test.ts that concern script generation.
/// </summary>
public class OobeBootstrapScriptGeneratorTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string Token = "22222222-2222-2222-2222-222222222222";
private const string DownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
private static BootstrapScriptValueValidator.ValidatedValues FixedValues() =>
new(TenantId, Token, DownloadUrl,
new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc));
private static string Generate() =>
OobeBootstrapScriptGenerator.GenerateSuccessScript(
FixedValues(),
new DateTime(2026, 7, 28, 12, 0, 0, DateTimeKind.Utc));
// ── Golden parity with the original Next.js template ──
[Fact]
public void SuccessScript_matches_the_typescript_reference_output()
{
var goldenPath = Path.Combine(AppContext.BaseDirectory, "TestData", "oobe-bootstrap-golden.ps1");
var golden = File.ReadAllText(goldenPath);
var actual = Generate();
// Normalize line endings on both sides: git checkout / editors may flip
// LF↔CRLF in either the .cs raw string or the golden asset; PowerShell
// accepts both. Content parity is what the test pins.
static string Normalize(string s) => s.Replace("\r\n", "\n");
Assert.Equal(Normalize(golden), Normalize(actual));
}
// ── Success-path markers (vitest parity) ──
[Fact]
public void SuccessScript_contains_the_expected_markers()
{
var script = Generate();
Assert.StartsWith("#Requires -RunAsAdministrator", script);
Assert.Contains($"--tenant-id '{TenantId}'", script);
Assert.Contains($"--bootstrap-token '{Token}'", script);
Assert.Contains($"$AgentDownloadUrl = '{DownloadUrl}'", script);
Assert.Contains("Generated: 2026-07-28T12:00:00.000Z", script);
Assert.Contains("Expires: 2026-08-01T12:00:00.000Z", script);
Assert.Contains("https://portal.autopilotmonitor.com", script);
}
// ── SECURITY INVARIANT: every substituted value sits inside a single-quoted
// PS literal (single-quoted strings do not expand $(), $var, or backticks).
// The expiry timestamp appears twice: once in the plain-text comment block
// and once quoted in the Write-Log line. ──
[Fact]
public void SuccessScript_frames_all_interpolated_values_in_single_quotes()
{
var script = Generate();
Assert.Contains($"'{DownloadUrl}'", script);
Assert.Contains($"'{Token}'", script);
Assert.Contains($"'{TenantId}'", script);
Assert.Contains("'Bootstrap token expires: 2026-08-01T12:00:00.000Z'", script);
}
// ── Error script: message surfaces via Write-Host, always as a script ──
[Fact]
public void ErrorScript_contains_the_message()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript("Invalid bootstrap code format.");
Assert.Contains("ERROR: Invalid bootstrap code format.", script);
Assert.Contains("Write-Host", script);
}
[Fact]
public void ErrorScript_doubles_single_quotes_for_powershell()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript("it's a 'bad' code");
Assert.Contains("it''s a ''bad'' code", script);
}
// ── Cap is applied to the RAW message before quote-escaping, so a
// quote-heavy message cannot double past the cap. ──
[Fact]
public void ErrorScript_caps_the_message_at_200_chars_before_escaping()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript(new string('X', 300));
Assert.Contains(new string('X', 200) + "...", script);
Assert.DoesNotContain(new string('X', 201), script);
}
[Fact]
public void ErrorScript_cap_counts_raw_length_not_escaped_length()
{
var script = OobeBootstrapScriptGenerator.GenerateErrorScript(new string('\'', 300));
// 200 raw quotes escape to 400 — allowed, because the cap is pre-escape.
Assert.Contains(new string('\'', 400) + "...", script);
}
}
@@ -0,0 +1,405 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// F3 PR6 (insights spec §F3): the Wilson primitive (hand-computed vectors), the radar's
/// detection gates and EVERY suppression branch (table-driven), the re-arm rule, the
/// dimension-concentration gates, the tracker keyspace round-trip (tri-states included)
/// and the alert wording contracts ("correlated, not necessarily causal").
/// </summary>
public class RuleRegressionRadarTests
{
private static readonly DateTime Target = new(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc);
private const string TenantA = "aaaaaaaa-0000-0000-0000-000000000001";
private const string RuleX = "ANALYZE-NET-001";
// ── Wilson interval (MetricsMath) — hand-computed 95 % vectors ──────────
[Theory]
[InlineData(10, 20, 0.2993, 0.7007)] // symmetric mid-range
[InlineData(0, 20, 0.0000, 0.1611)] // zero successes: lower pinned at 0
[InlineData(5, 25, 0.0886, 0.3913)]
public void WilsonInterval_MatchesHandComputedVectors(int successes, int trials, double lower, double upper)
{
var (l, u) = MetricsMath.WilsonInterval(successes, trials);
Assert.Equal(lower, l, 3);
Assert.Equal(upper, u, 3);
}
[Fact]
public void WilsonInterval_ZeroTrials_IsUninformative_NeverClaimsSeparation()
{
Assert.Equal((0.0, 1.0), MetricsMath.WilsonInterval(0, 0));
// A zero-denominator window can never be "separated" from anything.
Assert.False(MetricsMath.RateIncreaseSeparated(0, 0, 10, 500));
}
[Fact]
public void RateIncreaseSeparated_DisjointIntervals_True_OverlappingFalse()
{
// 15 % on n=100 (lower ≈ 0.093) vs 2 % baseline on n=500 (upper ≈ 0.036) → separated.
Assert.True(MetricsMath.RateIncreaseSeparated(15, 100, 10, 500));
// 5 % on n=100 vs 4 % baseline on n=500 → intervals overlap → noise, not a regression.
Assert.False(MetricsMath.RateIncreaseSeparated(5, 100, 20, 500));
}
// ── radar evaluation — gates + suppression branches ─────────────────────
private static RuleStatsEntry Entry(
string date, int fire, int sessions, string ruleId = RuleX, string ruleType = "analyze")
=> new()
{
TenantId = TenantA,
Date = date,
RuleId = ruleId,
RuleType = ruleType,
RuleTitle = "Network check",
Category = "network",
Severity = "warning",
FireCount = fire,
EvaluationCount = sessions,
SessionsEvaluated = sessions,
};
private static string Day(int daysBeforeTarget) => Target.AddDays(-daysBeforeTarget).ToString("yyyy-MM-dd");
/// <summary>Window fire=15/100 across two days; baseline fire=10/500 across three days — fires all gates.</summary>
private static List<RuleStatsEntry> RegressedEntries() => new()
{
Entry(Day(0), 10, 60),
Entry(Day(6), 5, 40), // windowStart boundary — still window
Entry(Day(7), 4, 200), // first baseline day
Entry(Day(20), 4, 200),
Entry(Day(34), 2, 100), // baselineStart boundary — still baseline
};
private static Dictionary<string, RuleRegressionRadar.RuleTimestamps> OldRule(
DateTime? createdAt = null, DateTime? updatedAt = null, string ruleId = RuleX)
=> new(StringComparer.OrdinalIgnoreCase)
{
[ruleId] = new RuleRegressionRadar.RuleTimestamps(
createdAt ?? Target.AddDays(-100), updatedAt ?? Target.AddDays(-50)),
};
[Fact]
public void Evaluate_AllGatesPass_FiresWithFullNumbers()
{
var findings = RuleRegressionRadar.Evaluate(RegressedEntries(), Target, OldRule());
var finding = Assert.Single(findings);
Assert.Equal(RuleX, finding.RuleId);
Assert.Equal(15, finding.WindowFireCount);
Assert.Equal(100, finding.WindowSessionCount);
Assert.Equal(10, finding.BaselineFireCount);
Assert.Equal(500, finding.BaselineSessionCount);
Assert.Equal(15.0, finding.WindowRatePct);
Assert.Equal(2.0, finding.BaselineRatePct);
Assert.Equal(7.5, finding.Lift);
Assert.Equal(Day(6), finding.WindowStartDate);
Assert.Equal(Day(0), finding.WindowEndDate);
}
[Fact]
public void Evaluate_ZeroBaselineFires_FiresAsNewSignal_WithNullLift()
{
var entries = new List<RuleStatsEntry>
{
Entry(Day(0), 15, 100),
Entry(Day(10), 0, 500),
};
var finding = Assert.Single(RuleRegressionRadar.Evaluate(entries, Target, OldRule()));
Assert.Null(finding.Lift); // no finite lift is ever invented (truthfulness rule 1)
Assert.Equal(0.0, finding.BaselineRatePct);
}
public static TheoryData<string, List<RuleStatsEntry>> SuppressedByCounts() => new()
{
// Below the ≥5 hit-session gate.
{ "below hit gate", new List<RuleStatsEntry> { Entry(Day(0), 4, 100), Entry(Day(10), 1, 500) } },
// Below the ≥20 evaluated-session gate.
{ "below session gate", new List<RuleStatsEntry> { Entry(Day(0), 5, 19), Entry(Day(10), 1, 500) } },
// No baseline denominator at all (rule disabled through the baseline, re-enabled recently).
{ "no baseline sessions", new List<RuleStatsEntry> { Entry(Day(0), 15, 100) } },
// Lift below 2× (10 % vs 6 %).
{ "lift below 2x", new List<RuleStatsEntry> { Entry(Day(0), 10, 100), Entry(Day(10), 30, 500) } },
// Lift 2.1× but Wilson intervals overlap (5/20 vs 60/500) — small-n noise never alerts.
{ "not separated", new List<RuleStatsEntry> { Entry(Day(0), 5, 20), Entry(Day(10), 60, 500) } },
// Gather rows are ignored entirely (audit Q6: per-batch dedup — bad math until fixed).
{ "gather ignored", new List<RuleStatsEntry>
{ Entry(Day(0), 50, 60, ruleType: "gather"), Entry(Day(10), 1, 500, ruleType: "gather") } },
};
[Theory]
[MemberData(nameof(SuppressedByCounts))]
public void Evaluate_SuppressionBranches_ProduceNoFinding(string branch, List<RuleStatsEntry> entries)
{
Assert.True(RuleRegressionRadar.Evaluate(entries, Target, OldRule()).Count == 0,
$"branch '{branch}' must not fire");
}
[Fact]
public void Evaluate_RuleEntityGates_Suppress()
{
var entries = RegressedEntries();
// Deleted rule (no entity) never alerts.
Assert.Empty(RuleRegressionRadar.Evaluate(
entries, Target, new Dictionary<string, RuleRegressionRadar.RuleTimestamps>()));
// Grace period: created inside baseline+window (< 34 days before target).
Assert.Empty(RuleRegressionRadar.Evaluate(
entries, Target, OldRule(createdAt: Target.AddDays(-20))));
// Edited inside the 7-day window — edits legitimately change hit rates.
Assert.Empty(RuleRegressionRadar.Evaluate(
entries, Target, OldRule(updatedAt: Target.AddDays(-2))));
// Edited just BEFORE the window → not suppressed.
Assert.Single(RuleRegressionRadar.Evaluate(
entries, Target, OldRule(updatedAt: Target.AddDays(-8))));
}
[Fact]
public void SumWindows_BoundaryDates_LandInTheRightBucket()
{
var entries = new List<RuleStatsEntry>
{
Entry(Target.AddDays(1).ToString("yyyy-MM-dd"), 100, 100), // future — ignored
Entry(Day(0), 1, 10),
Entry(Day(6), 2, 20), // last window day
Entry(Day(7), 4, 40), // first baseline day
Entry(Day(34), 8, 80), // last baseline day
Entry(Day(35), 100, 100), // beyond horizon — ignored
};
var sums = RuleRegressionRadar.SumWindows(entries, Target);
Assert.Equal(3, sums.WindowFire);
Assert.Equal(30, sums.WindowSessions);
Assert.Equal(12, sums.BaselineFire);
Assert.Equal(120, sums.BaselineSessions);
}
// ── re-arm rule ─────────────────────────────────────────────────────────
[Fact]
public void ShouldReArm_FiresStopped_OrRateBackUnderOnePointFive()
{
// Nothing fired in the window anymore → re-arm (also covers "no stats rows at all").
Assert.True(RuleRegressionRadar.ShouldReArm(new List<RuleStatsEntry>(), Target));
Assert.True(RuleRegressionRadar.ShouldReArm(
new List<RuleStatsEntry> { Entry(Day(0), 0, 100), Entry(Day(10), 10, 500) }, Target));
// 2 % window vs 2 % baseline → 1.0× < 1.5 → re-arm.
Assert.True(RuleRegressionRadar.ShouldReArm(
new List<RuleStatsEntry> { Entry(Day(0), 2, 100), Entry(Day(10), 10, 500) }, Target));
// 4 % window vs 2 % baseline → 2.0× ≥ 1.5 → still elevated, keep the episode.
Assert.False(RuleRegressionRadar.ShouldReArm(
new List<RuleStatsEntry> { Entry(Day(0), 4, 100), Entry(Day(10), 10, 500) }, Target));
// Zero-baseline episode ("new signal"): no rate threshold to fall under — only the
// fires-stopped branch re-arms it.
Assert.False(RuleRegressionRadar.ShouldReArm(
new List<RuleStatsEntry> { Entry(Day(0), 3, 100), Entry(Day(10), 0, 500) }, Target));
}
// ── dimension concentration ─────────────────────────────────────────────
private static SessionSummary DimSession(string osBuild = "", string model = "", string agentVersion = "")
=> new()
{
TenantId = TenantA,
SessionId = Guid.NewGuid().ToString(),
SerialNumber = "PF4X1ABC",
OsBuild = osBuild,
Manufacturer = "Contoso",
Model = model,
AgentVersion = agentVersion,
ImeAgentVersion = "",
StartedAt = Target,
};
[Fact]
public void DimensionConcentration_DominantOsBuild_ReportsBothShares()
{
var all = new List<SessionSummary>();
for (var i = 0; i < 20; i++) all.Add(DimSession(osBuild: "26100.4652"));
for (var i = 0; i < 80; i++) all.Add(DimSession(osBuild: "22631.3880"));
var hits = all.Take(16).Concat(all.Skip(20).Take(4)).ToList(); // 16 on the new build, 4 on the old
var result = RuleRegressionRadar.ComputeDimensionConcentration(hits, all)!;
Assert.Equal("osBuild", result.Dimension);
Assert.Equal("26100.4652", result.Value);
Assert.Equal(16, result.HitCount);
Assert.Equal(80.0, result.HitSharePct);
Assert.Equal(20.0, result.AllSharePct);
Assert.Equal(4.0, result.Lift);
}
[Fact]
public void DimensionConcentration_BelowGates_YieldsNull_NeverStretched()
{
var all = Enumerable.Range(0, 100).Select(_ => DimSession(osBuild: "26100.1")).ToList();
// Every session shares the build → lift 1.0 < 2 → no claim.
Assert.Null(RuleRegressionRadar.ComputeDimensionConcentration(all.Take(10).ToList(), all));
// Concentrated but only 4 hit sessions (< 5) → no claim.
var mixed = Enumerable.Range(0, 96).Select(_ => DimSession(osBuild: "22631.1"))
.Concat(Enumerable.Range(0, 4).Select(_ => DimSession(osBuild: "26100.9")))
.ToList();
var hits4 = mixed.Skip(96).ToList();
Assert.Null(RuleRegressionRadar.ComputeDimensionConcentration(hits4, mixed));
// No hit sessions at all → no claim.
Assert.Null(RuleRegressionRadar.ComputeDimensionConcentration(new List<SessionSummary>(), all));
}
[Fact]
public void DimensionConcentration_PicksTheHighestLiftAcrossDimensions()
{
var all = new List<SessionSummary>();
for (var i = 0; i < 25; i++) all.Add(DimSession(osBuild: "26100.1", model: "Laptop 5"));
for (var i = 0; i < 75; i++) all.Add(DimSession(osBuild: "22631.1", model: i < 25 ? "Laptop 5" : "Laptop 4"));
// Hits: 8 of 10 on the new build (lift 80/25 = 3.2); model "Contoso Laptop 5" is 50 % of
// fleet and 80 % of hits (lift 1.6 — below gate).
var hits = all.Take(8).Concat(all.Skip(25).Take(2)).ToList();
var result = RuleRegressionRadar.ComputeDimensionConcentration(hits, all)!;
Assert.Equal("osBuild", result.Dimension);
Assert.Equal(3.2, result.Lift);
}
// ── tracker keyspace (round-trip + key shape) ───────────────────────────
[Fact]
public void RuleRegressionRowKey_PrefixedAndCaseFolded()
{
Assert.Equal("ruleregression|analyze-net-001",
TableHardwareRejectionNotificationTracker.BuildRuleRegressionRowKey(" ANALYZE-NET-001 "));
}
[Fact]
public void RuleRegressionEntity_RoundTripsAllFields_IncludingDimension()
{
var alert = new RuleRegressionAlert
{
TenantId = TenantA,
RuleId = RuleX,
RuleTitle = "Network check",
WindowFireCount = 15,
WindowSessionCount = 100,
BaselineFireCount = 10,
BaselineSessionCount = 500,
WindowRatePct = 15.0,
BaselineRatePct = 2.0,
Lift = 7.5,
WindowStartDate = "2026-07-20",
WindowEndDate = "2026-07-26",
Dimension = new RuleRegressionDimension
{
Dimension = "osBuild", Value = "26100.4652",
HitCount = 12, HitSharePct = 80.0, AllSharePct = 20.0, Lift = 4.0,
},
FirstNotifiedAt = Target,
LastEvaluatedAt = Target.AddHours(2),
};
var entity = TableHardwareRejectionNotificationTracker.BuildRuleRegressionEntity(TenantA, alert);
Assert.Equal(TenantA.ToLowerInvariant(), entity.PartitionKey);
Assert.Equal("ruleregression|analyze-net-001", entity.RowKey);
var mapped = TableHardwareRejectionNotificationTracker.MapToRuleRegressionAlert(entity);
Assert.Equal(RuleX, mapped.RuleId);
Assert.Equal("Network check", mapped.RuleTitle);
Assert.Equal(15, mapped.WindowFireCount);
Assert.Equal(100, mapped.WindowSessionCount);
Assert.Equal(10, mapped.BaselineFireCount);
Assert.Equal(500, mapped.BaselineSessionCount);
Assert.Equal(15.0, mapped.WindowRatePct);
Assert.Equal(2.0, mapped.BaselineRatePct);
Assert.Equal(7.5, mapped.Lift);
Assert.Equal("2026-07-20", mapped.WindowStartDate);
Assert.Equal("2026-07-26", mapped.WindowEndDate);
Assert.Equal(Target, mapped.FirstNotifiedAt);
Assert.Equal(Target.AddHours(2), mapped.LastEvaluatedAt);
Assert.NotNull(mapped.Dimension);
Assert.Equal("osBuild", mapped.Dimension!.Dimension);
Assert.Equal(4.0, mapped.Dimension.Lift);
}
[Fact]
public void RuleRegressionEntity_TriStates_AbsentColumnsMapToNull_NeverInvented()
{
var alert = new RuleRegressionAlert
{
TenantId = TenantA, RuleId = RuleX,
Lift = null, // new signal — no finite lift
Dimension = null, // no clear concentration
FirstNotifiedAt = Target, LastEvaluatedAt = Target,
};
var entity = TableHardwareRejectionNotificationTracker.BuildRuleRegressionEntity(TenantA, alert);
Assert.False(entity.ContainsKey("Lift"));
Assert.False(entity.ContainsKey("DimensionJson"));
var mapped = TableHardwareRejectionNotificationTracker.MapToRuleRegressionAlert(entity);
Assert.Null(mapped.Lift);
Assert.Null(mapped.Dimension);
}
// ── wording contracts ───────────────────────────────────────────────────
[Fact]
public void DescribeDimension_UsesCorrelationWording_NeverCausal()
{
var text = MaintenanceService.DescribeDimension(new RuleRegressionDimension
{
Dimension = "osBuild", Value = "26100.4652",
HitCount = 12, HitSharePct = 87.0, AllSharePct = 22.0, Lift = 4.0,
})!;
Assert.Contains("87% of affected sessions are on osBuild 26100.4652", text);
Assert.Contains("vs 22% of all sessions (lift 4x)", text);
Assert.Contains("correlated, not necessarily causal", text);
Assert.Null(MaintenanceService.DescribeDimension(null));
}
[Fact]
public void BuildRegressionMessage_CarriesFullNumbers_AndHonestNoDimensionFallback()
{
var finding = new RuleRegressionFinding
{
TenantId = TenantA, RuleId = RuleX, RuleTitle = "Network check",
WindowFireCount = 15, WindowSessionCount = 100, WindowRatePct = 15.0,
BaselineFireCount = 10, BaselineSessionCount = 500, BaselineRatePct = 2.0,
Lift = 7.5, WindowStartDate = "2026-07-20", WindowEndDate = "2026-07-26",
};
var message = MaintenanceService.BuildRegressionMessage(finding, dimensionSummary: null);
Assert.Contains("15 of 100 evaluated sessions", message);
Assert.Contains("(15%)", message);
Assert.Contains("baseline 2% (10/500 over the prior 28 days)", message);
Assert.Contains("lift 7.5x", message);
Assert.Contains("No clear dimension concentration.", message);
var newSignal = MaintenanceService.BuildRegressionMessage(
new RuleRegressionFinding
{
RuleId = RuleX, RuleTitle = "Network check",
WindowFireCount = 15, WindowSessionCount = 100, WindowRatePct = 15.0,
BaselineFireCount = 0, BaselineSessionCount = 500, BaselineRatePct = 0.0,
Lift = null, WindowStartDate = "2026-07-20", WindowEndDate = "2026-07-26",
},
dimensionSummary: "dim summary");
Assert.Contains("new signal", newSignal);
Assert.Contains("dim summary.", newSignal);
}
}
@@ -45,6 +45,12 @@ public class SessionIndexProjectionTests
["IsSelfDeployingProfile"] = true,
// Device-validation path recorded at registration — shown on the session detail page.
["ValidatedBy"] = "AutopilotV1",
// Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges these).
["AvgApiLatencyMs"] = 513.5,
["ApiRequestCount"] = 100,
// Connection-type projection (UpdateSessionConnectionTypeAsync merges this) —
// search-filterable (connectionType= pushes an OData eq on the index).
["ConnectionType"] = "Ethernet",
};
var idx = TableStorageService.BuildSessionIndexEntity(session, IndexRowKey, StartedAt);
@@ -67,6 +73,9 @@ public class SessionIndexProjectionTests
Assert.True(idx.GetBoolean("ExcessiveEventsAutoActioned"));
Assert.True(idx.GetBoolean("IsSelfDeployingProfile"));
Assert.Equal("AutopilotV1", idx.GetString("ValidatedBy"));
Assert.Equal(513.5, idx.GetDouble("AvgApiLatencyMs"));
Assert.Equal(100, idx.GetInt32("ApiRequestCount"));
Assert.Equal("Ethernet", idx.GetString("ConnectionType"));
}
[Fact]
@@ -86,6 +95,8 @@ public class SessionIndexProjectionTests
Assert.False(idx.ContainsKey("FailureSource"));
Assert.False(idx.ContainsKey("StalledAt"));
Assert.False(idx.ContainsKey("ValidatedBy"));
Assert.False(idx.ContainsKey("AvgApiLatencyMs"));
Assert.False(idx.ContainsKey("ConnectionType"));
// Always-present counts/flags mirror the Sessions defaults.
Assert.Equal(0, idx.GetInt32("PlatformScriptCount"));
Assert.Equal(0, idx.GetInt32("RemediationScriptCount"));
@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Diagnostics;
using AutopilotMonitor.Shared.Models;
using Azure;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The diagnostics-archive copier preserves a session's diag ZIP with a session report.
/// Its contract: NEVER throw (a failed copy must not block the report), re-verify the
/// hosted tenant prefix (a tampered Sessions row must not reach foreign blobs), and honour
/// the same size cap as the download proxy. Tests pin every status branch via a recording
/// subclass — no Azurite, no HTTP.
/// </summary>
public class SessionReportDiagnosticsArchiveCopierTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private const string HostedBlob = $"{TenantA}/AgentDiagnostics-{SessionId}-20260729T120000.zip";
private const string CustomerBlob = $"AgentDiagnostics-{SessionId}-20260729T120000.zip";
private const string DestBlob = $"{TenantA}_{SessionId}_diag_archive_20260729_120000.zip";
[Theory]
[InlineData(null)]
[InlineData("")]
public async Task CopyAsync_NoSource_FailsSoft_NoStorageCalls(string? source)
{
var copier = new RecordingCopier();
var result = await copier.CopyAsync(TenantA, SessionId, source, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedNoDiagnostics, result.Status);
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_HostedBlobOfForeignTenant_Rejected_WithoutRead()
{
// Spoof guard: even if the Sessions row was tampered to point at another tenant's
// hosted blob, the classifier re-checks the prefix against the validated tenant.
var copier = new RecordingCopier();
var foreignBlob = $"{TenantB}/AgentDiagnostics-{SessionId}-x.zip";
var result = await copier.CopyAsync(TenantA, SessionId, foreignBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedInvalidBlobName, result.Status);
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_Hosted_HappyPath_UploadsWithFlatName()
{
var copier = new RecordingCopier { SourceSizeBytes = 1234 };
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.True(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.Copied, result.Status);
Assert.Equal(1234, result.SizeBytes);
Assert.Equal(1, copier.HostedOpenCalls);
Assert.Equal(HostedBlob, copier.HostedOpenLastPath);
Assert.Equal(1, copier.UploadCalls);
Assert.Equal(DestBlob, copier.UploadLastName);
Assert.DoesNotContain("/", copier.UploadLastName);
}
[Fact]
public async Task CopyAsync_CustomerSas_HappyPath_BuildsSasBlobUrl()
{
var copier = new RecordingCopier
{
SourceSizeBytes = 42,
CustomerSasUrl = "https://customer.blob/diag?sv=2024&sp=r&sig=x",
};
var result = await copier.CopyAsync(TenantA, SessionId, CustomerBlob, DestBlob);
Assert.True(result.Success);
Assert.Equal(1, copier.CustomerOpenCalls);
Assert.Equal(
$"https://customer.blob/diag/{CustomerBlob}?sv=2024&sp=r&sig=x",
copier.CustomerOpenLastUri!.ToString());
Assert.Equal(1, copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_CustomerSas_NoSasConfigured_FailsSoft()
{
var copier = new RecordingCopier { CustomerSasUrl = null };
var result = await copier.CopyAsync(TenantA, SessionId, CustomerBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedSasNotConfigured, result.Status);
Assert.Equal(0, copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_SourceAboveSizeCap_Rejected_NoUpload()
{
var copier = new RecordingCopier
{
MaxDownloadSizeMB = 1,
SourceSizeBytes = 2 * 1024 * 1024,
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedTooLarge, result.Status);
Assert.Equal(2 * 1024 * 1024, result.SizeBytes);
Assert.Equal(0, copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_SizeCapZero_MeansUnlimited()
{
var copier = new RecordingCopier
{
MaxDownloadSizeMB = 0,
SourceSizeBytes = 5L * 1024 * 1024 * 1024,
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.True(result.Success);
Assert.Equal(1, copier.UploadCalls);
}
[Theory]
[InlineData(404, SessionReportDiagnosticsArchiveCopier.Statuses.FailedSourceNotFound)]
[InlineData(403, SessionReportDiagnosticsArchiveCopier.Statuses.FailedSasReadDenied)]
[InlineData(500, SessionReportDiagnosticsArchiveCopier.Statuses.FailedError)]
public async Task CopyAsync_RequestFailed_MapsStatus_WithoutThrowing(int httpStatus, string expected)
{
var copier = new RecordingCopier
{
HostedOpenException = new RequestFailedException(httpStatus, $"storage error {httpStatus}"),
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(expected, result.Status);
Assert.Equal(0, copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_Timeout_MapsToTimeoutStatus()
{
var copier = new RecordingCopier
{
HostedOpenException = new OperationCanceledException(),
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedTimeout, result.Status);
}
[Fact]
public async Task CopyAsync_UploadFailure_FailsSoft_AsError()
{
// Read succeeded but the write into session-reports blew up — still fail-soft.
var copier = new RecordingCopier
{
UploadException = new InvalidOperationException("container gone"),
};
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedError, result.Status);
}
/// <summary>
/// Overrides every storage/config seam with an in-memory recording version, so the
/// tests exercise the REAL CopyAsync routing (classify → config → open → cap → upload)
/// without touching Azure.
/// </summary>
private sealed class RecordingCopier : SessionReportDiagnosticsArchiveCopier
{
public int MaxDownloadSizeMB { get; set; } = 500;
public long SourceSizeBytes { get; set; } = 100;
public string? CustomerSasUrl { get; set; }
public Exception? HostedOpenException { get; set; }
public Exception? UploadException { get; set; }
public int HostedOpenCalls { get; private set; }
public string? HostedOpenLastPath { get; private set; }
public int CustomerOpenCalls { get; private set; }
public Uri? CustomerOpenLastUri { get; private set; }
public int UploadCalls { get; private set; }
public string? UploadLastName { get; private set; }
protected override Task<AdminConfiguration> GetAdminConfigurationAsync()
=> Task.FromResult(new AdminConfiguration
{
MaxDiagnosticsDownloadSizeMB = MaxDownloadSizeMB,
DiagnosticsDownloadTimeoutSeconds = 120,
});
protected override Task<TenantConfiguration> GetTenantConfigurationAsync(string tenantId)
=> Task.FromResult(new TenantConfiguration
{
DiagnosticsBlobSasUrl = CustomerSasUrl!,
});
protected override Task<(Stream Content, long ContentLength)> OpenHostedSourceAsync(
string blobPath, CancellationToken cancellationToken)
{
HostedOpenCalls++;
HostedOpenLastPath = blobPath;
if (HostedOpenException != null) throw HostedOpenException;
return Task.FromResult<(Stream, long)>((new MemoryStream(new byte[8]), SourceSizeBytes));
}
protected override Task<(Stream Content, long ContentLength)> OpenCustomerSourceAsync(
Uri blobUri, CancellationToken cancellationToken)
{
CustomerOpenCalls++;
CustomerOpenLastUri = blobUri;
return Task.FromResult<(Stream, long)>((new MemoryStream(new byte[8]), SourceSizeBytes));
}
protected override Task UploadToReportsContainerAsync(
string destinationBlobName, Stream content, CancellationToken cancellationToken)
{
UploadCalls++;
UploadLastName = destinationBlobName;
if (UploadException != null) throw UploadException;
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Services;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Customer-supplied attachment file names go verbatim into ZIP entry names of the
/// session-report archive. The sanitizer must strip path components so a crafted name
/// cannot produce traversal-shaped entries that an extraction tool would write outside
/// the target directory.
/// </summary>
public class SessionReportZipEntryNameTests
{
[Theory]
[InlineData("agent.log", "agent.log")]
[InlineData("agent_20260729.log", "agent_20260729.log")]
[InlineData("logs/agent.log", "agent.log")]
[InlineData("..\\..\\evil.exe", "evil.exe")]
[InlineData("../../../etc/passwd", "passwd")]
[InlineData("C:\\Windows\\System32\\bad.dll", "bad.dll")]
[InlineData(" spaced.log ", "spaced.log")]
public void SanitizeZipEntryName_StripsPathComponents(string input, string expected)
{
Assert.Equal(expected, SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("..")]
[InlineData(".")]
[InlineData("logs/")]
[InlineData("a/../")]
public void SanitizeZipEntryName_FallsBackWhenNothingUsable(string? input)
{
Assert.Equal("fallback.log", SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
}
}
@@ -40,6 +40,7 @@ public class StoreSessionReregistrationPreserveTests
["PlatformScriptCount"] = 3,
["RemediationScriptCount"] = 5,
["RebootCount"] = 7,
["ConnectionType"] = "Ethernet",
};
existing.ETag = new ETag("0xEXISTING");
@@ -71,6 +72,9 @@ public class StoreSessionReregistrationPreserveTests
Assert.Equal(7, harness.Written.GetInt32("RebootCount"));
// EventCount preservation is the established sibling behaviour — assert it as a sanity anchor.
Assert.Equal(42, harness.Written.GetInt32("EventCount"));
// Merge-mode field (UpdateSessionConnectionTypeAsync) — must survive the Replace so the
// value isn't lost in the window until the restarted agent re-emits network_interface_info.
Assert.Equal("Ethernet", harness.Written.GetString("ConnectionType"));
}
[Fact]
@@ -0,0 +1,108 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Autopilot Monitor OOBE Bootstrap
Generated: 2026-07-28T12:00:00.000Z
Expires: 2026-08-01T12:00:00.000Z
This script was generated by the Autopilot Monitor platform.
It installs the monitoring agent before Intune enrollment begins,
enabling full session capture from the start of OOBE.
#>
$ErrorActionPreference = "Stop"
$AgentDownloadUrl = 'https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip'
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
# Create directories
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
try {
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
Write-Log 'Bootstrap token expires: 2026-08-01T12:00:00.000Z'
# Check if agent is already installed
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath -- skipping download"
}
else {
# Download agent ZIP
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
Write-Log "Downloading agent from $AgentDownloadUrl..."
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
Write-Log "Downloaded agent to $zipPath"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
$retryDelays = @(2, 4, 8)
$delay = $retryDelays[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
Start-Sleep -Seconds $delay
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# Extract
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Extracted agent to $AgentBinPath"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
}
# Install agent with bootstrap token
Write-Log "Installing agent with bootstrap token..."
& $agentExePath --install --no-auth --bootstrap-token '22222222-2222-2222-2222-222222222222' --tenant-id '11111111-1111-1111-1111-111111111111'
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "===== OOBE Bootstrap Complete ====="
Write-Log "Agent is now monitoring. View progress at https://portal.autopilotmonitor.com"
Write-Log ""
Write-Log "The agent will:"
Write-Log " - Monitor enrollment from the very start"
Write-Log " - Switch to certificate auth after Intune enrollment"
Write-Log " - Self-destruct when enrollment completes"
Write-Host ""
Write-Host "Autopilot Monitor agent installed successfully!" -ForegroundColor Green
Write-Host "View enrollment progress at https://portal.autopilotmonitor.com" -ForegroundColor Cyan
Write-Host ""
}
catch {
Write-Log "===== OOBE Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Host ""
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Check log file: $LogFile" -ForegroundColor Yellow
Write-Host ""
exit 1
}
@@ -0,0 +1,562 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Golden fixtures for <see cref="TimeAttributionCalculator"/> (F1 PR1, insights spec §F1) —
/// synthetic sessions only (no customer data), each pinning one semantic decision:
/// linear phase partition, clock skew, late start, WhiteGlove two-window split, missing /
/// truncated blocking lists, IME retry occupancy, and reboot gaps. EVERY fixture asserts the
/// exact-partition invariant: sum(span seconds) + unattributed == wall clock ==
/// the session's DurationSeconds (truthfulness rule 2 — never normalized, never interpolated).
/// </summary>
public class TimeAttributionCalculatorTests
{
private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc);
private const string AppA = "aaaaaaaa-1111-2222-3333-444444444444"; // in blocking list
private const string AppB = "bbbbbbbb-1111-2222-3333-444444444444"; // in blocking list, never observed
private const string AppC = "cccccccc-1111-2222-3333-444444444444"; // observed, NOT listed
private static long _seq;
private static EnrollmentEvent Evt(
DateTime ts, string eventType,
EnrollmentPhase phase = EnrollmentPhase.Unknown,
Dictionary<string, object>? data = null)
=> new()
{
EventType = eventType,
Timestamp = ts,
Sequence = ++_seq,
Phase = phase,
Data = data ?? new Dictionary<string, object>(),
};
private static Dictionary<string, object> AppData(string appId, string appName, string? state = null)
{
var d = new Dictionary<string, object> { ["appId"] = appId, ["appName"] = appName };
if (state != null) d["state"] = state;
return d;
}
/// <summary>Storage-shape blocking lists (EventDataNormalizer yields List&lt;object&gt;).</summary>
private static Dictionary<string, object> EspLists(long? win32Count = null, params string[] win32Ids)
=> new()
{
["espTrackedWin32AppIds"] = new List<object>(win32Ids),
["espTrackedUserWin32AppIds"] = new List<object>(),
["espTrackedMsiProductCodes"] = new List<object>(),
["espTrackedModernAppPfns"] = new List<object>(),
["espTrackedWin32Count"] = win32Count ?? win32Ids.Length,
["espTrackedMsiCount"] = 0L,
["espTrackedModernCount"] = 0L,
};
private static TimeAttributionInput Input(
List<EnrollmentEvent> events, DateTime completedAt, int durationSeconds,
string status = "Succeeded", bool wg = false, DateTime? resumedAt = null)
=> new()
{
TenantId = "00000000-0000-0000-0000-0000000000t1",
SessionId = "00000000-0000-0000-0000-0000000000s1",
Status = status,
StartedAt = T0,
CompletedAt = completedAt,
DurationSeconds = durationSeconds,
IsPreProvisioned = wg,
ResumedAt = resumedAt,
Events = events,
};
/// <summary>The invariant every fixture must satisfy exactly (spec §0 rule 2).</summary>
private static void AssertExactPartition(SessionTimeBreakdown b)
{
Assert.Equal(b.WallClockSeconds, b.Segments.Sum(s => s.Seconds) + b.UnattributedSeconds);
Assert.True(b.UnattributedSeconds >= 0, "unattributed must never be negative");
}
// ── golden: linear UDE session ──────────────────────────────────────────
private static List<EnrollmentEvent> LinearSession()
=> new()
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(2), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA, AppB })),
Evt(T0.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(T0.AddMinutes(6), "app_install_started", data: AppData(AppA, "App A")),
Evt(T0.AddMinutes(10), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
Evt(T0.AddMinutes(11), "app_install_started", data: AppData(AppC, "Untracked App")),
Evt(T0.AddMinutes(13), "app_install_completed", data: AppData(AppC, "Untracked App", "Installed")),
Evt(T0.AddMinutes(15), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Evt(T0.AddMinutes(20), "esp_phase_changed", EnrollmentPhase.AppsUser),
Evt(T0.AddMinutes(25), "esp_phase_changed", EnrollmentPhase.FinalizingSetup),
Evt(T0.AddMinutes(28), "desktop_arrived"),
Evt(T0.AddMinutes(30), "enrollment_complete"),
};
[Fact]
public void Linear_Session_PartitionsIntoAllFiveSegments_Exactly()
{
var b = TimeAttributionCalculator.Compute(
Input(LinearSession(), completedAt: T0.AddMinutes(30), durationSeconds: 1800))!;
AssertExactPartition(b);
Assert.Equal(1800, b.WallClockSeconds);
Assert.Equal(0, b.UnattributedSeconds);
Assert.Equal(TimeAttributionFlags.None, b.QualityFlags);
Assert.Equal(TimeAttributionCalculator.CurrentVersion, b.AttributionVersion);
var totals = b.GetSegmentTotals();
Assert.Equal(300, totals[TimeAttributionSegments.DevicePrep]); // T0 → apps entry (+5m)
Assert.Equal(600, totals[TimeAttributionSegments.EspApps]); // +5m → +15m
Assert.Equal(480, totals[TimeAttributionSegments.IdentityHello]); // AccountSetup 300 + Finalizing 180
Assert.Equal(300, totals[TimeAttributionSegments.UserEsp]); // +20m → +25m
Assert.Equal(120, totals[TimeAttributionSegments.DesktopHandoff]); // desktop_arrived → terminal
}
[Fact]
public void Linear_Session_BlockingJoin_IsPositiveEvidenceOnly()
{
var b = TimeAttributionCalculator.Compute(
Input(LinearSession(), completedAt: T0.AddMinutes(30), durationSeconds: 1800))!;
// App A is listed → blocking, interval from EVENT timestamps (started → completed).
var interval = Assert.Single(b.BlockingApps);
Assert.Equal(AppA, interval.AppId);
Assert.Equal(240, interval.Seconds); // +6m → +10m
// App C installed fine but is NOT in the lists → unknown, NEVER "not blocking":
// it appears nowhere (no negative claim), and only App A counts as matched.
Assert.Equal(1, b.BlockingAppCount);
// Occupancy = App A's interval clipped to the esp_apps span.
Assert.Equal(240, b.EspAppsOccupancySeconds);
}
// ── skew: backward anchor is dropped, dust stays honest ─────────────────
[Fact]
public void BackwardPhaseAnchor_IsDropped_WithClockSkewFlag()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(10), "esp_phase_changed", EnrollmentPhase.AppsDevice),
// Replayed declaration whose timestamp runs BACKWARD in canonical order.
Evt(T0.AddMinutes(3), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Evt(T0.AddMinutes(30), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(30), durationSeconds: 1800))!;
AssertExactPartition(b);
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.ClockSkewDropped));
var totals = b.GetSegmentTotals();
Assert.Equal(600, totals[TimeAttributionSegments.DevicePrep]);
Assert.Equal(1200, totals[TimeAttributionSegments.EspApps]); // runs to window end — no phantom AccountSetup
Assert.False(totals.ContainsKey(TimeAttributionSegments.IdentityHello));
}
[Fact]
public void SubSecondBoundaries_FloorIntoUnattributedDust_NeverInvented()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddSeconds(90.5), "esp_phase_changed", EnrollmentPhase.AppsDevice),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddSeconds(300), durationSeconds: 300))!;
AssertExactPartition(b);
var totals = b.GetSegmentTotals();
Assert.Equal(90, totals[TimeAttributionSegments.DevicePrep]); // floor(90.5)
Assert.Equal(209, totals[TimeAttributionSegments.EspApps]); // floor(209.5)
Assert.Equal(1, b.UnattributedSeconds); // the dust, disclosed
}
// ── late start ──────────────────────────────────────────────────────────
[Fact]
public void AgentLateStart_SetsPartialObservation()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddSeconds(1), "agent_late_start"),
Evt(T0.AddMinutes(10), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
AssertExactPartition(b);
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.PartialObservation));
}
// ── WhiteGlove: two windows in ONE session, pause never attributed ──────
[Fact]
public void WhiteGlove_SplitsIntoTwoWindows_PauseExcluded_WallClockIsDurationSeconds()
{
var part1End = T0.AddMinutes(20);
var resumedAt = T0.AddDays(2); // 2-day pause
var completedAt = resumedAt.AddMinutes(10); // part 2 = 600 s
// Stored combined duration: part1 (1200 s) + part2 (600 s) — pause excluded by design.
const int duration = 1800;
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })),
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(part1End, "whiteglove_part1_complete"),
// — pause —
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.AccountSetup),
Evt(resumedAt.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.FinalizingSetup),
Evt(resumedAt.AddMinutes(8), "desktop_arrived"),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, duration, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
// Wall clock is the stored DurationSeconds — NOT CompletedAt − StartedAt (≈ 2 days).
Assert.Equal(1800, b.WallClockSeconds);
Assert.Equal(TimeAttributionFlags.None, b.QualityFlags); // part1 anchored on the event
var totals = b.GetSegmentTotals();
Assert.Equal(120, totals[TimeAttributionSegments.DevicePrep]); // W1: T0 → +2m
Assert.Equal(1080, totals[TimeAttributionSegments.EspApps]); // W1: +2m → part1End
Assert.Equal(450, totals[TimeAttributionSegments.IdentityHello]); // W2: +30s → +8m
Assert.Equal(120, totals[TimeAttributionSegments.DesktopHandoff]); // W2: +8m → +10m
// W2's 30 s resume gap has no phase context — unattributed, not guessed.
Assert.Equal(30, b.UnattributedSeconds);
// No span may live inside the pause.
Assert.DoesNotContain(b.Segments, s => s.StartUtc > part1End && s.EndUtc < resumedAt);
}
[Fact]
public void WhiteGlove_WithoutPart1CompleteEvent_FallsBackAndFlags()
{
var resumedAt = T0.AddDays(1);
var completedAt = resumedAt.AddMinutes(10);
var lastPart1Event = T0.AddMinutes(20);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(lastPart1Event, "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(resumedAt.AddSeconds(10), "agent_started", EnrollmentPhase.AccountSetup),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.WhiteGloveAnchorsIncomplete));
Assert.Equal(1800, b.WallClockSeconds);
}
[Fact]
public void WhiteGlove_Part2LongerThanStoredDuration_DegradesToSingleWindow_Flagged()
{
// Stored duration (600 s) contradicts the resume stamp (part 2 alone spans 900 s):
// no honest two-window split exists — degrade to the single derived window instead
// of inventing a negative part 1.
var resumedAt = T0.AddDays(1);
var completedAt = resumedAt.AddMinutes(15);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(resumedAt.AddSeconds(5), "agent_started", EnrollmentPhase.AccountSetup),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 600, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
Assert.Equal(600, b.WallClockSeconds);
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.WhiteGloveAnchorsIncomplete));
}
// ── blocking list missing / truncated ───────────────────────────────────
[Fact]
public void NoBlockingLists_MeansUnknown_NotZero()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(T0.AddMinutes(3), "app_install_started", data: AppData(AppA, "App A")),
Evt(T0.AddMinutes(8), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
Evt(T0.AddMinutes(10), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
AssertExactPartition(b);
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.BlockingSetUnknown));
Assert.Empty(b.BlockingApps);
Assert.Equal(0, b.BlockingAppCount);
Assert.Null(b.EspAppsOccupancySeconds); // unknown — NOT 0 (rule 1)
}
[Fact]
public void EspConfigWithoutListKeys_IsNotEvidence_LaterEmissionNeverErasesEarlierLists()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })),
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(T0.AddMinutes(3), "app_install_started", data: AppData(AppA, "App A")),
Evt(T0.AddMinutes(8), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
// Later emission whose probe found no Diagnostics key — no espTracked* keys at all.
Evt(T0.AddMinutes(9), "esp_config_detected",
data: new Dictionary<string, object> { ["source"] = "registry_firstsync" }),
Evt(T0.AddMinutes(10), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
Assert.False(b.QualityFlags.HasFlag(TimeAttributionFlags.BlockingSetUnknown));
Assert.Equal(AppA, Assert.Single(b.BlockingApps).AppId);
}
[Fact]
public void TruncatedBlockingList_SetsFlag_AbsentAppsStayUnknown()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
// Uncapped total (60) exceeds the emitted list (1 id) → cap dropped entries.
Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Count: 60, AppA)),
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(T0.AddMinutes(3), "app_install_started", data: AppData(AppC, "Maybe Capped App")),
Evt(T0.AddMinutes(7), "app_install_completed", data: AppData(AppC, "Maybe Capped App", "Installed")),
Evt(T0.AddMinutes(10), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.BlockingSetTruncated));
// App C is absent from the (capped) list → it stays UNKNOWN: no interval, no claim.
Assert.Empty(b.BlockingApps);
Assert.Equal(0, b.BlockingAppCount);
}
// ── retry: occupancy spans to the LAST terminal event (audit Q1) ────────
[Fact]
public void Retry_IntervalRunsFromFirstStart_ToLastTerminalEvent()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })),
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(T0.AddMinutes(6), "app_install_started", data: AppData(AppA, "App A")),
Evt(T0.AddMinutes(8), "app_install_failed", data: AppData(AppA, "App A")),
Evt(T0.AddMinutes(9), "app_install_started", data: AppData(AppA, "App A")), // IME retry
Evt(T0.AddMinutes(12), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
Evt(T0.AddMinutes(15), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(15), durationSeconds: 900))!;
AssertExactPartition(b);
var interval = Assert.Single(b.BlockingApps);
// First started (+6m) → LAST terminal (+12m) = 360 s. The payload timing would have
// frozen at the first failure (+8m) — event timestamps are the only valid source.
Assert.Equal(360, interval.Seconds);
Assert.Equal(360, b.EspAppsOccupancySeconds);
}
// ── reboots: spans from event gaps, never the detection event's stamp ───
[Fact]
public void RebootSpan_ComesFromEventGapAroundLastBootUtc_NotFromDetectionTimestamp()
{
var boot = T0.AddMinutes(12);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(T0.AddMinutes(10), "app_install_started", data: AppData(AppA, "App A")), // last pre-reboot
Evt(T0.AddMinutes(14), "system_reboot_detected", // next run, detection-time
data: new Dictionary<string, object> { ["lastBootUtc"] = boot.ToString("o") }),
// Duplicate detection of the SAME boot (batch replay) — must not double-count.
Evt(T0.AddMinutes(14.5), "system_reboot_detected",
data: new Dictionary<string, object> { ["lastBootUtc"] = boot.ToString("o") }),
Evt(T0.AddMinutes(20), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
AssertExactPartition(b);
var span = Assert.Single(b.RebootSpans);
// Gap = last event before boot (+10m) → first observation at/after boot (+14m).
Assert.Equal(T0.AddMinutes(10), span.StartUtc);
Assert.Equal(T0.AddMinutes(14), span.EndUtc);
Assert.Equal(240, span.Seconds);
Assert.Equal(240, b.RebootSeconds);
// The reboot began inside the apps phase — cross-cutting annotation, segment unchanged.
Assert.Equal(TimeAttributionSegments.EspApps, span.SegmentKey);
Assert.Equal(1200 - 300, b.GetSegmentTotals()[TimeAttributionSegments.EspApps]);
}
// ── WhiteGlove pause vs. intervals/reboots (Codex review P1): the pause lies INSIDE
// the windows' hull, so hull-clamping alone would count it — a reboot bracketing the
// pause must contribute only its in-window flanks, never the pause itself ─────────────
[Fact]
public void WhiteGlove_RebootGapAcrossPause_CountsOnlyInWindowFlanks()
{
var part1End = T0.AddMinutes(20);
var resumedAt = T0.AddDays(2); // 2-day pause
var completedAt = resumedAt.AddMinutes(10); // part 2 = 600 s → part 1 = 1200 s
var bootInsidePause = T0.AddDays(1);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(part1End, "whiteglove_part1_complete"),
// — pause: device sealed, user boots it a day later —
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.AccountSetup),
Evt(resumedAt.AddSeconds(40), "system_reboot_detected",
data: new Dictionary<string, object> { ["lastBootUtc"] = bootInsidePause.ToString("o") }),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
var span = Assert.Single(b.RebootSpans);
// Gap = part1End → first post-boot event (+30 s into part 2). In-window seconds are
// ONLY the part-2 flank (30 s) — the ~2-day pause never counts (pre-fix: ~172 830 s
// of "reboot" in a 30-minute enrollment).
Assert.Equal(part1End, span.StartUtc);
Assert.Equal(resumedAt.AddSeconds(30), span.EndUtc);
Assert.Equal(30, span.Seconds);
Assert.Equal(30, b.RebootSeconds);
}
[Fact]
public void WhiteGlove_BlockingIntervalAcrossPause_CountsOnlyInWindowSeconds()
{
var part1End = T0.AddMinutes(20);
var resumedAt = T0.AddDays(2);
var completedAt = resumedAt.AddMinutes(10);
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(10), "app_install_started", data: AppData(AppA, "App A")),
Evt(part1End, "whiteglove_part1_complete"),
// — pause —
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.AccountSetup),
// The app's final terminal lands in part 2 (user-scope finish after resume).
Evt(resumedAt.AddMinutes(2), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
var interval = Assert.Single(b.BlockingApps);
// Chronological endpoints stay the real event times…
Assert.Equal(T0.AddMinutes(10), interval.StartUtc);
Assert.Equal(resumedAt.AddMinutes(2), interval.EndUtc);
// …but Seconds is the in-window sum: part 1 tail (10 min) + part 2 head (2 min).
Assert.Equal(720, interval.Seconds);
// Occupancy intersects with the esp_apps span (part 1 only) — pause never leaks in.
Assert.Equal(600, b.EspAppsOccupancySeconds);
}
[Fact]
public void WhiteGlove_IntervalWhollyInsidePause_MakesNoClaim_AndNoSkewFlag()
{
var part1End = T0.AddMinutes(20);
var resumedAt = T0.AddDays(2);
var completedAt = resumedAt.AddMinutes(10);
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"),
// Both endpoints inside the pause (post-seal tail activity) — observed, but in no window.
Evt(part1End.AddHours(1), "app_install_started", data: AppData(AppA, "App A")),
Evt(part1End.AddHours(2), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.AccountSetup),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
// No in-window observation → no interval claim; and nothing is wrong with the clocks,
// so the skew flag must NOT fire (it would exclude the session from fleet medians).
Assert.Empty(b.BlockingApps);
Assert.False(b.QualityFlags.HasFlag(TimeAttributionFlags.ClockSkewDropped));
}
// ── 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)
}
}
@@ -0,0 +1,458 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// F1 PR2 (insights spec §F1 "Data &amp; compute changes"): persistence round-trips for the
/// SessionTimeBreakdowns / TimeAttributionAggregates rows (table-storage-serialization rule —
/// every property must survive Store→Map, tri-states included), the pure daily-aggregation
/// core (<see cref="MaintenanceService.BuildTimeAttributionAggregates"/>: clean/flagged/missing
/// disclosure, per-class bucketing, fixed segment stack, per-app gates) and the what-if bound.
/// </summary>
public class TimeAttributionPersistenceAndAggregationTests
{
private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc);
private const string TenantA = "aaaaaaaa-0000-0000-0000-000000000001";
private const string TenantB = "bbbbbbbb-0000-0000-0000-000000000002";
private const string AppX = "aaaaaaaa-1111-2222-3333-444444444444";
private const string AppY = "bbbbbbbb-1111-2222-3333-444444444444";
// ── breakdown row round-trip ────────────────────────────────────────────
[Fact]
public void BreakdownEntity_RoundTripsAllFields_IncludingJsonLists()
{
var breakdown = new SessionTimeBreakdown
{
TenantId = TenantA,
SessionId = "s-1",
AttributionVersion = 1,
EventCountAtCompute = 261,
WallClockSeconds = 1800,
UnattributedSeconds = 30,
RebootSeconds = 240,
BlockingAppCount = 3,
EspAppsOccupancySeconds = 600,
QualityFlags = TimeAttributionFlags.PartialObservation | TimeAttributionFlags.BlockingSetTruncated,
Segments = new List<TimeAttributionSpan>
{
new() { SegmentKey = TimeAttributionSegments.DevicePrep, StartUtc = T0, EndUtc = T0.AddMinutes(5), Seconds = 300 },
new() { SegmentKey = TimeAttributionSegments.EspApps, StartUtc = T0.AddMinutes(5), EndUtc = T0.AddMinutes(30), Seconds = 1500 },
},
RebootSpans = new List<RebootSpan>
{
new() { StartUtc = T0.AddMinutes(10), EndUtc = T0.AddMinutes(14), Seconds = 240, SegmentKey = TimeAttributionSegments.EspApps },
},
BlockingApps = new List<BlockingAppInterval>
{
new() { AppId = AppX, AppName = "App X", StartUtc = T0.AddMinutes(6), EndUtc = T0.AddMinutes(16), Seconds = 600 },
},
};
var mapped = TableStorageService.MapToSessionTimeBreakdown(
TableStorageService.BuildSessionTimeBreakdownEntity(breakdown));
Assert.Equal(TenantA, mapped.TenantId);
Assert.Equal("s-1", mapped.SessionId);
Assert.Equal(1, mapped.AttributionVersion);
// Sweep change signal (Codex review: late batches must trigger a recompute) — a lost
// column would read as 0 and recompute forever OR mask real drift.
Assert.Equal(261, mapped.EventCountAtCompute);
Assert.Equal(1800, mapped.WallClockSeconds);
Assert.Equal(30, mapped.UnattributedSeconds);
Assert.Equal(240, mapped.RebootSeconds);
Assert.Equal(3, mapped.BlockingAppCount);
Assert.Equal(600, mapped.EspAppsOccupancySeconds);
Assert.Equal(TimeAttributionFlags.PartialObservation | TimeAttributionFlags.BlockingSetTruncated, mapped.QualityFlags);
Assert.Equal(2, mapped.Segments.Count);
Assert.Equal(TimeAttributionSegments.DevicePrep, mapped.Segments[0].SegmentKey);
Assert.Equal(T0, mapped.Segments[0].StartUtc);
Assert.Equal(300, mapped.Segments[0].Seconds);
var reboot = Assert.Single(mapped.RebootSpans);
Assert.Equal(TimeAttributionSegments.EspApps, reboot.SegmentKey);
Assert.Equal(240, reboot.Seconds);
var app = Assert.Single(mapped.BlockingApps);
Assert.Equal(AppX, app.AppId);
Assert.Equal("App X", app.AppName);
Assert.Equal(600, app.Seconds);
}
[Fact]
public void BreakdownEntity_OccupancyTriState_AbsentColumnMapsToNull_NeverZero()
{
var unknown = new SessionTimeBreakdown
{
TenantId = TenantA, SessionId = "s-1", WallClockSeconds = 600,
EspAppsOccupancySeconds = null, QualityFlags = TimeAttributionFlags.BlockingSetUnknown,
};
var entity = TableStorageService.BuildSessionTimeBreakdownEntity(unknown);
Assert.False(entity.ContainsKey("EspAppsOccupancySeconds"));
Assert.Null(TableStorageService.MapToSessionTimeBreakdown(entity).EspAppsOccupancySeconds);
// Zero is a real measured value (blocking set known, nothing occupied) — must survive.
var zero = new SessionTimeBreakdown
{
TenantId = TenantA, SessionId = "s-2", WallClockSeconds = 600, EspAppsOccupancySeconds = 0,
};
Assert.Equal(0, TableStorageService.MapToSessionTimeBreakdown(
TableStorageService.BuildSessionTimeBreakdownEntity(zero)).EspAppsOccupancySeconds);
}
// ── aggregate row round-trip ────────────────────────────────────────────
[Fact]
public void AggregateEntity_RoundTripsAllFields()
{
var aggregate = new TimeAttributionDailyAggregate
{
TenantId = TenantA,
Date = "2026-07-26",
EnrollmentClass = "user_driven",
AttributionVersion = 1,
CleanSessionCount = 23,
FlaggedExcludedCount = 4,
MissingBreakdownCount = 1,
ComputedAt = T0,
SegmentStats = new List<TimeAttributionSegmentStat>
{
new() { SegmentKey = TimeAttributionSegments.EspApps, MedianSeconds = 600, P75Seconds = 700, P90Seconds = 900 },
},
TopBlockingApps = new List<TimeAttributionBlockingAppStat>
{
new()
{
AppId = AppX, AppName = "App X", SessionCount = 12,
MedianSeconds = 300, P75Seconds = 400, MedianSavingSeconds = 120, P75SavingSeconds = 200,
},
},
};
var entity = TableStorageService.BuildTimeAttributionAggregateEntity(aggregate);
Assert.Equal("2026-07-26|user_driven", entity.RowKey);
var mapped = TableStorageService.MapToTimeAttributionAggregate(entity);
Assert.Equal(TenantA, mapped.TenantId);
Assert.Equal("2026-07-26", mapped.Date);
Assert.Equal("user_driven", mapped.EnrollmentClass);
Assert.Equal(1, mapped.AttributionVersion);
Assert.Equal(23, mapped.CleanSessionCount);
Assert.Equal(4, mapped.FlaggedExcludedCount);
Assert.Equal(1, mapped.MissingBreakdownCount);
var segment = Assert.Single(mapped.SegmentStats);
Assert.Equal(TimeAttributionSegments.EspApps, segment.SegmentKey);
Assert.Equal(600, segment.MedianSeconds);
Assert.Equal(900, segment.P90Seconds);
var app = Assert.Single(mapped.TopBlockingApps);
Assert.Equal(AppX, app.AppId);
Assert.Equal(12, app.SessionCount);
Assert.Equal(120, app.MedianSavingSeconds);
}
// ── enrollment class ────────────────────────────────────────────────────
[Fact]
public void EnrollmentClass_Precedence_WhiteGlove_Wdp_SelfDeploying_UserDriven()
{
Assert.Equal("whiteglove", TimeAttributionCalculator.GetEnrollmentClass(
new SessionSummary { IsPreProvisioned = true, EnrollmentType = "v2", IsSelfDeployingProfile = true }));
Assert.Equal("device_preparation", TimeAttributionCalculator.GetEnrollmentClass(
new SessionSummary { EnrollmentType = "v2", IsSelfDeployingProfile = true }));
Assert.Equal("self_deploying", TimeAttributionCalculator.GetEnrollmentClass(
new SessionSummary { IsSelfDeployingProfile = true }));
Assert.Equal("user_driven", TimeAttributionCalculator.GetEnrollmentClass(new SessionSummary()));
}
// ── what-if bound ───────────────────────────────────────────────────────
private static BlockingAppInterval Interval(string appId, DateTime start, DateTime end)
=> new() { AppId = appId, AppName = appId, StartUtc = start, EndUtc = end, Seconds = (int)(end - start).TotalSeconds };
[Fact]
public void WhatIf_LastEndingApp_SavesTailToPreviousEnd_OthersSaveNothing()
{
var intervals = new List<BlockingAppInterval>
{
Interval(AppX, T0, T0.AddMinutes(10)), // ends 10:10
Interval(AppY, T0.AddMinutes(5), T0.AddMinutes(20)), // ends 10:20 — critical-path end
};
// Removing Y moves the path end 10:20 → 10:10.
Assert.Equal(600, TimeAttributionCalculator.WhatIfSavingSeconds(intervals, AppY));
// Removing X changes nothing — Y still ends at 10:20.
Assert.Equal(0, TimeAttributionCalculator.WhatIfSavingSeconds(intervals, AppX));
// An app without a measured interval yields no claim.
Assert.Equal(0, TimeAttributionCalculator.WhatIfSavingSeconds(intervals, "cccccccc-0000-0000-0000-000000000000"));
}
[Fact]
public void WhatIf_OnlyBlockingApp_FallsBackToOwnStart()
{
var intervals = new List<BlockingAppInterval> { Interval(AppX, T0.AddMinutes(5), T0.AddMinutes(20)) };
Assert.Equal(900, TimeAttributionCalculator.WhatIfSavingSeconds(intervals, AppX));
}
[Fact]
public void WhatIf_IdleGapBeforeLastApp_CountsIntoTheBound()
{
// A ends 10:00; X runs 10:05 → 10:20. Removing X exposes 10:00 as the path end —
// the ESP waited for X to START too, so the bound is 20 min, not 15 (spec formula).
var intervals = new List<BlockingAppInterval>
{
Interval(AppY, T0.AddMinutes(-30), T0),
Interval(AppX, T0.AddMinutes(5), T0.AddMinutes(20)),
};
Assert.Equal(1200, TimeAttributionCalculator.WhatIfSavingSeconds(intervals, AppX));
}
// ── daily aggregation core ──────────────────────────────────────────────
private static SessionSummary Session(string tenantId, DateTime startedAt, bool wg = false)
=> new()
{
TenantId = tenantId,
SessionId = Guid.NewGuid().ToString(),
StartedAt = startedAt,
Status = SessionStatus.Succeeded,
IsPreProvisioned = wg,
};
private static SessionTimeBreakdown Breakdown(
TimeAttributionFlags flags = TimeAttributionFlags.None,
int espAppsSeconds = 600,
int unattributed = 0,
List<BlockingAppInterval>? apps = null)
=> new()
{
AttributionVersion = TimeAttributionCalculator.CurrentVersion,
WallClockSeconds = espAppsSeconds + 300 + unattributed,
UnattributedSeconds = unattributed,
QualityFlags = flags,
Segments = new List<TimeAttributionSpan>
{
new() { SegmentKey = TimeAttributionSegments.DevicePrep, StartUtc = T0, EndUtc = T0.AddSeconds(300), Seconds = 300 },
new() { SegmentKey = TimeAttributionSegments.EspApps, StartUtc = T0.AddSeconds(300), EndUtc = T0.AddSeconds(300 + espAppsSeconds), Seconds = espAppsSeconds },
},
BlockingApps = apps ?? new List<BlockingAppInterval>(),
};
[Fact]
public void Aggregates_BucketPerTenantClassDate_AndMirrorGlobal()
{
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
{
(Session(TenantA, T0), Breakdown(espAppsSeconds: 100)),
(Session(TenantA, T0), Breakdown(espAppsSeconds: 300)),
(Session(TenantA, T0, wg: true), Breakdown(espAppsSeconds: 900)), // separate class
(Session(TenantB, T0), Breakdown(espAppsSeconds: 500)), // separate tenant
};
var aggregates = MaintenanceService.BuildTimeAttributionAggregates(
pairs, Array.Empty<SessionSummary>(), T0);
// Tenant A: user_driven + whiteglove; Tenant B: user_driven; global: user_driven + whiteglove.
Assert.Equal(5, aggregates.Count);
var tenantAUde = aggregates.Single(a => a.TenantId == TenantA && a.EnrollmentClass == "user_driven");
Assert.Equal("2026-07-26", tenantAUde.Date);
Assert.Equal(2, tenantAUde.CleanSessionCount);
Assert.Equal(TimeAttributionCalculator.CurrentVersion, tenantAUde.AttributionVersion);
// Median of {100, 300} = 100 (nearest-rank: ceil(0.5·2)−1 = index 0 — repo convention).
Assert.Equal(100, tenantAUde.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.EspApps).MedianSeconds);
var wg = aggregates.Single(a => a.TenantId == TenantA && a.EnrollmentClass == "whiteglove");
Assert.Equal(1, wg.CleanSessionCount); // classes never mixed
var globalUde = aggregates.Single(a => a.TenantId == "global" && a.EnrollmentClass == "user_driven");
Assert.Equal(3, globalUde.CleanSessionCount); // A(2) + B(1)
}
[Fact]
public void Aggregates_FlaggedSessions_AreExcludedFromStats_ButDisclosed()
{
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
{
(Session(TenantA, T0), Breakdown(espAppsSeconds: 100)),
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.PartialObservation, espAppsSeconds: 99999)),
};
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
pairs, Array.Empty<SessionSummary>(), T0)
.Single(a => a.TenantId == TenantA);
Assert.Equal(1, aggregate.CleanSessionCount);
Assert.Equal(1, aggregate.FlaggedExcludedCount);
// The flagged 99999s outlier must not shape the median.
Assert.Equal(100, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.EspApps).MedianSeconds);
}
[Fact]
public void Aggregates_AllFlaggedDay_YieldsDisclosureRow_WithoutStats()
{
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
{
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.ClockSkewDropped)),
};
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
pairs, Array.Empty<SessionSummary>(), T0)
.Single(a => a.TenantId == TenantA);
Assert.Equal(0, aggregate.CleanSessionCount);
Assert.Equal(1, aggregate.FlaggedExcludedCount);
Assert.Empty(aggregate.SegmentStats); // no clean sessions → no statistical claim
Assert.Empty(aggregate.TopBlockingApps);
}
[Fact]
public void Aggregates_BlockingOnlyFlags_StayInSegmentStats()
{
// BlockingSetUnknown/Truncated limit per-app blocking EVIDENCE, not the measured spans.
// Gating on any flag starved the fleet aggregates in production (903/1000 breakdowns
// carried BlockingSetUnknown, 2026-07-27) — blocking-only flagged sessions must count
// as clean; only duration-critical flags exclude.
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
{
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.BlockingSetUnknown, espAppsSeconds: 100)),
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.BlockingSetTruncated, espAppsSeconds: 300)),
(Session(TenantA, T0), Breakdown(
flags: TimeAttributionFlags.BlockingSetUnknown | TimeAttributionFlags.PartialObservation,
espAppsSeconds: 99999)), // duration-critical bit set → still excluded
};
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
pairs, Array.Empty<SessionSummary>(), T0)
.Single(a => a.TenantId == TenantA);
Assert.Equal(2, aggregate.CleanSessionCount);
Assert.Equal(1, aggregate.FlaggedExcludedCount);
// Median of {100, 300} = 100 (nearest-rank, lower value — repo convention).
Assert.Equal(100, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.EspApps).MedianSeconds);
}
[Fact]
public void Aggregates_MissingBreakdowns_AreCounted_NeverGuessed()
{
var missing = new List<SessionSummary> { Session(TenantA, T0) };
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
Array.Empty<(SessionSummary, SessionTimeBreakdown)>(), missing, T0)
.Single(a => a.TenantId == TenantA);
Assert.Equal(0, aggregate.CleanSessionCount);
Assert.Equal(1, aggregate.MissingBreakdownCount);
Assert.Empty(aggregate.SegmentStats);
}
[Fact]
public void Aggregates_SegmentStack_AlwaysCarriesAllSegmentsPlusUnattributed_AbsentAsZero()
{
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
{
(Session(TenantA, T0), Breakdown(unattributed: 42)),
};
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
pairs, Array.Empty<SessionSummary>(), T0)
.Single(a => a.TenantId == TenantA);
Assert.Equal(6, aggregate.SegmentStats.Count); // 5 canonical + unattributed — the full stack
// The fixture has no user_esp span → honest 0, not absent.
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.UserEsp).MedianSeconds);
Assert.Equal(42, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.Unattributed).MedianSeconds);
}
[Fact]
public void Aggregates_RollingDateKey_MergesAllDatesIntoRangeStatistics()
{
// Two sessions on DIFFERENT days — the rolling row must merge them (a median of
// per-day medians would not be the range median; the rolling rows are the honest
// range statistics the fleet panel reads).
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
{
(Session(TenantA, T0), Breakdown(espAppsSeconds: 100)),
(Session(TenantA, T0.AddDays(-3)), Breakdown(espAppsSeconds: 300)),
};
var rolling = MaintenanceService.BuildTimeAttributionAggregates(
pairs, Array.Empty<SessionSummary>(), T0, MaintenanceService.RollingAggregateDateKey);
var row = rolling.Single(a => a.TenantId == TenantA);
Assert.Equal(MaintenanceService.RollingAggregateDateKey, row.Date);
Assert.Equal(2, row.CleanSessionCount);
// Rolling rows sort AFTER every "yyyy-MM-dd|…" key so date-range reads and the
// age-based retention filter never touch them.
Assert.True(string.CompareOrdinal($"{row.Date}|user_driven", "2026-12-31|zzz") > 0);
}
[Fact]
public void Aggregates_PerAppRows_GateAtFiveSessions_AndCarryWhatIfBounds()
{
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>();
// App X in 5 sessions (ends last each time → saving = its full tail over device-prep-only baseline).
for (var i = 0; i < 5; i++)
{
pairs.Add((Session(TenantA, T0), Breakdown(apps: new List<BlockingAppInterval>
{
Interval(AppX, T0.AddMinutes(5), T0.AddMinutes(15)), // only measured blocking app → saving 600
})));
}
// App Y in only 4 sessions → below the per-app row gate.
for (var i = 0; i < 4; i++)
{
pairs.Add((Session(TenantA, T0), Breakdown(apps: new List<BlockingAppInterval>
{
Interval(AppY, T0.AddMinutes(5), T0.AddMinutes(6)),
})));
}
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
pairs, Array.Empty<SessionSummary>(), T0)
.Single(a => a.TenantId == TenantA);
var row = Assert.Single(aggregate.TopBlockingApps);
Assert.Equal(AppX, row.AppId);
Assert.Equal(5, row.SessionCount);
Assert.Equal(600, row.MedianSeconds);
Assert.Equal(600, row.MedianSavingSeconds); // sole blocking app → bound = full interval
}
// ── stale-bucket reconcile key consistency (Codex review) ───────────────
[Fact]
public void RollingAggregateEntity_RowKey_MatchesTargetedDeleteKey()
{
// The sweep's reconcile deletes by (Date, EnrollmentClass) — the entity builder and the
// delete path must agree on the RK shape for BOTH daily and rolling rows, or stale
// rolling rows would never be removed.
var rolling = new TimeAttributionDailyAggregate
{
TenantId = TenantA, Date = "rolling30", EnrollmentClass = "whiteglove",
};
Assert.Equal("rolling30|whiteglove", TableStorageService.BuildTimeAttributionAggregateEntity(rolling).RowKey);
var daily = new TimeAttributionDailyAggregate
{
TenantId = TenantA, Date = "2026-07-27", EnrollmentClass = "user_driven",
};
Assert.Equal("2026-07-27|user_driven", TableStorageService.BuildTimeAttributionAggregateEntity(daily).RowKey);
}
[Fact]
public void TimeAttribution_InclusiveWindowStart_YieldsExactly30CalendarDays()
{
// Codex review: both range ends are inclusive — subtracting the full WindowDays
// returned 31 day keys under a "windowDays: 30" label.
var today = new System.DateTime(2026, 7, 27);
Assert.Equal(today.AddDays(-29),
Functions.Metrics.TimeAttributionResponse.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponse.WindowDays));
}
}
@@ -413,6 +413,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "EnableTimezoneAutoSet", config.EnableTimezoneAutoSet },
{ "NtpServer", config.NtpServer },
{ "EnableImeMatchLog", config.EnableImeMatchLog },
{ "EnableGatherRuleDebugLog", config.EnableGatherRuleDebugLog },
{ "LogLevel", config.LogLevel },
{ "MaxBatchSize", config.MaxBatchSize },
{ "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl },
@@ -514,6 +515,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
EnableTimezoneAutoSet = entity.GetBoolean("EnableTimezoneAutoSet"),
NtpServer = string.IsNullOrWhiteSpace(entity.GetString("NtpServer")) ? "time.windows.com" : entity.GetString("NtpServer"),
EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"),
EnableGatherRuleDebugLog = entity.GetBoolean("EnableGatherRuleDebugLog"),
LogLevel = entity.GetString("LogLevel"),
MaxBatchSize = entity.GetInt32("MaxBatchSize"),
DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"),
@@ -1,6 +1,8 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
@@ -13,6 +15,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
/// - hardware rejection: "{manufacturer-lower}|{model-lower}" (trimmed)
/// - TPM PSS unsupported: "tpmpss|{serial-lower}" (trimmed; the literal "tpmpss" prefix
/// cannot collide with a hardware key because no real manufacturer is named "tpmpss")
/// - F3 rule regression: "ruleregression|{ruleId-lower}" (trimmed; payload-carrying rows
/// that are refreshed while the episode is active and deleted on re-arm)
/// Race-safe via AddEntityAsync: Azure Table Storage returns 409 Conflict if the entity already exists.
/// </summary>
public class TableHardwareRejectionNotificationTracker : IHardwareRejectionNotificationTracker
@@ -109,16 +113,102 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task<bool> TryRegisterRuleRegressionAsync(string tenantId, RuleRegressionAlert alert)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(alert?.RuleId))
return false;
try
{
await _table.AddEntityAsync(BuildRuleRegressionEntity(tenantId, alert!));
_logger.LogInformation(
"RuleRegression tracker registered: tenant={TenantId} rule={RuleId}", tenantId, alert!.RuleId);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Episode already active — one bell per episode.
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"RuleRegression tracker failed: tenant={TenantId} rule={RuleId}", tenantId, alert!.RuleId);
// Fail closed so we do not double-fire if the row was actually written.
return false;
}
}
public async Task RefreshRuleRegressionAsync(string tenantId, RuleRegressionAlert alert)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(alert?.RuleId))
return;
try
{
// Replace-mode upsert with the FULL payload incl. the caller-carried
// FirstNotifiedAt — the retention sweep re-arms on the ORIGINAL age, so a
// refresh must never rejuvenate the row.
await _table.UpsertEntityAsync(BuildRuleRegressionEntity(tenantId, alert!), TableUpdateMode.Replace);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"RuleRegression tracker refresh failed: tenant={TenantId} rule={RuleId} (stale numbers remain)",
tenantId, alert!.RuleId);
}
}
public async Task DeleteRuleRegressionAsync(string tenantId, string ruleId)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(ruleId))
return;
try
{
await _table.DeleteEntityAsync(tenantId.ToLowerInvariant(), BuildRuleRegressionRowKey(ruleId));
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Already gone (retention sweep or concurrent pass) — idempotent.
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"RuleRegression tracker delete failed: tenant={TenantId} rule={RuleId} (episode stays until retention)",
tenantId, ruleId);
}
}
public async Task<List<RuleRegressionAlert>> GetRuleRegressionsAsync(string tenantId)
{
var results = new List<RuleRegressionAlert>();
if (string.IsNullOrWhiteSpace(tenantId)) return results;
try
{
var partitionKey = tenantId.ToLowerInvariant();
// '}' (0x7D) sorts directly after '|' (0x7C) — prefix range over "ruleregression|…".
var filter = $"PartitionKey eq '{partitionKey}' and RowKey ge '{RuleRegressionRowKeyPrefix}' and RowKey lt 'ruleregression}}'";
await foreach (var entity in _table.QueryAsync<TableEntity>(filter: filter))
{
results.Add(MapToRuleRegressionAlert(entity));
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "RuleRegression tracker list failed for tenant {TenantId}", tenantId);
}
return results;
}
public async Task<int> DeleteOlderThanAsync(DateTime cutoffUtc)
{
try
{
// Rows are insert-once (AddEntityAsync, never updated), so FirstNotifiedAt == creation time.
// Pruning here resets the lifetime dedup for BOTH key spaces in this table: a hardware
// model rejected again after the cutoff rings once more, and so does a TPM-PSS device
// that reports again ("tpmpss|{serial}" rows are pruned by the same sweep). Acceptable
// in both cases — the portal only surfaces recent rejections anyway, and a device whose
// TPM was never fixed is worth surfacing again after a month of silence.
// FirstNotifiedAt == the row's first registration (rule-regression refreshes carry
// it forward unchanged). Pruning here resets the dedup for EVERY key space in this
// table: a hardware model rejected again after the cutoff rings once more, so does
// a TPM-PSS device that reports again ("tpmpss|{serial}" rows), and a rule
// regression still active after the window fires a fresh bell — a month-old
// still-burning regression is worth a reminder (spec §F3 retention re-arm).
var filter = $"FirstNotifiedAt lt datetime'{cutoffUtc:yyyy-MM-ddTHH:mm:ss}Z'";
var query = _table.QueryAsync<TableEntity>(filter: filter, select: new[] { "PartitionKey", "RowKey" });
@@ -160,5 +250,72 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
var sn = (serialNumber ?? string.Empty).Trim().ToLowerInvariant();
return $"tpmpss|{sn}";
}
internal const string RuleRegressionRowKeyPrefix = "ruleregression|";
internal static string BuildRuleRegressionRowKey(string ruleId)
{
var id = (ruleId ?? string.Empty).Trim().ToLowerInvariant();
return $"{RuleRegressionRowKeyPrefix}{id}";
}
// internal static: entity builder + mapper are pinned by round-trip unit tests
// (table-storage-serialization rule — every property must survive Store→Map).
internal static TableEntity BuildRuleRegressionEntity(string tenantId, RuleRegressionAlert alert)
{
var entity = new TableEntity(tenantId.ToLowerInvariant(), BuildRuleRegressionRowKey(alert.RuleId))
{
["TenantId"] = tenantId,
["RuleId"] = alert.RuleId,
["RuleTitle"] = alert.RuleTitle ?? string.Empty,
["WindowFireCount"] = alert.WindowFireCount,
["WindowSessionCount"] = alert.WindowSessionCount,
["BaselineFireCount"] = alert.BaselineFireCount,
["BaselineSessionCount"] = alert.BaselineSessionCount,
["WindowRatePct"] = alert.WindowRatePct,
["BaselineRatePct"] = alert.BaselineRatePct,
["WindowStartDate"] = alert.WindowStartDate ?? string.Empty,
["WindowEndDate"] = alert.WindowEndDate ?? string.Empty,
["FirstNotifiedAt"] = alert.FirstNotifiedAt,
["LastEvaluatedAt"] = alert.LastEvaluatedAt,
};
// Tri-states: absent column = "no claim" — a zero-baseline alert has no finite lift,
// and a missing dimension means "no clear concentration", never a guessed one.
if (alert.Lift.HasValue)
entity["Lift"] = alert.Lift.Value;
if (alert.Dimension != null)
entity["DimensionJson"] = JsonSerializer.Serialize(alert.Dimension);
return entity;
}
internal static RuleRegressionAlert MapToRuleRegressionAlert(TableEntity entity)
{
RuleRegressionDimension? dimension = null;
var dimensionJson = entity.GetString("DimensionJson");
if (!string.IsNullOrEmpty(dimensionJson))
{
try { dimension = JsonSerializer.Deserialize<RuleRegressionDimension>(dimensionJson!); }
catch (JsonException) { dimension = null; } // corrupt column — degrade to "no claim"
}
return new RuleRegressionAlert
{
TenantId = entity.GetString("TenantId") ?? entity.PartitionKey,
RuleId = entity.GetString("RuleId") ?? entity.RowKey.Substring(RuleRegressionRowKeyPrefix.Length),
RuleTitle = entity.GetString("RuleTitle") ?? string.Empty,
WindowFireCount = entity.GetInt32("WindowFireCount") ?? 0,
WindowSessionCount = entity.GetInt32("WindowSessionCount") ?? 0,
BaselineFireCount = entity.GetInt32("BaselineFireCount") ?? 0,
BaselineSessionCount = entity.GetInt32("BaselineSessionCount") ?? 0,
WindowRatePct = entity.GetDouble("WindowRatePct") ?? 0,
BaselineRatePct = entity.GetDouble("BaselineRatePct") ?? 0,
Lift = entity.GetDouble("Lift"),
WindowStartDate = entity.GetString("WindowStartDate") ?? string.Empty,
WindowEndDate = entity.GetString("WindowEndDate") ?? string.Empty,
Dimension = dimension,
FirstNotifiedAt = entity.GetDateTimeOffset("FirstNotifiedAt")?.UtcDateTime ?? DateTime.MinValue,
LastEvaluatedAt = entity.GetDateTimeOffset("LastEvaluatedAt")?.UtcDateTime ?? DateTime.MinValue,
};
}
}
}
@@ -99,6 +99,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task DeleteEventSessionIndexEntryAsync(string tenantId, string sessionId)
=> _storage.DeleteEventSessionIndexEntryAsync(tenantId, sessionId);
public Task<List<(string TenantId, string SessionId)>> GetAllSessionTombstoneKeysAsync()
=> _storage.GetAllSessionTombstoneKeysAsync();
/// <summary>
/// Tenant offboarding cascade-worker session enumerator. Yields one row per session
/// belonging to <paramref name="tenantId"/>. Storage exceptions are intentionally
@@ -111,5 +111,53 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<int> DeleteRuleStatsOlderThanAsync(DateTime cutoffDate)
=> _storage.DeleteRuleStatsOlderThanAsync(cutoffDate);
public Task<SessionTimeBreakdown?> ComputeAndStoreSessionTimeBreakdownAsync(string tenantId, string sessionId)
=> _storage.ComputeAndStoreSessionTimeBreakdownAsync(tenantId, sessionId);
public Task<SessionTimeBreakdown?> GetSessionTimeBreakdownAsync(string tenantId, string sessionId)
=> _storage.GetSessionTimeBreakdownAsync(tenantId, sessionId);
public Task ResolveEspBlockingForSessionAsync(string tenantId, string sessionId)
=> _storage.ResolveEspBlockingForSessionAsync(tenantId, sessionId);
public Task<bool> SaveTimeAttributionAggregateAsync(TimeAttributionDailyAggregate aggregate)
=> _storage.SaveTimeAttributionAggregateAsync(aggregate);
public Task<List<TimeAttributionDailyAggregate>> GetTimeAttributionAggregatesAsync(string tenantId, DateTime startDate, DateTime endDate)
=> _storage.GetTimeAttributionAggregatesAsync(tenantId, startDate, endDate);
public Task<List<TimeAttributionDailyAggregate>> GetRollingTimeAttributionAggregatesAsync(string tenantId)
=> _storage.GetRollingTimeAttributionAggregatesAsync(tenantId);
public Task DeleteTimeAttributionAggregateAsync(string tenantId, string dateKey, string enrollmentClass)
=> _storage.DeleteTimeAttributionAggregateAsync(tenantId, dateKey, enrollmentClass);
public Task<int> DeleteTimeAttributionAggregatesOlderThanAsync(DateTime cutoffDate)
=> _storage.DeleteTimeAttributionAggregatesOlderThanAsync(cutoffDate);
public Task<DeviceHistory?> GetDeviceHistoryAsync(string tenantId, string serialKey)
=> _storage.GetDeviceHistoryAsync(tenantId, serialKey);
public Task<List<DeviceHistory>> GetDeviceHistoriesByTenantAsync(string tenantId)
=> _storage.GetDeviceHistoriesByTenantAsync(tenantId);
public Task<bool> UpsertDeviceHistoryAsync(DeviceHistory history)
=> _storage.UpsertDeviceHistoryAsync(history);
public Task DeleteDeviceHistoryAsync(string tenantId, string serialKey)
=> _storage.DeleteDeviceHistoryAsync(tenantId, serialKey);
public Task<bool> SaveDeviceJourneyAggregateAsync(DeviceJourneyDailyAggregate aggregate)
=> _storage.SaveDeviceJourneyAggregateAsync(aggregate);
public Task<List<DeviceJourneyDailyAggregate>> GetDeviceJourneyAggregatesAsync(string tenantId, DateTime startDate, DateTime endDate)
=> _storage.GetDeviceJourneyAggregatesAsync(tenantId, startDate, endDate);
public Task DeleteDeviceJourneyAggregateAsync(string tenantId, string dateKey)
=> _storage.DeleteDeviceJourneyAggregateAsync(tenantId, dateKey);
public Task<int> DeleteDeviceJourneyAggregatesOlderThanAsync(DateTime cutoffDate)
=> _storage.DeleteDeviceJourneyAggregatesOlderThanAsync(cutoffDate);
}
}
@@ -213,6 +213,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["ReportType"] = string.IsNullOrEmpty(metadata.ReportType) ? ReportTypes.Session : metadata.ReportType
};
// Diagnostics-copy columns only exist on rows where the copy was requested —
// legacy-shaped rows stay untouched and map back to null.
if (metadata.DiagnosticsBlobName != null)
entity["DiagnosticsBlobName"] = metadata.DiagnosticsBlobName;
if (metadata.DiagnosticsCopyStatus != null)
entity["DiagnosticsCopyStatus"] = metadata.DiagnosticsCopyStatus;
await _reportsTableClient.UpsertEntityAsync(entity);
return true;
}
@@ -365,8 +372,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
SubmittedBy = entity.GetString("SubmittedBy") ?? string.Empty,
SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue,
AdminNote = entity.GetString("AdminNote") ?? string.Empty,
ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType
ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType,
DiagnosticsBlobName = NullIfEmpty(entity.GetString("DiagnosticsBlobName")),
DiagnosticsCopyStatus = NullIfEmpty(entity.GetString("DiagnosticsCopyStatus"))
};
}
private static string? NullIfEmpty(string? value)
{
return string.IsNullOrEmpty(value) ? null : value;
}
}
}
@@ -30,6 +30,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<RuleResult>> GetRuleResultsAsync(string tenantId, string sessionId)
=> _storage.GetRuleResultsAsync(tenantId, sessionId);
public Task<List<string>> GetRuleHitSessionIdsAsync(string tenantId, string ruleId, DateTime sinceUtc, int maxResults = 2000)
=> _storage.GetRuleHitSessionIdsAsync(tenantId, ruleId, sinceUtc, maxResults);
public Task<bool> StoreGatherRuleAsync(GatherRule rule, string tenantId = "global")
=> _storage.StoreGatherRuleAsync(rule, tenantId);
@@ -84,7 +84,32 @@ 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);
// F1 PR2: compute + persist the time-attribution breakdown once, now that the
// terminal write stamped CompletedAt/DurationSeconds and the event stream is
// complete. Succeeded/Failed only — Incomplete deliberately stores no duration,
// so there is no wall clock to partition (the compute would no-op anyway; the
// gate just saves the point-reads). Fail-soft; the 30d maintenance sweep
// self-heals any miss.
if (status == SessionStatus.Succeeded || status == SessionStatus.Failed)
await _storage.ComputeAndStoreSessionTimeBreakdownAsync(tenantId, sessionId);
// F2 PR4: upsert this terminal session into its device's history chain at the
// same seam, so the session-detail "Attempt N" banner is fresh rather than a
// sweep-cycle old. ALL three terminal statuses — Incomplete is a non-successful
// journey attempt, unlike the duration-gated breakdown above. Re-terminal
// reclassifications (admin mark, late completion) flow through here too and
// update the chain entry's status. Fail-soft + idempotent; the maintenance
// sweep self-heals any miss.
await _storage.UpdateDeviceHistoryForSessionAsync(tenantId, sessionId);
}
return transitioned;
}
@@ -123,6 +148,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
=> _storage.UpdateSessionImeAgentVersionAsync(tenantId, sessionId, version);
public Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
double avgLatencyMs, int requestCount)
=> _storage.UpdateSessionNetworkLatencyAsync(tenantId, sessionId, avgLatencyMs, requestCount);
public Task UpdateSessionConnectionTypeAsync(string tenantId, string sessionId,
string connectionType)
=> _storage.UpdateSessionConnectionTypeAsync(tenantId, sessionId, connectionType);
public Task<List<SessionSummary>> GetSessionsWithEventCountAboveAsync(string tenantId, int threshold)
=> _storage.GetSessionsWithEventCountAboveAsync(tenantId, threshold);
@@ -55,11 +55,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<Dictionary<string, object>>> GetSoftwareInventoryAsync(string tenantId)
=> _storage.GetSoftwareInventoryAsync(tenantId);
public async Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync()
public async Task<List<Dictionary<string, object>>> GetAllSoftwareInventoryAsync(IEnumerable<string>? selectColumns = null)
{
var tableClient = _storage.GetTableClient(Constants.TableNames.SoftwareInventory);
var results = new List<Dictionary<string, object>>();
await foreach (var entity in tableClient.QueryAsync<TableEntity>(maxPerPage: 1000))
await foreach (var entity in tableClient.QueryAsync<TableEntity>(maxPerPage: 1000, select: selectColumns))
{
var item = new Dictionary<string, object>();
foreach (var prop in entity)
@@ -141,13 +141,21 @@ 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.
var total = g.Count();
var succeeded = g.Count(s => s.Status == "Succeeded");
var succeededAll = g.Where(s => s.Status == "Succeeded").ToList();
var skipped = succeededAll.Count(MetricsMath.IsSkipTerminalState);
var installed = succeededAll.Where(s => !MetricsMath.IsSkipTerminalState(s)).ToList();
var measured = installed.Where(MetricsMath.HasMeasuredDuration).ToList();
var failed = g.Count(s => s.Status == "Failed");
var completed = g.Where(s => s.Status == "Succeeded").ToList();
var failureRate = MetricsMath.TerminalFailureRatePct(failed, succeeded);
var failureRate = MetricsMath.TerminalFailureRatePct(failed, installed.Count);
var firstHalf = g.Where(s => s.StartedAt < midpoint).ToList();
var secondHalf = g.Where(s => s.StartedAt >= midpoint).ToList();
@@ -158,12 +166,14 @@ namespace AutopilotMonitor.Functions.Functions.Apps
appName = g.Key,
appType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
totalInstalls = total,
succeeded,
succeeded = installed.Count,
skipped,
unmeasured = installed.Count - measured.Count,
failed,
failureRate,
avgDurationSeconds = completed.Count > 0 ? Math.Round(completed.Average(s => s.DurationSeconds), 0) : 0,
maxDurationSeconds = completed.Count > 0 ? completed.Max(s => s.DurationSeconds) : 0,
avgDownloadBytes = completed.Count > 0 ? (long)completed.Average(s => s.DownloadBytes) : 0,
avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
trend,
trendDelta,
lastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt)
@@ -182,6 +192,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
success = true,
totalApps = apps.Count,
totalInstalls = summaries.Count,
collisionExcluded,
windowDays = days,
apps
};
@@ -198,6 +209,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
success = true,
totalApps = apps.Count,
totalInstalls = summaries.Count,
collisionExcluded,
windowDays = days,
count = page.Count,
offset,
@@ -223,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
@@ -236,11 +253,14 @@ namespace AutopilotMonitor.Functions.Functions.Apps
appName,
appType = string.Empty,
windowDays = days,
collisionExcluded,
bucket = "day",
summary = new
{
totalInstalls = 0,
succeeded = 0,
skipped = 0,
unmeasured = 0,
failed = 0,
failureRate = 0,
avgDurationSeconds = 0,
@@ -260,13 +280,17 @@ namespace AutopilotMonitor.Functions.Functions.Apps
}
var total = summaries.Count;
var succeeded = summaries.Count(s => s.Status == "Succeeded");
// PR0 classification — skips leave rate + durations; durations read measured rows only.
var succeededAll = summaries.Where(s => s.Status == "Succeeded").ToList();
var skipped = succeededAll.Count(MetricsMath.IsSkipTerminalState);
var installed = succeededAll.Where(s => !MetricsMath.IsSkipTerminalState(s)).ToList();
var measured = installed.Where(MetricsMath.HasMeasuredDuration).ToList();
var succeeded = installed.Count;
var failed = summaries.Count(s => s.Status == "Failed");
var completed = summaries.Where(s => s.Status == "Succeeded").ToList();
var failureRate = MetricsMath.TerminalFailureRatePct(failed, succeeded);
var avgDurationSeconds = completed.Count > 0 ? Math.Round(completed.Average(s => s.DurationSeconds), 0) : 0;
var p95DurationSeconds = Percentile(completed.Select(s => s.DurationSeconds).ToList(), 0.95);
var avgDownloadBytes = completed.Count > 0 ? (long)completed.Average(s => s.DownloadBytes) : 0;
var avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0;
var p95DurationSeconds = Percentile(measured.Select(s => s.DurationSeconds).ToList(), 0.95);
var avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0;
// Trend (same rule as list endpoint).
var firstHalf = summaries.Where(s => s.StartedAt < midpoint).ToList();
@@ -287,7 +311,8 @@ namespace AutopilotMonitor.Functions.Functions.Apps
{
var vTotal = g.Count();
var vFailed = g.Count(s => s.Status == "Failed");
var vSucceeded = g.Count(s => s.Status == "Succeeded");
// Same PR0 convention as the top-level rate: skips are not attempts.
var vSucceeded = g.Count(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s));
return new
{
appVersion = g.Key,
@@ -347,7 +372,8 @@ namespace AutopilotMonitor.Functions.Functions.Apps
{
var modelTotal = g.Count();
var modelFailed = g.Count(x => x.Summary.Status == "Failed");
var modelSucceeded = g.Count(x => x.Summary.Status == "Succeeded");
// PR0 convention: skips are not attempts (same population as the headline rate).
var modelSucceeded = g.Count(x => x.Summary.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(x.Summary));
var modelFailureRate = MetricsMath.TerminalFailureRatePct(modelFailed, modelSucceeded);
var lift = failureRate > 0
? Math.Round(modelFailureRate / failureRate, 2)
@@ -373,11 +399,14 @@ namespace AutopilotMonitor.Functions.Functions.Apps
appName,
appType,
windowDays = days,
collisionExcluded,
bucket,
summary = new
{
totalInstalls = total,
succeeded,
skipped,
unmeasured = installed.Count - measured.Count,
failed,
failureRate,
avgDurationSeconds,
@@ -490,10 +519,13 @@ namespace AutopilotMonitor.Functions.Functions.Apps
private static (string Trend, double? TrendDelta) ComputeFailureTrend(
List<AppInstallSummary> firstHalf, List<AppInstallSummary> secondHalf)
{
// PR0 convention: skips are not attempts — keep the trend on the same population
// as the headline failure rate, or a wave of not-applicable update policies would
// fake an "improving" trend.
var fhFailed = firstHalf.Count(s => s.Status == "Failed");
var fhSucceeded = firstHalf.Count(s => s.Status == "Succeeded");
var fhSucceeded = firstHalf.Count(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s));
var shFailed = secondHalf.Count(s => s.Status == "Failed");
var shSucceeded = secondHalf.Count(s => s.Status == "Succeeded");
var shSucceeded = secondHalf.Count(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s));
if (fhFailed + fhSucceeded < 5 || shFailed + shSucceeded < 5)
return ("stable", null);
@@ -531,16 +563,17 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var items = kv.Value;
var bTotal = items.Count;
var bFailed = items.Count(s => s.Status == "Failed");
var bSucceeded = items.Count(s => s.Status == "Succeeded");
var bCompleted = items.Where(s => s.Status == "Succeeded").ToList();
// PR0 convention: skips are not attempts; durations read measured rows only.
var bInstalled = items.Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s)).ToList();
var bMeasured = bInstalled.Where(MetricsMath.HasMeasuredDuration).ToList();
return (object)new
{
bucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc),
installs = bTotal,
succeeded = bSucceeded,
succeeded = bInstalled.Count,
failed = bFailed,
failureRate = MetricsMath.TerminalFailureRatePct(bFailed, bSucceeded),
avgDurationSeconds = bCompleted.Count > 0 ? Math.Round(bCompleted.Average(s => s.DurationSeconds), 0) : 0
failureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count),
avgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0
};
})
.ToList();
@@ -93,9 +93,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
Success = true,
ShortCode = session.ShortCode,
// www directly — the bare apex is only a registrar-level 301 to www,
// so pointing there just adds a redirect hop for every bootstrap.
BootstrapUrl = $"{Constants.WebsiteBaseUrl}/go/{session.ShortCode}",
// go domain — Front Door rewrites /{code} to /api/bootstrap/go/{code}
// (GetBootstrapScriptFunction). URLs issued before the migration keep
// working on the legacy www /go route until the static-export cutover.
BootstrapUrl = $"{Constants.BootstrapGoBaseUrl}/{session.ShortCode}",
ExpiresAt = session.ExpiresAt,
Message = $"Bootstrap session created. Valid for {validityHours} hours."
};
@@ -0,0 +1,142 @@
using System;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// GET /api/bootstrap/go/{code} — the OOBE bootstrap script endpoint.
/// Customer-facing as <c>irm https://go.autopilotmonitor.com/{code} | iex</c>
/// (Front Door rewrites /{code} on the go domain to this route). Successor of the
/// portal's Next.js /go/[code] route, moved here so the portal can become a static
/// export; validation happens in-process (BootstrapSessionService), no HTTP hop.
///
/// Contract: ALWAYS HTTP 200 with text/plain — error conditions return a small
/// PowerShell script whose Write-Host surfaces the message, so irm | iex shows the
/// error on the OOBE console instead of crashing on a non-2xx status.
/// Cache-Control: no-store comes from NoStoreCacheMiddleware (the script embeds the
/// bearer token); X-Content-Type-Options: nosniff from SecurityHeadersMiddleware.
/// </summary>
public class GetBootstrapScriptFunction
{
private readonly ILogger<GetBootstrapScriptFunction> _logger;
private readonly BootstrapSessionService _bootstrapService;
private readonly RateLimitService _rateLimitService;
private readonly TenantConfigurationService _configService;
// Same gate the portal route enforced: 4-10 alphanumeric chars, checked before
// any service call. Stricter than the length-only check in
// ValidateBootstrapCodeFunction on purpose.
private static readonly Regex CodeFormatRegex = new(
"^[a-z0-9]{4,10}$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
internal static bool IsValidCodeFormat(string? code) =>
!string.IsNullOrEmpty(code) && CodeFormatRegex.IsMatch(code);
public GetBootstrapScriptFunction(
ILogger<GetBootstrapScriptFunction> logger,
BootstrapSessionService bootstrapService,
RateLimitService rateLimitService,
TenantConfigurationService configService)
{
_logger = logger;
_bootstrapService = bootstrapService;
_rateLimitService = rateLimitService;
_configService = configService;
}
[Function("GetBootstrapScript")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "bootstrap/go/{code}")] HttpRequestData req,
string code)
{
try
{
// Rate limit by source IP (prevent brute-force enumeration of short codes).
// Own bucket, deliberately not shared with bootstrap-validate. AFD-aware
// extraction: traffic arrives via go.autopilotmonitor.com, where the trusted
// hop is Front Door egress — X-Azure-ClientIP is honored under FDID proof.
// On breach the response is still a 200 error script (irm | iex must surface
// the message); Retry-After is informational for non-iex clients.
var clientIp = ClientIpExtractor.GetRateLimitClientIp(req);
var rateLimitResult = _rateLimitService.CheckRateLimit($"bootstrap-script:{clientIp}", 20); // 20 req/min
if (!rateLimitResult.IsAllowed)
{
_logger.LogWarning("Bootstrap script rate limit hit from IP {ClientIp}", clientIp);
var limited = await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Rate limit exceeded. Try again later."));
if (rateLimitResult.RetryAfter.HasValue)
limited.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
return limited;
}
if (!IsValidCodeFormat(code))
{
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Invalid bootstrap code format."));
}
var session = await _bootstrapService.ValidateCodeAsync(code);
if (session == null)
{
_logger.LogWarning("Bootstrap script request failed for code {Code} from IP {ClientIp}", code, clientIp);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap code not found, expired, or revoked."));
}
// Feature gate — same generic message as not-found so a disabled tenant is
// indistinguishable from an unknown code (no enumeration oracle).
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!tenantConfig.BootstrapTokenEnabled)
{
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap code not found, expired, or revoked."));
}
var agentDownloadUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentZipFileName}";
var expiresAtUtc = session.ExpiresAt.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(session.ExpiresAt, DateTimeKind.Utc)
: session.ExpiresAt.ToUniversalTime();
if (!BootstrapScriptValueValidator.TryValidate(
session.TenantId, session.Token, agentDownloadUrl, expiresAtUtc,
out var values, out var reason))
{
// Log the failure category only — never the offending value, to keep
// potential attack payloads out of Application Insights.
_logger.LogWarning("Bootstrap script value validation failed for code {Code}: {Reason}", code, reason);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Bootstrap response failed validation. Contact support."));
}
var script = OobeBootstrapScriptGenerator.GenerateSuccessScript(values, DateTime.UtcNow);
return await ScriptResponseAsync(req, script);
}
catch (Exception ex)
{
// Never inline exception detail into the script — it would execute on the
// OOBE console and could leak internals.
_logger.LogError(ex, "Error generating bootstrap script for code {Code}", code);
return await ScriptResponseAsync(
req, OobeBootstrapScriptGenerator.GenerateErrorScript("Failed to generate bootstrap script. Please try again."));
}
}
private static async Task<HttpResponseData> ScriptResponseAsync(HttpRequestData req, string script)
{
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
await response.WriteStringAsync(script);
return response;
}
}
}
@@ -0,0 +1,179 @@
using System;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// Generates the OOBE bootstrap PowerShell script served at /api/bootstrap/go/{code}
/// (customer-facing: irm https://go.autopilotmonitor.com/{code} | iex).
/// Ported 1:1 from the portal's app/go/[code]/route.ts when the endpoint moved into
/// the backend for the static-export migration.
/// </summary>
public static class OobeBootstrapScriptGenerator
{
/// <summary>
/// Hard cap on error messages embedded in the generated PS script. Defense
/// against an upstream bug or compromise emitting a huge message that would
/// otherwise be inlined verbatim into the script body.
/// </summary>
internal const int MaxErrorMessageLength = 200;
/// <summary>
/// Builds the always-HTTP-200 error script: irm | iex executes it and the
/// Write-Host surfaces the message on the OOBE console.
/// </summary>
public static string GenerateErrorScript(string message)
{
// Cap length BEFORE quote-escaping so the cap is on raw input, not the
// post-escape size (which can double for '-heavy strings).
var truncated = message.Length > MaxErrorMessageLength
? message.Substring(0, MaxErrorMessageLength) + "..."
: message;
var safeMsg = truncated.Replace("'", "''");
return $"Write-Host ''; Write-Host 'ERROR: {safeMsg}' -ForegroundColor Red; Write-Host ''";
}
// SECURITY INVARIANT: All template parameters MUST come from
// BootstrapScriptValueValidator.TryValidate. The single-quoted PowerShell
// string literals below are the second line of defense against injection
// (single-quoted PS strings do NOT expand $(...), $var, or backticks).
// Do NOT change to double-quoted without re-reviewing the validator's
// character classes end-to-end.
//
// The template is a raw string with __TOKEN__ placeholders substituted via
// Replace — deliberately NOT string interpolation or string.Format, because
// the script body is full of PowerShell $vars and {braces}.
public static string GenerateSuccessScript(
BootstrapScriptValueValidator.ValidatedValues values,
DateTime generatedAtUtc)
{
// "yyyy-MM-ddTHH:mm:ss.fffZ" — byte parity with JS toISOString(), which the
// previous portal implementation used for both timestamps. Inputs are UTC by
// contract; no ToUniversalTime() — it would shift Unspecified-kind values
// (e.g. from table storage) by the server's local offset.
var generatedAt = generatedAtUtc.ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'");
var expiresAt = values.ExpiresAtUtc.ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'");
return Template
.Replace("__GENERATED_AT__", generatedAt)
.Replace("__EXPIRES_AT__", expiresAt)
.Replace("__AGENT_DOWNLOAD_URL__", values.AgentDownloadUrl)
.Replace("__BOOTSTRAP_TOKEN__", values.Token)
.Replace("__TENANT_ID__", values.TenantId)
.Replace("__PORTAL_URL__", Constants.PortalBaseUrl);
}
private const string Template = """
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Autopilot Monitor OOBE Bootstrap
Generated: __GENERATED_AT__
Expires: __EXPIRES_AT__
This script was generated by the Autopilot Monitor platform.
It installs the monitoring agent before Intune enrollment begins,
enabling full session capture from the start of OOBE.
#>
$ErrorActionPreference = "Stop"
$AgentDownloadUrl = '__AGENT_DOWNLOAD_URL__'
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
# Create directories
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
try {
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
Write-Log 'Bootstrap token expires: __EXPIRES_AT__'
# Check if agent is already installed
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath -- skipping download"
}
else {
# Download agent ZIP
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
Write-Log "Downloading agent from $AgentDownloadUrl..."
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
Write-Log "Downloaded agent to $zipPath"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
$retryDelays = @(2, 4, 8)
$delay = $retryDelays[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
Start-Sleep -Seconds $delay
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# Extract
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Extracted agent to $AgentBinPath"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
}
# Install agent with bootstrap token
Write-Log "Installing agent with bootstrap token..."
& $agentExePath --install --no-auth --bootstrap-token '__BOOTSTRAP_TOKEN__' --tenant-id '__TENANT_ID__'
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "===== OOBE Bootstrap Complete ====="
Write-Log "Agent is now monitoring. View progress at __PORTAL_URL__"
Write-Log ""
Write-Log "The agent will:"
Write-Log " - Monitor enrollment from the very start"
Write-Log " - Switch to certificate auth after Intune enrollment"
Write-Log " - Self-destruct when enrollment completes"
Write-Host ""
Write-Host "Autopilot Monitor agent installed successfully!" -ForegroundColor Green
Write-Host "View enrollment progress at __PORTAL_URL__" -ForegroundColor Cyan
Write-Host ""
}
catch {
Write-Log "===== OOBE Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Host ""
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Check log file: $LogFile" -ForegroundColor Yellow
Write-Host ""
exit 1
}
""";
}
}
@@ -13,7 +13,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// GET /api/bootstrap/validate/{code} — Validate a bootstrap code and return the token.
/// Anonymous endpoint — called by the Next.js /go/{code} route (server-side).
/// Anonymous endpoint — legacy consumer is the Next.js /go/{code} route (alive for
/// pre-migration URLs until the static-export cutover); the canonical script path
/// is GetBootstrapScriptFunction, which validates in-process instead.
/// Rate-limited by IP to prevent brute-force enumeration.
/// </summary>
public class ValidateBootstrapCodeFunction

Some files were not shown because too many files have changed in this diff Show More