ehsan.blog
~/blog/how-to-get-real-filenames-instead-of-minified-chunk-paths-in-error-logs — zsh
cat how-to-get-real-filenames-instead-of-minified-chunk-paths-in-error-logs.md

How to turn minified chunk paths in your error logs back into real filenames

·9 min read

A user says the checkout page crashed. You open your logs and find this:

plaintext
TypeError: Cannot read properties of undefined (reading 'items')
    at u (/app/.next/static/chunks/checkout.js:1:518)
    at Object.d (/app/.next/static/chunks/checkout.js:1:593)

This is useless. The function is called u. The line number is 1, because the whole bundle is one long line. The column is 518, which is a character offset into that line.

You know the checkout page broke. You don’t know where.

The fix is source maps. Most people know the word but have never looked at what actually happens during the lookup. I went and read the code, ran it against a real minified bundle, and found one behaviour that will hand you a wrong answer while looking completely confident about it. That last part is the bit worth your time.

What a source map is

When you build your app, the bundler takes your readable TypeScript and squashes it. Long names become single letters. Line breaks disappear. The result is small and fast, and unreadable.

A source map is a second file that records where everything went. It’s plain JSON. It sits next to your bundle as checkout.js.map, and the bundle points at it with a comment on the last line:

js
//# sourceMappingURL=checkout.js.map

Think of it as a receipt. “The thing now at line 1, column 518 came from checkout.ts, line 3, column 49.”

The interesting field inside is mappings. It looks like this:

plaintext
gCAAA,SAAgB,YAAhB,CAA6B,IAA7B

That is not corrupted. It’s a list of numbers, packed into text to keep the file small. Each , separates one position. Each ; starts a new line of the generated file. The numbers are stored as base64 characters where each character carries five bits of value plus one bit that means “keep reading”, and the very last bit of the number holds the sign.

You don’t need to write that decoder. But it helps to know the format is just numbers, because it explains the failure mode later.

Turning it on

In @developerehsan/nextjs-logger this is one config value:

ts
configureLogger({ sourceMaps: 'dev' }) // 'dev' | 'always' | 'off'

The default is 'dev', and 'dev' means “resolve only when NODE_ENV is development”. So locally it already works. In production it does nothing until you say 'always'.

If you want it in production for browser errors, you also need your build to actually ship the maps:

ts
// next.config.ts
export default { productionBrowserSourceMaps: true }

One detail that surprised me: the resolving never happens in the browser. Browser errors get relayed to your server first, and the server reads the .map files straight off disk with fs.readFileSync. There’s no network fetch involved, and no extra dependency doing the decoding. The library ships its own decoder in about 250 lines and has exactly one runtime dependency, @tanstack/pacer, which is for something else entirely.

That also means the browser URL has to be turned into a file path. A frame pointing at https://yoursite.com/_next/static/chunks/checkout.js gets rewritten to <your-cwd>/.next/static/chunks/checkout.js. I tested what happens with a nasty URL, and it refuses anything containing .., and anything without /_next/ in the path:

plaintext
https://example.com/_next/static/chunks/checkout.js  ->  <cwd>/.next/static/chunks/checkout.js
https://example.com/_next/../../etc/passwd           ->  null
https://example.com/static/chunks/foo.js             ->  null

Good. A logger that reads arbitrary files based on a string a browser sent you would be a bad logger.

The before and after, for real

I didn’t want to trust the examples. So I wrote a tiny file, minified it with esbuild, crashed it on purpose, and fed the real stack frame through the library’s own resolver.

The source:

ts
// src/checkout.ts
export function computeTotal(cart: Cart | undefined) {
  const veryLongDescriptiveVariableName = cart!.items
  let runningTotalAccumulator = 0
  for (const lineItem of veryLongDescriptiveVariableName) {
    runningTotalAccumulator += lineItem.price
  }
  return runningTotalAccumulator
}

Calling it with undefined throws. Here’s the real frame Node gave me, and what came back out:

plaintext
in :  at u (.../e2e/out/checkout.js:1:518)
out:  at u (../src/checkout.ts:3:49)

Line 3 is const veryLongDescriptiveVariableName = cart!.items. Column 49 is the i of items. The error message was reading 'items'. It landed on the exact character.

The ../ in front is just because my output folder sat next to my source folder, and those paths come from the map’s own sources list. In a Next.js build they get cleaned up, so prefixes like [project]/ and webpack:/// are stripped and you see app/checkout/form.tsx instead.

The trap: a wrong answer that looks right

