Symbolicator
JavaScript

JavaScript

For JavaScript, the debug info is a source map plus the minified bundle it was built from. Browsers report errors against the shipped bundle, so without symbolication an exception looks like this:

TypeError: Cannot read properties of undefined (reading 'name')
anonymous()
    app.bundle.js:1:48211
anonymous()
    app.bundle.js:1:13377

With the map uploaded, TracePath resolves it to this:

TypeError: Cannot read properties of undefined (reading 'name')
renderUserCard()
    src/components/UserCard.tsx:42:17
handleClick()
    src/pages/Profile.tsx:108:5

This page covers the JavaScript-specific mechanics: how to upload maps, how a frame is parsed, how it resolves against a source map, and how the minified bundle recovers function names. For when symbolication runs, the cache, and the .tw format, see Architecture.

Uploading maps and bundles

Maps go to the upload endpoint on the API host, authenticated with your project's Upload Token. Open the project's Connection page in the dashboard to copy it (generate one if the project has none). It is a separate credential from the ingest token your application runs with, and it is the only credential this endpoint accepts.

POST /api/sourcemaps/upload
Authorization: Bearer <upload token>
Content-Type: multipart/form-data

Upload each .map together with the bundle it was generated from — the bundle is what recovers function names:

curl -H "Authorization: Bearer $TRACEPATH_UPLOAD_TOKEN" \
  -F "files=@dist/app.bundle.js" \
  -F "files=@dist/app.bundle.js.map" \
  https://app.tracepath.dev/api/sourcemaps/upload
FieldRequiredNotes
filesyesRepeatable. Only .map, .js, .cjs, and .mjs entries are stored; anything else in the request is ignored without an error. 50 MB per file.

The response reports how many files were stored:

{ "uploaded": 2 }

Because unrecognized extensions are skipped silently, check that count against what you sent — a 0 from a successful-looking request means nothing matched.

Failure modes worth handling in CI:

StatusWhen
401Missing Authorization header, or a token that is not an upload token for a live project.
400No files field at all, or a single file over 50 MB.
413The whole request exceeds 250 MB. Split the batch.
422Too many parts in one request (the limit is around a thousand). Split the batch.
503Too many uploads in flight at once. The request waited for a slot and gave up; retry it.

Uploads take effect immediately: the endpoint evicts cached state for the filenames it stored, so exceptions arriving right after a deploy resolve against the new maps.

The frame format

The browser's raw error.stack string is not what gets resolved. Stack formats are not standardized: V8 (Chrome, Edge, Hermes) emits at funcName (file:line:col), while Firefox and JavaScriptCore (Safari) emit funcName@file:line:col. Both families are parsed and normalized into a canonical two-line wire format: a function name line ending in (), then a location line indented by four spaces:

anonymous()
    app.bundle.js:1:48211

The resolver recognizes location lines with a single regex:

^(\s{4})(.+):(\d+):(\d+)$

Anything that doesn't match (the error message, the name lines, blank lines) passes through untouched. This is the first invariant of the pipeline: the resolver can only rewrite a line or leave it exactly as it was. There is no failure mode that drops or corrupts a frame.

The resolution pipeline

For each location line, in order:

1. Find the right source map

By debug ID first. When the exception payload carries a debug ID for the frame's file, the map is addressed at sourcemaps/{projectId}/by-debug-id/{debugId}.js.map and the bundle at the same key without .map. This is content-addressed matching: the ID was embedded into the bundle and its map by the build, so the frame can never resolve against a map from a different build, regardless of filenames, CDNs, or concurrent deploys.

The upload endpoint reads the ID out of the files themselves and writes the debug-ID alias alongside the filename key, so nothing else has to be configured:

FileWhere the debug ID is read from
.mapthe top-level debugId field of the map JSON (legacy debug_id is also accepted)
bundlethe last //# debugId=<uuid> (or //@ debugId=<uuid>) comment in the final 4 KB of the file

The value must be a canonical UUID (8-4-4-4-12 hex); anything else is ignored and the artifact is stored under its filename only. If no artifact with that debug ID was uploaded, resolution falls back to filename matching below.

By filename otherwise. The frame's file name is reduced to its basename (any query string or fragment stripped first), and the map is addressed in object storage at a deterministic key:

Frame fileStorage key
app.bundle.jssourcemaps/{projectId}/app.bundle.js.map
https://cdn.example.com/assets/app.bundle.jssourcemaps/{projectId}/app.bundle.js.map
app.bundle.js?v=abc123sourcemaps/{projectId}/app.bundle.js.map

The minified bundle, when it was uploaded next to the map, lives at the same key without the .map suffix and feeds function name resolution (below).

No map in storage: the line passes through unchanged, and that filename enters a miss cooldown so subsequent exceptions skip the storage read entirely (see Performance).

2. Get the resolver

The parsed lookup structure comes from the cache tiers. First use pays a storage read plus a parse (or a memory-mapped .tw open, which skips the parse); everything after is a memory lookup.

3. Look up the position

