DEV Community

Cover image for Verify a minified stack against its JavaScript source map
Amorizz
Amorizz

Posted on Edited on

Verify a minified stack against its JavaScript source map

A few weeks ago I wrote that minified browser code gives you function names like t and n until you apply source maps, and then admitted I hadn't tested it. This weekend I tested it.

I didn't want another upload walkthrough. I wanted to know one thing: when a frame says a @ app.min.js:1:88, how do I prove the map I have turns it into the right function and the right line? And what does it look like when the map is wrong?

So I built a 15-line checkout module, minified it with esbuild, threw a known error through the official Sentry SDK into a local tap, and applied the map by hand with the source-map package. Then I broke it two ways on purpose.

Versions from run-0-versions.txt: Node v24.22.1, esbuild 0.28.2, source-map 0.8.0, @sentry/node 11.11.0. Everything ran locally on 2026-11-08. No tracker was involved, so this works the same if you run GlitchTip, self-hosted Sentry, or your own ingest.

TL;DR

  • With the matching map, the SDK frame a @ app.min.js:1:88 resolved to chargeCard at src/checkout.js:5, the exact throw line. The caller t resolved to processOrder at line 12.
  • A stale map gave no error and no warning. It mapped the crash to return { charged: total }; and the caller to module.exports = { processOrder };. A map stored under the wrong release wasn't found at all, and the event was stored anyway with o and u.
  • What fixed it for me is a check: throw a known error from the built bundle, apply the map you're about to upload, and fail if the crash frame isn't chargeCard on a line containing throw. Mine prints PASS or FAIL and exits 1 on a miss.

The mental model: a map is a position lookup with a two-part key

A source map answers one question: "this line and column in the bundle came from which file, line and column in the source?" Your tracker gets a position from the SDK, finds a map, and asks that question. It can only find the right map if the event's key matches how you stored it.

Here's what each side holds, from my lab files:

Piece What it holds in this lab
SDK frame filename, function (a), lineno (1), colno (88)
.map file version, sources, sourcesContent, mappings, names
Lookup key the event's release (shop@1.0.0) plus the frame's file name

Three things have to line up: the bundle that actually ran, the map built from that same bundle, and the key you stored the map under. Any one of them off and you get either minified frames or wrong frames. Wrong frames are the worse case, because they look fine.

Step 1: a bundle with two named functions

The source is two functions so a demangled frame is obvious at a glance:

// src/checkout.js: two named functions so a demangled frame is obvious.
function chargeCard(order) {
  const total = order.items.reduce((sum, item) => sum + item.price * item.qty, 0);
  if (order.card.balance < total) {
    throw new Error(`card declined: needed ${total}, balance ${order.card.balance}`);
  }
  return { charged: total };
}

function processOrder(order) {
  if (!order.items.length) return { charged: 0 };
  return chargeCard(order);
}

module.exports = { processOrder };
Enter fullscreen mode Exit fullscreen mode

Install pinned and build:

npm i --save-exact esbuild@0.28.2 source-map@0.8.0 @sentry/node@11.11.0
npx esbuild src/checkout.js --bundle --minify --sourcemap --platform=node --outfile=dist/app.min.js
Enter fullscreen mode Exit fullscreen mode

esbuild wrote dist/app.min.js (294b) and dist/app.min.js.map (1.0kb). The whole bundle is one line:

function a(e){let r=e.items.reduce((n,c)=>n+c.price*c.qty,0);if(e.card.balance<r)throw new Error(`card declined: needed ${r}, balance ${e.card.balance}`);return{charged:r}}function t(e){return e.items.length?a(e):{charged:0}}module.exports={processOrder:t};
//# sourceMappingURL=app.min.js.map
Enter fullscreen mode Exit fullscreen mode

chargeCard became a. processOrder became t. Those are the names your tracker sees without a map.

Step 2: capture the frames the SDK actually sends

Don't trust err.stack for this. Read what the SDK puts in exception.values[0].stacktrace.frames, because that's what your tracker symbolicates.

I pointed Sentry.init at http://devkey@127.0.0.1:9999/1 and ran a tiny tap on that port. It unzips each envelope, keeps event items, and appends the release, the error message and the frames to a JSONL file:

