Field notes · 2026-05-09

The bug the trace overlay caught

The page rendered. The status pill correctly counted tokens streaming in — twelve, eighteen, twenty-four. The trace overlay showed three neat rows of sw chunk events arriving from the local Ollama process. The output box stayed empty. Not "showing an error" empty. Not "the model returned nothing" empty. Just empty, with the rest of the UI happily reporting that streaming was working.

This is the kind of bug that costs a day if you have to debug it by guessing, and roughly two seconds if you happen to have a tool that makes the contract violation visible.

Logs over tests Page-as-integration-test Asymmetric cost of observability

The fix and what it was hiding

The bug lived in extension/content.js, line 89, in a Chrome MV3 extension I'd just stood up to bridge a hosted webpage to the visitor's local Ollama. Commit 7fbc70b. Four-line diff:

-      postToPage({ requestId, kind: 'generate:' + msg.kind, ...msg });
+      // Spread first so the explicit prefixed kind below wins; otherwise the
+      // raw kind from msg ('chunk' / 'done' / etc) clobbers the prefix and the
+      // page-side listener never matches.
+      postToPage({ ...msg, requestId, kind: 'generate:' + msg.kind });

That is the entire fix.

For non-JS readers: object spread (...msg) copies all of msg's keys into the surrounding object literal. Later keys overwrite earlier ones. The original line set kind: 'generate:' + msg.kind first, then spread msg, so msg.kind (the bare string 'chunk', 'done', or 'error') clobbered the prefixed 'generate:chunk' version. The page-side listener only matched on the prefixed names, so chunks arrived at the content script, got forwarded with the wrong key, and were silently dropped by the page.

This is a contract bug. The producer (the content script in extension/content.js) and the consumer (the page-side handler in page/client.js) had agreed that streaming frames would carry a key named 'generate:chunk'. The producer's code looked correct in isolation — it was clearly trying to emit 'generate:chunk'. The consumer's code looked correct in isolation — it was clearly listening for 'generate:chunk'. The defect was in the interaction between two lines of producer code (the explicit kind assignment and the spread that followed). One human reading the producer code sees "the kind is 'generate:' + msg.kind, that's right." Another human reading the consumer code sees "we listen for 'generate:chunk', that's right." Neither reading catches it.

Why a unit test wouldn't have caught this

I want to be careful here. I am not making the case that unit tests are bad. I am making a narrower claim: for an integration in unfamiliar tech, written quickly, where the human author does not yet know the contract well enough to author the test, unit tests written in that state will rubber-stamp the bug.

Imagine I had written tests for this prototype before I had a verified working version. The natural tests would have been:

Both tests pass. The integration is broken. Each side's mock encodes the author's mental model of the other side's contract. The mismatch is invisible to either side's tests because the tests are between two halves of the same mistake.

The escape hatch in test-design theory is "well, write a true integration test that exercises both sides at once." Yes. But you need a way to run both sides at once before you can write that test. In Chrome MV3, that means an actual browser, an actual extension load, an actual page load — at which point you may as well have a human watching, because the human is going to verify the result anyway. A headless test that drives Chrome and asserts "output text is non-empty after streaming completes" would have caught this specific bug, but it requires you to know in advance that "output text is non-empty" is the right assertion. If the bug had been "output text is wrong but non-empty" or "output text is right but the cancel signal is dropped," the assertion would have missed.

The pattern: tests written by an author who doesn't yet understand the contract are negotiating the contract with themselves. The assertion is "the system does what I think it should do," which is isomorphic to "the bug I am about to write is not present."

What did catch it

The trace overlay. Specifically: a horizontal scrolling list of log rows, color-coded by source, in the bottom-right of the demo page. Every hop that touched a request emitted a row with that request's ID. Three rows from the service worker said sw chunk: 4 chars (#1), sw chunk: 5 chars (#2), sw chunk: 3 chars (#3). Three rows from the content script said content relay-generate-chunk: 4 chars, content relay-generate-chunk: 5 chars, content relay-generate-chunk: 3 chars. Zero rows from the page said page chunk-in.

That last absence was the diagnosis. The rest of the timeline implied "chunks made it from Ollama, through the SW, to the content script, and then vanished." There are exactly two possible explanations: either the page-side postMessage listener wasn't registered (easy to falsify by clicking ping, which the same listener handles, and seeing it work), or the listener was firing and then short-circuiting — which means the message shape didn't match. Two seconds.

I want to be honest about what this was not: it wasn't cleverness, it wasn't intuition, it wasn't years of MV3 experience pattern-matching the symptom. I am writing the prototype precisely because I do not have years of MV3 experience. The diagnosis was pattern recognition on a visible timeline — which is the kind of thinking the human eye is genuinely good at and which fancy debugging tools are surprisingly bad at.

I am also being honest about uncertainty: I cannot prove that, in a parallel universe where the trace overlay didn't exist, this bug would have taken a day to find. It might have taken twenty minutes. What I can say is that the alternative diagnostic path I'd have taken without the overlay starts with "is Ollama broken? Is my streaming code wrong? Is this an MV3 quirk?" and proceeds to open three DevTools windows (page, content-script-host page, service worker), set breakpoints, and trace by hand. That path might converge in twenty minutes; the overlay-supported path verifiably converged in two seconds, including the time to read the rows.

