Skip to content

Commit ecd606e

Browse files
isaacsclaude
andauthored
feat(bun): Add bunHttpServerIntegration (#22870)
Adds a `bunHttpIntegration` to `@sentry/bun` that isolates incoming requests for servers built on `node:http` when running under the Bun runtime. ### Problem Bun does not emit the `http.server.request.start` diagnostics channel that the Node SDK relies on (`getHttpServerSubscriptions` in `@sentry/core`) to isolate each incoming request. As a result, servers built on `node:http` — notably Next.js running via `bun --bun` — do not get a fresh isolation scope and propagation context per request. Two unrelated incoming requests can therefore end up sharing a single `trace_id`. This was surfaced by the `nextjs-16-bun` e2e app: `propagation.test.ts › Does not propagate outgoing fetch requests not covered by tracePropagationTargets` failed because the inbound and outbound transactions shared one trace. ### Approach `bunHttpServerIntegration` patches `http.Server.prototype.emit` and, on each server's first `'request'` event, hands that server to the exact same core instrumentation the Node SDK uses (`getHttpServerSubscriptions` → `instrumentServer`). Core then installs its per-instance `emit` wrapper, which shadows the prototype patch for all subsequent requests to that server. No changes to `@sentry/core`, `@sentry/opentelemetry`, or the tracer — this only reuses existing core logic at a Bun-specific entry point. Two decisions worth calling out: - **Prototype patch, not `createServer`.** The server is typically created and `listen()`ed *before* Sentry initializes. Next.js in particular creates its `http.Server` and calls `listen()` before running the `instrumentation.ts` `register()` hook that loads the Sentry config, so a `createServer` patch installs too late to see the already-created server. Patching `Server.prototype.emit` catches pre-existing servers on their next request. - **`spans` option.** When another layer already emits incoming-request spans (Next.js emits its own OpenTelemetry `http.server` spans), pass `spans: false` so the integration only isolates the request and resets its trace, without creating duplicate transactions. This mirrors how the Node `httpIntegration` uses `disableIncomingRequestSpans`. The integration is a no-op outside Bun (guarded on `process.versions.bun`), and is added by default when using the bun sdk. ### e2e wiring The `nextjs-16-bun` test app uses `@sentry/nextjs` (→ `@sentry/node`), so it does not pick this up automatically. `@sentry/bun` is added as a dependency of that app and `bunHttpIntegration({ spans: false })` is added to its server config. ### Known limitation Servers created via an ES module namespace import (`import * as http from 'node:http'`) are covered, since we patch the shared `http.Server` class rather than a module binding. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: isaacs <i@izs.me>
1 parent 8f4f24d commit ecd606e

6 files changed

Lines changed: 254 additions & 0 deletions

File tree

dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
},
1414
"dependencies": {
1515
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
16+
"@sentry/bun": "file:../../packed/sentry-bun-packed.tgz",
1617
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
1718
"import-in-the-middle": "^2",
1819
"next": "16.2.3",

dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { bunHttpServerIntegration } from '@sentry/bun';
12
import * as Sentry from '@sentry/nextjs';
23

34
Sentry.init({
@@ -8,4 +9,8 @@ Sentry.init({
89
tracesSampleRate: 1.0,
910
dataCollection: { userInfo: true },
1011
tracePropagationTargets: ['http://localhost:3030/propagation/test-outgoing-fetch/check'],
12+
// Bun does not emit the `node:http` diagnostics channel the Node SDK uses to isolate incoming
13+
// requests, so each request would otherwise share one trace. Next.js emits its own server spans,
14+
// hence `spans: false` — this only isolates the request and resets its trace.
15+
integrations: [bunHttpServerIntegration({ spans: false })],
1116
});

packages/bun/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,5 +198,6 @@ export {
198198
initWithoutDefaultIntegrations,
199199
} from './sdk';
200200
export { bunServerIntegration } from './integrations/bunserver';
201+
export { bunHttpServerIntegration } from './integrations/bunHttpServer';
201202
export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics';
202203
export { makeFetchTransport } from './transports';
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { errorMonitor } from 'node:events';
2+
import http from 'node:http';
3+
import https from 'node:https';
4+
import type { HttpIncomingMessage, HttpServerResponse, IntegrationFn, Span } from '@sentry/core';
5+
import { defineIntegration, getHttpServerSubscriptions, HTTP_ON_SERVER_REQUEST } from '@sentry/core';
6+
7+
const INTEGRATION_NAME = 'BunHttpServer' as const;
8+
9+
interface BunHttpServerOptions {
10+
/**
11+
* Whether to create `http.server` spans for incoming requests.
12+
*
13+
* Set this to `false` when another layer already emits incoming-request spans
14+
* (e.g. Next.js running on Bun, which creates its own OpenTelemetry spans).
15+
* The integration then only isolates each request and resets its trace, without
16+
* creating duplicate transactions.
17+
*
18+
* @default true
19+
*/
20+
spans?: boolean;
21+
22+
/**
23+
* Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests.
24+
*
25+
* @default true
26+
*/
27+
sessions?: boolean;
28+
29+
/**
30+
* Number of milliseconds until sessions are flushed as a session aggregate.
31+
*
32+
* @default 60000
33+
*/
34+
sessionFlushingDelayMS?: number;
35+
36+
/**
37+
* Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`.
38+
*/
39+
ignoreRequestBody?: (url: string, request: http.RequestOptions) => boolean;
40+
41+
/**
42+
* Controls the maximum size of incoming HTTP request bodies attached to events.
43+
*
44+
* @default 'medium'
45+
*/
46+
maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always';
47+
48+
/**
49+
* Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`.
50+
*
51+
* The `urlPath` param consists of the URL path and query string (if any) of the incoming request.
52+
*/
53+
ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean;
54+
55+
/**
56+
* Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc.
57+
*
58+
* @default true
59+
*/
60+
ignoreStaticAssets?: boolean;
61+
62+
/**
63+
* A hook that can be used to mutate the span for incoming requests.
64+
* This is triggered after the span is created, but before it is recorded.
65+
*/
66+
onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void;
67+
68+
/**
69+
* A hook that can be used to mutate the span one last time when the response is finished.
70+
*/
71+
onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void;
72+
}
73+
74+
let hasPatched = false;
75+
76+
const _bunHttpServerIntegration = ((options: BunHttpServerOptions = {}) => {
77+
return {
78+
name: INTEGRATION_NAME,
79+
setupOnce() {
80+
instrumentBunHttpServer(options);
81+
},
82+
};
83+
}) satisfies IntegrationFn;
84+
85+
/**
86+
* Instruments incoming `node:http`/`node:https` server requests under the Bun runtime.
87+
*
88+
* Unlike Node.js, Bun does not emit the `http.server.request.start` diagnostics channel that the
89+
* Node SDK relies on to isolate each incoming request. As a result, servers built on `node:http`
90+
* (such as Next.js running via `bun --bun`) do not get a fresh isolation scope and trace per request,
91+
* so unrelated requests can end up sharing one trace.
92+
*
93+
* This closes that gap by patching `http.Server.prototype.emit` and, on the first `'request'` event
94+
* of each server, handing that server to the same core instrumentation the Node SDK uses
95+
* (`getHttpServerSubscriptions` → `instrumentServer`). We patch the prototype (not `createServer`)
96+
* because the server is typically created before Sentry is initialized — e.g. Next.js creates and
97+
* `listen()`s its server before running the `instrumentation.ts` `register()` hook that loads the
98+
* Sentry config.
99+
*
100+
* This is intended for `node:http`-based servers. For `Bun.serve`, use {@link bunServerIntegration}.
101+
*
102+
* ```js
103+
* Sentry.init({
104+
* integrations: [
105+
* Sentry.bunHttpServerIntegration(),
106+
* ],
107+
* })
108+
* ```
109+
*/
110+
export const bunHttpServerIntegration = defineIntegration(_bunHttpServerIntegration);
111+
112+
/**
113+
* Patches `http.Server.prototype.emit` so each server's incoming requests are isolated using the
114+
* same core instrumentation the Node SDK uses.
115+
*
116+
* Only exported for tests.
117+
*/
118+
export function instrumentBunHttpServer(options: BunHttpServerOptions = {}): void {
119+
// This only makes sense under Bun; on Node the diagnostics channel already handles this.
120+
if (!process.versions.bun || hasPatched) {
121+
return;
122+
}
123+
124+
const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions({
125+
...options,
126+
// Pass the real `errorMonitor` symbol so core observes `'error'` events without consuming
127+
// them — otherwise it would swallow errors before they reach user-supplied `'error'` handlers.
128+
errorMonitor,
129+
});
130+
131+
// Track which servers we have already handed to core, so we instrument each server exactly once.
132+
// After core instruments a server it installs its own `emit` on the instance, which shadows this
133+
// prototype patch for all subsequent requests to that server.
134+
const instrumented = new WeakSet<object>();
135+
136+
const patchEmitOn = (ServerClass: typeof http.Server): void => {
137+
// oxlint-disable-next-line typescript/unbound-method
138+
const originalEmit = ServerClass.prototype.emit;
139+
ServerClass.prototype.emit = function (this: http.Server, event: string, ...args: unknown[]): boolean {
140+
if (event === 'request' && !instrumented.has(this)) {
141+
instrumented.add(this);
142+
// Hand the server to core, which patches this instance's `emit` to isolate requests.
143+
onServerRequest({ server: this }, HTTP_ON_SERVER_REQUEST);
144+
// Re-dispatch the in-flight request through the instance emit core just installed.
145+
return this.emit(event, ...args);
146+
}
147+
return originalEmit.call(this, event, ...args) as boolean;
148+
} as typeof originalEmit;
149+
};
150+
151+
patchEmitOn(http.Server);
152+
// In Bun `https.Server` reuses `http.Server`, but patch it explicitly in case that ever diverges.
153+
if (https.Server !== http.Server) {
154+
patchEmitOn(https.Server);
155+
}
156+
157+
hasPatched = true;
158+
}

packages/bun/src/sdk.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { channelIntegrations, isOrchestrionInjected } from '@sentry/server-utils
2626
import { bunServerIntegration } from './integrations/bunserver';
2727
import { makeFetchTransport } from './transports';
2828
import type { BunOptions } from './types';
29+
import { bunHttpServerIntegration } from './integrations/bunHttpServer';
2930

3031
/**
3132
* The orchestrion channel-subscriber integrations, listening on the diagnostics
@@ -88,6 +89,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] {
8889
processSessionIntegration(),
8990
// Bun Specific
9091
bunServerIntegration(),
92+
bunHttpServerIntegration(),
9193
];
9294
}
9395

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import http from 'node:http';
2+
import { getActiveSpan, getTraceData, spanToJSON } from '@sentry/core';
3+
import { beforeAll, describe, expect, test } from 'bun:test';
4+
import { init } from '../../src';
5+
6+
async function startServer(handler: http.RequestListener): Promise<{ port: number; close: () => Promise<void> }> {
7+
const server = http.createServer(handler);
8+
const port = await new Promise<number>(resolve => {
9+
server.listen(0, () => resolve((server.address() as { port: number }).port));
10+
});
11+
return {
12+
port,
13+
close: () => new Promise<void>(resolve => server.close(() => resolve())),
14+
};
15+
}
16+
17+
/** Read the trace id the SDK would propagate for the current request. Works in both OTel and non-OTel modes. */
18+
function currentTraceId(): string | undefined {
19+
return getTraceData()['sentry-trace']?.split('-')[0];
20+
}
21+
22+
describe('Bun HTTP Server Integration', () => {
23+
beforeAll(() => {
24+
init({
25+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
26+
tracesSampleRate: 1.0,
27+
// Avoid sending anything to Sentry
28+
transport: () => ({ send: async () => ({}), flush: async () => true }),
29+
});
30+
});
31+
32+
test('creates an http.server span for incoming requests', async () => {
33+
let span: ReturnType<typeof spanToJSON> | undefined;
34+
35+
const { port, close } = await startServer((_req, res) => {
36+
const activeSpan = getActiveSpan();
37+
span = activeSpan ? spanToJSON(activeSpan) : undefined;
38+
res.end('ok');
39+
});
40+
41+
await fetch(`http://localhost:${port}/users?id=123`).then(res => res.text());
42+
43+
await close();
44+
45+
expect(span).toBeDefined();
46+
expect(span?.op).toBe('http.server');
47+
expect(span?.description).toBe('GET /users');
48+
expect(span?.data['sentry.origin']).toBe('auto.http.server');
49+
});
50+
51+
test('isolates each incoming request with a distinct trace id', async () => {
52+
const traceIds: Array<string | undefined> = [];
53+
54+
const { port, close } = await startServer((_req, res) => {
55+
traceIds.push(currentTraceId());
56+
res.end('ok');
57+
});
58+
59+
await fetch(`http://localhost:${port}/a`).then(res => res.text());
60+
await fetch(`http://localhost:${port}/b`).then(res => res.text());
61+
62+
await close();
63+
64+
expect(traceIds).toHaveLength(2);
65+
expect(traceIds[0]).toEqual(expect.any(String));
66+
expect(traceIds[1]).toEqual(expect.any(String));
67+
expect(traceIds[0]).not.toBe(traceIds[1]);
68+
});
69+
70+
test('continues an incoming trace from headers', async () => {
71+
const incomingTraceId = 'cafecafecafecafecafecafecafecafe';
72+
let observedTraceId: string | undefined;
73+
74+
const { port, close } = await startServer((_req, res) => {
75+
observedTraceId = currentTraceId();
76+
res.end('ok');
77+
});
78+
79+
await fetch(`http://localhost:${port}/`, {
80+
headers: { 'sentry-trace': `${incomingTraceId}-1234567890abcdef-1` },
81+
}).then(res => res.text());
82+
83+
await close();
84+
85+
expect(observedTraceId).toBe(incomingTraceId);
86+
});
87+
});

0 commit comments

Comments
 (0)