DEV Community

Cover image for Build vs buy exception monitoring, tested with a 36-line DIY ingest
Amorizz
Amorizz

Posted on

Build vs buy exception monitoring, tested with a 36-line DIY ingest

I almost wrote my own error tracker once. Not as a product, just a small endpoint so a side project could stop paying for a plan it barely used. This week I finally built that small version, pointed the official Sentry SDK at it, and threw errors at it until it showed me where the real work is.

(Housekeeping: this was meant to go out on Sunday. The box I write and test these posts on spent the weekend stuck at boot. Fitting, for a post about running your own infrastructure.)

Everything below ran on 2026-10-20 on a Linux box with Node 20.19.2 and @sentry/node 11.8.0. The "buy" numbers come from public pages I read the same day: Sentry's pricing page, the self-hosted Sentry docs and its docker-compose.yml at release 26.10.0, and GlitchTip's install docs. Prices and requirements move, so check them again before you decide anything.

TL;DR

  • An ingest that accepts real Sentry SDK events, groups them and writes them to disk fits in 36 lines of Node. Getting events in is the small part.
  • My DIY version showed three problems in a few seconds of testing: grouping by message turned 3 bugs into 32 issues, a 502-error burst delivered 63 events, and 7 errors thrown while it was down never arrived.
  • If you buy instead, the choice is SaaS (Sentry Team from $26/mo with 50K errors), heavy self-hosting (self-hosted Sentry asks for 4 cores and 16 GB RAM plus 16 GB swap) or light self-hosting (GlitchTip runs on Postgres plus one service).

The mental model: an error tracker is four jobs

An error tracker does four jobs. Ingest takes events off the wire. Grouping turns thousands of events into the dozen bugs they really are. Storage keeps them long enough to be useful and short enough to fit on disk. Alerting tells a human, once, at the right moment.

The SDK side is already solved for you. The official Sentry SDKs send events as "envelopes" over HTTP to whatever host the DSN points at, so any server that reads that format can receive them. That's why several trackers accept the official SDKs, and it's also why "just build it" looks cheap. Ingest is the first job and the smallest one.

The build option: 36 lines that accept real SDK events

This is the whole thing. It checks the DSN key, unzips the envelope, keeps items that carry an exception, and groups them by a fingerprint built from the error type plus the innermost three in-app stack frames.

// catcher.js: the "build" option. Accepts Sentry SDK envelopes, groups errors, keeps them in a file.
const http = require('http'), zlib = require('zlib'), fs = require('fs'), crypto = require('crypto');
const KEY = process.env.KEY || 'devkey', OUT = process.env.OUT || 'events.jsonl';
const issues = new Map(); // fingerprint -> { title, count, first, last }

function fingerprint(ex) {
  const frames = (ex.stacktrace && ex.stacktrace.frames) || [];
  const inApp = frames.filter(f => f.in_app).slice(-3); // innermost 3 app frames
  const key = ex.type + '|' + inApp.map(f => `${f.module || f.filename}:${f.function}`).join('>');
  return crypto.createHash('sha1').update(key).digest('hex').slice(0, 12);
}

http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/issues') {
    return res.end(JSON.stringify([...issues.values()].sort((a, b) => b.count - a.count), null, 1));
  }
  const auth = (req.headers['x-sentry-auth'] || '') + req.url;
  if (!auth.includes(`sentry_key=${KEY}`)) { res.statusCode = 401; return res.end(); }
  const chunks = [];
  req.on('data', c => chunks.push(c));
  req.on('end', () => {
    let buf = Buffer.concat(chunks);
    if (req.headers['content-encoding'] === 'gzip') buf = zlib.gunzipSync(buf);
    for (const line of buf.toString().split('\n')) {
      let item; try { item = JSON.parse(line); } catch { continue; }
      if (item.discarded_events) console.log('client report:', JSON.stringify(item.discarded_events));
      if (!item.exception) continue;
      const ex = item.exception.values.at(-1);
      const fp = fingerprint(ex), now = new Date().toISOString();
      const i = issues.get(fp) || { fp, title: `${ex.type}: ${ex.value}`, count: 0, first: now };
      i.count++; i.last = now; issues.set(fp, i);
      fs.appendFileSync(OUT, JSON.stringify(item) + '\n');
    }
    res.end('{}');
  });
}).listen(9999, '127.0.0.1', () => console.log('catcher on :9999'));
Enter fullscreen mode Exit fullscreen mode

The client report line was not in my first version. I'll get to why I added it.

The app is the official SDK with the DSN pointed at localhost, and three kinds of errors a real app throws: a "not found" with an id in the message, a TypeError from a missing field, and a JSON parse error.

