Upload source maps
Updated Jul 28, 2026 · 1d ago
If your frontend ships minified JavaScript, browser errors reach Epok as a.min.js:1:74213 and stay that way until you upload the maps. Upload is a plain HTTP POST from your CI, one call per bundle per release. After that, expanding a frontend error in the app shows your original file, line, and function name.
Time to first symbolicated stack: ~15 min · Needs: RUM already reporting errors (set that up first) · API key: app.getepok.dev → Settings → API Keys
First, the failure you will actually hit
The maps upload, CI goes green, and the stacks stay minified. Nothing errors — because nothing can. The upload endpoint validates the map and stores it; whether that map ever matches a real frame is decided later, by four things that have to line up. Get them right before you wire the call in and you skip the whole debugging session.
urlis the URL the browser loads the bundle from — not the build-output path (dist/assets/app.min.js), not a relative path (/assets/app.min.js). Maps are looked up by exact string match against the URL in the stack frame, so it must be the full absolute URL your CDN or origin serves, character for character. This is the constraint that breaks most first attempts: the value sitting in your build script is almost never the value the browser reports.releaseis a label, and the app does not match on it — the in-app error view resolves with no release at all, which selects the newest map for each bundle URL. Two consequences, and they pull in opposite directions. You do not have to thread a release id into your browser build to get symbolication in the app: upload a map for the right URL and it will be used. But if two releases publish different bundles at the same URL, the newest map wins and frames resolve to plausible but wrong lines — worse than not resolving, because nothing marks them as suspect. Content-hashed bundle filenames make that impossible and are the real fix. The release only becomes a matching key when you call resolve yourself with a non-empty release; then it is an exact string match with no normalisation, sov1.4.2and1.4.2are different releases.- Lines are 1-based, columns are 0-based on the resolve call. Nobody guesses this correctly. Browser stack traces already use this convention, so frames parsed straight out of a stack string are right as-is — but if you compute positions yourself, an off-by-one on the column lands you in a neighbouring mapping and returns a wrong-but-confident answer. Both fields must be JSON numbers; a quoted
"128"is ignored and the frame passes through unresolved. - Frames with no map pass through untouched — they are returned unchanged rather than erroring. That is deliberate: vendor bundles routinely ship no maps, and a stack that is half-resolved is far more useful than a request that fails. The cost is that a partial upload is indistinguishable from a partial failure at a glance. Check the
resolvedcount in the response, not whether the call succeeded.
Wire the upload into CI
The call belongs immediately after your bundler step, in the same job, keyed off whatever id you use to name the build. Run it before you deploy: maps uploaded after an error happened still work (resolution is live, not a reprocessing pass), but maps uploaded before the deploy mean the first production error is already readable.
The body is {"release", "url", "map"}, where map is the source map itself — either the parsed JSON object or the raw file contents as a string. Gzip the body. That is not an optimisation: app.getepok.dev caps request bodies at 2 MB on the wire, and an uncompressed map for any non-trivial bundle clears that on its own. Source maps are JSON and compress roughly ten to one, so Content-Encoding: gzip is the thing keeping a normal map under the cap. Uploading needs a key with ingest scope, which your build pipeline already holds.
RELEASE="$(git rev-parse --short HEAD)" # the SAME id your browser build reports
for map in dist/assets/*.js.map; do
# The URL the BROWSER loads this bundle from — not the path on disk.
bundle="https://cdn.example.com/assets/$(basename "${map%.map}")"
jq -n --arg release "$RELEASE" --arg url "$bundle" --slurpfile map "$map" \
'{release: $release, url: $url, map: $map[0]}' \
| gzip \
| curl -sSf -X POST \
"https://app.getepok.dev/api/v1/tenants/$EPOK_TENANT_ID/sourcemaps" \
-H "Authorization: Bearer $EPOK_INGEST_KEY" \
-H 'Content-Type: application/json' \
-H 'Content-Encoding: gzip' \
--data-binary @-
done
# each call -> {"status":"ok","release":"9f3c1ab","url":"https://…","bytes":214893}The map is parsed before it is stored, so a truncated or malformed map is rejected at build time with a 400 rather than discovered during an incident. bytes in the response is the stored (compressed) size. Re-uploading the same release and URL replaces the previous map.
EPOK_TENANT_ID is the numeric account id your key belongs to, and it has to match the key — the path and the credential are checked against each other. That is a helpful constraint rather than a guessing game: get it wrong and you get a 403 reading API key does not match tenant, immediately and unambiguously, so a single upload attempt tells you whether the value is right.
Do not point this at ingest.getepok.dev. Source maps are app-plane endpoints — the host is app.getepok.dev, and the same key can behave differently across the two planes (see Authentication).
See what CI actually uploaded
This is the fastest way to check constraint 1 and 2 — the stored url and release are right there to compare against what the browser reports. The listing returns the 500 most recent uploads, newest first. Reading it needs a key with read scope: an ingest-only CI key uploads fine and then gets a 403 here, which is expected, not a bug.
curl -s "https://app.getepok.dev/api/v1/tenants/$EPOK_TENANT_ID/sourcemaps" \
-H "Authorization: Bearer $EPOK_READ_KEY" | jq .
# {
# "sourcemaps": [
# {
# "release": "9f3c1ab",
# "url": "https://cdn.example.com/assets/app.min.js",
# "byte_size": 214893,
# "created_at": "2026-07-20T11:04:18.221084+00:00"
# }
# ]
# }Resolve a frame end to end
The resolve endpoint symbolicates minified frames on demand — up to 100 per call. Send the release, and frames of {url, line, column} with line 1-based and column 0-based. Send an empty release to resolve against the newest map for each URL. This call is how you prove the whole chain works before an incident makes you care.
curl -s -X POST \
"https://app.getepok.dev/api/v1/tenants/$EPOK_TENANT_ID/sourcemaps/resolve" \
-H "Authorization: Bearer $EPOK_READ_KEY" \
-H 'Content-Type: application/json' \
-d '{
"release": "9f3c1ab",
"frames": [
{"url": "https://cdn.example.com/assets/app.min.js",
"line": 1, "column": 74213}
]
}'Resolved frames come back with their original fields intact plus original_source, original_line, original_column, and — when the map records one — original_name. resolved counts how many frames were symbolicated, which is the number to assert on in a smoke test.
{
"frames": [
{
"url": "https://cdn.example.com/assets/app.min.js",
"line": 1,
"column": 74213,
"original_source": "src/checkout/PaymentForm.tsx",
"original_line": 128,
"original_column": 22,
"original_name": "submitPayment"
}
],
"resolved": 1
}Verify it's working
- Trigger a frontend error on the deployed build.
- Open RUM in the app and find it under Recent frontend errors — errors show up within seconds of the browser exporting them.
- Click stack on the error row. The frames resolve on expand — the first expansion against a large map pays for parsing it, and the parsed map is then cached, so later expansions of the same bundle are quick. Resolved frames render as
at submitPayment src/checkout/PaymentForm.tsx:128with a ● mapped marker. Unmapped frames keep their raw minified line.
Because resolution happens at expand time and not at ingest, uploading a map during an incident retroactively fixes stacks that were captured before it existed. There is no reindex to wait for.
Branch A — the upload itself is rejected. A 401 means no key was parsed from the request at all; a 403 means the key was read but is wrong for this call — either it lacks ingest scope, or it belongs to a different tenant than the tenant_id in the path. Check the scheme you sent against the per-plane table in Authentication and remember these are app-plane endpoints on app.getepok.dev, not ingest. A 413 is two different limits sharing one status code, and the first is the one you will actually meet: 2 MB on the wire, enforced at the edge on whatever bytes you send — measured after gzip, which is why the snippet gzips. Separately, the app refuses a map over 24 MB decompressed. So a 30 MB map that gzips to 1.5 MB gets past the edge and is then rejected by the app — split the bundle. If you are not compressing at all, it is the 2 MB wire cap, and gzip alone fixes it. A 400 means the body was missing one of release, url, map; or the map did not parse; or release / url was empty or absurdly long (the caps are 200 and 2,000 characters).
Branch B — upload accepted, frames still minified. This is the common one, and the four constraints above are the diagnostic sequence, in order. (1) Compare the url in the listing against the URL in the actual stack frame — a mismatch here explains most cases, and it is usually a build path where a CDN URL should be. (2) Only if you are calling resolve with an explicit release: compare it against the listing, exact string. If you are looking at the in-app view, release is not your problem — it sends none. (3) If frames resolve to the wrong place rather than not at all, suspect the 1-based line / 0-based column convention, or a stale map winning the newest-per-URL fallback. (4) If some frames resolve and others do not, that is probably correct behaviour — vendor and framework bundles ship no maps and are meant to pass through. Confirm with the resolved count from a direct resolve call.
One capacity note that matters to busy pipelines: a tenant holds up to 1,000 maps. At the cap, uploading does not fail — the oldest releases are evicted whole (all-or-nothing per release, so no release is left half symbolicated) down to a watermark below the cap, which leaves room for a full new release without re-evicting mid-deploy. Old releases lose symbolication silently rather than new deploys being rejected. That is the right trade during an incident, but it does mean stacks from a months-old release may no longer resolve.
What you do not need
- No separate source map key or token. The ingest key your build already uses for telemetry uploads maps too.
- No bundler plugin. There is nothing to install into webpack, Vite, Rollup, or esbuild. It is an HTTP POST from a shell script — which means it works identically in any CI, and you can read exactly what it does.
- No public map hosting. You do not have to deploy
.js.mapfiles next to your bundles or leave asourceMappingURLpointing at them. Upload from CI and keep the maps off your CDN.
Next
- Browser RUM & session replay — the guide this one completes: get errors reporting first, then symbolicate them.
- Traces (APM) — the backend half. A frontend error and the backend span that caused it share a trace id.
- Authentication — which credential schemes each plane accepts, and 401 triage.
- API reference — the rest of the app-plane endpoints.