Automation Pipeline6 min read

My dashboard was counting card names, not people

I was sizing up whether to add a tip button. Pulling the numbers, I found the gate's denominator wasn't people at all. The token column held paths, verdict bands, card slugs and share tokens depending on the app - and the culprit was two question marks that let callers overwrite the visitor id.

#analytics#instrumentation#first-principles#reality-check#gotchas
Concept diagram: the token column held four different meanings - path, verdict band, card slug, share token - so counting distinct tokens counted content variety. Making token always the visitor id and moving the share token to a src label fixes it.
One column, one meaning. The fix was removing the override, not patching 19 call sites.

I was weighing whether to put a tip button on my free web apps. Sizing the outcome before building is the right order, so I pulled per-app visitors and share rates. I only wanted to know whether the expected revenue beat the work.

The numbers were strange. One app showed 198 result views from 5 unique visitors. Did one person check their compatibility forty times? Another showed 529 visitors — and all 529 were distinct URL paths.

There's a question worth stopping on here. When your dashboard says "visitors", have you ever confirmed that the column producing that number actually points at one human? I never had. Ten days earlier I had switched the gate's denominator from "page loads" to "distinct tokens" and written it up as an improvement.

One column held four different meanings

The analytics table has a single token column. The gate counts it to decide "how many people use this app". I pulled thirty days of rows and classified the tokens by shape.

App What was in token What actually got counted
Dev blog url.pathname number of paths
Zodiac match band:3|ctx:all number of verdict bands
Tarot five-of-wands number of cards
Daily fortune two visitor ids + a share token one person as three

When the dashboard said "124 visitors", those were 124 paths. "Counting card names" isn't a metaphor here — it's literal.

It isn't the first time I judged something on an inflated number. Previously I summed a report that restated its prior three days, tripling the counts for two months. That time the values were inflated; this time the thing being counted was wrong.

The culprit was two question marks

I assumed each app had its own bug. It didn't. The shared tracking component looked like this:

body: JSON.stringify({ event, token: token ?? visitorToken(), locale, src }),

token ??if the caller passes a value, it overwrites the visitor token. And the shared-result pages were doing exactly that:

<TrackEvent event="result_view" token={token} locale={locale} />

That token is the share link's token. One result becomes one visitor. How many call sites? Nineteen apps. Not nineteen separate mistakes — one template copied nineteen times.

This is where the path forks. Do you patch nineteen call sites, or remove the ability to overwrite at all?

Patching turns the board green today. Then the next shared-result page you add breaks it exactly the same way — because that's how all nineteen got here.

The fix was one property name

// token identifies the visitor and nothing else. Share tokens live in src.
const src = new URLSearchParams(window.location.search).get('src')
  ?? (shareToken ? 'shared' : null);
 
body: JSON.stringify({ event, token: visitorToken(), locale, src }),

I renamed the prop from token to shareToken. Now token is always the visitor id, and callers have no syntax for overwriting it. I stripped the prop from nine more apps that don't have share pages yet — no runtime change, but leaving it there guarantees a repeat the next time one gets added.

The side effect turned out to be the real payoff. Visits arriving via a share link had no ?src, so they were all filed as "unknown source". Now they land as src='shared'. Share-driven traffic became countable for the first time — which also means I'd been running experiments to increase sharing while unable to measure the result.

One key you must not standardize

The daily fortune app had two visitor ids: one for analytics, one belonging to the app. Obviously I should merge them onto the portfolio-standard key.

I shouldn't have. That app-owned key was the seed for computing the daily fortune. Change the value and every returning visitor gets a different reading today. You don't change what users see in order to fix your analytics. So I unified onto the seed key instead of the standard one, and matched the id-generation rule down to the .slice(0, 24) of the old function — otherwise the same person gets a different seed depending on which page they entered through.

The blog went the other way. It's proxy-side server logging, so there is no way to identify a visitor at all (I set no cookie). I moved the path to a different column and left token empty. The gate now reports this app as "not measurable". That's the truth. Saying "I don't know" is more accurate than saying "zero".

Verification was lining up one session's events

How do you know it's fixed? I used the app locally as one person would, then pulled every event that session produced.

landing · daily_open · verdict_view · move_view · share_click   token=919bb826-b546-42e2-97b7-
result_view (entered via share link)                            token=919bb826-…  src=shared

All six carry the same token. Before the fix, those six scattered across three different id spaces.

The honest part

The historical data is unrecoverable. There is no way to reconstruct which of the path, band and card-slug rows in that thirty-day window were people. I did not restate them; I'll trust only new measurements. The sample clock for the experiment I actually wanted to run reset to the deploy timestamp.

I already fixed this gate once, when it was ruling on samples too small to rule on. This time the problem wasn't the sample size but what the sample was made of. Same gate, fooled two different ways.

I considered adding a filter in the aggregation layer to hide the mess. I didn't. five-of-swords and a random id are not distinguishable by shape. A plausible regex is right today and quietly wrong on the next content slug. I fixed it only at the source.

And all of this came out of sizing up one tip button. I still don't know whether to add it. What I do know is that the number I was going to decide with was not a number I could decide with.

Three checks you can run now

  1. Count by shape. Pull the whole visitor-id column and classify it with a regex. If anything that isn't a uuid or random string is in there, that app's visitor count is already wrong.
  2. Grep for ??. Any nullish-coalescing on an identifier inside a tracking function means "a caller can overwrite this". Grep the call sites and one of them is usually passing the wrong thing.
  3. Line up one session. Use the app once and pull every event in between. If the tokens differ, that app cannot compute a funnel — the numerator and denominator live in different spaces.

Check 1 is the cheapest of the three. It's a single query, and skipping it is why I spent ten days believing a broken denominator was an improvement.

Pick the number you look at most on your own dashboard, and confirm the column behind it holds only that. If card names come out like they did for me, I'd like to hear what shape they were.

Related