// app.js
const Sentry = require('@sentry/node');
Sentry.init({ dsn: 'http://devkey@127.0.0.1:9999/1', tracesSampleRate: 0,
  release: 'demo@1.0.0', debug: !!process.env.DEBUG });

function loadUser(id) { throw new Error(`User ${id} not found`); }
function renderInvoice(inv) { return inv.total.toFixed(2); } // TypeError when total is missing
function parseConfig(s) { return JSON.parse(s); }

const N = Number(process.env.N || 30);
for (let i = 0; i < N; i++) {
  try { loadUser(1000 + i); } catch (e) { Sentry.captureException(e); }
}
try { renderInvoice({}); } catch (e) { Sentry.captureException(e); }
try { parseConfig('{bad'); } catch (e) { Sentry.captureException(e); }

Sentry.flush(5000).then(ok => console.log('flush ok:', ok, 'sent', N + 2));
Enter fullscreen mode Exit fullscreen mode

To run it:

npm i @sentry/node@11.8.0
node catcher.js &
node app.js
curl -s localhost:9999/issues
Enter fullscreen mode Exit fullscreen mode

Test 1: grouping is the actual product

Grouping is what separates an error tracker from a log file, and it's the first thing a DIY version gets wrong. Here's the default run, 30 "not found" errors plus the two others (timestamps trimmed from the /issues output):

flush ok: true sent 32
[
 { "fp": "e5f026ad1f00", "title": "Error: User 1000 not found", "count": 30 },
 { "fp": "51a3afcc1f57", "title": "TypeError: Cannot read properties of undefined (reading 'toFixed')", "count": 1 },
 { "fp": "38027f816eae", "title": "SyntaxError: Expected property name or '}' in JSON at position 1", "count": 1 }
]
events stored: 32
bytes on disk: 131768
groups if you key on type+message: 32
Enter fullscreen mode Exit fullscreen mode

The stack fingerprint got it right: 3 bugs, 3 issues. The last line is what you get with the first idea everyone has, keying on type plus message. Every user id becomes its own "bug", and 3 problems show up as 32 rows.

My fingerprint is a compromise too, and it fails in the other direction. Rename loadUser in a refactor and the old issue looks new. Minified browser code is worse, because the function names are t and n until you apply source maps. I didn't test the browser case here, but that's why real trackers carry a source map pipeline and let you override fingerprints per project.

Test 2: 500 errors in a tight loop, 63 arrived

Bursts are where a homemade ingest loses data without telling you. Same app, N=500:

flush ok: true sent 502
events stored: 63 of 502 sent
bytes on disk: 259554
Enter fullscreen mode Exit fullscreen mode

This one isn't the catcher's fault. With DEBUG=1 the SDK explains itself:

      1 Sentry Logger [error]: Skipped sending event because buffer is full.
    439 Sentry Logger [log]: Recording outcome: "queue_overflow:error"
Enter fullscreen mode Exit fullscreen mode

The SDK transport keeps a bounded queue of in-flight requests. In @sentry/core it's DEFAULT_TRANSPORT_BUFFER_SIZE = 64 (in transports/base.js). A tight loop fills it, and new events are dropped and counted as queue_overflow. That's the right call for the SDK: your app shouldn't fall over because its error reporting got busy.

Here's the part that matters for build vs buy. After a drop, the SDK sends a client_report envelope item saying how many events it lost. My first catcher skipped every item without an exception, so it threw away the only message that said anything was missing. With the one extra line, the catcher log shows:

client report: [{"reason":"queue_overflow","category":"error","quantity":438}]
client report: [{"reason":"queue_overflow","category":"error","quantity":1}]
Enter fullscreen mode Exit fullscreen mode

63 stored plus 439 dropped is 502. A tracker that reads these reports can tell you "you lost 439 events in this burst". A tracker that doesn't shows 63 and a calm dashboard.

Size is the other surprise. Those 63 events took 259,554 bytes as JSON lines, about 4 KB each, because every event carries its stack, modules list and context. Multiply by your worst day and you have your disk and retention budget. GlitchTip's docs give their own rough figure: about 30 GB of disk for an instance taking 1 million events a month.

Test 3: the tracker went down, the errors went with it

Your error tracker's uptime becomes part of your app's uptime the day you build it. I stopped the catcher, ran the app with N=5, then started the catcher again:

flush ok: true sent 7
events stored after catcher came back: 0
Enter fullscreen mode Exit fullscreen mode

And with DEBUG=1:

     11 Sentry Logger [error]: Error while sending envelope: Error: connect ECONNREFUSED 127.0.0.1:9999
      7 Sentry Logger [log]: Recording outcome: "network_error:error"
      2 Sentry Logger [warn]: Dropping client report. Will not send outcomes (reason: network_error).
Enter fullscreen mode Exit fullscreen mode

