Telemetry architecture Unity MaterialDesign UI
Repo README

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

  1. 01
    Capture 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.

  2. 02
    The ledger joins file to event.

    The exporter writes first, hashes the exact bytes, then emits ui_snapshot_taken. No write means no ledger point.

  3. 03
    The viewer reconstructs, never mutates.

    LP files, InfluxDB, and imported files normalize to RawEvent; the catalog unions; timeline, replay, and aggregate are read models.

Invariant A timeline is only honest if the pixels and the events use the same session id and the same nanosecond clock.

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

0 ms
Hover or click an item to read its contract.
work wait / window disk network event intentionally skipped failure

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.

  • The session id is the correlation root. It is attached to every event and to the snapshot's app info.
  • Owner resolution is a walk. The owning view is the view itself, the innermost open popup containing the element, or the top-of-stack fallback.
  • The ledger is an event, not a separate store. snapshotTaken arrives only after the exporter's write succeeded.
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.

  • Dedupe is cycle-scoped. Cross-cycle dedupe starved pooled views on reopen; this implementation deliberately resets at view end.
  • The chain is committed after the write. A failed write rolls the fingerprint back, so identical content is not lost to a later dedupe pass.
  • Layout force is gated. The capture skips UILayout.forceNow when layout is dirty only in the flag's opinion; the viewer's geometry warning arms a one-force recovery latch.
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.

  • No write, no ledger. The ledger point is emitted after success, never before.
  • Theme before snapshot. The manifest is registered before a file references it; outside a session context the capture falls back to inline theme.
  • Deltas are store-resolvable. The chain carries base hashes and ancestor hashes so the server does not need the ledger to materialize it.
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.

  • The viewer is a consumer of the same files. A pending tail remains inspectable while InfluxDB is down.
  • Retention can delete pending files. The queue drops missing paths; the outbox never blocks forever on a vanished file.
  • The first failure is logged once. Repeated failures are suppressed until recovery; telemetry never throws into the app.
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.

  • Union, not first-wins. A pending tail cannot hide a complete Influx ledger.
  • eventCount is an invariant. The timeline must draw every event the projection counted.
  • Missing is honest. Unconfigured roots, unreachable sources, and missing artifacts are distinct states.
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.

  • Latest ≤ t is per span. It is the same correlation rule as aggregate, not a separate heuristic.
  • No invented UI. A broken chain or missing manifest degrades with a warning chip.
  • Declined optimizations stay visible. ADR-0010 and ADR-0011 are declines with numbers, not forgotten ideas.

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

Read-only, by construction. The viewer calls 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.