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:
/planemitted only twofilter-changeevents. It was impossible to tell whether the trajectory planner had ever been used, as opposed to opened./flyemitted a start and an end (mission-load,mission-complete) with nothing between, so "where do visitors stop" was unanswerable.science-section-viewcarried 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
/planand/flyfunnels work natively in Umami's own UI with no SQL. sourceRoute()gives any future event free provenance — the value already flowed throughroute-enter'sfrom_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 inFLY_ACT_EVENTor 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-phaseevent with aphaseproperty. 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-depthinto 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
loadevent — deterministic, no timer.
Not covered / out of scope
- Translations of the ADR-092
/privacystrings 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.jsonmirrors 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 landingroute-enterand oftenlocale-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.15next to a Chrome version; 2,842/sciencerequests in 30 days, flat over 24 hours, no referrer), ~40% of sessions. The rest: our pre-load buffer flushed on the script'sloadevent, before Umami sends its pageview (it waits forreadyState === "complete").
Decision (operator: one Umami site, no second site for technical events):
- Load-time events are held until the visit engages (any other event, or the heartbeat) and then sent in order (
PASSIVE_EVENTSplus{ passive: true }at call sites). Nothing is dropped for engaged visits; instant leavers send only the pageview. engaged { s }at 30 s and 120 s of visible tab time.- No tracker for crawlers (
isLikelyBot():navigator.webdriver, HeadlessChrome, bot user agents, the impossible WebKit/Chrome string). - 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.