DEV Community

Ted
Ted

Posted on Originally published at tedagentic.com

The Top Row Had 150 Clicks. It Wasn't a Listing.

I track every click that leaves one of my sites. When someone taps a booking button, a small function records which listing it was for, which page it happened on and a few other details, and a dashboard adds them up. Its most useful panel is a table called Top Performing Entities: the listings people click most.

I opened it to see which listings were earning their place. The top row had 150 clicks in 30 days, well ahead of second place. It carried the name of a real listing.

It wasn't one. It was 29 different listings, stacked on top of each other.

Five kinds of value in one column

Each click row stores an entity_id — the ID of the listing that was clicked. The dashboard groups by it. That's the whole design: count the rows per ID, sort, show the top ten.

So I counted what the column actually held. Of the 806 clicks in the last 30 days:

database UUID        441
"unknown"            157
slug or label        108
list position        100
Enter fullscreen mode Exit fullscreen mode

Five different ideas of what an ID is — the slugs and the hand-written labels share a line — in one column. Each had a reasonable origin.

unknown came from a button. On a phone, every detail page shows a sticky bar at the bottom of the screen with the main call to action. The component that draws it takes the listing's ID as an optional prop, and falls back like this:

entityId={entityId || "unknown"}
Enter fullscreen mode Exit fullscreen mode

None of the four page types that used the bar passed an ID. Most of the site's visitors are on phones, so this was one of the most-clicked buttons on the site, and every click on it was logged as unknown. All 157 that month came from it; 463 since June.

List positions came from the main listings page. The database gives every listing a UUID, but a shared TypeScript type declared id: number. To satisfy it, the code that turned database rows into cards made one up:

id: 9000 + index,
Enter fullscreen mode Exit fullscreen mode

That's a position in a list, not a property of a listing. Sort the list differently, filter it, add a listing, and the same number points somewhere else. It did: 9037 was logged for two different listings, and so was 9010.

Slugs and labels came from blog posts, whose links were written by hand — sometimes with the database ID, sometimes the URL slug, and sometimes a short label that matched nothing in the database at all.

None of it was an error. Every insert succeeded, because the column was plain text and accepted all of it.

It hadn't always been. It started as a UUID column, and months ago I loosened it to text so an events page could log IDs that weren't UUIDs. That was the one thing that could have refused unknown and 9037 — though it would have refused them by dropping the clicks, which is its own kind of wrong.

What the dashboard did with it

Grouping by that column did two opposite things at once.

It split listings that should have been one row. The most-clicked real listing was spread across five different IDs: its UUID from its own page, a list position, a slug from one blog post, a label from another, and unknown. Another was spread across seven. In the same 30 days, 76 listing names came back under 102 distinct IDs.

And it merged listings that should have been separate. Every unknown click from every listing went into one bucket. The dashboard labelled each row with the name on the first click it happened to read, so 150 clicks from 29 listings wore one listing's name — and sat at the top of the table, because the sum of everyone's missing IDs will outscore any single listing.

The panel next to it was worse, because it drew a conclusion. A "missed revenue" report flagged listings that got clicks but no affiliate clicks, as candidates for a booking link. It flagged six. Two of them had affiliate clicks all along, logged under a different ID for the same listing.

An ID column isn't an identity. It's whatever each piece of code decided to send.

The column promises that one value means one thing, and nothing enforces that promise at the moment of writing. Each caller — a button, a list, a blog post someone wrote by hand — picks what to send, and the table stores it. When they disagree, the database has no way to know. The disagreement only surfaces where someone finally groups by the column and trusts the result.

Resolve, don't trust

There were two fixes, and the order mattered.

The first was to stop believing the label. The dashboard now loads the ID, slug and name of every listing and resolves each click to a real row — by ID first, then by slug, then by name, with names normalised so "The Example Inn" and "Example Inn", or "& Spa" and "and Spa", land on the same listing. Only then does it group: by the resolved row, not the logged value, and under the listing's name from the database rather than whatever the click carried.

Before shipping it, I ran the same rule in SQL over every listing click ever recorded, to see what it would leave behind:

matched by ID      1,219
matched by slug      156
matched by name      811
unmatched            140   (of 2,326)
Enter fullscreen mode Exit fullscreen mode

The 140 are almost all listings deleted since — there's no row to resolve them to, but their logged names are consistent, so they still group correctly. The top row went from 150 anonymous clicks to the real most-clicked listing, at 110. The missed-revenue report went from six flags to four.

The second fix was to stop sending bad IDs. The sticky bar now receives the listing's ID from every page that uses it. The listings page keeps each row's own ID instead of a list position, and the shared type now says id: string, which is what it was all along. The hand-written blog list tracks the database ID instead of its label. Old rows keep their old values; the resolver handles those.

Doing it in that order meant the dashboard was right about history the moment it shipped, not just about clicks from then on.

A few days later the same lesson turned up one layer over. A small script checks every morning that each listing still carries a paid booking link. The site counted links from three booking domains as paid; the checker, written earlier, knew only two. The first listing to use the third domain would have been counted as unpaid on the day it started paying. Two pieces of code, one idea, two definitions — and each was right about its own.

The build passed

One detail from the fix, because it nearly shipped a quieter version of the same bug.

The resolver builds its lookup with new Map(). The build succeeded. The typechecker didn't:

error TS2350: Only a void function can be called with the 'new' keyword.
error TS2558: Expected 0 type arguments, but got 2.
Enter fullscreen mode Exit fullscreen mode

The dashboard file imports an icon library, and one of its icons is called Map. In that file, Map wasn't JavaScript's built-in — it was a React component, and calling new on it throws. The bundler strips types without checking them, so it compiled without complaint.

It wouldn't even have crashed. The lookup runs inside a data-fetching hook that catches errors, and I'd written the resolver to fall back to the logged names whenever the lookup wasn't available. So the dashboard would have loaded and looked normal, while quietly grouping by the very column this post is about — the fix switched off by its own safety net. globalThis.Map put it right.

I've written before that a fallback chain is an error-suppression system, and that green is not verification. This was both, in four lines.

What I'd check first

If a dashboard ranks things by an ID that several parts of your code write, count what's actually in the column before trusting the ranking:

SELECT
  CASE
    WHEN entity_id ~ '^[0-9a-f]{8}-'          THEN 'uuid'
    WHEN entity_id ~ '^[0-9]+$'               THEN 'numeric'
    WHEN entity_id IN ('unknown', 'null', '') THEN 'placeholder'
    ELSE 'other'
  END AS kind,
  count(*)
FROM clicks
GROUP BY 1
ORDER BY 2 DESC;
Enter fullscreen mode Exit fullscreen mode

If more than one kind comes back, the top of that table is a claim, not a count.

And look hard at the fallbacks. || "unknown" looks like defensive code. It was a default that set policy for 463 clicks, and the policy was: forget which listing this was.

The top row wasn't my best listing. It was the one place every missing ID agreed to meet.

Top comments (0)