Set Up Error Tracking in an AI-Built App
Agents write defensive code, which means your app is probably swallowing the errors your users hit. Here is the hour that turns the lights on.
Error tracking means catching the failures your users hit and never report. Setting it up in an app an AI built takes about an hour and has three parts: capture errors in the browser, capture them on the server, and decide which ones are allowed to wake you up. The order matters, because an AI coding agent will usually have written code that swallows errors rather than reporting them, so the capture layer finds problems that were already there.
The quick version, if you want the shape before the detail:
Add a browser handler for thrown errors and a second one for rejected promises.
Add a catch-all on the server that logs the request path, the status, and a request identifier.
Send both to one place you will actually look at.
Alert on a rate, never on a single event.
Why an AI-built app is usually darker than it looks
Agents write defensive code. Asked to make something work, they wrap risky calls in a try block, and the catch block often does the minimum that stops a crash:
try {
const res = await fetch("/api/orders");
const data = await res.json();
render(data);
} catch (e) {
// nothing, or console.log(e), or a generic toast
}That code works. It also guarantees that when /api/orders starts returning a 500, nobody finds out until a customer emails. We wrote about this pattern specifically in stop your AI coding agent swallowing errors, and it is the reason error tracking has outsized value here. You are not adding observability to a well-instrumented app. You are turning the lights on.
Before you install anything, search your own codebase for empty catch blocks and for console.log(e). Each one is a place where an error already happened and went nowhere.
Browser errors: two listeners, not one
The first listener is the obvious one. MDN's reference for the window error event describes it as firing when a resource fails to load or cannot be used, including when a script throws during loading or inside an event handler.
window.addEventListener("error", (event) => {
report({ kind: "error", message: event.message, source: event.filename,
line: event.lineno, col: event.colno, stack: event.error?.stack });
});The second listener is the one people forget, and it is where most modern failures live. MDN is explicit: if a promise is rejected, "including an uncaught throw within an async function," and no rejection handler is attached, "an unhandledrejection event is fired instead." An app full of await calls that only listens for error is deaf to its own most common failure.
window.addEventListener("unhandledrejection", (event) => {
report({ kind: "unhandledrejection", message: String(event.reason),
stack: event.reason?.stack });
});Two details that save an afternoon. If you use the older window.onerror property instead of addEventListener, the handler receives five arguments rather than one event, which MDN notes exists for historical reasons. And if you want to suppress the default console output from onerror, you return true, not false, though the script still stops executing.
A third gap is worth knowing about: errors thrown by scripts served from another origin without the right headers arrive stripped of their detail, so you get a bare message and no useful line number. If your bundle is on a CDN and your reports are all empty, that is why.
Server errors: one handler and one identifier
On the server the goal is different. You are not hunting for silent failures so much as making the loud ones traceable. One handler at the edge of the request lifecycle, catching whatever reached it:
app.use((err, req, res, next) => {
const id = crypto.randomUUID();
logger.error({ id, path: req.path, method: req.method,
status: err.status || 500, message: err.message, stack: err.stack });
res.status(err.status || 500).json({ error: "Something went wrong", id });
});The request identifier in both the log and the response is the single highest-value line here. A user screenshots an error containing id: 7f3c... and you find the exact log entry in one search, instead of guessing from a timestamp.
Do not put the stack trace in the response body. It belongs in your logs and nowhere a browser can read it.
What to record, and what not to
Record | Leave out |
|---|---|
Message, stack, and where it happened | Full request bodies |
Request path, method, status code | Passwords, tokens, API keys, session cookies |
A request or trace identifier | Card numbers and bank details |
Release or build version | Full names and addresses, unless you need them and have a basis |
Browser and operating system family | Precise location |
Two rules make this manageable. Strip the sensitive fields at the point you build the report, not at the destination, because once it is sent it is somebody else's retention policy. And attach the release version to every report, because the only question you will ask in the first ten seconds of an incident is whether this started with the last deploy.
That exclusion list overlaps with how you store credentials in the first place, which is covered in environment variables and secrets in an AI-built app. An error report is one of the commoner ways a secret leaves a system, because nobody reviews a log the way they review a response payload.
Alert on rates, not events
This is where most setups fail, and it fails in one direction: too many alerts, then the alerts get muted, then the outage is found by a customer. Some workable defaults for a small app:
A new error type that has never been seen before: notify once, quietly, with no paging.
A known error crossing a rate, say 20 occurrences in five minutes: notify.
Any error on the checkout or signup path: notify at a much lower threshold, because those two paths are revenue and growth.
Server 5xx rate above a small percentage of requests over ten minutes: this is the one that means something is actually broken.
Everything else: logged, reviewed weekly, no notification.
The test for a good threshold is whether you would get out of bed for it. If not, it is a log line, not an alert.
Source maps, or your stack traces are decoration
A production bundle is minified, so the stack trace that arrives says the error happened at character 48210 of main-a3f9.js. That is not a location, it is a coordinate in a file no human wrote. Source maps translate it back to your actual file and line.
Three things to get right, and the third is the one that goes wrong:
First, generate them. Most build tools produce source maps for production when asked, and many default to not producing them because they slow the build slightly.
Second, upload them to wherever you read errors, as part of the deploy, keyed to the release version. A source map that does not match the bundle it describes is worse than none, because it will confidently point at the wrong line.
Third, do not serve them publicly from your own domain. A public source map hands anybody your original source, including comments and any logic you thought was private. Upload them to your error tool and exclude them from the deployed assets. If the directory listing on your site includes a .map file, that is the bug.
A quick way to tell whether this is already broken: look at any error report from the last week. If the file name has a hash in it and the line number is in the tens of thousands, source maps are not working, and every stack trace you have collected so far is unreadable.
Verify it with a deliberate failure
Untested error tracking is an assumption. Prove it in four steps, in a staging environment:
Throw from a button click. Confirm the report arrives with a usable stack.
Reject a promise with no handler. Confirm a second report arrives, tagged differently from the first.
Make an endpoint return a 500. Confirm the server log has the identifier and that the identifier also reached the browser.
Deploy a trivial change, then repeat step one and confirm the release version on the report changed.
Step four is the one people skip and the one that breaks silently when a build pipeline stops injecting the version.
Frequently asked questions
Do I need a paid service for this?
No, not to start. Writing structured errors to your platform's log stream and reading them is a legitimate first stage, and it costs nothing. A hosted tool earns its price when you need grouping, release tracking and alert routing rather than raw lines.
Will error tracking slow my app down?
Not measurably, if reports are sent without blocking. Send them with a fire-and-forget request and cap how many you send per session so a loop cannot flood anything.
How is this different from uptime monitoring?
Uptime checks ask whether the site answers from outside. Error tracking asks what happened to real users inside the app. A site can return 200 to a monitor on every check while a third of its users cannot complete a purchase. Related reading on taking those claims at face value: how to tell if an AI vendor's uptime claim is real.
Should I ask the AI agent to add this for me?
Yes, with a specific brief: both browser listeners, a server handler with a request identifier, a named exclusion list for sensitive fields, and no stack traces in responses. Then check each of those four things yourself. An unreviewed observability layer is the one component where a quiet failure means you learn nothing for weeks. The general method is in reviewing AI-generated code before you ship it, and the tooling picture is in how to pick AI coding tools.
How did this land?
About the author

Developer Advocate
Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.