// tap.js: fake DSN host. Saves each error event's frames to events.jsonl.
const http = require('http'), zlib = require('zlib'), fs = require('fs');
http.createServer((req, res) => {
  const chunks = [];
  req.on('data', c => chunks.push(c));
  req.on('end', () => {
    let buf = Buffer.concat(chunks);
    if (req.headers['content-encoding'] === 'gzip') buf = zlib.gunzipSync(buf);
    const lines = buf.toString().split('\n');
    for (let i = 1; i < lines.length && lines[i]; i += 2) {
      if (JSON.parse(lines[i]).type !== 'event') continue;
      const ev = JSON.parse(lines[i + 1]);
      fs.appendFileSync(process.env.OUT || 'events.jsonl', JSON.stringify({
        release: ev.release,
        value: ev.exception.values[0].value,
        frames: ev.exception.values[0].stacktrace.frames,
      }) + '\n');
    }
    res.end('{}');
  });
}).listen(9999, '127.0.0.1', () => console.log('tap on :9999'));
Enter fullscreen mode Exit fullscreen mode

run.js inits the SDK with release: 'shop@1.0.0', requires the bundle, calls processOrder with a card balance of 50 and an 80 total, and calls Sentry.captureException in the catch. The in-app frames the SDK sent (run-3-sdk-frames.txt):

release: shop@1.0.0
{"filename":"dist/app.min.js","function":"a","lineno":1,"colno":88}
{"filename":"dist/app.min.js","function":"t","lineno":1,"colno":209}
{"filename":"run.js","function":"Object.?","lineno":11,"colno":3}
Enter fullscreen mode Exit fullscreen mode

That's the "before" picture. Message card declined: needed 80, balance 50, function a, line 1.

Step 3: apply the map and print the original line

Applying a map is originalPositionFor with the frame's line and column. Printing the source line next to the result is what turns "it returned something" into proof you can eyeball.

The core of symbolicate.js:

const map = await new SourceMapConsumer(raw);
for (const f of ev.frames.filter(f => f.in_app && f.filename.endsWith('.min.js')).reverse()) {
  // SDK colno is 1-based, source-map wants 0-based columns
  const pos = map.originalPositionFor({ line: f.lineno, column: f.colno - 1 });
  const src = pos.source ? map.sourceContentFor(pos.source, true) : null;
  const text = src && pos.line ? src.split('\n')[pos.line - 1].trim() : '(no source)';
  // pos.name is the token at the crash column, not the enclosing function.
  // For the function, map the token right after "function <minName>(" in the bundle.
  const at = minLines[f.lineno - 1].indexOf(`function ${f.function}(`);
  const fn = at < 0 ? null : map.originalPositionFor({ line: f.lineno, column: at + 9 }).name;
  console.log(`${f.function} @ app.min.js:${f.lineno}:${f.colno}`);
  console.log(`  -> ${pos.source}:${pos.line}:${pos.column} token=${pos.name} function=${fn}`);
  console.log(`     ${text}`);
}
Enter fullscreen mode Exit fullscreen mode

Output with the matching map (run-4-demangle.txt):

release shop@1.0.0, map dist/app.min.js.map
a @ app.min.js:1:88
  -> ../src/checkout.js:5:10 token=null function=chargeCard
     throw new Error(`card declined: needed ${total}, balance ${order.card.balance}`);
t @ app.min.js:1:209
  -> ../src/checkout.js:12:9 token=chargeCard function=processOrder
     return chargeCard(order);
Enter fullscreen mode Exit fullscreen mode

That's the "after" picture, and it's right. The crash frame lands on the throw. The caller lands on return chargeCard(order); inside processOrder.

