I wired Sentry into Freebo overnight. The whole thing — new org, staging and production projects, sourcemap upload from Railway, first real error triggered in prod. Four hours from “we should have this” to a green issue page. That part was fast.
What took the actual time was writing the scrubber.
Every Sentry tutorial you find ends at the same line: paste the DSN, call Sentry.init, done. That is a totally fine setup if your app renders blog posts. It is a bad idea if your app moves money. The default event that Sentry captures for a crashed request includes the request body, the request headers, the URL, the user’s email if you set it, and the raw text of the exception. Every one of those is a place where a payments SaaS will leak a Stripe customer ID or an operator’s email into a third-party system that you now have to answer for.
So the interesting part of a real Sentry setup is not the init call. It’s the beforeSend hook.
Two orgs, two projects, one token
Before the code, the account layout. There is one Sentry organization for Freebo (freebo.ai) with two projects inside it: staging-node and production-node. Same for the web SPA. That split matters for one reason — you never want a staging error paging you at 2am, and you never want a production regression buried under noise from a broken PR preview.
The auth token I created for the sourcemap upload got named railway-web-sourcemaps. It’s not a required convention. But two years from now when the token gets rotated or an unfamiliar name shows up in an audit log, I’d rather read railway-web-sourcemaps than key-42. Naming is documentation you can’t accidentally delete.
The env vars
The API and the SPA both treat Sentry as an optional runtime dependency. If the DSN isn’t set, the SDK never even loads. That keeps @sentry/node and @sentry/react out of local dev and CI, and it means the container will still boot if a bad DSN value gets pushed.

The VITE_SENTRY_DSN on the SPA side is build-time baked. To change it you have to rebuild the web service, not restart it — a distinction that will bite you exactly once if you forget. The SENTRY_AUTH_TOKEN is a build-time secret only. It uploads sourcemaps to Sentry so exceptions in prod render readable stack traces, then the maps get deleted from dist before deploy so they’re never shipped to the browser.
The scrubber is the whole config
Here is the piece that took the most thinking. beforeSend runs on every event before it leaves your infrastructure. It’s the last chance to catch a leak.
The naive version scrubs the request body. The version I actually shipped scrubs seven surfaces:

The one people miss is the last one. When Postgres throws a unique-constraint violation, it writes the offending value into the exception message. So you get an issue titled duplicate key value violates unique constraint "users_email_key" Key (email)=(alice@example.com). That title renders as the Sentry issue title, in the Slack notification, in the daily digest email. If you only scrub event.request and event.user, the customer’s email still flies out — as the subject line of the alert.
The regex is stupidly simple. Emails: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g. Stripe IDs: /\b(cus|pi|ch|seti|sub|in|prod|price|acct|pm)_[A-Za-z0-9]+/g. Both get run over every exception message before delivery.
Error messages render as the Sentry issue title and commonly echo literal values (Postgres: Key (email)=(…)) — scrub them too.
— apps/api/src/lib/sentry.ts
The key-based scrub is more interesting. Any object property whose key is email, customer_email, phone, customer_phone, password, authorization, cookie, token, refresh_token, access_token, or secret gets replaced with [REDACTED] before the event ships. Same for notes / customer_notes — those get scrubbed and truncated to 80 chars because operators paste all kinds of PII into free-text notes fields.
Depth is capped at 4 and array length at 50 so a runaway serialize can’t lock up the process. The whole scrubber is wrapped in a try/catch that swallows errors — the last thing you want is a bug in the redactor blocking legitimate error delivery.
Sentry logs bypass beforeSend
Here is a gotcha the docs bury. beforeSend runs on events. Sentry Logs (a separate SDK feature I turned on to forward Pino error and fatal levels) go through a completely different code path — beforeSendLog. If you only scrub beforeSend, every log message is unfiltered.
Which meant duplicating the scrub logic into beforeSendLog. Not glamorous. Necessary.
Boot Sentry before anything else
The order matters. In server.ts the very first line inside buildServer is await initSentryApi() — before the Fastify plugins register, before routes get wired, before middleware runs. Any exception thrown during plugin registration is exactly the kind of thing you want captured, and it’s the kind of thing you’ll miss if you init Sentry inside a lifecycle hook that only fires after boot.
What “operator context” looks like without an email
Freebo is multi-tenant. Every request belongs to a location_id (the tenant) and a user_id (the operator user). Both are useful in Sentry to filter and search. Neither is PII — they’re internal IDs — so I attach them to the Sentry scope on every request via a preHandler hook:
server.addHook('preHandler', async (request) => {
if (request.user?.user_id) {
setSentryUser({
user_id: request.user.user_id,
location_id: request.locationId ?? null,
});
}
});
The Sentry.setUser call would normally include the email if you handed it one. I don’t. If the operator’s email needs to be looked up to reproduce an issue, that’s what our internal admin is for — Sentry gets the ID, the admin resolves it. One less place for the email to live.
What the real setup cost
Startup to first real error captured in prod: four hours. Most of that was the scrubber and the tests around it. The actual Sentry account setup, project provisioning, and Railway environment plumbing was maybe forty-five minutes.
The tutorial version of Sentry is a ten-line PR. The version I actually run in production is a 175-line lib/sentry.ts, a companion lib/sentry.ts on the web side, three env vars in each service, an auth token named for its purpose, and a boot order that captures errors thrown before Fastify is even listening.
Every “just paste the DSN” tutorial is technically correct and quietly wrong. If your app touches money, write the scrubber first. The init call is the easy part.