The pattern, and when to use it

For agent-built integrations in unfamiliar tech, where the human operator does not yet know the contract well enough to author tests the agent might break — ultra-loud in-product logging is a more trustworthy verification primitive than unit tests.

The page IS the integration test. The trace overlay IS the assertion mechanism. The human IS the test runner.

This is not anti-test in general. For stable contracts in familiar tech, where the author knows what should happen well enough to encode it, unit tests are fine. They catch regressions, they document intent, they let you refactor without fear. None of that is in dispute.

The shift I'm pointing at is "tests after understanding, logs while learning." When you are wiring two layers together for the first time, what you do not yet know is the contract between them. You can build a thing that lets you see the contract — every message, every hop, time-ordered, in one window — and use that thing as the verifier. Once the contract has stabilized and you can author tests that genuinely encode it, swap in tests. Until then, the trace overlay is doing real work that a test would only pretend to do.

Concretely: cost of building the trace overlay in this prototype was about a hundred lines of code (the LogBus in page/log.js) plus forwarding plumbing in extension/content.js and extension/background.js. That investment paid back the first time something went wrong, which was within an hour of shipping the fourth slice.

The operational rule I now follow when starting work in tech I don't have instinct for: install robust logging in slice 1, before I know whether I'll need it. The asymmetry is what makes it always worth paying upfront. The downside if you didn't need it is small — an hour of plumbing the LogBus, some breadcrumb calls in each hop. The downside if you needed it and didn't build it is much worse: a bug surfaces, you have to context-switch out of implementing the next thing and back into retrofitting observability into code you've already shipped. The retrofit itself takes longer than the upfront build, and it pollutes the mental state you'd otherwise be using to chase the bug.

The same asymmetry applies to isolation. Standing this prototype up in its own folder, separate from the larger project it'll eventually feed back into, was deliberate. The implementation agent gets a focused context — manifest, page, three contexts, nothing else. No production code to confuse the mental model with, no half-relevant patterns to overgeneralize from. If the extracted pattern turns out to be wrong, you throw away one folder.

An honest aside about prior failures

This is the third or fourth time in the last few months I've built a clickable "human-driven integration test page" only after a multi-day rework session that wouldn't have happened if I'd built the page first.

Two examples, abstracted out of the domain they came from.

Project A: a multi-layer identity reconciliation across two third-party systems. Each side had unit tests for its half of the handshake. The integration had a unit test that mocked the other side. All green. We built four slices on that signal. Then we ran against the real systems and the reconciliation was structurally wrong — call-ordering assumptions the mocks honored but the real systems didn't. Every slice on top of the broken foundation was code we took back out.

Project B: an agentic orchestration pipeline. Unit tests on both sides of the handshake aligned the contract shapes exactly. What they could not catch were environment-level hiccups — transient auth, retry shapes, state drift between calls, partial failures. The agent generated more code on top of the wrong assumption faster than I could surface the underlying issue. Eight hours of wrestling and refactoring. At one point I was nearly hand-writing the lines for the agent, because I no longer trusted any signal short of "I clicked the button and it worked."

What hurt most, both times, was the compounding: every slice we shipped while trusting the green suite added code on top of the broken assumption. By the time real-world behavior surfaced the bug, the rework was larger than the original integration. And the agent treated the green tests as ground truth when planning the next slice, which made its confidence highest in exactly the situations where confidence was least warranted.

What I built to dig out, both times, was the same artifact: a page with buttons that exercised the real integration in real conditions, with me as the test runner. Not Playwright. Not a better mock. A page with buttons. The rework rate dropped the moment the page existed.

The deeper claim: for an integration whose product feel depends on real third-party behavior — auth flows, identity handshakes, async event ordering, token lifecycles — the unit-test layer can rubber-stamp green forever and you'll still ship a product whose user-facing feel doesn't work. The identity layer in Project A was still evolving because the touch-and-feel feedback couldn't happen until the integration was real. Mocks could not give us that signal, no matter how thorough.

This session avoided the loop by building the page-with-buttons first.

The artifact

The prototype is public, MIT-licensed, vanilla JavaScript with zero runtime dependencies and no build step. Roughly 1500 lines total. Every file is meant to be read top-to-bottom by a human; the docs are written assuming you'll do exactly that.

A five-minute walkthrough lives in TUTORIAL.md in the repo. Five named failure modes, each clickably triggerable, are documented in docs/02-debugging-guide.md. The wire format for every message between the four contexts (page, content script, service worker, Ollama) is frozen in docs/03-protocol.md. The trace overlay itself is page/log.js (the bus) and page/trace.js (the rendering); both are short.

The reuse guide (docs/04-reuse.md) names the three things that change when you fork it for a different LLM endpoint or page origin. Bring an LM Studio instead of Ollama, change three files, done.

Close

When in doubt, build more observation early.


Built and written in one working day. Reach out: derek@steppeintegrations.com.