Log what your Sentry SDK actually sends before you leave SaaS
I was about to tell someone "just swap the DSN" when I realized I couldn't say what the DSN actually receives. So I put a tap behind the DSN of a small Express app and sent it 100 requests. The SDK shipped 546 KB, and th
I was about to tell someone "just swap the DSN" when I realized I couldn't say what the DSN actually receives. So I put a tap behind the DSN of a small Express app and sent it 100 requests. The SDK shipped 546 KB, and the one error event in there was 7.6 KB of it.
Last week I pointed the official SDK at a 36-line homemade ingest to see what breaks. This week is the other half of that question: if I point my DSN at something that only does errors, what do I lose?
This post is the tap, the numbers, and the init I'd use if I only wanted errors. It works the same whether you're moving to self-hosted Sentry, GlitchTip, something you wrote, or just trying to cut your SaaS bill without moving at all.
Everything below ran on 2026-10-25 with Node 20.19.2, @sentry/node 11.9.0 and Express 4.22.3. SDK defaults change between majors, so run the tap against your own version before you trust my table.
TL;DR
- The official SDK sends several kinds of data to the DSN, not just errors. In my "everything on" setup, 100 requests produced 305 spans, 100 log records, 90 metric records, 2 cron check-ins and 1 error event. Spans were 81% of the bytes.
- An errors-only init (no
tracesSampleRateline, drop logs and metrics inbeforeSendLog/beforeSendMetric) took the same traffic from 546,514 bytes on the wire to 9,111. About 60x less. - Three things surprised me:
tracesSampleRate: 0still counted 512 spans, 10 failed checkouts showed up as 1 event, and every error carried the raw request body.
The mental model: the DSN gets envelopes, envelopes hold typed items
Every SDK request to your DSN is an "envelope": one header line, then pairs of lines (item header, item payload). The item header has a type. That type is what decides which product feature the data feeds on the SaaS side.
| Item type | What it feeds in SaaS Sentry | If your new host only does errors |
|---|---|---|
event |
Issues: the errors themselves | Kept. This is the part every errors-only tool does |
span |
Tracing and performance views | Gone. Send them nowhere, or to a tracing backend |
log |
Logs | Gone, unless your target has a logs product |
trace_metric |
Metrics | Gone |
check_in |
Crons monitors ("the nightly job didn't run") | Check your target. If it ignores them, you lose missed-job alerts |
sessions |
Release health, crash-free rates | Check your target |
client_report |
Counts of what the SDK dropped and why | Worth reading even if nothing shows it |
So "what do I keep" has a mechanical answer. Run your app against something that logs item types, and look at the column on the right.
The tap: 30 lines that log every item type
This is a fake DSN host. It accepts anything, unzips gzip bodies, and writes one JSON line per item with its type, its size and a few keys. It stores nothing else.
// tap.js: a fake DSN host. Logs one line per envelope item: type, size, a few keys.
const http = require('http'), zlib = require('zlib'), fs = require('fs');
const OUT = process.env.OUT || 'tap.jsonl';
let envelopes = 0;
http.createServer((req, res) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => {
let buf = Buffer.concat(chunks);
const wire = buf.length, env = ++envelopes;
if (req.headers['content-encoding'] === 'gzip') buf = zlib.gunzipSync(buf);
const lines = buf.toString().split('\n');
// envelope = header line, then pairs of (item header, item payload)
for (let i = 1; i < lines.length && lines[i]; i += 2) {
const head = JSON.parse(lines[i]);
const body = lines[i + 1] || '';
let payload = {}; try { payload = JSON.parse(body); } catch {}
if (head.type === 'event') fs.appendFileSync('events-full.jsonl', body + '\n');
fs.appendFileSync(OUT, JSON.stringify({
type: head.type, bytes: Buffer.byteLength(body), env, wire,
items: payload.items ? payload.items.length : undefined,
keys: Object.keys(payload).sort(),
reason: payload.discarded_events,
}) + '\n');
}
res.end('{}');
});
}).listen(9999, '127.0.0.1', () => console.log('tap on :9999'));
It also keeps full error events in events-full.jsonl, because I wanted to see what's inside them. More on that below.
A 17-line summary.js groups those lines by type, adds up bytes, sums the wire size per envelope and prints any client report reasons. Nothing clever: count, sum, sort.
To point any app at it, change the DSN host and keep the key and project id shape:
SENTRY_DSN=http://devkey@127.0.0.1:9999/1
The key doesn't matter, the tap never checks it. Don't run this on anything but localhost.
The app: a small Express API with everything turned on
The app has two routes. /products writes a log line and bumps a counter. /checkout throws a TypeError when the client forgets the coupon. There's also a nightly job wrapped in a cron monitor. The Sentry config is picked from a profile so I could rerun the same traffic with different inits:
const Sentry = require('@sentry/node');
const configs = {
saas: { tracesSampleRate: 1.0 },
errors: {
// errors-only: no tracesSampleRate at all, drop logs and metrics before they leave
beforeSendLog: () => null,
beforeSendMetric: () => null,
},
};
Sentry.init({ dsn: 'http://[email protected]:9999/1', release: '[email protected]', environment: 'lab',
...configs[process.env.PROFILE || 'saas'] });
const express = require('express');
const app = express();
app.use(express.json());
app.get('/products', (req, res) => {
Sentry.logger.info('listed products', { count: 3 });
Sentry.metrics.count('products.listed', 1);
res.json([{ id: 1 }, { id: 2 }, { id: 3 }]);
});
app.post('/checkout', (req, res) => {
Sentry.logger.info('checkout started', { items: req.body.items.length });
const code = req.body.coupon.code; // TypeError when the client forgets the coupon
res.json({ ok: true, code });
});
Sentry.setupExpressErrorHandler(app);
app.use((err, req, res, next) => res.status(500).json({ error: 'internal' }));
app.post('/admin/nightly', async (req, res) => {
await Sentry.withMonitor('nightly-invoices', async () => { /* work */ },
{ schedule: { type: 'crontab', value: '0 3 * * *' } });
res.json({ ok: true });
});
The load comes from a separate process: 100 sequential requests, every 10th one a checkout with a cookie and a JSON body that includes card_last4, then one call to the nightly job. Then the app flushes and exits. Running it in a separate process matters. My first version fired the requests from inside the app process, and the client's own fetch breadcrumbs piled up into every error event, which made the events look much bigger than they are.
npm i @sentry/[email protected] [email protected]
node tap.js &
PROFILE=saas node app.js &
node load.js
node summary.js tap.jsonl
Result 1: the "everything on" init sent 546 KB, 1.2% of it errors
With tracesSampleRate: 1.0 and the default integrations, here's what the tap saw:
statuses: {"200":90,"500":10}
type items bytes share
span 61 503499 80.9% (305 records inside)
log 1 62418 10.0% (100 records inside)
trace_metric 1 47980 7.7% (90 records inside)
event 1 7600 1.2%
check_in 2 661 0.1%
client_report 1 110 0.0%
total 67 622268
envelopes: 67, bytes on the wire (gzip where the SDK used it): 546514
client_report: [{"reason":"event_processor","category":"error","quantity":9}]
Two things I didn't expect. First, I never turned logs or metrics on. Calling Sentry.logger.info and Sentry.metrics.count was enough for 11.9.0 to batch and send them. Second, spans dominate everything else, at roughly 1.6 KB each, about 3 per request.
If you leave SaaS for an errors-only host and keep this init, all of that still leaves your servers. Where it ends up depends on the host. The best case is that it's thrown away on arrival. Either way you pay for the CPU and bandwidth to build and send it.
Result 2: the errors-only init sent 9.1 KB
Same traffic, same app, with the errors profile:
type items bytes share
event 1 7234 88.2%
check_in 2 590 7.2%
client_report 1 236 2.9%
sessions 1 145 1.8%
total 5 8205
envelopes: 5, bytes on the wire (gzip where the SDK used it): 9111
client_report: [{"reason":"before_send","category":"log_item","quantity":100},{"reason":"before_send","category":"trace_metric","quantity":90},{"reason":"event_processor","category":"error","quantity":9}]
546,514 bytes down to 9,111. The init change is three things: delete the tracesSampleRate line, and return null from beforeSendLog and beforeSendMetric. The client report confirms it, with 100 log items and 90 metrics dropped by before_send, on purpose.
Notice what's still there: the two check_in items and a sessions item. Those aren't errors. If the host you're moving to doesn't handle cron check-ins, your "nightly job didn't run" alert dies quietly on migration day. That's the row I'd check first. (The saas run didn't send a sessions item at all and the errors runs did. I didn't dig into why, so I'm only reporting it.)
Gotcha: tracesSampleRate: 0 is not the same as deleting it
I ran a third profile, errors-only plus tracesSampleRate: 0. No spans hit the wire, so it looks the same. The client report says otherwise:
client_report: [{"reason":"sample_rate","category":"span","quantity":512},{"reason":"before_send","category":"log_item","quantity":100},...]
With the rate at 0, tracing is still configured. The SDK created spans, sampled every one of them out, and counted 512 as dropped by sample_rate for the same traffic. The wire total went from 9,111 to 9,816 bytes, mostly because that report is bigger. Small on the wire, but the span instrumentation still runs on every request. If you want tracing off, delete the line instead of setting it to 0.
Gotcha: 10 failed checkouts, 1 event
All three runs above stored 1 error event for 10 failing requests. The client report explains it: event_processor, error, quantity 9.
That's the Dedupe integration. It drops an error when it looks the same as the previous one the SDK captured (same message and stack). My errors came every 10th request with nothing else failing in between, so 9 of 10 were dropped before they left the process. Removing it:
integrations: all => all.filter(i => i.name !== 'Dedupe')
gave 10 events and 72,289 bytes, about 7.2 KB per event.
Why this matters when you switch: people compare "errors per day" between the old and new tracker to check the migration worked. Both numbers are after the SDK's dedupe and sampling. If the counts don't match, check the client reports before you blame the new host. I keep Dedupe on, by the way. 10 identical events in a row aren't 10 bugs.
Gotcha: every error carried the request body
This is the one I'd fix even if I never migrated. Here's part of the stored error event from the errors-only run:
request.data = {"items":[1,2],"card_last4":"4242"}
request.cookies = {'session': '[Filtered]'}
user = {'ip_address': '::ffff:127.0.0.1'}
The SDK filtered the cookie value on its own. It did not filter the JSON body, and it attached the client IP. I never set sendDefaultPii. With SaaS that data sits with your vendor. Self-hosted, it sits on your disk, in your backups, for as long as your retention says. Decide on purpose:
beforeSend(event) {
if (event.request) {
delete event.request.cookies;
delete event.request.data;
delete event.request.headers?.cookie;
}
if (event.user) delete event.user.ip_address;
return event;
}
With that, the event went from 7,234 to 7,099 bytes. The stored event had only headers, method and url under request, and an empty user. Most of an error event is the exception (4,026 bytes of JSON here) and the modules list (1,588), not the request, so scrubbing costs you almost nothing when you debug.
What you keep, what you give up
From this run, the honest subset when you go errors-only:
- You keep: errors with stack traces, release and environment tags, request method and URL, breadcrumbs, and the SDK's own drop accounting in client reports if your host reads them.
- You give up: tracing, logs and metrics as products. If you used any of them, plan a replacement before the switch, not after.
- Check before switching: cron check-ins and release health sessions. They still get sent with an errors-only init.
-
Not covered here: browser Session Replay, profiling and mobile SDKs. I only tested
@sentry/node. Run the same tap against your browser bundle before you trust this list for the frontend.
Failure checklist
-
You left
tracesSampleRate: 0in. Nothing on the wire, but tracing still runs. Delete the line. -
Logs and metrics leave without an opt-in. In 11.9.0, calling
Sentry.loggerorSentry.metricswas enough. Drop them inbeforeSendLog/beforeSendMetric, or remove the calls. -
Cron monitors break silently.
withMonitorkeeps sendingcheck_initems to the DSN. Confirm your new host handles them, or move the monitor somewhere that does. - Error counts don't match after the switch. Compare client reports, not dashboards. Dedupe and sampling run before anything leaves the process.
- The tap shows bigger events than prod. If your load generator runs inside the app process, its own breadcrumbs end up in every event. Use a separate process.
-
Request bodies in your error store. The default Node setup attached the JSON body and client IP. Scrub in
beforeSendbefore you own the disk. - The tap breaks on attachments. It assumes one JSON line per item. Attachments carry a length header and binary payloads, so add parsing for them if your app sends files.
I'm building Epure, a lightweight self-hosted error tracker for small teams, and it only keeps the event rows of that first table. That's why I ran this before telling anyone to swap a DSN. The tap doesn't care where you're going, though, so run it whatever you pick.
What tracesSampleRate is in your production Sentry.init right now?
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.