Here’s the part I’d want someone to tell me.

The lookup doesn’t get an exact hit for every column. A source map only records positions where something interesting happened. Between those points, there is nothing. So the code takes your column, and does a binary search for the last recorded position at or before it. That’s the normal, correct way to do it.

But what if your column comes before the first recorded position on that line? There is nothing at or before it. The code then falls back to the first position on the line.

In my test bundle, the first recorded position on line 1 sits at column 412. I asked it to resolve column 1:

plaintext
first mapped generatedColumn on line 1 = 412
resolve column 1  ->  { file: "../src/checkout.ts", line: 1, column: 1 }

It gave me checkout.ts:1:1. Confidently. A real file, a real line. And it means nothing, because there was no mapping for column 1 at all. The honest answer was “I don’t know.”

This is the failure I want you to remember, because it doesn’t look like a failure. A missing map looks like a failure, you get the ugly chunk path back and you know something’s off. This one gives you a filename and a line number, and you go read that line and get confused about why it looks fine.

When does it bite? When a frame’s column lands before the first mapping on its line. In practice you’re most likely to see it as a suspicious cluster of :1:1 or top-of-file hits. If a resolved location makes no sense for the error, don’t assume you’re misreading it. Check whether the original minified column was tiny.

Practical rule: treat a resolved location as a strong hint, not a fact. If the line it points to can’t possibly throw the error you’re holding, believe the error, not the map.

Other things that quietly return nothing

While I was in there I tested the other exits. All of these return “no result”, which means your log keeps the ugly location and never throws:

plaintext
unparseable JSON in the .map file  ->  null
a map with no "mappings" string     ->  null
a map that uses "sections"          ->  null
the file doesn't exist              ->  frame returned unchanged

That third one is worth flagging. Maps with a sections array are called index maps. They’re a valid part of the spec, used when a tool stitches several maps into one file. This decoder rejects them outright. If your build produces index maps, resolution silently does nothing and you’ll never see an error about it.

The failing-soft behaviour is the right call, though. A logger is the code that runs when something has already gone wrong. It’s the last place you want a second crash.

Why it caches, and why it caches failures too

Parsing a map means decoding every position in the file. You don’t want that on every log line.

So parsed maps are cached, keyed by the bundle’s file path. Two things about that cache are worth knowing.

It also caches the misses. I stubbed the filesystem and asked for a map on a file that has none, three times. It touched the disk on the first call only:

plaintext
filesystem probes for 3 lookups on a mapless file: 2  (both from the first call)

Two probes, because one call checks for checkout.js.map and then checks the bundle itself for an inline map comment. After that it’s a cached “no”. So a chunk with no map doesn’t cost you a disk hit on every error. That matters when something downstream is broken and the same code path is throwing thousands of times a minute.

And the cache is capped at 64 maps. When it fills up, it doesn’t evict the oldest one, it clears the whole thing and starts over. That’s a blunt instrument, and it’s fine, because the worst case is re-parsing some maps once in a while. It’s worth knowing if you ever see a periodic latency blip on a build with a lot of chunks.

Checking that it actually works

Since it fails quietly, you have to go look.

Throw an error somewhere on purpose, in the environment you care about, and read the log line. If you see your own filenames, you’re done. If you still see chunk paths, walk the chain in this order:

  1. Is sourceMaps set to 'always'? 'dev' does nothing when NODE_ENV isn’t development.
  2. For browser errors, is productionBrowserSourceMaps: true set in next.config.ts?
  3. Are the .map files actually in the deployed output? Some deploy steps strip them.
  4. Can the server reach them on disk? Resolution reads files, so it needs the build output present where the process runs.

One more thing to keep in mind: only the first 20 stack frames get rendered. Deep frames past that are dropped before resolution ever runs.

Also, this feature covers two separate things, not one. The stack trace of an error is the obvious one. The other is the caller field, the file and line where you called log.info(...) from. Without resolution that field degrades into a chunk path too, and you see it on every log line, not just errors. That one is arguably more annoying. It’s server-only by design, so client log lines never carry a caller.

The takeaway

Source-map resolution is a small amount of machinery: find the map, decode the numbers, binary search the column, cache the result. Turning it on is one config line.

The thing to hold onto is what happens at the edges. A missing map tells you loudly, by giving you back the same ugly path you started with. A column that falls before the first mapping tells you nothing, and hands you a clean-looking filename and line number that are simply made up. Knowing that difference is what stops you from spending an afternoon staring at the wrong line of code.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.