Reproduce the Bug, Don't Describe It
There is a problem with bug reports that I’ve always found annoying: no matter how well you write them, you’re still describing something that already happened.
“I clicked edit, changed the filter, and then it broke.”
That’s useful, of course, but there’s a lot missing from that sentence. Maybe you clicked something else before edit. Maybe a request returned successfully but with a slightly different payload. Maybe there was a console warning that looked harmless at the time and turned out to be the important part. Unless someone was watching over your shoulder, all of that is now something you have to reconstruct from memory.
This became even more obvious to me when I started using AI agents for debugging. You can give an agent a good description of the problem and it will usually make a reasonable attempt at reproducing it, but “reasonable” is exactly the problem. If something is missing from your description, the agent has to fill in the blank. Now you may have it investigating a perfectly plausible sequence of events that simply isn’t the sequence that caused your bug.
At some point I started wondering why I was spending so much time describing what happened when the browser had already seen the whole thing happen.
So I built a recorder.
Recording the session instead
The idea isn’t particularly complicated. While developing, I can start recording, reproduce whatever I’m trying to debug, stop, and export the session.
During that time it captures the things I’d normally end up checking manually:
mouse · keyboard · forms · selection · drag-and-drop · scroll · window
navigation · console output · network requests · uncaught errors
The important part for me wasn’t just capturing those things, though. I wanted them in one timeline.
If I have mouse events in one file, network requests somewhere else and console logs in DevTools, I still have to figure out how they relate to each other. The recorder instead gives me the sequence as it happened: I clicked this, which was followed by this request, then this warning appeared, then I navigated here, then the application threw an error.
Navigation, runtime errors and custom actions can also have screenshots attached. I don’t need screenshots of every mouse movement — that would be ridiculous — but having one at the moment something actually changes or fails is surprisingly useful.
This is also deliberately a development tool, not an analytics or monitoring system. There’s no server collecting sessions and nothing gets uploaded automatically. Recording is something I explicitly start, and exporting is something I explicitly do.
The browser only knows half the story
Browser events can tell me that someone clicked a button, but they can’t tell me that the dashboard just entered edit mode.
That distinction matters quite a lot once you’re looking at a recording. A sequence like this:
click
click
network request
click
runtime error
is technically accurate, but not particularly helpful.
The application itself knows more. It knows that the first click entered edit mode, that the second changed the revenue filter, and that a particular piece of state was recalculated as a result. I didn’t want the recorder trying to infer those things from the DOM, so I added a very simple way for application code to record its own actions.
That means the same timeline can look more like this:
click "Edit dashboard"
custom "dashboard entered edit mode"
network GET /dashboard/123
click "Revenue filter"
custom "filter changed: revenue"
runtime error
There’s nothing clever happening there. In fact, I prefer that there isn’t. When the application knows something meaningful happened, it records it at that point. I trust that much more than a generic debugging tool trying to reverse-engineer application semantics from browser events.
Recording without taking over the application
One thing I was quite careful about was making sure the recorder didn’t become another permanent layer inside the application.
DOM events are easy enough: register a listener when the recorder starts and keep the cleanup function around so it can be removed later. Console output, network requests and client-side navigation are a little different because there isn’t a DOM event I can listen to for everything I want.
For those I wrap the globals themselves — console.log/info/warn/error, fetch, XMLHttpRequest, history.pushState and history.replaceState. Before replacing anything, though, the original function is saved. When the recorder is torn down, the original goes back.
I didn’t want a debugging session to leave behind wrappers that continue affecting the application after recording has stopped. Debugging tools creating debugging problems is a particularly unpleasant kind of irony.
The recording lifecycle itself is intentionally boring: idle, recording, paused, stopped. Everything eventually goes through the same push function, which ignores events unless the session is actively recording. That gives pause an actual meaning rather than simply hiding events from the final export.
I also use a capped ring buffer for the timeline. Leaving the recorder running shouldn’t mean watching memory usage grow indefinitely, and for this particular use case the events immediately before the failure are generally more useful than something that happened twenty minutes earlier. Once the limit is reached, old events make room for new ones.
I don’t want passwords in a debugging tool
Redaction ended up being one of the decisions I felt strongest about.
The obvious approach would be to record everything normally and sanitize it during export. It also means the unsanitized data exists inside the recording for the entire session, which I don’t particularly like. If a password shouldn’t be in the exported file, there’s no good reason for me to keep it in memory first and promise to delete it later.
So redaction happens before an event is stored.
Typing into something identified as a password, token or card field produces a fixed redacted value. For normal form inputs, the recorder doesn’t need the value either; in most cases knowing which field changed and the length of the value is enough. Console arguments get similar treatment when objects contain keys that look sensitive.
This also makes the export code much less scary. There isn’t one final sanitization step that has to be perfect because it’s the last thing standing between a secret and session.json. The sensitive value simply never became part of the session.
Screenshots complicated things a little
I originally thought attaching screenshots would be one of the simpler parts. Take a screenshot when something important happens, put it on the event, done.
Except screenshots aren’t instant.
Waiting for one before recording the next event would mean the recorder could interfere with the application it’s supposed to observe. That’s a bad trade, so screenshots are asynchronous: the event enters the timeline immediately, the capture happens separately, and the image gets attached when it’s ready.
If it fails, I still have the event. I’d much rather lose one screenshot than stall the recording.
There is a slightly awkward edge case when exporting because someone can stop the session while a screenshot is still being generated. I handle that by giving pending captures a short amount of time to finish rather than waiting indefinitely. Again, the timeline matters more than any individual screenshot.
The same thinking applies to DOM elements. I don’t store references to the actual node that received an event because that reference is almost worthless outside the live page. React can re-render it, the page can navigate, and obviously a DOM node can’t meaningfully survive being written to JSON.
Instead, the recorder stores a description: selector, role, visible text and other useful information when available. Enough to identify the element later, without pretending the original DOM still exists.
What comes out the other side
Once the session is stopped, export produces a folder like this:
browser-debug-session/
├── report.md
├── session.json
├── actions.json
├── console.json
├── network.json
├── runtime.json
├── environment.json
├── metadata.json
└── screenshots/
session.json is the complete timeline, while the smaller files make it easier to jump directly to network activity, console output, runtime errors or application actions. report.md is there because sometimes I just want to open something readable without digging through JSON.
This folder is really the reason I built the whole thing. I can attach it to a conversation with an AI agent and say “this is what happened”, or send it to another engineer without first trying to remember every detail of the reproduction.
It doesn’t magically diagnose the bug. That’s not what I wanted it to do.
It just removes one surprisingly unreliable part of debugging: reconstructing the past.
Then it stopped belonging to the project
The recorder started as something I needed inside a specific application, so naturally that’s where I built it. After using it for a while, though, I realized almost nothing in it knew anything about that application.
Clicks aren’t domain-specific. Neither are console errors, network requests, navigation or screenshots. Even the custom action API was intentionally generic because the application provides the meaning.
At that point keeping it inside the original codebase started to feel arbitrary, so I eventually extracted it into a standalone package with its own release cycle.
I think that order is important. I didn’t sit down and decide to create a generic debugging library, then go looking for a problem it could solve. I had an annoying problem in a real project, built something to solve it there, used it, and only then realized the boundary I’d drawn around it was wrong.
The extraction itself is probably another post.
For this one, the idea I want to keep is much simpler:
A bug report will always be someone’s description of what happened. When possible, I’d rather just have what happened.