Readable stack traces
A release build does not ship with the information a readable stack needs. This page is how you hand it over, what Monitor keeps from it, and what it deliberately does not.
What actually needs this
Less than you would think on one platform, and all of it on the other.
Flutter. Dart and Flutter exceptions arrive readable with no setup at all, and that is most of what a Flutter app throws. What needs symbols is the rest: native iOS and Android crashes, which arrive as memory addresses, and release builds compiled with --obfuscate, whose names are mangled.
Next.js. All of it. A production build is minified on both sides, so without source maps every frame is a one letter function in a content hashed chunk: main-a91f3c0e.js:1:48231 on the browser side and /var/task/.next/server/chunks/[root-of-the-server]__16r6x5r._.js:19:308519 on the server.
Turn it on for the project
It is off until somebody turns it on, and that is a cost decision rather than a caution.
The switch is on the project's Monitor settings screen, under Symbolication. With it off, a new project is useful in its first minute and stores nothing beyond its events: native and minified frames still appear, marked unsymbolicated, with a line saying what turning it on would do.
Turning it off again deletes every index the project holds and tells you how many bytes that freed. That is deliberate. A setting that stopped new uploads and kept paying for the old ones would be a label rather than a lever.
The uploader
One file, no dependencies, no build step, so it can be committed into your repository and read in full by whoever inherits it.
It has two commands: upload-symbols for a Flutter build and upload-sourcemaps for a Next.js one. It runs on any Node binary, which is the point: the way a CI upload step actually breaks is a transitive dependency changing under it six months later, and a file with no dependencies cannot break that way.
Uploads authenticate with the upload token from the settings screen, not with the DSN. The DSN's public key ships inside every copy of your app, and an endpoint it could open would let anyone who unzipped the build spend your project's storage. Put the token in a CI secret or an encrypted environment variable. It is derived from the project key rather than stored, so rotating the key rotates the token.
Flutter
Two build flags and one upload step.
Build with split debug info
Without these flags the build produces no symbol file, so there is nothing to upload and the names are gone for good.
flutter build ipa --release \
--split-debug-info=build/symbols \
--obfuscateoptions.beforeSend = (event, hint) {
final error = event.throwable;
// What breaks under --obfuscate. It does not throw: the runtime type is
// a mangled name now, so the comparison is simply false and the filter
// stops filtering without saying anything.
// if (error.runtimeType.toString() == 'MyNetworkException') return null;
// What survives it, because type identity does.
if (error is MyNetworkException) return null;
return event;
};Upload after the build
In CI, after the build step, once build/symbols and the Xcode archive exist. It walks those paths for *.dSYM bundles, *.symbols files and mapping.txt, gzips each one and uploads it, and exits non zero if any upload failed, because a build that quietly failed to upload its symbols is an unreadable crash three weeks later. --dry-run lists what it found and uploads nothing.
node tools/unrealops.mjs upload-symbols \
--dsn "$UNREALOPS_DSN" \
--token "$UNREALOPS_UPLOAD_TOKEN" \
--release "$APP_VERSION" \
build/symbols build/ios/archiveNext.js
One config line, one build command, and a switch for the server half.
Emit browser source maps
Server maps come out of next build without asking. Browser ones do not exist at all until this is set, so a build without it has nothing to upload for the half of the stack your users actually hit.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Without this the browser half of the build emits no maps at all, so
// there is nothing to upload for the half your users actually hit. The
// server half emits them either way.
productionBrowserSourceMaps: true,
};
export default nextConfig;Upload from the build
On Vercel this is the whole of it, run after next build in the same step, because Vercel's build container is the only place the maps exist. It is one line because the Build Command field is one line, and it carries no secret of its own: both credentials come from project environment variables.
next build && node tools/unrealops.mjs upload-sourcemaps --dsn "$UNREALOPS_DSN" --token "$UNREALOPS_UPLOAD_TOKEN" --release "$VERCEL_GIT_COMMIT_SHA" --delete-after-upload .nextAnywhere that is not Vercel, the same thing:
node tools/unrealops.mjs upload-sourcemaps \
--dsn "$UNREALOPS_DSN" \
--token "$UNREALOPS_UPLOAD_TOKEN" \
--release "$VERCEL_GIT_COMMIT_SHA" \
--delete-after-upload \
.nextPoint it at .next, the build root, rather than at one half of it. Which halves are uploaded is a per project setting rather than an argument, so the command stays the same on every project and turning the server half on later changes nothing in your repository. The uploader asks the endpoint once, before it walks anything, and skips .next/server entirely when the answer is no.
Uploads run eight at a time, because the cost of one is almost entirely the round trip rather than the bytes and a build emits hundreds: the same 253 map upload takes 165 seconds one at a time and 18 seconds at the default. --concurrency tunes it, or sets it back to one.
Decide about the server half
A second switch under the first one, off by default. The browser half covers what your users hit in the page; this covers what throws in a server component, a route handler or middleware, which arrives as /var/task/.next/server/chunks/[root-of-the-server]__16r6x5r._.js:19:308519 and stays that way without it.
It is off by default because it spends your own pooled symbol allowance rather than ours, not because it is harder. Measured on a real Next.js 16 build, the browser half stores about 0.8 MB a release and the server half about 2.2 MB. A site whose server side is a few route handlers gets little from it; a site whose errors happen in server components gets everything. Turning it on takes effect on the next deploy, with nothing to change in the repository.
What is stored is an address lookup, not your source
The uploaded artifact is parsed once and then discarded.
A dSYM is DWARF describing types, declarations, locations and strings for every function in your app and the whole Flutter engine. Symbolication asks one question, which is what function, file and line an address belongs to, so the answer to that is extracted and the file is thrown away. Measured on a real Flutter build, 137.7 MB of dSYMs becomes 1.70 MB stored.
A source map is the same trade with a second reason. sourcesContent is the entire original text of every module the bundle covers, so storing a map as it arrives would mean storing a copy of your application. It is dropped during extraction, which makes it a privacy property before it is a storage one: your source code is not in the database at all.
What stays unresolved, stated here rather than discovered later: an address inside an inlined callee reports the function it was inlined into; functions described only by a range list rather than a low and high address get no name; a frame inside an R8 outlined method keeps the outline's own line number; and a resolved JavaScript frame has a file and a line but no surrounding source text, because that text was the part that was dropped. Each of those loses detail on a frame. None of them loses an event.
Symbols are resolved when you open the issue
Not at ingest, and that is what makes a late upload still work.
CI routinely uploads a build's symbols minutes after that build's first crash has already been reported. A crash symbolicated at ingest would stay hex forever. Resolving on read means that the moment a build's symbols land, every crash that ever arrived from that build becomes readable, with nothing to reprocess and nothing to click.
Every frame is marked symbolicated, unsymbolicated or symbols missing, and a missing one names the artifact it wanted, so the dashboard says which dSYM to upload for which build instead of showing you an address and letting you assume that was the best anyone could do.
How a frame finds its map, and why the release matters
Two mechanisms, because the two builds people actually run produce different evidence.
A build with a Sentry bundler plugin injects a debug id into every bundle and the event names it. That is the better mechanism: it survives a rename and it cannot match the wrong build. A plain next build injects nothing, so those frames are matched on the path of the generated file, scoped to the release. A frame from release 4 is read against release 4's map even when release 5 shipped a chunk with the same name, and only when nothing matches the release does the newest upload answer.
Which is why the release your events carry has to be the release your maps were uploaded under. Uploading without --release is allowed and the uploader warns about it: two builds that name a chunk the same way then become indistinguishable, and an artifact with no release is the first thing deleted when storage runs short, because nothing can tell which build it belongs to.
Storage, and what happens when it fills
The newest release always wins.
Symbol storage is pooled across your whole organization rather than divided per project: 5 MB on Developer, 15 MB on Solo, 50 MB on Studio. Measured against real builds, that is roughly one release, three releases and ten releases of a full stack application.
When a new release's symbols arrive and there is no room, the oldest release's indexes are deleted, and then the next oldest, until the new one fits. Losing symbolication for last month's release is a normal consequence of a small plan; losing it for the build currently in production is the product not working. So what a smaller plan buys is fewer releases of history, not less of the product.
- Artifacts uploaded with no release at all are evicted first, because nothing can promise they belong to the build in production.
- After those, releases go oldest first, and the release being uploaded is never evicted, so file eight of a build cannot eat file one.
- An upload is refused only when a single release is larger than the whole allowance, before anything is deleted. The message names both numbers, so the arithmetic is on screen rather than in your head.
- Every upload response names the releases it evicted, so the fact lands in your CI log rather than on a screen nobody opens.
- Indexes are also swept after 90 days without a crash on their build, whether or not anything needs the room.
There are limits on one upload as well as on the total: an artifact is read and parsed up to 192 MiB uncompressed, a single source map up to 64 MiB, and extraction carries a 20 second budget. Each of those refuses rather than truncating, because a half read symbol file answers some addresses correctly and misleads on the rest.
The usage screen shows what this project holds, what the organization holds, and how many releases that is. If an upload was refused or a release quietly stopped resolving, the troubleshooting page has the symptom.