Look at the token= column, though. For frame t, the map's name at that position is chargeCard, because the column points at the call to chargeCard. If your symbolicator labels frames with pos.name, it calls the processOrder frame chargeCard. That's why my script does a second lookup on the function t( declaration to get the enclosing name. The fn lookup is a lab shortcut that only works for function x( declarations. Real symbolicators work out the enclosing scope properly, so it's worth checking that yours does.

Step 4: break it with a stale map

A stale map doesn't fail. It returns a confident answer for the wrong code.

I made v1.0.1: added an applyCoupon function above chargeCard and used it in the total. esbuild renamed everything. applyCoupon is now a, chargeCard is o, processOrder is u. The SDK sent o @ app.min.js:1:156 and u @ app.min.js:1:277 with release: shop@1.0.1.

Then I did what happens when a deploy ships the new bundle but the upload step reuses an old artifact. I applied the v1.0.0 map to the v1.0.1 frames (run-7-stale-map.txt):

release shop@1.0.1, map dist/app.min.js.map
o @ app.min.js:1:156
  -> ../src/checkout.js:7:2 token=null function=null
     return { charged: total };
u @ app.min.js:1:277
  -> ../src/checkout.js:15:32 token=null function=null
     module.exports = { processOrder };
Enter fullscreen mode Exit fullscreen mode

No exception from source-map. No warning. The crash "happened" on a return statement, and the caller "is" the module.exports line. If your tracker shows only file and line, this looks like a real stack. You'd go read line 7 of checkout.js and find nothing wrong there.

With the right v1.0.1 map, the same frames resolve to ../src-v2/checkout.js:11:10 (the throw, now 6 lines lower) and processOrder at line 18 (run-7b-right-map.txt).

Step 5: miss the release key

A map stored under the wrong release isn't used at all, and the event still goes in.

resolve.js builds the key the way a tracker keys artifacts: artifacts/<release>/<file>.map. I only stored the v1.0.0 map. Output (run-8-release-lookup.txt):

a @ app.min.js:1:88 key=artifacts/shop@1.0.0/app.min.js.map FOUND
t @ app.min.js:1:209 key=artifacts/shop@1.0.0/app.min.js.map FOUND
event stored anyway: "card declined: needed 80, balance 50" (10 frames)
o @ app.min.js:1:156 key=artifacts/shop@1.0.1/app.min.js.map MISSING, frame stays minified
u @ app.min.js:1:277 key=artifacts/shop@1.0.1/app.min.js.map MISSING, frame stays minified
event stored anyway: "card declined: needed 80, balance 50" (10 frames)
Enter fullscreen mode Exit fullscreen mode

This one at least looks broken. You see o and u and know the map is missing. Honestly I'd take this failure over the stale map every time.

Turn it into a check you run before upload

Throw a known error from the bundle you're about to deploy, symbolicate it with the map you're about to upload, and assert both the function and the line. With EXPECT=chargeCard set, symbolicate.js checks that the crash frame resolves to that function and that the source line contains throw (run-11-verify.txt):

PASS crash frame is chargeCard at a throw
exit 0
...
FAIL expected chargeCard at a throw, got null at "return { charged: total };"
exit 1
Enter fullscreen mode Exit fullscreen mode

In a real app you don't throw from checkout in CI. Add a tiny canary export (a function that throws a fixed message) to the bundle, call it from a script after the build, and point the check at that. If the deploy job fails on exit 1, a stale map never reaches your tracker. The whole lab is in run-all.sh, a 51-line script of shell plus four small JS files (tap.js, run.js, symbolicate.js, resolve.js).

What about node --enable-source-maps?

For Node you can skip uploads and let Node apply the map at throw time. I tried it. It's good, but it isn't complete.

With node --enable-source-maps, the SDK sent this (run-10-sdk-enable-source-maps.txt):

{"filename":"src/checkout.js","function":"chargeCard","lineno":5,"colno":11}
{"filename":"src/checkout.js","function":"t","lineno":12,"colno":10}
Enter fullscreen mode Exit fullscreen mode

File and line are right for both frames. The function name is right for the crash frame and still t for the caller. Plain err.stack printed at Object.t (.../src/checkout.js:12:10) too. Also note 5:11 here versus 5:10 from source-map. Same spot, one is 1-based and one is 0-based.

Browsers don't have this flag, so frontend bundles still need the upload path.

Failure checklist

  1. Stale map. New bundle, old map. You get plausible wrong lines with no error (return { charged: total }; in my run). Only a known-throw check catches it.
  2. Release string mismatch. The event says shop@1.0.1, the map is stored under shop@1.0.0. Frames stay o and u, and the event is stored anyway. Use the exact same string in Sentry.init and in the upload command.
  3. File name mismatch. The file name is the other half of the key. My resolve.js uses the basename; real trackers match on the frame's URL or path, often with a ~/ prefix for "any host". I didn't run this case separately, but in resolve.js it fails the same way as the release miss.
  4. Token name used as function name. pos.name at frame t was chargeCard, the callee. A symbolicator that trusts it mislabels the caller frame.
  5. Column base. SDK frames and Node stacks are 1-based (5:11). source-map columns are 0-based (5:10). In my bundle, forgetting the - 1 landed on the same mapping (run-5-no-col-fix.txt is identical to run-4), so a check like mine wouldn't catch it here. It bites when the crash column sits right at a segment boundary.
  6. Map served publicly with your source. esbuild puts sourcesContent in the map by default, so a .map next to app.min.js on your CDN ships your original code. With --sources-content=false the map dropped from 1024 to 493 bytes and still resolved chargeCard and processOrder, but printed (no source). That also means the throw line check needs the source from your repo instead.
  7. --enable-source-maps is partial. It fixed file and line but left the caller as t.

Where this fits for me

I'm building Epure, a small self-hosted error tracker that matches maps by release and file name, so steps 4 and 5 are exactly the failures I care about. The source maps guide has the upload call and its limits. Nothing above depends on it. The check runs before any tracker sees the map.

Takeaway

A successful map upload only tells you a file arrived. A known throw that resolves to chargeCard on a throw line tells you it's the right file for the code that's running. That's one extra CI step, and it turns the stale map from a silent wrong answer into a failed deploy.

Top comments (0)