Last updated on
Adding Bugsink Error Monitoring (and the Three Bugs I Found Testing It)
Filed under Operations
Until recently, this site had no error monitoring. When something broke in production (a failed request, an unhandled exception in a Server Component, a bad Payload hook), I found out anecdotally, or not at all. No stack traces, no error rates, no way to connect a user-visible failure to a deploy. For a site with real (if modest) traffic, that's not a great place to be.
The fix seemed obvious: add Sentry. It has the best Next.js SDK of any error-tracking tool, instruments client, server, and edge runtimes automatically, and source-maps stack traces at build time. I got as far as reading the pricing page before reconsidering.
The bottleneck was pricing, not the SDK
Sentry's SDK is genuinely excellent. Sentry's pricing model is built around teams with real event volume and multiple seats, which is not what a personal blog needs. For a project where "a lot of errors" might mean a few dozen a month, paying for headroom you'll never approach is money spent on nothing.
The alternative I landed on is Bugsink, a self-hostable error tracker that implements Sentry's own ingestion protocol. That last part is the whole pitch: the @sentry/nextjs SDK doesn't know or care where its DSN points. Point it at Bugsink instead of sentry.io, and every client, server, and edge integration Sentry ships works unchanged. It's the same package and the same API, so there's no separate integration to learn.
Bugsink's free tier covers 15K events a month, and cheap paid tiers scale from there. If I ever want to leave hosted services behind entirely, a €5/month VPS handles over a million events a day self-hosted. For this project, that's the right shape of solution: pay for what you use, and there's a next step if you outgrow the free tier that isn't "start paying per seat."
Wiring it up
The integration itself is standard @sentry/nextjs: an instrumentation-client.ts for the browser (Turbopack, which this app always builds with, dropped support for the older sentry.client.config.ts file), server and edge config loaded from instrumentation.ts, and a global-error.tsx as the last-resort boundary for anything that escapes a route's own error handling. A beforeSend hook strips cookies, auth headers, and anything that looks like a password or token before an event leaves the app. That's defense in depth on top of Bugsink not being a public-facing service. Everything only initializes in production builds with a DSN configured, so local development never reports.
None of that is Bugsink-specific. It's the same setup you'd write for Sentry itself. The interesting part, the part worth writing about, is what happened when I tried to verify it worked.
The results: three ways it was silently broken
It's tempting to treat "the build succeeds and the code typechecks" as proof that error monitoring works. It isn't. I deployed the integration, then deliberately threw test errors from a route handler, a Server Component, and a client component to confirm each one reported. Two out of three didn't.
The browser was blocking its own error reports
This site enforces a fairly strict Content Security Policy. That policy is a leftover from an earlier security hardening pass, not something added for this project. connect-src only allowed the site's own origin and the CDN that media is served from. Bugsink's ingest endpoint wasn't on that list, so the moment the client-side SDK tried to send an event, the browser blocked the request outright with a CSP violation. The SDK initialized correctly, built the event correctly, and then had its own outgoing request refused by the site's own security headers.
This is the kind of bug that's invisible unless you're specifically watching the browser console during a real error: the app doesn't crash, nothing looks wrong on the surface, and every server-side integration works fine because CSP only governs the browser. The fix was a one-line addition to the policy's connect-src directive.
Route handler errors were getting lost mid-flight
This one took longer to track down. A Server Component that threw reported to Bugsink correctly. A route handler that threw the exact same way, using the exact same instrumentation hook, didn't. Not intermittently. Every single time.
The cause is a known, if obscure, class of bug in serverless error reporting: Vercel freezes a function's runtime the instant its HTTP response is sent. Sentry's event queue is asynchronous: the function that reports an error returns immediately and sends the actual network request in the background. Normally Sentry's own build-time instrumentation wraps the response object to guarantee that background send finishes before the function is allowed to return. That instrumentation is webpack-based, though, and this app builds exclusively with Turbopack, so the SDK explicitly skips that particular optimization. It falls back to relying on Next.js's own error-reporting hook alone.
A Server Component render has enough incidental work happening after an error (building the fallback UI, finishing the response stream) that the async send had time to complete before the function froze. A route handler's response, by contrast, completes almost immediately after the throw, with nothing else keeping the process alive. The fix was to explicitly await Sentry's flush call inside the error hook itself:
export async function onRequestError(
...args: Parameters<typeof Sentry.captureRequestError>
) {
Sentry.captureRequestError(...args)
await Sentry.flush(2000)
}Next.js awaits that hook before it finishes the response, so the explicit flush delays just long enough for the event to leave the building before the lambda freezes.
Source maps failed the build, for a very Bugsink-specific reason
The last one surfaced in the build logs, not the runtime. Sentry's build plugin doesn't just upload source maps: by default it also tries to create a "release" via its own API, a concept tied to versioned deploys that Sentry's dashboard groups events by. Bugsink doesn't implement that API at all; it matches source maps to events using debug IDs embedded directly in the compiled output, which sidesteps the whole concept of a release. The build plugin's release-creation call failed outright with "project not found." Separately, the actual upload step failed because the project slug I'd configured matched my Bugsink account's subdomain, not the project's own slug, which turned out to be a different string entirely.
Both were one-line fixes: disable release creation in the Sentry Next.js config (release: { create: false }), and use the project's actual slug rather than assuming it matched the account name.
What Bugsink gets you, and where the seams show
The honest comparison: Bugsink is not a drop-in Sentry replacement so much as a compatible subset. The Sentry protocol is well documented, and Bugsink implements the core of it faithfully: event ingestion, source maps (with a version caveat, since it documents known issues with sentry-cli 3.0.0 and later, so pin accordingly), issue grouping, a real dashboard. What it doesn't implement are the parts of Sentry's API surface tied to Sentry's own product model. Releases are the one I ran into. If a tool or a piece of documentation assumes releases exist, expect friction.
In exchange, you get a different pricing shape: no per-seat cost, tiers that scale with actual event volume instead of headcount, and a real self-hosting option if you ever want to stop depending on someone else's infrastructure entirely. For a project sized like this one, that trade is an easy one to make. For a team that leans on Sentry's release tracking, deployment integrations, or performance monitoring product, it's a much closer call. Check what you actually use before assuming compatibility covers it.
The actual lesson
None of these three bugs would have shown up in code review. They wouldn't have shown up in a typecheck, a lint pass, or a successful build. Every one of them only became visible by doing the thing that feels almost embarrassingly obvious in hindsight: deliberately breaking the thing, in the actual deployed environment, and checking whether the error monitoring tool caught it.
"It compiles and deploys" and "it works" are different claims, and the gap between them is exactly where integrations like this one hide their failures, quietly, without an error of their own, because the error-reporting tool itself is what's broken. If you're standing up error monitoring for the first time, budget time to trigger a failure on every code path it's supposed to cover, in the real environment it'll run in, and go look at the dashboard. Anything less means assuming the monitoring system itself has no bugs, which isn't a safe assumption for any piece of software.
Comments
No comments yet. Be the first to comment.