The honest state of most logging is "pretty good." Most routes catch their errors and write them somewhere. The problem is the rest. A handful of endpoints swallow the exception and return an empty result. A few throw with no try/catch at all, so the framework returns a 500 and the error never reaches your logs. And the browser, where users actually experience the breakage, reports nothing. "Pretty good" logging is exactly the logging that hides the failure you most need to see.
We set out to make a stronger guarantee on our platform dashboard: every failure, server-side and client-side, lands in one searchable place. Here is the shape of the work, and the lessons that transfer to any Next.js or Node codebase.
Step 1: Measure the gap before touching anything
Across 370+ API routes, 349 already called the logger. That sounds fine until you count the silent ones: 20 routes failed with no log at all, and 58 bare catch {} blocks swallowed errors entirely. The instinct is to "add logging everywhere." The better move is to quantify first, because it tells you whether you need a structural fix or a cleanup. With ~94% coverage already, this was a fill-the-gap job, not a rewrite.
Step 2: A wrapper, applied by codemod, not by hand
Hand-editing 370 files is how you introduce 370 chances for a typo. Instead we wrote one wrapper:
export const GET = withLogging('leads/[id] GET', async (req, ctx) => { ... });
It runs the handler inside a try/catch, logs any uncaught throw with the route name plus structured error fields, and returns a clean 500. A second variant re-throws instead of returning JSON, for streaming and image routes where a JSON 500 would be the wrong response shape.
To apply it everywhere safely, the codemod used a rename-and-rewrap trick that avoids the hardest part of source transformation: matching the closing brace of a function body. Instead of rewriting the function, it renames export async function GET to async function GET__impl and appends one line at the end of the file wrapping it. No brace counting, no body manipulation, almost no risk. We ran it folder by folder, rebuilding between batches so a bad transform surfaced immediately.
One sharp lesson here: a wrapper only catches what escapes the handler. Routes that already had an inner catch returning a 500 or an empty array still swallowed their errors before the wrapper ever saw them. The wrapper is a safety net for the uncaught case; the inner swallows still had to be fixed by hand. We logged them while keeping their graceful fallback, and left the genuinely benign micro-swallows (a per-item JSON.parse skip, an optional cache read) alone, because logging those is noise, not signal.
Step 3: Capture the client side too
Server logging is half the picture. A React render crash or a failed fetch in the browser is invisible to your server logs. We added two error boundaries (a page-level one and a root one) plus a small reporter that POSTs failures to a /api/client-log endpoint, tagged source: 'client', into the same log stream.
The non-obvious bug: error boundaries fire before authentication. A crash on the login page happens when no session exists. Our auth middleware was returning 401 on the logging endpoint, so the very errors most worth seeing were being rejected. The endpoint has to be public. Public, of course, means hardened: we added a per-IP rate limit, a same-origin check, a body-size cap, and URL query-string scrubbing so a token sitting in ?token=... never gets written to a log.
Step 4: Make the logs usable, not just complete
Complete logs you cannot navigate are a different kind of useless. Three additions turned the pile into a tool.
Correlation. Every log line within a request now carries the same request id and the acting user id, set once by the wrapper using Node's AsyncLocalStorage and read transparently by the log writer. No threading ids through every function call. You can now follow one user's failing session end to end, and tie a client error to the server requests around it.
Alerting. Writing a failure down is not the same as noticing it. A lightweight check fires a single notification when error volume spikes past a threshold in a short window, with a cooldown so an ongoing incident does not spam you. Failures now come to you instead of waiting to be discovered.
Grouping. A flat list of 800 errors hides the three problems causing them. A grouped view folds identical failures into counted buckets, so the screen reads "redis client error x751" and "leads/[id] PATCH error x40" instead of an endless scroll. The first time we ran it, it instantly surfaced that 527 entries shared one generic "api error" message, which led to the last cleanup: giving each route a specific message so the buckets are actually actionable.
Step 5: Stop the coverage from rotting
The failure mode of any "we fixed everything" effort is the route someone adds next month. So the final piece is a tiny guard script that scans every route and fails if one can error without logging. Wired into CI, it makes the guarantee durable instead of a one-time heroic sweep. This is the part teams skip, and it is the part that decides whether the work still holds in six months.
What transfers
Three principles outlast the specifics. Measure the gap before you fix it, so you build the right size of solution. Use a wrapper plus a codemod for anything that repeats across hundreds of files, and prefer transforms that sidestep the risky parts of parsing. And complete is not the goal; actionable is: correlation, alerting, and grouping are what turn "we log everything" into "we find and fix it fast."
At Expedis AI we build autonomous systems that run real business operations, which means a failure that hides is a failure that compounds. Observability is not a feature you bolt on at the end. It is the difference between a system you trust and one you merely hope is working.
.png&w=384&q=75)