Isotherm

Your heatmap starts lying the day after you redesign the page

Old clicks drawn over a new layout still render, still look plausible, and are wrong — so Isotherm fingerprints the DOM and lets the page declare its own version instead of asking a human to.

You move the "Export" button from the right of the toolbar to a dropdown on the left. You deploy on a Tuesday. On Wednesday you open the heatmap for /admin/orders and there is a bright red blob sitting on the right of the toolbar, where nothing is anymore. It's over a whitespace gap next to the date filter now.

Nothing errored. The map rendered. The blob is real data — three months of people clicking Export, faithfully recorded, faithfully drawn at the coordinates they happened at. It's just that those coordinates now point at empty space, and the heatmap is telling you that users are obsessed with a gap.

That's the good case, because it's visible. The bad case is when the pixels land on something. You move Export, and where it used to be there is now a "Refund" button. Now the heatmap says people are hammering Refund. The map is plausible, internally consistent, renders without a warning, and is entirely fiction. Someone will make a decision from it.

A heatmap is not "of a URL"

The thing a heatmap is drawn over is a layout. The URL is just an address that happened to serve that layout on the day the click was recorded. /admin/orders before and after a redesign are two different pages that share a name, and averaging their clicks produces a map of a page that never existed — not a blurry version of either, a third thing that is wrong about both.

Which means "no data" is a strictly better outcome than a stale map. An empty heatmap makes you go look at something else. A wrong heatmap makes you go change the Refund flow.

I'll take that further: a heatmap tool that can't tell you which layout you're looking at is worse than no heatmap tool, because its failure mode is silent and confident. Every other analytics number degrades honestly — page views drop when traffic drops. Heatmap coordinates degrade into confabulation.

The version-number trap

The obvious fix is a version parameter. Put it in the snippet:

<script src="/isotherm.js" data-site="pk_live_xxx" data-version="7"></script>

Bump it when you redesign. Or, same idea with a nicer UI: a "Start a new version" button in the dashboard that the customer clicks after they deploy.

This does not work, and it's worth being precise about why, because the reason generalizes.

The failure has no immediate consequence. Forget to bump the version and nothing breaks on Tuesday. The events keep flowing. The map keeps rendering. It even keeps looking better — more data, denser heat, the blobs get more confident. There is no error, no empty state, no red banner. The signal that you forgot arrives weeks later, if it ever arrives, as a decision that turned out to be based on a page that hasn't existed since March.

Anything a human has to remember to do at deploy time, where forgetting produces no feedback, is a thing they will eventually not do. Not because they're careless. Because a data-version attribute in a Blade layout is not on any checklist that the person shipping a CSS change to a toolbar is looking at. The heatmap tool is not their job. It's a script tag they pasted in six months ago and forgot.

And there's a nastier version. The person who bumps the version and the person who redesigns the page are frequently not the same person, and increasingly not even the same company. You are a consultant. Your client's team ships. You find out about the redesign from the heatmap.

So: don't ask. Let the page declare its own identity.

The page fingerprints itself

The SDK hashes the DOM skeleton and sends it with every batch. Same layout, same string, on every visitor's machine, with no coordination between them.

The whole trick is choosing what counts as "the same page". Two rules do most of the work. Runs of identical siblings collapse — an orders table with 24 rows and the same table with 25 rows are the same layout, and without this every new order would fork the heatmap:

// "*" means "one or more" — deliberately not the count.
parts.push(run > 1 ? `${sig}${body}*` : `${sig}${body}`);

And content is thrown away. Text, values, generated ids. VOLATILE_CLASS strips styled-components hashes, CSS-module names, Tailwind JIT classes. State classes (is-, has-, active, open, hover) are dropped too, or the fingerprint would depend on what the visitor's mouse was doing when it was taken. What survives is tags, stable classes, landmark roles, and nesting — hashed with a double-pass FNV-1a to get 64 bits.

What's left is sensitive to exactly the things that invalidate a heatmap: a button moving, a column appearing, a sidebar reordering. Add one button to a toolbar and you get a new version, on the deploy that caused it, from the first visitor after the deploy.