Browser stack traces use 1-based columns; the source map spec uses 0-based. The resolver subtracts 1 before the lookup and adds 1 back when printing. This off-by-one matters more than it sounds: minified bundles put the entire program on line 1, so being one column off can land on a different token entirely and resolve to the wrong original file.

Here is a real lookup, taken verbatim from our test fixtures. The original file:

// built with: terser -c -m --module original.js --source-map includeSources
function abcd() {}
export default abcd;

terser renames abcd to t and emits:

function t(){}export default t;

with this source map:

{
  "version": 3,
  "names": ["abcd"],
  "sources": ["tests/fixtures/simple/original.js"],
  "sourcesContent": ["...the original file, embedded..."],
  "mappings": "AACA,SAASA,oBACMA"
}

A frame pointing at minified.js:1:11 (the t in function t) resolves through the mappings (delta-encoded VLQ segments of generated position, source index, original position, name index) to tests/fixtures/simple/original.js:2:10, with the name token abcd.

4. Rewrite the lines

The location line becomes original-file:line:column (same indentation). If a function name was resolved, the name line directly above it (the anonymous() line) is rewritten to abcd().

If the source map carries "sources": [null] entries (older uglify pipelines), the file name is rendered as <unknown> but the line, column, and name still resolve.

A maximum of 50 frames per trace are resolved; deeper frames pass through minified.

Function names: scope analysis

Locations come straight from the source map and are exact. Function names are harder, because a source map's names token at a position is the identifier at that token, not the name of the enclosing function that a stack trace conventionally shows. Getting the enclosing function right requires scope analysis: parsing the minified bundle into an AST and mapping positions to function scopes. This is the same approach Sentry's symbolic takes, and it is why the minified bundle has to be uploaded alongside each .map.

When a bundle is available, the resolver:

  1. Parses it into every function in the file: declarations, function expressions, arrow functions, and class methods, each with the generated position of its name. If the bundle fails to parse as a script (ESM syntax), it's retried with module syntax neutralized.
  2. Flattens the nested scopes into a sorted list of transitions: "from this generated position onward, the innermost enclosing function's name sits at line L, column C".
  3. For each mapping in the source map, resolves the enclosing function's name position back through the map itself, yielding the original, pre-minification name.

Take preact's create-element.js, which the old name heuristic famously got wrong:

export function createElement(type, props, children) {
  let normalizedProps = {},
    key, ref, i;
  for (i in props) {                         // error around here (line 40)
    ...
  }
}

A frame inside that loop now resolves to createElement, because the scope walk knows which function contains the error position rather than guessing from nearby text.

When no bundle was uploaded (a map-only upload, or a .map with no sibling bundle in the build output), there is no scope information and no name is resolved: the name line stays whatever the client reported (typically anonymous()), while file, line, and column still resolve exactly. Locations never depend on the bundle, so the click-through is right even when the label is missing.

Resolved names never affect issue grouping, so name quality can keep improving (for example, by uploading bundles where you previously uploaded only maps) without re-bucketing existing issues. See Issues for how grouping works.

Bundle parsers

Scope analysis parses the minified bundle into an AST, and the engine ships two parsers: a pure-Go one that is always available and used by default, and an optional Rust parser that is substantially faster on large bundles.

The parser choice affects build speed only, never output: a parity test asserts the two against each other across the whole fixture suite, and .tw files are interchangeable between them. Since parsing happens only when a resolver is built, the parser matters most where map churn is heavy.

Limitations today

In rough order of impact:

  1. Function names require the bundle. Map-only uploads resolve locations without names.
  2. Filename matching collides on stable names. Without debug IDs, two bundles with the same filename in different directories collide, and the most recent upload wins. Content-hashed bundle names (the default in Vite, Next, Angular, and most bundlers) avoid this in practice, and debug IDs eliminate it entirely by addressing maps by build content instead of by name.
  3. Maps with null sources are ambiguous. Frames resolve as <unknown>, though line, column, and name still resolve.
  4. 50 resolved frames per trace. Deeper frames stay minified.
  5. Module-level code has no name. Errors thrown outside any function (Hermes/Metro global prologue, top-level module code) resolve location-only rather than being labeled <global>.

How we verify it

The resolver is tested for parity against symbolic, the symbolication engine behind Sentry, using its own fixture suite (webpack, Metro, Hermes, preact, inlined functions, null-source maps). Every fixture frame's expected file, line, column, and name is asserted exactly, so any drift fails the build.

All frames match symbolic on location. With scope analysis, names match too for frames inside a function, including the cases the old text-scanning heuristic got wrong (preact's createElement and assign among them). The remaining name gap is module-level code, where symbolic reports <global> and TracePath reports no name (limitation 5 above).

Two more invariants are tested directly: the .tw format round-trips (a resolver serialized and reopened answers every lookup identically, including from a memory-mapped file), and the two bundle parsers produce identical scope output across the fixture suite, so the parser choice can never change symbolication results.

Related pages

  • Architecture: when symbolication runs, the .tw format, and the cache tiers
  • Performance: what happens when an artifact is missing, and how to tell
  • OpenTelemetry: which OTLP traces are symbolicated on ingest