Two things to notice. flush() resolved true even though nothing arrived, so it means "the queue drained", not "the events made it". And with the default Node transport, the 7 errors were not queued for later. I didn't try any offline or retry transport options, so I can't speak for them. With the defaults, an outage of your tracker is a hole in your history. It even drops the client report about the hole.

The worst time for that is also the most likely one: the same incident that takes down your app box takes down the tracker you put next to it.

The buy options, priced from public docs

If you'd rather not own those three problems, there are three realistic ways to buy your way out, and they cost very different things.

Option What you run Published minimum Cost model
Sentry SaaS, Team plan Nothing n/a $26/mo billed annually with 50K errors; then $0.0003625 per error from 50K to 100K
Self-hosted Sentry 53 compose services (28 with COMPOSE_PROFILES=errors-only) 4 CPU, 16 GB RAM + 16 GB swap, 20 GB disk Your box and your upgrades; FSL license
GlitchTip Postgres 14+ and one service, Valkey optional 512 MB RAM recommended, 256 MB all-in-one Your box; they ask self-hosters for a $5/user/mo donation
Build Your code Whatever you pick Your time, forever

Some detail behind those rows:

  • Sentry SaaS. The Developer plan is free for one user. On the published Team table, 100K errors a month comes to $26 + 50,000 x $0.0003625, about $44/mo. Their calculator may round differently. You get everything their platform does, and your error data lives with them.
  • Self-hosted Sentry. The docs describe it as "the Business plan without any software limitations and no paid tier", with a few exceptions. One that matters for this post: spike protection isn't available self-hosted, because it's tied to billing quotas. The install script's hard floor for the errors-only profile is 7000 MB of RAM and 2 CPUs, against 14000 MB and 4 CPUs for the full set.
  • GlitchTip. The smallest footprint of the three. It also does uptime checks and log ingestion now, which you can switch off with environment variables.

You can count the self-hosted services yourself in a minute:

curl -sL https://raw.githubusercontent.com/getsentry/self-hosted/master/docker-compose.yml -o compose.yml
python3 count-services.py
Enter fullscreen mode Exit fullscreen mode
# count-services.py (needs PyYAML)
import yaml
svcs = yaml.safe_load(open("compose.yml"))["services"]
always = [n for n, s in svcs.items() if not s.get("profiles")]
print("services defined:", len(svcs))
print("started with COMPOSE_PROFILES=errors-only:", len(always))
Enter fullscreen mode Exit fullscreen mode
services defined: 53
started with COMPOSE_PROFILES=errors-only: 28
Enter fullscreen mode Exit fullscreen mode

Those are compose service definitions, and two of them are cleanup jobs, so it isn't the same as 28 always-busy processes. It's still 28 things to keep upgraded together.

How I'd decide

Pick by what you can't give up, not by what the dashboard looks like.

  • Data can leave your servers, and you're a small team. Buy SaaS. At low volume it's probably cheaper than the hours you'd spend on any of the other rows.
  • Data has to stay on your servers, and you have a spare 16 GB box and someone who enjoys upgrades. Self-hosted Sentry, probably with the errors-only profile.
  • Data has to stay on your servers, and the box is small. A light self-hosted tracker: Postgres plus one or two containers.
  • You need ingest and a list, nothing else, and you accept the checklist below. Build.

If you build anyway: the failure checklist

  1. Fingerprint on stack frames, not messages. Ids, emails and timestamps in messages will explode your issue count. My run: 32 groups by message, 3 by stack.
  2. Read client_report items. They're the only signal that the SDK dropped events before they reached you.
  3. Send rate limits back. The SDK transport reads rate limit headers from your responses (updateRateLimits in the same transports/base.js), so your server can tell clients to back off instead of drowning.
  4. Monitor the monitor from somewhere else. Test 3 showed silent loss when the ingest is down, and flush() still returned true.
  5. Budget disk per event. About 4 KB per Node event in my test. Set retention before the disk sets it for you.
  6. Treat the DSN key as public. In a browser app anyone can read it from your bundle. The key check in my catcher stops typos, not abuse, so add origin checks and per-key rate limits.
  7. Plan for source maps if you have browser JS, or your stack fingerprints will be minified names.

Where I land

I'm building Epure, which sits in the light self-hosted row: errors only, the official Sentry SDKs via a DSN swap, a Rust binary plus Postgres 16 in two containers. It doesn't do APM, replay, profiling or logs, and not every SDK feature has a counterpart. If that row fits you, the docs show the DSN swap. The tests above ran against my throwaway catcher, not Epure, so they say nothing about it either way.

Ingest is an afternoon. Grouping, drop accounting and staying up while your app falls over are the actual job. Build only the part you're willing to be paged for, and buy or self-host the rest.

Top comments (0)