Server-side, that's one firstOrCreate:

$fingerprint = preg_match('/^[a-f0-9]{8,32}$/i', $fingerprint) ? strtolower($fingerprint) : 'unknown';

$version = PageVersion::firstOrCreate(
    ['page_id' => $page->id, 'fingerprint' => $fingerprint],
    ['first_seen_at' => now(), 'last_seen_at' => now()],
);

A missing or malformed fingerprint gets its own unknown bucket rather than being dropped or merged into a real version. An old SDK still produces a usable heatmap — one that is honestly labelled as coming from a layout we can't identify.

The version then has to be part of identity, not just a tag on the row. The movement grid is upserted, and the unique key had to change:

$table->unique(
    ['page_version_id', 'device', 'grid_size', 'gx', 'gy'],
    'move_cells_cell_unique',
);

Two cells at the same (gx, gy) on either side of a redesign are not the same cell. The pixel means something different now. Leave page_id in that key and the new layout's hover data quietly accumulates into the old layout's grid — the exact bug, just one level down.

What this gets wrong

Structural fingerprinting overfires. A feature flag that renders an extra <div> for half your users gives you two versions of the same page. So does an A/B arm, or an admin panel that shows an extra column to superusers. If you did nothing about this you'd trade one silent failure for a very loud one: a heatmap fragmented across nine versions, none with enough data to mean anything.

So the dashboard gives the owner control instead of responsibility. Name a version, or merge two that split for a reason you don't care about. The merge is a pointer, not a rewrite: the child gets merged_into_id set, events keep the version id they were recorded against, and nothing is deleted. A merge is reversible and a misclick cannot destroy months of data.

The read path resolves it downward, not upward. HeatmapController::data() aggregates over $version->memberIds(), which collects this version plus every version pointing at it, recursively:

public function memberIds(): array
{
    $ids = [$this->id];

    foreach (PageVersion::where('merged_into_id', $this->id)->get() as $child) {
        $ids = array_merge($ids, $child->memberIds());
    }

    return $ids;
}

That recursion has no depth cap and issues one query per node. There is a PageVersion::effectiveId() that walks the chain the other way with an 8-hop cycle guard — and it is dead code. Nothing calls it. I wrote the guard, put it in the wrong method, and then wrote the read path against a different traversal. If a cycle ever gets into merged_into_id, memberIds() recurses until PHP dies, and the 8-hop cap protects nothing. The fix is either a parent_id-style depth check inside memberIds() or a database constraint that makes the cycle unrepresentable. Neither is in yet. Treat the merge graph as trusted input, because that is currently what the code assumes.

It also underfires. A redesign that changes only CSS — same DOM, new grid, everything in a different place — produces the same fingerprint. The heatmap will lie exactly as it did before, and this technique will not save you. Same for anything below MAX_DEPTH = 12, where the walk stops. Coordinates are stored twice for this reason (page-absolute pixels and a CSS selector plus the offset inside the element's box), so a click can be re-anchored onto the element it actually hit rather than the pixel it landed on. That covers a lot of the CSS-only case. It doesn't cover all of it.

data-hm-id is the escape hatch, and it is narrower than I like to pretend. In the click selector it does exactly what you'd want — segmentFor() returns [data-hm-id="export-btn"] and stops, so the element keeps its identity through a refactor that changes its tag, its classes, and its position among siblings. Clicks re-anchor onto it. In the fingerprint it does much less:

let sig = tag + stableClasses(el);
if (role) sig += `[${role}]`;
if (marked) sig += `#${marked}`;

The marker is appended to the tag and classes, not substituted for them. Rename <button class="btn-primary"> to <a class="link-cta"> and the signature changes whether or not it carries a data-hm-id, so you still get a new page version. The annotation stabilizes where a click is anchored, not whether the layout is considered the same layout. Making it override the tag and classes in signature() is a two-line change; I haven't made it, because a marked element that changes tag genuinely might be a different layout, and I don't yet know which way that call should go.

That's the line I'd draw generally. It's fine to let a user express intent. It's not fine to make correctness depend on them remembering to.

Related