Skip to content

ADR-093 — Journey instrumentation, event naming for Umami reports, and no session replay ​

Status · Accepted · 2026-09-10 · Operator-directed · builds on ADR-081 (self-hosted Umami) and ADR-092 (opt-out + privacy posture) · consumption runbook: docs/guides/analytics-consuming-events.md

Context ​

The event registry answered "what is popular" and could not answer "did the visitor get anywhere". Three holes were concrete:

  • /plan emitted only two filter-change events. It was impossible to tell whether the trajectory planner had ever been used, as opposed to opened.
  • /fly emitted a start and an end (mission-load, mission-complete) with nothing between, so "where do visitors stop" was unanswerable.
  • science-section-view carried no provenance, so "did exploring Mars lead to reading about it" was unanswerable.

Separately, an audit of the live prod database found app-load had fired 0 times against 731 route-enters — see Decision 4.

The instance is Umami 3.3.0, which offers funnel, journey, retention, goals, heatmap, breakdown, attribution, UTM, performance and revenue reports.

Decision ​

1. Six journey events, milestone-deduped ​

explore-depth, plan-run, plan-window-select, fly-<act>, science-to-app, tour-complete, plus a source property on science-section-view.

Every one fires on first reach and never on a repeat: a re-render, a timeline scrub, or walking back down the scale ladder must not inflate a funnel. Dedup keys live in module-level in-memory state, not a cookie and not storage — ADR-057 bans client storage and ADR-092 spent the third cookie. Module scope survives SPA navigation and dies on reload, which is the correct lifetime: a genuine return visit should count again.

tour-complete fires from nextTourId() reaching the end of the sequence, and deliberately not from stopTour(), which is abandonment. Mixing the two would make the completion rate meaningless.

plan-window-select fires only on a deliberate pick (pointer or the touch magnifier). The auto-selected "cheapest viable" default and the ?dep/?tof deep-link restore do not fire it — counting those would make plan-window-select ÷ plan-run meaningless.

2. Kebab-case event names; snake_case properties ​

Unchanged from the existing registry, and enforced by the analytics.test.ts guard (/^[a-z][a-z0-9-]*$/). An outside proposal recommended snake_case event names; adopting it would have failed that test and split the dashboard vocabulary. Rejected.

3. One event NAME per /fly act — the deliberate exception ​

Umami's Funnel, Journey and Goal steps match on an event name or a URL. There is no property condition in the step form (docs). A single fly-phase event carrying phase=cruise would therefore collapse into one indistinguishable funnel step.

So /fly emits fly-ascent, fly-coast, fly-cruise, fly-descent, fly-recovery, with mission and dest as properties. This inverts the otherwise-correct "stable vocabulary + properties, never one event per value" rule, and the reason is specifically Umami's step matcher.

explore-depth keeps the level property rather than splitting into eight names: the scale ladder is strictly monotonic, so the Properties breakdown gives the same drop-off curve and eight names would bloat the registry for no new answer.

4. track() buffers events fired before the tracker script loads ​

+layout.svelte calls initAnalytics() — which appends a <script defer> — and then track('app-load', …) on the next line. The deferred script has not executed, so window.umami was undefined and every app-load since launch evaporated. The module claimed events were "safe before the script loads (it queues)"; that was false — Umami's queue only exists once its own script defines the global.

track() now buffers into a capped (50) in-memory queue and flushes in order on the script's load event. Without this, the six new milestone events would have inherited the same flaw: explore-depth fires from an effect at mount and plan-run on the first grid load.

5. No session replay, therefore no heatmaps ​

Umami 3.3 supports heatmaps, but they are driven by the session recorder. We are not enabling it. Doing so would require a website setting, a different tracker script (/recorder.js), a new path in ops/caddy/orrery-analytics.caddy (which allowlists exactly /script.js and /api/send), and replacing frame-ancestors 'none' with the Umami origin in ops/docker/nginx.conf.

Session replay is a materially different category of collection from counting events; /privacy currently describes something narrower; and the CSP change loosens a header set to none on purpose. Revisit only with an ADR amendment and a /privacy copy change.

Consequences ​

Positive

  • The three previously-unanswerable questions become answerable, and the /plan and /fly funnels work natively in Umami's own UI with no SQL.
  • sourceRoute() gives any future event free provenance — the value already flowed through route-enter's from_route.
  • The pre-load buffer fixes a silent data-loss bug that predates this work.

Negative

  • fly-* breaks the one-name-many-properties convention, and a future act added to the state machine needs a matching entry in FLY_ACT_EVENT or it silently won't report. Mitigated by a test asserting the full act→name map.
  • Registry growth: 24 → 34 event names.
  • Milestone dedup is per page-load, so session-scoped rates still need count(distinct session_id) in SQL rather than raw event counts.

Neutral

  • No change to the env ladder (ADR-082), the opt-out gate (ADR-092), or the edge vhost. Every new event routes through the same suppressed-when-opted-out track().

Alternatives considered ​

  • A fly-phase event with a phase property. Tidier vocabulary; cannot be funnelled in Umami. Rejected once the docs made the step matcher explicit — this was originally implemented the tidy way and changed after reading them.
  • Splitting explore-depth into eight names too. Rejected: monotonic ladder, breakdown is equivalent, registry cost is real.
  • A polling flush for the pre-load buffer. Rejected in favour of the script's load event — deterministic, no timer.

Not covered / out of scope ​

  • Translations of the ADR-092 /privacy strings remain outstanding (en-US ships, 13 locales fall back).
  • No e2e coverage for any journey event or the opt-out toggle. Unit tests cover the dedup and the buffer; nothing has exercised them in a browser.
  • Umami-side reports are not version-controlled — they live in the Umami database. ops/observability/dashboards/orrery-analytics.json mirrors the key metrics in Grafana precisely because that is version-controlled, but it is a secondary surface, not the primary one.

Amendment 2026-10-19 — engagement-gated load events, heartbeat, crawler filter ​

An analysis of the prod Umami data (30 days) found the reports misleading:

  • Bounce ~7% where ~92% of visits had at most one pageview. Umami counts a bounce only for a visit with exactly one event; every load also sent app-load, the landing route-enter and often locale-suggest-shown, pwa-install-available, sw-activated.
  • Visit duration ~0 s for 92% of visits: all automatic events fire in the first second and nothing fires while someone reads.
  • 660 of 1,502 visits had no pageview, 610 of them from one profile: a JS-rendering crawler with an impossible user agent (AppleWebKit/605.1.15 next to a Chrome version; 2,842 /science requests in 30 days, flat over 24 hours, no referrer), ~40% of sessions. The rest: our pre-load buffer flushed on the script's load event, before Umami sends its pageview (it waits for readyState === "complete").

Decision (operator: one Umami site, no second site for technical events):

  1. Load-time events are held until the visit engages (any other event, or the heartbeat) and then sent in order (PASSIVE_EVENTS plus { passive: true } at call sites). Nothing is dropped for engaged visits; instant leavers send only the pageview.
  2. engaged { s } at 30 s and 120 s of visible tab time.
  3. No tracker for crawlers (isLikelyBot(): navigator.webdriver, HeadlessChrome, bot user agents, the impossible WebKit/Chrome string).
  4. The pre-load buffer flushes after the document is complete, behind Umami's pageview.

Rejected: dropping the technical events (loses the stuck-build signal the privacy page describes); a second Umami site for them (operator: no).

Not covered: the build signal from visitors who leave before 30 s without interacting is no longer recorded; crawlers with an ordinary browser user agent and no webdriver flag still count. Before/after windows are not comparable.

Orrery — architecture documentation · MIT · No tracking