Architecture field guide
Telemetry, from a click to a pixel.
A Unity signal becomes a nanosecond event and a snapshot file. A SHA-256 ledger point joins the two. The viewer unions sources, projects a session, and replays the UI at any moment — without ever writing back.
The 60-second model
- 01Capture is event-driven.
View activation, structural mutation, and F9 feed a settle/debounce/pacing pipeline. The result is a tree walk, an anchor or delta, and bytes on disk.
- 02The ledger joins file to event.
The exporter writes first, hashes the exact bytes, then emits
ui_snapshot_taken. No write means no ledger point. - 03The viewer reconstructs, never mutates.
LP files, InfluxDB, and imported files normalize to
RawEvent; the catalog unions; timeline, replay, and aggregate are read models.
Core mechanics
Six ideas that explain the rest
Start here. Each card links to the component that owns the mechanic; the map and timelines let you follow it end to end.
Interactive component chart
The system, left to right
Five planes: Unity raises, capture encodes, transport stores, the server reads, the browser renders. Click a node for its contract and isolate its direct neighbourhood; hover an edge to read the hand-off.
Data-flow stepper
One interaction, twelve hand-offs
Step through the full path. Each step highlights the components involved on the map above.
Interactive timeline lab
Where the milliseconds go
Four views of the same system: capture, view lifetime, delivery, and the viewer read path. Durations are illustrative; config-backed values are labelled.
Inside one capture
Deep dives
The parts, with the sharp edges
Each section is self-contained. Code references are repo-relative and validated by check-links.mjs.
01Session, events, and view spansWhat the runtime actually records
Telemetry owns the configuration and the hidden TelemetryHost. EventCollector owns the session id, subscribes to UIInteractions, and turns a raise into a TelemetryEvent. The framework never calls telemetry directly; when no listener exists, every raise is a no-op.
A view span is opened by view_open and closed by view_close, with dwell seconds. A close raised by a button and the lifecycle raise are deduped through the same open-span map, so exactly one pair is emitted per visible cycle. Popups get their own span and are excluded by name when popupExcludedViews says so.
02Capture decisions and budgetsSettle, debounce, pace, dedupe, chain
UISnapshotTriggers is the policy layer. A view transition waits viewTransitionSettle (500 ms). A structural mutation refreshes a per-view mutationDebounce window (250 ms). Captures closer than viewTransitionMinInterval (1 s) are put in a deferred queue — pacing waits, it does not drop. Near-simultaneous duplicate raises of the same view are suppressed within 100 ms.
After the walk, a mutation capture compares a structural fingerprint with the last exported tree of the same view cycle. Equal means no export, no ledger, and no timing line. Otherwise binary mode can emit a sparse delta against the last materialized member; if there is no anchor yet, the capture becomes the anchor.
03The storage contractBytes first, hash second, ledger third
UISnapshotExporter prepares the path and theme mode on the main thread, then moves serialization, encoding, hashing, and the write to the thread pool. Binary output is MDS v3: magic MDS\n, a string table, anchors, delta members, and a session theme manifest. Legacy YAML v2 with optional gzip remains readable.
The SHA-256 is computed over the exact bytes. The ledger point carries that hash plus the logical storage key; the key is a lookup hint, the hash is authoritative. Snapshot retention is newest-survives by count and total size, which can intentionally break old chains or manifest references. The viewer responds with a coded warning rather than inventing a tree.
04Delivery and failurePersist before send, ack before delete
The active sink is chosen per write. With influxEnabled=true, InfluxOutboxSink batches 64 events or 1 s of age, writes a pending-*.lp file before the first POST, and deletes that file only after a 2xx. With Influx disabled, FileEventSink appends one session-<id>.lp per session and rotates on session change.
On failure, the pending file stays in the queue. Retries start at the flush interval (1 s), double to a 60 s cap, and recover an old backlog by scanning the events directory at startup. Coalescing combines up to 16 files or 512 KB per request; an oversized head is sent alone so it cannot stall the queue. Delivery is at-least-once; identical series overwrite, so replay is idempotent.
05Viewer sources and read modelsUnion, project, derive
Every provider returns RawEvent: measurement, nanosecond timestamp, tags, fields. The LP-file source is canonical for nanosecond truth; the Influx source handles OSS and annotated CSV; imported files replace the universe rather than merging with it. SourceRuntime keeps the mutable mode and roots between env defaults and providers.
SessionCatalog queries providers in priority order (files first, Influx last), unions sessions present in several providers, and collapses wire-exact duplicates on measurement + timestamp + full tag set + key. projectSessions builds session bounds, event counts, view spans, dwell, and snapshot ledger entries without opening a snapshot. Timeline and aggregate consume the same unioned stream.
06Replay, filmstrips, and aggregatePixels from the same ledger
buildTimelineFrame orders every event, packs spans into rows, and correlates each interaction with the latest anchor of the same view at or before t. The timeline view draws marbles, density bars, and lazy filmstrips. At the playhead it resolves every span covering t, loads that span's capture, and stacks views bottom-to-top by canvas layer: BACKGROUND < DEFAULT < MENU < POPUP.
@mdu/snapshot-render is the single renderer for the web client, the standalone snapshot viewer, and the server's DOM-free normalization path. It resolves theme roles, lays out flex nodes, draws minimaps, and computes overlay placement from captured anchors. Aggregate reuses the same snapshots and canonical keys, but ignores captured themes and uses a hardcoded light Material-3 baseline for comparison.
Reference
Events and formats
The wire surface the viewer and the runtime agree on. Tags stay bounded; identifying payload lives in fields.
Snapshot formats
HTTP surface
Routes and sources
All routes are read-only except the source-config and import controls, which only affect the viewer process.
Viewer environment
GET /health and POST /api/v2/query on InfluxDB, never the write endpoint. The Influx token is server-side only and is never echoed by any source-config route.
Failure matrix
What breaks, what survives
Each failure has an owner, a visible state, and a recovery path. The design preference is an honest degraded state over a silent wrong replay.
Configuration
The defaults that shape behaviour
From Assets/Runtime/Telemetry/TelemetryConfig.cs. Defaults are contract: changing one changes the docs and often the tests.
Source map
Where to read the code
Repo-relative paths, validated by the link checker. The component inspector carries the precise file and symbol for each node.
Decision record
Performance decisions, including the declines
ADRs 0007/0008/0009 are one migration. 0010 and 0011 were declined on measured numbers and remain part of the record.
Maintenance
Change recipes
Short checklists for the changes most likely to break an invariant.