Sentry Performance Costs Doubled Overnight. Here's What We Found.
Our Sentry bill jumped from ~$900 to ~$2,100 in a single billing cycle with no traffic change. Here's the investigation, the culprits we found, and the sampling strategy we settled on.

We opened the Sentry billing dashboard on a Tuesday morning and saw a number we didn't expect: performance event volume up roughly 130% month-over-month, with no corresponding traffic bump. Nobody had shipped a sampling change. Nobody had onboarded a new service. And yet the projected bill had more than doubled.
This is the postmortem. What broke, how we found it, and the sampling rules we run now.
The setup before the spike
We run a mid-sized Next.js app on Vercel, a couple of Node workers on AWS ECS, and a Python service on Cloud Run. All three send errors and performance data to Sentry. Our config had been stable for about eight months:
tracesSampleRate: 0.1on the Next.js frontendtracesSampleRate: 0.2on the Node workerstracesSampleRate: 0.05on the Python service- Profiling disabled everywhere
- Session replay on, sampled at 1% of sessions and 100% on error
Monthly performance events sat around 8–10 million. Bill hovered around $900. Predictable, boring, exactly what you want from an observability line item.
Then the number moved.
Spotting the shape of the spike
The first thing we did was pull the daily event count from Sentry's stats endpoint into a quick spreadsheet. Two things jumped out:
- The spike wasn't gradual. It started on a specific day, roughly 11 days into the billing cycle.
- It wasn't uniform. Errors were flat. Session replays were flat. Transactions — performance events — were the entire delta.
That narrowed the search. Something had started producing many more transactions per user request, or many more requests were being sampled in.
We cross-referenced the date with our deploy log. Three deploys landed that day across two repos. One of them was a Next.js version bump. That turned out to matter.
The Next.js instrumentation change
We had bumped from an older Next.js minor to a newer one, and along with it, updated @sentry/nextjs. The changelog mentioned expanded automatic instrumentation for Server Actions and Route Handlers. In practice, that meant a class of internal requests we'd never traced before were now being traced.
More importantly: some of those handlers ran in loops. One in particular was a polling endpoint hit by an internal dashboard every 3 seconds. Previously untraced. Now traced, at 10% sampling. That single endpoint accounted for roughly 18% of the new transaction volume.
Culprit two: a chatty background worker
The Node workers on ECS were the second problem. A teammate had added a new job that fanned out per-tenant reconciliation checks. Each fan-out created a child span, and the parent transaction stayed open until all children resolved. In Sentry's model, that's one transaction — but the underlying HTTP calls the worker made to internal services were themselves instrumented, and each of those was being counted as a separate transaction on the receiving service.
So one job kicked off, and downstream, we got dozens of transactions per run. The job ran every minute. Do the math and it accounts for another big chunk of the delta.
This is the tradeoff nobody tells you about with distributed tracing on a per-event pricing model: instrumenting more services means you multiply your transaction count, not just enrich your traces.
Culprit three: health checks
The third one was embarrassing. Our load balancer health checks on ECS were hitting an endpoint that Sentry's Express integration was auto-instrumenting. At tracesSampleRate: 0.2 and a health check every 10 seconds across multiple tasks, that's a non-trivial baseline of pure noise. It had been that way for months — but combined with the other two, it pushed us over the plan tier and into overage territory.
The fix: a real tracesSampler
A flat tracesSampleRate is fine until it isn't. Once you have endpoints with wildly different value-per-trace, you need a function. Here's a simplified version of what we run on the Node side now:
import * as Sentry from '@sentry/node';
const HIGH_VOLUME_LOW_VALUE = new Set([
'/health',
'/healthz',
'/ready',
'/metrics',
]);
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampler: (ctx) => {
const name = ctx.transactionContext.name ?? '';
const op = ctx.transactionContext.op;
// Drop health checks and metrics scrapes entirely
if (HIGH_VOLUME_LOW_VALUE.has(name)) return 0;
// Always sample errors-in-progress (parent decided)
if (ctx.parentSampled !== undefined) return ctx.parentSampled;
// Background jobs: sample lightly, they're chatty
if (op === 'queue.process' || name.startsWith('job.')) {
return 0.02;
}
// Checkout and auth: sample heavily, they matter
if (name.startsWith('/api/checkout') || name.startsWith('/api/auth')) {
return 0.5;
}
// Everything else
return 0.1;
},
});
A few things worth calling out:
parentSampledrespect. If an upstream service already decided to sample a trace, we honor it. Otherwise you get broken traces where the frontend has a span but the backend doesn't, which is worse than no trace at all.- Hard-zero for health checks. Not
0.001. Zero. There is no debugging scenario where you need a sampled health check. - Route-based weighting. Checkout traces are worth more than a random GET on a marketing page. Price them accordingly.
What we did about the polling endpoint
For the Next.js dashboard polling case, we didn't want to blanket-drop the route — sometimes it does fail and we want to see it. So we used a Sentry beforeSendTransaction hook to drop successful transactions on that specific route while keeping failures:
Sentry.init({
// ...
beforeSendTransaction(event) {
const name = event.transaction ?? '';
const status = event.contexts?.trace?.status;
if (name === 'GET /api/internal/dashboard-poll' && status === 'ok') {
return null; // drop
}
return event;
},
});
This approach — sample in generously, then filter on the way out — costs you SDK CPU but saves you money and keeps the signal. For high-volume low-value routes it's the right shape.
What we didn't do
We considered two things and rejected them:
- Turning off performance monitoring entirely on the Python service. Tempting, because it was the smallest contributor. But we'd lose the one place where we can see database latency correlations that don't show up in logs. Kept it, just dropped the sample rate to 0.02.
- Moving to self-hosted Sentry. Every couple of years someone runs the math and it looks great on paper. Then you remember you need someone to own the Postgres, the ClickHouse, the upgrades, the retention policy, the outages. For our team size, the managed bill is cheaper than an engineer-week per quarter. If you're at a scale where performance events are in the hundreds of millions, the calculus flips.
Numbers after the change
After about ten days on the new config, we saw performance events settle at roughly 4.5 million per month — below where we started, because we cleaned up the pre-existing health check noise as part of the fix. Bill came back to the $700–$800 range. We lost nothing we actually looked at. The traces we care about — checkout, auth, background job failures — are all still there, and in some cases more useful because they're not buried under noise.
The lesson isn't "Sentry is expensive." It's that any per-event observability tool will bite you the moment your instrumentation surface grows without your sampling logic keeping up. SDK upgrades quietly expand what gets traced. New services multiply traces across boundaries. Health checks accumulate.
Where we'd start
If you're staring at a bill that grew faster than your traffic, don't start by cutting sample rates across the board. In order:
- Pull daily transaction counts and find the exact day the shape changed. Correlate with deploys and SDK bumps.
- Group transactions by route or operation and find the top ten by volume. It's almost never evenly distributed.
- Zero out health checks, metrics scrapes, and internal polling endpoints.
- Replace flat
tracesSampleRatewith atracesSamplerthat weighs routes by business value. - Use
beforeSendTransactionfor surgical drops on noisy-but-sometimes-interesting routes.
If you want a hand auditing observability spend or setting up sampling rules that survive the next SDK upgrade, that's the kind of work our team does on DevOps & cloud engagements. And if you've got a war story of your own about an observability bill that got away from you, we'd read it — we've been there.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Pulumi vs Terraform in 2026: A Migration Story We Almost Regretted
We migrated a mid-sized AWS + Vercel estate from Terraform to Pulumi, hit real walls, and rolled part of it back. Here's what actually happened and when Pulumi is worth it.

OpenTelemetry Sampling: Why Head-Based Cost Us Real Incidents
We ran head-based sampling in OpenTelemetry for a year and it burned us during two real incidents. Here's what tail sampling actually costs, what it saved, and how we'd configure it from scratch.
CloudWatch Logs to Grafana Loki: A Migration That Actually Paid Off
We moved a mid-size AWS workload off CloudWatch Logs and onto self-hosted Loki. Here's what broke, what the bill actually looked like, and when we'd tell you not to do it.
