Bun + Elysia error monitoring with the Sentry SDK (2026)
Bun shipped 1.0 in September 2023 and spent the next two and a half years catching up on every runtime edge case Node had paved over. By mid-2026, Bun + Elysia is the stack people pick when cold start and request latency matter more than ecosystem inertia. The error-tracking story is its own thing: two SDKs, one hook with an order rule, and three failure modes that swallow exceptions in production.
20 seconds. bun add @sentry/bun or bun add @sentry/elysia. Initialize Sentry in an instrument.ts file. Import that file as the first line of your entry. With @sentry/elysia, wrap the app with Sentry.withElysia(new Elysia()) and 5xx errors capture themselves. With @sentry/bun, register an .onError() hook on the root Elysia instance before any .use() or .group() and call Sentry.captureException(error) inside it.
60 seconds. The two SDK choices split on how much magic you want. @sentry/elysia is the path of least code; it auto-registers the onError hook, captures every 5xx, and integrates Sentry tracing with Elysia's request lifecycle. @sentry/bun is the path you take when the same process also runs Bun workers, scripts, or non-Elysia HTTP code, because you keep one SDK across the whole runtime. Either way, source maps with bun build --sourcemap=external are mandatory the moment you ship a bundled binary, and Bun's --compile single-file executable mode breaks auto-instrumentation. Three Bun-specific gotchas: bun --hot reload swallows handler errors during the rebuild window, Bun.serve() error callback semantics differ from a thrown exception, and workers spawned by Bun.spawn don't propagate to the parent's global handler.
This guide covers the two install paths, the onError hook registration order, source maps for Bun's bundler, the three gotchas, and how to point the SDK at urgentry instead of Sentry SaaS without touching application code.
Why Bun + Elysia is its own error-tracking story
Bun replaced Node's V8 with JavaScriptCore, replaced Node's module resolver with its own, replaced npm with a built-in package manager, and replaced the test runner, bundler, and TypeScript loader with native equivalents. By the time you get to error tracking, almost nothing about the runtime is the same as Node, and the things that look the same have different timing semantics.
Elysia adds a second layer of difference on top. It uses an Elysia-specific lifecycle (onRequest, onParse, onTransform, onBeforeHandle, onAfterHandle, onError, onResponse), encodes routes as decorator-chained method calls instead of Express-style middleware, and uses a static analysis pass at build time to turn that chain into a hot path. The onError hook is where every uncaught exception inside the lifecycle ends up. Skip wiring it, and exceptions return a 500 with no record on your backend.
The other reason to write this down is movement. There is a real PR landed on May 19, 2026 (jkrumm/free-planning-poker #266) that ripped Sentry out of a three-service stack and replaced it with self-hosted OpenTelemetry shipping to a ClickStack/HyperDX instance on the same VPS. One of those three services is a Bun + Elysia API. Teams are migrating this stack right now, and the install they migrate from or to needs to be the right one.
The two install paths
Sentry ships two packages that target this stack, and the choice is mostly about how much process surface the SDK has to cover.
Path A: @sentry/bun (the runtime SDK)
@sentry/bun is the Bun runtime equivalent of @sentry/node. It instruments the Bun process: uncaught exceptions, unhandled promise rejections, fetch outgoing requests for breadcrumbs, and tracing spans. It does not know anything about Elysia. You register the Elysia onError hook yourself and call Sentry.captureException inside it.
Pick this path if any of these are true: the same process runs Bun workers, you have non-Elysia HTTP handlers via Bun.serve() directly, you use bun as a script runner for batch jobs that also need error capture, or you want one SDK across every Bun entry point.
Path B: @sentry/elysia (the framework SDK)
@sentry/elysia wraps the Elysia instance. Sentry.withElysia(new Elysia()) returns an Elysia object with an onError hook already registered, request spans wired into the lifecycle, and a default policy that captures every 5xx. Client errors (3xx and 4xx) are ignored by default; you can override with the shouldHandleError option.
Pick this path if the service is Elysia-on-Bun and only Elysia-on-Bun. Less code, fewer order-of-registration bugs, automatic span correlation with Elysia routes.
Install and initialize
Both paths start with the same shape: a dedicated instrument.ts file that runs first, then the entry file that imports it before anything else.
bun add @sentry/bun
# or
bun add @sentry/elysia
instrument.ts for the @sentry/bun path:
// instrument.ts
import * as Sentry from "@sentry/bun";
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV ?? "development",
release: process.env.RELEASE_SHA,
tracesSampleRate: 1.0, // turn this down in production
sendDefaultPii: false,
});
instrument.ts for the @sentry/elysia path:
// instrument.ts
import * as Sentry from "@sentry/elysia";
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV ?? "development",
release: process.env.RELEASE_SHA,
tracesSampleRate: 1.0,
enableLogs: true,
});
Then the entry file:
// index.ts
import "./instrument"; // MUST be the first import
import { Elysia } from "elysia";
import * as Sentry from "@sentry/bun";
new Elysia()
.onError(({ code, error }) => {
Sentry.captureException(error);
if (code === "VALIDATION") return error.message;
})
.get("/", () => "hello")
.listen(3000);
The import "./instrument" line being the first import is not a style preference. Sentry's instrumentation patches modules as they load. If elysia loads before @sentry/bun has registered its hooks, none of Elysia's modules carry tracing instrumentation for the lifetime of the process. There is no second chance.
For the Elysia SDK path, the equivalent entry file is shorter:
// index.ts
import "./instrument";
import * as Sentry from "@sentry/elysia";
import { Elysia } from "elysia";
const app = Sentry.withElysia(new Elysia())
.get("/", () => "hello")
.listen(3000);
The onError hook registration order rule
Elysia lifecycle hooks are local by default. A hook registered on an Elysia instance applies to routes registered after the hook on that instance, and to plugins mounted after the hook via .use(). Routes and plugins registered before the hook are invisible to it.
This produces a specific failure shape on the @sentry/bun path: someone moves the Sentry onError hook below a .use(authPlugin) line during a refactor, the auth plugin's routes throw exceptions for a week, and the dashboard stays empty. The application returns 500s, the user sees nothing, the dev who refactored sees nothing.
The fix is two rules.
- Register the Sentry
onErroron the root instance, first. Before.use(), before.group(), before any route. - If you need a plugin-local override, do it additively. Plugins inherit ancestor hooks but can register their own additional
onErrorfor plugin-specific recovery logic. The root hook still fires.
The Elysia SDK avoids the ordering question because Sentry.withElysia() registers the hook on the instance it returns before anything else can. You still have to wrap the root instance, but that's harder to get wrong than wrapping a child group.
What gets captured automatically, and what doesn't
With @sentry/elysia and Sentry.withElysia():
- Every uncaught exception inside a route handler, an
onBeforeHandle, anonAfterHandle, or anonParsebecomes a Sentry event with a stack trace tagged with the route path. - Every 5xx response is captured. 4xx responses (including Elysia's built-in
VALIDATIONerrors that map to 400) are not captured by default. - Each request opens a transaction span; outgoing
fetchcalls inside the handler become child spans.
With @sentry/bun and a hand-rolled onError:
- The same exceptions, but only because you wrote
Sentry.captureException(error)in the hook. If the hook does anything else (returns a 200 with a fallback payload, swallows the error to a log line), nothing reaches Sentry. - Process-level uncaught exceptions and unhandled rejections outside the Elysia request path are captured automatically. This is the catch for errors inside
setIntervalhandlers,queueMicrotasktasks, and other code that runs outside any HTTP request. - No automatic route tagging. Add it manually with
Sentry.setTag("route", ctx.path)inonErrorif you want it.
Source maps for the Bun bundler
Bun runs TypeScript directly in development; you ship .ts files and the runtime handles it. Production is different. The two production shapes are bun build output (a bundled JavaScript file) and bun build --compile output (a single-file native executable). Both transform your source. Both need source maps for stack traces to be readable.
# Bundle for production, generate external maps
bun build ./index.ts \
--outdir=./dist \
--target=bun \
--sourcemap=external
# Single-file executable, generate external maps
bun build ./index.ts \
--compile \
--outfile=./server \
--sourcemap=external
Upload the maps to your error backend with sentry-cli, scoped to a release:
RELEASE_SHA=$(git rev-parse --short HEAD)
sentry-cli releases new "$RELEASE_SHA"
sentry-cli sourcemaps upload \
--release="$RELEASE_SHA" \
--url-prefix="~/dist" \
./dist
sentry-cli releases finalize "$RELEASE_SHA"
Set the same release string in your Sentry.init call and the events line up with the maps. Without maps, stack frames point at minified column numbers in a single concatenated .js file, and grouping fragments across every deploy because the line numbers shift.
For urgentry, the same sentry-cli sourcemaps upload command works; only the SENTRY_URL environment variable changes to point at your urgentry instance. See the frontend source maps guide for the upload mechanics in detail; the Bun server-side flow is identical.
The three Bun-specific gotchas
1. bun --hot reload swallows handler errors during the rebuild window
bun --hot watches your source, swaps modules in-place when files change, and keeps the HTTP server alive. The swap window is short but not zero, and during it, an inbound request can hit a half-loaded handler. Bun catches the resulting TypeError internally and returns a 500 with no stack trace, and because the swap completed by the time you check, the error is not reproducible.
This only happens in dev. The production path is bun run or the compiled binary, neither of which hot-reloads. The fix is to know it exists: if you see a phantom 500 in dev that you cannot reproduce, and you saved a file in the last second, ignore it. If you see it in production, you are not running production correctly. bun --hot should not be your production runtime.
2. Bun.serve() error callback vs throw behavior
When you use Bun.serve({ fetch, error }) directly (not through Elysia), the error callback runs when fetch throws. That callback gets the exception object, but its return value becomes the HTTP response. If you forget to throw or Sentry.captureException inside the callback and just return a Response, the exception is consumed and the global uncaught-exception handler never fires.
// Wrong: the exception is consumed silently
Bun.serve({
fetch() { throw new Error("kaboom"); },
error(err) {
return new Response("oops", { status: 500 });
// Sentry sees nothing.
},
});
// Right: capture before responding
Bun.serve({
fetch() { throw new Error("kaboom"); },
error(err) {
Sentry.captureException(err);
return new Response("oops", { status: 500 });
},
});
Elysia's onError hook composes with this: it runs first for exceptions thrown inside Elysia's lifecycle, and the Bun.serve error callback is the fallback for anything that escapes. If you use the @sentry/elysia SDK, this is handled. If you mix Elysia with raw Bun.serve handlers in the same process (rare but possible), you need both hooks wired.
3. Workers spawned by Bun.spawn don't propagate to the parent's handler
Bun.spawn starts a subprocess. The subprocess has its own runtime, its own globals, its own crash. An uncaught exception in the worker terminates the worker and emits an exit code. The parent's Sentry handler does not see the exception; it only sees the exit code on the spawned process's exited promise.
Two patterns work. The first is to initialize Sentry inside the worker, give it the same DSN, and let the worker report its own crashes. The second is to wrap the worker's main function in a try/catch that writes the exception to stderr in a parseable shape, and parse stderr in the parent to call Sentry.captureMessage with the worker's context.
// In the worker entry file: same instrument.ts import,
// so worker crashes report under a separate component tag.
import "./instrument";
import * as Sentry from "@sentry/bun";
Sentry.setTag("component", "worker");
// ... worker logic ...
The same applies to Worker from the Web Workers API, which Bun also supports. Each worker is a fresh runtime; instrument each one.
Pointing the SDK at urgentry
The SDK code does not change. Only the DSN changes. Set SENTRY_DSN to your urgentry instance's DSN, restart the process, throw a test exception, and confirm it lands.
export SENTRY_DSN="https://PUBLIC_KEY@errors.yourdomain.com/PROJECT_ID"
bun run dist/index.js
urgentry accepts the same Sentry envelope format @sentry/bun and @sentry/elysia emit. The compatibility matrix lists every ingest and REST operation; the DSN is the only swap. For source maps, set SENTRY_URL alongside SENTRY_AUTH_TOKEN for sentry-cli and uploads go to urgentry instead of Sentry SaaS.
Frequently asked questions
Should I use @sentry/bun or @sentry/elysia?
If your service is Elysia-on-Bun and you want the least code, use @sentry/elysia. It wraps your Elysia instance with Sentry.withElysia() and auto-registers an onError hook that captures every 5xx. If you also run Bun workers, scripts, or non-Elysia HTTP code in the same process, use @sentry/bun and wire the onError hook yourself; you keep one SDK across the whole runtime that way.
Why does onError fire on plugins above it but not on plugins below it?
Elysia lifecycle hooks only apply to routes registered after the hook. If you call .onError() after a .use(plugin) line, the plugin's routes are invisible to the hook. Register the Sentry onError hook on the root Elysia instance before any .use() or .group() that owns routes you want covered. The order is hook then routes, every time.
What breaks when I bun build into a single-file executable?
Sentry's auto-instrumentation hooks into module loading. Bun's --compile single-file executables resolve all imports at build time and there is no module-load event at runtime. The SDK still captures errors you pass to Sentry.captureException, but http.client spans, the Bun.serve auto-wrap, and any integration that relies on require-hook patching go silent. For bundled deployments, switch to manual instrumentation and verify with a forced test error before you call it done.
Do I still need source maps if Bun runs TypeScript directly?
Yes. Bun executes TypeScript without a build step, but production crashes ship with line numbers from the file the runtime actually loaded, including any code Bun.build or bun build produced for a worker or a bundled binary. Generate external source maps with --sourcemap=external and upload them with sentry-cli sourcemaps upload, scoped to a release. Without them, your stack frames point at minified .js columns and grouping fragments across versions.
Can urgentry receive Bun + Elysia events without changing SDK code?
Yes. The DSN is the only swap. Change SENTRY_DSN to point at your urgentry instance, restart the process, throw a test error, and confirm it lands. The envelope format the @sentry/bun and @sentry/elysia SDKs emit is the same one urgentry accepts. The compatibility matrix at /sentry-alternative/ lists every ingest and REST operation.
Sources
- Sentry for Bun (@sentry/bun) — the official runtime SDK reference, including the
instrument.tspattern and the bundled-code caveat. - Sentry for Elysia (@sentry/elysia) — the framework SDK reference, including
Sentry.withElysia()and theshouldHandleErroroption. - Elysia lifecycle documentation — canonical reference for hook registration order, including the rule that hooks only apply to routes registered after the hook.
- Bun guide: Add Sentry to a Bun app — the install path, including the first-import constraint for
instrument.ts. - jkrumm/free-planning-poker PR #266 — a real May 19, 2026 PR migrating a three-service stack including a Bun + Elysia API from Sentry to self-hosted OpenTelemetry on a single VPS.
- @sentry/bun on npm — current version, release history, and dependency surface.
- urgentry compatibility matrix — source-scanned audit of all 218 Sentry REST API operations mapped to urgentry handlers.
One DSN swap. Full Sentry SDK compatibility.
urgentry accepts the @sentry/bun and @sentry/elysia envelope format on a $5 VPS. 218 API operations covered. SQLite by default, Postgres optional. Change one environment variable and Bun events start arriving.