Two Next.js dashboards, one Sentry org, a 30-day window. Between them they stored 13.5 million transactions and about 24,600 errors — roughly 550 traces per error. The bill tracks the first number. Every conversation about the bill starts with the second.
Then the number everyone does look at: on one dashboard an estimated 70–78% of stored errors were noise; on the other, 90–95%. A single injected third-party script accounted for 77% of a month's volume on its own. The biggest issue on the other app — 6,210 events, 46% of its volume — reported 0 users affected.
So the problem runs in two directions at once, and they aren't the same problem. The money is in traces. The signal is lost in errors. Turning the error sample rate down is the one move that fails at both.
Key Takeaways
- At this volume, the errors were free. 24,600 errors/30d sits inside the 50,000 a paid Sentry plan includes. The error cleanup is a signal project, not a cost project — and selling it as cost-saving is how it gets deprioritised.
- Never sample errors. Classify them. Sampling leaves an issue list you can't reason about: an issue with 4 events might be a rare edge case or 1% of a storm.
- One capture layer per failure. One dashboard captured the same failed request three times, the other double-counted every server render. Real incidents bill 2–3× and split across differently-grouped issues.
- Spans are the metered spend. Replace a flat
tracesSampleRatewith atracesSamplerthat prices traffic by class: health checks 0, production 0.10, key flows 0.20, staging 0.05, CI bots 0.setUser({ id })is what makes triage work. Without it "users affected" is blank or, worse, inferred from IP — which collapses a whole customer office behind one NAT into a single user.
Where the volume actually is
| Dashboard A | Dashboard B | |
|---|---|---|
| Stored errors / 30d | 13,460 | 11,124 |
| Estimated noise share | ~70–78% | ~90–95% |
| Largest single issue | 6,210 events (46%), 0 users affected | 8,573 events (77%), one injected script |
| Non-production share | 61 events from local | 10.3% from staging |
| Transactions / 30d | 13.5M combined, at 10% client sampling | |
| Replays / 14d | 0 | 0 |
One caveat before any of these get used as a baseline: these are stored events pulled from Discover queries. Anything already dropped by an inbound filter or by spike protection is invisible here, so true ingest is at least this high. Plan-level consumption wasn't readable through the API surface available, so this is a volume audit, not an invoice reconciliation.
Worth saying once so the scope is clear: this is about ingest volume. Sentry costs you in other currencies too — on this same codebase the Sentry webpack plugin accounted for 94.5 seconds of a five-minute production build, which is a real cost and an entirely separate conversation.
What that actually costs
This is where the audit's instinct and the price list disagree, so it is worth doing the arithmetic rather than asserting it.
Sentry bills each category against its own quota, and a paid plan includes 50,000 errors and 5 million spans per month. Both dashboards together produced 24,600 errors. That is half the included allowance — the error volume costs nothing at all, and would still cost nothing at twice the volume.
Per unit, errors are in fact the expensive category: on Team pay-as-you-go, an overage error runs $0.0003625 against $0.0000020 for a span, roughly 180× more. It just never bites, because the included allowances run 100:1 in the same direction and errors scale with your defect rate rather than your traffic.
Spans are the other story, and the honest answer is that the audit didn't measure the number that matters. Sentry moved tracing billing from transactions to spans for plans activated on or after 11 June 2024, and one transaction contains many spans. 13.5M transactions is therefore a floor on span volume, not the span volume:
| Spans per transaction | Spans / 30d | Overage over the included 5M |
|---|---|---|
| 1 (the floor) | 13.5M | ~$17 |
| 10 | 135M | ~$253 |
| 25 | 337.5M | ~$618 |
Illustrative, at Team-tier list rates, and the tiering means the marginal span gets cheaper as you buy more. But the shape holds: the entire question of what this org pays Sentry lives in a multiplier nobody had measured, while the meetings were about an error count that was inside the free allowance.
That isn't an argument for ignoring the errors. It is an argument for being straight about why you're fixing them.
The noise, made concrete
The noise share in that first table is an estimate — it comes from classifying top issues by hand, not from a filter that counts itself. Dashboard B is the one where it is easy to show your work:
That green sliver is the generous reading of "real". It still contains deploy-skew errors and a fetch regression worth fixing — which is the actual cost of noise. The bugs were in there the whole time, underneath eight thousand events from a script nobody shipped.
1. Never sample errors — classify them
The instinct when a quota alert fires is to turn error sampleRate down. Don't. Sampled errors give you an issue list you can't reason about: an issue with 4 events might be a rare edge case or a storm you're seeing 1% of, and there is no way to tell which from inside Sentry. You have made the cheap category cheaper and the expensive category — engineering time spent on triage — worse.
Make the lever classification instead. Expected failures should never become error-level events at all:
- 4xx responses — stale-deeplink 404s, 422 validation, permission denials
- user aborts and mid-navigation cancellations, including cancelled uploads
- offline
Failed to fetch - session-expiry churn (this was 46% of one project's volume on its own)
- third-party script failures and browser-extension frames
Where to drop them is not a billing question
I assumed, writing the audit, that filtering client-side saved quota and filtering server-side didn't. That is wrong, and the docs are unambiguous: events removed by inbound filters "do not consume quota", and neither do events rejected by a per-key rate limit, dropped by spike protection, or discarded by beforeSend. Sentry's own guide to managing the event stream orders the tactics easiest-first and puts inbound filters at the top precisely because they're free and take thirty seconds.
So choose where to filter on other grounds, and they're good ones:
- PII egress. An event dropped by
beforeSendorignoreErrorsnever leaves the browser. An inbound-filtered one was transmitted to a third party first, then discarded. For a payload containing a response body, that difference is the whole ballgame. - Expressiveness. Inbound filters match on message, URL, release, browser. Your SDK sees the
Responseobject — it knows the status code, whether the request was aborted, whether the user is offline.status >= 500isn't a filter you can express in a settings page. - Reviewability. A
denyUrlsentry arrives in a pull request. A checkbox in the Sentry UI changes what production reports with no diff, no reviewer and no history.
One denyUrls entry for browser-extension frames removed about 7,000 phantom events on Dashboard B. That is the house pattern; the remaining work is extending it and porting it to the other app.
A caution from the same audit, though. An existing suppression used the substring "Failed to load https://" to kill analytics-tag noise, which also silences every future first-party asset load failure. Pin these to a host regex, never a bare substring. A filter that is too broad is a monitoring outage that won't alert you.
2. One capture layer per failure
Dashboard A captured the same failed request up to three times: once in the fetcher, once in a page-level try/catch, once in an error boundary. Thirteen server pages had their own captureException. Dashboard B double-counted every server render error — onRequestError reports the real server event, then the client error boundary reports the redacted placeholder React hands it.
Two costs, and the second is worse. Every real incident bills 2–3×, and each capture point produces a differently grouped issue, so one defect arrives as three unrelated-looking entries with three different event counts and none of them true.
The policy is one sentence: exactly one layer captures.
- Error boundaries and
onRequestErrorown unexpected errors. - Fetchers throw typed errors and capture nothing.
- A boundary skips capture when
error.digestis present. In production Next.js replaces a server error with a generic message plus adigesthash so nothing sensitive reaches the client — which makes the digest a reliable signal that the server already reported the real one. Capturing the placeholder adds an event with strictly less information than the one you already have. - Deliberate handled captures are still allowed. They are a decision at a call site, not a default of a layer.
Dashboard A had a grouping problem worth naming separately, because it is common and invisible until you look: a fetcher set error.message = JSON.stringify({ message, responseStatus, response }). Every distinct response body becomes a near-unique fingerprint, so one failing endpoint fans out into hundreds of issues — and, with sendDefaultPii: true and no beforeSend scrubbing, it ships response-body PII in the one field Sentry won't redact for you. Response bodies belong in Sentry.setContext(), which is scrubbable. Messages stay templated.
3. Spans are the metered spend, so meter them
A flat tracesSampleRate can't express what you actually want, which is that different traffic deserves different density. tracesSampler can:
| Traffic class | Rate | Why |
|---|---|---|
Health checks (/ping), the tunnel route, prefetches | 0 | Pure noise in the trace budget |
| Production, default | 0.10 | The current effective rate — kept, but now with the junk excluded |
| Production, key business flows | 0.20 | The paths where a slow trace is worth money |
| Staging, human traffic | 0.05 | Was 1.0 on Dashboard B |
| Staging, CI and regression-bot traffic | 0 | A test suite should not bill for observability |
Sentry.init({
// tracesSampler takes precedence over tracesSampleRate, which stays as
// the fallback when the sampler returns undefined.
tracesSampler: ({ name, attributes, inheritOrSampleWith }) => {
// Infrastructure chatter is never worth a trace.
if (name.includes("/ping") || name.includes("/monitoring")) return 0;
// CI should cost nothing. Match on however your runner identifies itself.
if (String(attributes?.["user_agent.original"] ?? "").includes("Playwright")) {
return 0;
}
if (process.env.NEXT_PUBLIC_ENV !== "production") return 0.05;
if (KEY_FLOWS.some((path) => name.includes(path))) return 0.2;
// Runtime-tunable, so a spike can be throttled without a redeploy.
return inheritOrSampleWith(
Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_RATE ?? 0.1),
);
},
});
Three details that are easy to get wrong. inheritOrSampleWith respects an upstream service's sampling decision instead of silently overriding it, which is what keeps a distributed trace from arriving in pieces — it needs SDK v9 or newer. Returning 0 is a guarantee, not a probability. And tracesSampler doesn't conflict with tracesSampleRate: the sampler decides first, an inherited parent decision second, the flat rate last.
The staging row is the one that surprised people. Dashboard B sampled staging traces at 100%, so every browser-automation regression run billed every transaction it generated — a cost that scales with how good your test coverage is.
Keep the production rate readable from an environment variable rather than baked into the bundle. Dashboard A already did this, and it is the difference between throttling a spike in minutes and throttling it in a deploy cycle.
4. Keep non-production out of the production budget
Staging and local events were sharing the production project's quota and its issue list. On Dashboard B, staging was 10.3% of project volume, with staging 500s sitting in the production top-issues list. Dashboard A had 61 events arriving from local — small, but it means someone's laptop is a data source.
Three changes, in order of how much they buy you:
- Gate
enabledexplicitly for dev and local. Not "we set a different DSN" — an explicit boolean, so the default is off. - Rate-limit staging at the DSN key, or give it its own project once staging churn stops being a rounding error. Rate-limited events are rejected before quota accounting, so this is a free ceiling.
- Filter alert rules to
environment:production. Otherwise the first thing staging teaches you is to ignore Sentry alerts.
The number that makes triage work: users affected
Neither dashboard could answer "how many users does this issue affect?" — which is the number that should be deciding what gets fixed first, and the input a budget alert should eventually fire on.
Here is what was actually wired:
| Surface | Dashboard A | Dashboard B |
|---|---|---|
| Client errors | setUser({ id }) post-login, id only — works | No setUser anywhere |
Server errors (server actions, RSC, onRequestError) | No user context | No user context |
| Middleware / proxy events | No user context, though the proxy already decodes the session JWT | Same gap |
IP fallback (sendDefaultPii) | true → users counted by IP where the id is missing | false → users affected is simply blank |
| Tenant dimension | None | None; only an accidental username shipped in one capture |
The consequence is visible in the live data. Dashboard A's number-one issue — 6,210 events of middleware token-refresh churn — reported 0 users affected, because middleware events carry no user. A client-captured login issue on the same project correctly showed 81. So the issue list was sorted by a column that was blank exactly where the volume was.
Dashboard A's IP fallback is the more interesting failure, because it looks like it is working. Enabling sendDefaultPii turns on automatic IP collection, and Sentry will use that IP as the user identity when nothing better is set. In B2B traffic a whole customer office sits behind one NAT, so "users affected" quietly reports 1 for an incident spanning a dozen people. A blank field makes you go and check. A wrong number doesn't.
How it should be wired
- Identity is a stable account UUID and nothing else.
Sentry.setUser({ id })— no email, no username, no name. Put the tenant next to it as an indexed tag so an issue can be bisected into "one customer or many". That tag does more triage work than the user id does. - Client: Dashboard A keeps its post-login
setUser; Dashboard B adds the identical call at session establishment. That single call turns on users-affected for every client error on the project. - Server: set the user on the request-isolated scope at the point where the session is already being read — the session helper is the natural single place. The Next.js SDK forks an isolation scope per request, so this doesn't leak between concurrent requests. The exception is work that outlives the request: a fire-and-forget background job needs an explicit
withIsolationScope, because there is no request boundary left for the SDK to fork around. - Middleware: the refresh-failure captures should attach the user id from the JWT they're already decoding. That one change gives the largest issue on the project a real blast radius for the first time.
- Retire IP inference. Once explicit ids flow everywhere, turn the IP fallback off. Note that
sendDefaultPiiis deprecated in favour ofdataCollectionand goes away in the next major SDK version, so this is a setting you're touching anyway — do it with the ids already in place rather than after. - Verify the payoff in Sentry, not in the diff. Users-affected populated on new server-side events,
user.id:<uuid>searchable for support escalations, and crash-free-users release health finally computable — that metric needs the user set oninitialScope, which is why the charts were empty rather than wrong. It is also what lets an alert fire on users affected rather than raw event count, which is the version of the alert that survives a noise burst.
Note what step 1 costs you and what it buys. An id-only user means you can't search Sentry by email during a support escalation; you look the account up first. In exchange, an incident report carries no personal data, and "did we scrub that correctly" stops being a question you've to keep answering in beforeSend.
What "done" looks like
Targets, per project, against the 30-day baselines above:
| Today | Target | |
|---|---|---|
| Noise share of stored errors | ~75% (A) / ~90% (B) | < 10% — every top-10 issue is actionable |
| Captures per real failure | 2–3 | 1 |
| Users affected | Blank, or IP-inferred | Populated on every surface |
| Errors / 30d | 13,460 (A) / 11,124 (B) | ≤ 4,000 (A) / ≤ 1,500 (B) |
| Spans / 30d | Unmeasured; ≥ 13.5M | Measured first, then bounded |
| Budget alerting | Quota exhaustion | Metric alert at ~1.5× budget |
The span row changed while I was writing this, and it is the useful kind of correction: the original plan said "hold transactions under 10M", which is a target expressed in a unit Sentry stopped billing in. Measure the span count first. A budget in the wrong unit is worse than no budget, because it reports success.
The alert threshold is the other part I wouldn't skip. A weekly per-project event-count alert at 1.5× the budget catches a storm while it is still a storm. Quota exhaustion catches it after you've stopped collecting the data you need to diagnose it — and it always happens mid-incident, because that is when volume spikes.
The honest limits
This is a plan, not a result. The audit is real and the numbers are measured; the 4,000/1,500 error budgets are projections of what volume looks like once the noise classes are dropped, and they need a full cycle of real data before anyone treats them as a baseline.
Four caveats. Stored events aren't ingested events, so every count here is a floor. The noise percentages are hand classification of top issues, not a measured filter rate. The cost arithmetic uses Team-tier list prices as published today, against an org whose plan tier wasn't readable through the API. And org-side settings — inbound filters, data scrubbing rules, spike protection — weren't part of this audit at all, which means there is a whole layer of configuration between these apps and the invoice that nobody has read.
One thing I'm confident about regardless of how the numbers land. The two goals people treat as opposed here — spend less, see more — turned out not to be in tension, because they aren't even the same budget. Traces are where the money is. Errors are where the attention is. Every event removed by policies 1 and 2 buys back attention, and at 24,600 errors a month it buys back no money at all, which is exactly the argument to make out loud rather than dress up as savings.
The 858 events at the green end of that chart contained a real fetch regression, six days old, that nobody had looked at. It was not hidden by a hard bug. It was hidden by 8,573 events from a script that was never ours.