Symptom
A product funnel had read landing → chat_started 92%, then chat_started → registration_started 0%, for eleven consecutive days. Meanwhile the backend's own user_registered counter was non-zero. Two ledger items had been carried for over a week trying to explain the event: was it double-firing, was the client-side de-dupe fix working, was the event dead? Every daily re-query returned zero and concluded "unevaluable."
The event was not broken. It was measuring the opposite of what the funnel assumed.
Cause
The conversion event was emitted from inside a modal component, not from the state transition it named:
<!-- RegistrationModal.svelte, mounted ONCE in the authenticated layout -->
$: on_transient_page =
$page.url.pathname.startsWith('/login/link') || $page.url.pathname === '/welcome';
$: if (logged_in && !user.registered_at && !on_transient_page && !completed_in_session) {
show_dialog = true;
if (!captured) { captured = true; ph_capture('registration_started'); }
}/welcome is the route where registration actually happens (a conversational flow). It is in the suppression list. So:
- Convert on the happy path → the flow's own completion handler sets
completed_in_sessionand emits nothing. The modal is suppressed forever after. - Abandon the flow for any other page while unregistered → the modal mounts →
registration_startedfires.
The event is a leak indicator wearing a progress indicator's name. A zero is not health and not silence; it is the absence of abandonment, which on a low-traffic product is indistinguishable from the absence of everything.
The aggravating factor: the in-flow surface emitted a rich vocabulary of its own (*_loaded, *_resume_choice, *_chat_started, *_navigate) — so the surface looked instrumented. It just shared no event names with the funnel being queried.
Diagnostic procedure
When a funnel step reads exactly 0 while a downstream counter is non-zero, do not debug the event. Do this instead:
- Grep for every emitter of the event name. Not the docs, not the type union — the actual call sites. Expect one; be suspicious if it's a component rather than a transition handler.
- For each emitter, find every mount site, and ask whether the converting path mounts it. Route guards,
ifblocks around the mount, and "transient page" allow/deny lists are where this dies. - Walk the conversion path yourself and write down the events it emits, in order. Compare that list to the funnel definition. In our case the intersection was empty.
- Check the reverse: what does trigger the event? If the answer is a path users take when they give up, your funnel's last step is inverted.
- Check for name collisions in the other direction. Two of our steps were emitted by two different surfaces with different semantics — one from an
IntersectionObserver(scroll visibility, essentially automatic) and one from a deliberate button tap. A rate mixing those is not a number. That also explained why the step had swung between 17% and 92% with no behavioral change.
Generalizations
- Instrument transitions, not components. An event named for a user intention should fire from the code that effects that intention. Attaching it to a UI component couples the metric's meaning to that component's mount conditions, and mount conditions get route guards added to them by people who aren't thinking about analytics.
- A conversion completed over a side channel emits nothing by default. Ours completed via a server-sent event; the handler updated app state and refreshed the user, and nobody thought of it as a funnel moment. Any completion path that isn't a form submit is a place instrumentation goes missing.
- Zeros need a positive control before you interpret them. "Event X read 0 today" is only evidence about X if you have separately established that X's emitter is reachable on the path you care about. Absent that, a run of zeros generates weeks of false hypotheses (we produced two).
- Suppression guards deserve an analytics comment.
pathname === '/welcome'in a deny-list looks like pure UI hygiene. It silently redefined a business metric. - If dev/staging runs without an analytics key, client instrumentation cannot be exercised locally at all, and this class of bug has no local reproduction. Establish emitter claims by source reading and treat that as expected practice, not a workaround.