The catch block that cost me an afternoon
An error that is only visible in debug mode is not handled, it is hidden — and in a browser SDK, "debug mode" means a mode nobody is ever in.
A customer's admin panel had a heatmap with clicks on it and no backdrop. The dashboard said no snapshot yet, which is what it says for the first thirty seconds after a new layout version appears. It had been saying that for about a week.
Isotherm draws heat over a frozen copy of the page rather than a live iframe, and the copy is captured by exactly one visitor. The server hands out that permission as a lock (Cache::add("snapshot:pending:{$version->id}", true, now()->addMinutes(2))), because a thousand people on a page would otherwise each serialize the DOM and upload a couple of megabytes at the same moment. So the flow is: a visitor gets elected, their browser walks the page, and a snapshot arrives. Or it does not, and two minutes later somebody else gets asked.
On that page, capture() threw. Every time, on every visitor, for the same reason. And this is what the SDK did about it:
} catch (err) {
if (config.debug) console.warn('[isotherm] snapshot failed:', err);
return null;
}
config.debug defaults to false — DEFAULTS.debug = false, and the attribute that would override it, data-debug, is absent from every production install that has ever existed. So the warning did not print. The catch returned null, and this is what the caller in index.ts did with the null:
snapshot.capture(captureConfig).then((html) => {
if (html) snapshot.upload(config, fp, url, html);
});
Nothing. No else. Two places where the error could have surfaced: one gated behind a flag nobody sets, and one that treats "there is no snapshot" as "there is nothing to do." The failure was not swallowed by accident. I wrote a place for the error to go and then closed it.
"Not yet" and "never" render identically
The thing that made this expensive was not the throw. It was that the failure produced a state the dashboard already had a friendly message for. A snapshot is asynchronous and best-effort by design, so pending is a normal, expected, temporary condition. A permanent, deterministic failure landed in exactly the same visual state as the normal temporary one, and every subsequent visitor was elected and failed identically, refreshing the illusion that it was still on its way. There was no error anywhere: not in the server logs (nothing reached the server), not in my console (I was not the visitor), and not in the customer's console (debug was off, and even with it on you would have to be sitting on the failing page with devtools open at the moment a two-minute lock happened to land on you).
I spent the afternoon reading snapshot.ts looking for the bug. The bug was that snapshot.ts could not tell me what the bug was.
Un-gating the warning is not the fix
The obvious repair is to delete if (config.debug) so the warning always prints. I did that, and it is worth roughly nothing on its own — but I want to be exact about what else changed in that block, because the un-gating is the cosmetic half:
} catch (err) {
console.warn('[isotherm] snapshot failed:', err);
throw err;
}
return null became throw err. That is the load-bearing edit. A null has nowhere to go once it reaches a .then((html) => { if (html) … }); a rejection does. The caller now has a branch to hang the reporting off, and it does:
.catch((err) => {
console.warn('[isotherm] snapshot failed:', err);
snapshot.reportFailure(config, fp, url, String(err && err.message ? err.message : err));
});
Because console.warn in a browser SDK writes to a console owned by somebody else. Not the person who wants to know. Not the person who can act. The customer's end user, who is trying to reconcile an invoice and does not read consoles, gets a message addressed to me. It is technically no longer a silent failure, in the sense that a tree falling in a forest technically makes a sound.
The failure has to travel to where the question is being asked, which is the dashboard. So the SDK tells the server that it could not take the picture, and why:
export function reportFailure(config: Config, fingerprint: string, url: string, reason: string): void {
const body = JSON.stringify({
k: config.site,
u: url,
f: fingerprint,
error: reason.slice(0, 300),
});
fetch(`${config.endpoint}/api/v1/snapshot`, { method: 'POST', body, /* … */ })
.catch(() => {
// Reporting a failure that itself fails is not worth another word.
});
}
Same endpoint as a real snapshot. SnapshotController treats a body with no html and a non-empty error as a failure report, writes it to the version, and answers 202 with {"ok":true,"stored":false,"noted":true}:
if ($html === null) {
$version->forceFill(['snapshot_error' => Str::limit((string) $failure, 290, '')])->save();
return $this->respond(['ok' => true, 'stored' => false, 'noted' => true], 202, $origin);
}
And heatmap.blade.php stops saying no snapshot yet and starts saying Could not copy this page. The browser reported: <the message>. Plus one line clarifying that the heat below is still accurate and drawn at the page's true size, it just has no backdrop.
That is the delta for the failures that throw. Which, it turns out, is not all of them.
The road I did not take
The uncomfortable part of this design is that an anonymous browser now writes a free-text string into a column the dashboard renders. That is a real surface, and the safe version is obvious: a closed enum of error codes. CANVAS_TAINTED, OVER_BUDGET, SERIALIZE_FAILED. Nothing arbitrary crosses the wire, nothing arbitrary is stored, and the dashboard maps a code to a sentence I wrote myself.
I rejected it, and I want to be precise about why. An enum is a taxonomy of the failures I have already thought of. That set is, definitionally, not the set that was costing me afternoons. The message that actually unblocked the customer page was a SecurityError string I would never have enumerated, thrown from a place I did not expect. Encoding it as UNKNOWN would have left me exactly where I started, one indirection prettier.
What I did instead is bound the string rather than close it. The endpoint sits behind the same site-key and Origin gate as ingest. It only accepts a report for a version the server already knows about and is still missing a snapshot for, so nobody can invent a row to write into. The reason is truncated twice, at reason.slice(0, 300) in the browser and Str::limit(…, 290, '') on the server, so the column cannot be used as storage. And it is escaped at render. The worst an attacker who has already met the Origin check can do is race a legitimate visitor to write a rude sentence into their own site's dashboard.
Where this is wrong
Two places, and the second one is worse than the first.
upload() still has the original shape, and I left it that way on purpose:
.catch((err) => {
if (config.debug) console.warn('[isotherm] snapshot upload failed', err);
});
A failed upload is transient and self-healing: the two-minute election lock lapses, the next visitor is asked, and the snapshot arrives. Reporting a network failure over the network is also somewhat optimistic. The dividing line is not "is it an error", it is does the failure recur identically forever, or does the next attempt fix it. Report the deterministic ones. A retry is a cheaper answer than a log line, when a retry works.
Now apply my own dividing line to capture(). A snapshot over MAX_BYTES (3 MB) gets its inlined images stripped and re-measured, which usually gets it under. When it does not:
if (out.length > MAX_BYTES) {
// Genuinely unshippable. Say so out loud rather than returning null into the void.
console.warn(`[isotherm] snapshot is ${Math.round(out.length / 1024)} kB even without images, …`);
return null;
}
That is a deterministic failure. The same page produces the same bytes for the next visitor and the one after. It never throws, so it never reaches the .catch in index.ts, so reportFailure() is never called, no snapshot_error row is written, and the dashboard says no snapshot yet forever — the exact bug I opened this post with, still live, in the file this post is about. By my own rule it should be reported. It is not. The warning above it is un-gated, so at least it prints into somebody else's console, which I have already argued is worth roughly nothing.
And read that comment again. Say so out loud rather than returning null into the void, one line above return null, into the void. I wrote it while fixing the other path and it described what I intended, not what I shipped.
There is a second one, in index.ts:
Telling the server also stops it re-asking every visitor forever.
It does not. electSnapshotter() checks $version->hasSnapshot() and nothing else, so a version with a snapshot_error and no HTML is still eligible, and the server keeps electing a fresh victim every two minutes, forever, each one paying for a full settle() pass (up to 60 scroll steps, a 1500 ms wait on lazy images) before failing exactly as the last one did.
I found both of these while writing this post, by reading the code instead of the comment. An error visible only in debug mode is not handled, it is hidden. A comment describing behavior the code does not have is the same failure wearing a different hat. Both of them look completely fine, which is the entire problem, and knowing that in the abstract did not stop me writing two more of them in the commit that was supposed to be the fix.
-
Never cache an Eloquent model
Caching a hydrated Site model made every second request 500 with __PHP_Incomplete_Class; caching a flat readonly value object fixed it and made the ingest path queueable.
-
requestAnimationFrame does not fire in a hidden tab, and my code waited forever
An `await requestAnimationFrame` inside the snapshot settle pass hung forever in background tabs, holding the server's single-visitor election lock and blocking every other visitor.
-
Scroll depth has to be idempotent, and mine was not
Incrementing a bucket per scroll event counted one visitor several times in the shallow bands; the fix is a per-session high-water mark with the curve derived at read time.