From 0df122c4c4de3dfa4abd8436c9ed94193fab6fca Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 30 Jul 2026 14:45:10 +0200 Subject: [PATCH 1/6] feat(bun): Add `bunHttpServerIntegration` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun does not emit the `http.server.request.start` diagnostics channel that the Node SDK relies on to isolate each incoming request. As a result, servers built on `node:http` (such as Next.js running via `bun --bun`) do not get a fresh isolation scope and trace per request, so unrelated requests can share one trace. `bunHttpIntegration` closes this gap by patching `http.Server.prototype.emit` and, on each server's first `request` event, handing the server to the same core instrumentation the Node SDK uses (`getHttpServerSubscriptions` → `instrumentServer`). We patch the prototype rather than `createServer` because the server is typically created and `listen()`ed before Sentry initializes (e.g. Next.js creates its server before running the `instrumentation.ts` `register()` hook). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nextjs-16-bun/package.json | 1 + .../nextjs-16-bun/sentry.server.config.ts | 5 + packages/bun/src/index.ts | 1 + .../bun/src/integrations/bunHttpServer.ts | 133 ++++++++++++++++++ .../test/integrations/bunHttpServer.test.ts | 66 +++++++++ 5 files changed, 206 insertions(+) create mode 100644 packages/bun/src/integrations/bunHttpServer.ts create mode 100644 packages/bun/test/integrations/bunHttpServer.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json index 509c2b2c3a9f..346e10ca7d9f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "import-in-the-middle": "^2", "next": "16.2.3", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts index abf631fb0060..75e20e590771 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts @@ -1,3 +1,4 @@ +import { bunHttpServerIntegration } from '@sentry/bun'; import * as Sentry from '@sentry/nextjs'; Sentry.init({ @@ -8,4 +9,8 @@ Sentry.init({ tracesSampleRate: 1.0, dataCollection: { userInfo: true }, tracePropagationTargets: ['http://localhost:3030/propagation/test-outgoing-fetch/check'], + // Bun does not emit the `node:http` diagnostics channel the Node SDK uses to isolate incoming + // requests, so each request would otherwise share one trace. Next.js emits its own server spans, + // hence `spans: false` — this only isolates the request and resets its trace. + integrations: [bunHttpServerIntegration({ spans: false })], }); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4a6c25eed27d..a37fbf651231 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -199,5 +199,6 @@ export { initWithoutDefaultIntegrations, } from './sdk'; export { bunServerIntegration } from './integrations/bunserver'; +export { bunHttpServerIntegration } from './integrations/bunHttpServer'; export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics'; export { makeFetchTransport } from './transports'; diff --git a/packages/bun/src/integrations/bunHttpServer.ts b/packages/bun/src/integrations/bunHttpServer.ts new file mode 100644 index 000000000000..cb5cea764500 --- /dev/null +++ b/packages/bun/src/integrations/bunHttpServer.ts @@ -0,0 +1,133 @@ +import http from 'node:http'; +import https from 'node:https'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, getHttpServerSubscriptions, HTTP_ON_SERVER_REQUEST } from '@sentry/core'; + +const INTEGRATION_NAME = 'BunHttpServer' as const; + +interface BunHttpServerOptions { + /** + * Whether to create `http.server` spans for incoming requests. + * + * Set this to `false` when another layer already emits incoming-request spans + * (e.g. Next.js running on Bun, which creates its own OpenTelemetry spans). + * The integration then only isolates each request and resets its trace, without + * creating duplicate transactions. + * + * @default true + */ + spans?: boolean; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests. + * + * @default true + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default 60000 + */ + sessionFlushingDelayMS?: number; + + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + */ + ignoreRequestBody?: (url: string, request: http.RequestOptions) => boolean; + + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * @default 'medium' + */ + maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; +} + +let hasPatched = false; + +const _bunHttpServerIntegration = ((options: BunHttpServerOptions = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentBunHttpServer(options); + }, + }; +}) satisfies IntegrationFn; + +/** + * Instruments incoming `node:http`/`node:https` server requests under the Bun runtime. + * + * Unlike Node.js, Bun does not emit the `http.server.request.start` diagnostics channel that the + * Node SDK relies on to isolate each incoming request. As a result, servers built on `node:http` + * (such as Next.js running via `bun --bun`) do not get a fresh isolation scope and trace per request, + * so unrelated requests can end up sharing one trace. + * + * This closes that gap by patching `http.Server.prototype.emit` and, on the first `'request'` event + * of each server, handing that server to the same core instrumentation the Node SDK uses + * (`getHttpServerSubscriptions` → `instrumentServer`). We patch the prototype (not `createServer`) + * because the server is typically created before Sentry is initialized — e.g. Next.js creates and + * `listen()`s its server before running the `instrumentation.ts` `register()` hook that loads the + * Sentry config. + * + * This is intended for `node:http`-based servers. For `Bun.serve`, use {@link bunServerIntegration}. + * + * ```js + * Sentry.init({ + * integrations: [ + * Sentry.bunHttpServerIntegration(), + * ], + * }) + * ``` + */ +export const bunHttpServerIntegration = defineIntegration(_bunHttpServerIntegration); + +/** + * Patches `http.Server.prototype.emit` so each server's incoming requests are isolated using the + * same core instrumentation the Node SDK uses. + * + * Only exported for tests. + */ +export function instrumentBunHttpServer(options: BunHttpServerOptions = {}): void { + // This only makes sense under Bun; on Node the diagnostics channel already handles this. + if (!process.versions.bun || hasPatched) { + return; + } + + const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions({ + spans: options.spans ?? true, + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS ?? 60_000, + maxRequestBodySize: options.maxRequestBodySize ?? 'medium', + ignoreRequestBody: options.ignoreRequestBody, + }); + + // Track which servers we have already handed to core, so we instrument each server exactly once. + // After core instruments a server it installs its own `emit` on the instance, which shadows this + // prototype patch for all subsequent requests to that server. + const instrumented = new WeakSet(); + + const patchEmitOn = (ServerClass: typeof http.Server): void => { + // oxlint-disable-next-line typescript/unbound-method + const originalEmit = ServerClass.prototype.emit; + ServerClass.prototype.emit = function (this: http.Server, event: string, ...args: unknown[]): boolean { + if (event === 'request' && !instrumented.has(this)) { + instrumented.add(this); + // Hand the server to core, which patches this instance's `emit` to isolate requests. + onServerRequest({ server: this }, HTTP_ON_SERVER_REQUEST); + // Re-dispatch the in-flight request through the instance emit core just installed. + return this.emit(event, ...args); + } + return originalEmit.call(this, event, ...args) as boolean; + } as typeof originalEmit; + }; + + patchEmitOn(http.Server); + // In Bun `https.Server` reuses `http.Server`, but patch it explicitly in case that ever diverges. + if (https.Server !== http.Server) { + patchEmitOn(https.Server); + } + + hasPatched = true; +} diff --git a/packages/bun/test/integrations/bunHttpServer.test.ts b/packages/bun/test/integrations/bunHttpServer.test.ts new file mode 100644 index 000000000000..9511c8eee836 --- /dev/null +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -0,0 +1,66 @@ +import http from 'node:http'; +import { getCurrentScope } from '@sentry/core'; +import { beforeAll, describe, expect, test } from 'bun:test'; +import { init } from '../../src'; +import { instrumentBunHttpServer } from '../../src/integrations/bunHttpServer'; + +async function startServer(handler: http.RequestListener): Promise<{ port: number; close: () => Promise }> { + const server = http.createServer(handler); + const port = await new Promise(resolve => { + server.listen(0, () => resolve((server.address() as { port: number }).port)); + }); + return { + port, + close: () => new Promise(resolve => server.close(() => resolve())), + }; +} + +describe('Bun HTTP Server Integration', () => { + beforeAll(() => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 0, + // Avoid sending anything to Sentry + transport: () => ({ send: async () => ({}), flush: async () => true }), + }); + // Only performs isolation + trace reset, no span creation (Next.js emits its own spans). + instrumentBunHttpServer({ spans: false }); + }); + + test('isolates each incoming request with a distinct trace id', async () => { + const traceIds: string[] = []; + + const { port, close } = await startServer((_req, res) => { + traceIds.push(getCurrentScope().getPropagationContext().traceId); + res.end('ok'); + }); + + await fetch(`http://localhost:${port}/a`).then(res => res.text()); + await fetch(`http://localhost:${port}/b`).then(res => res.text()); + + await close(); + + expect(traceIds).toHaveLength(2); + expect(traceIds[0]).toEqual(expect.any(String)); + expect(traceIds[1]).toEqual(expect.any(String)); + expect(traceIds[0]).not.toBe(traceIds[1]); + }); + + test('continues an incoming trace from headers', async () => { + const incomingTraceId = 'cafecafecafecafecafecafecafecafe'; + let observedTraceId: string | undefined; + + const { port, close } = await startServer((_req, res) => { + observedTraceId = getCurrentScope().getPropagationContext().traceId; + res.end('ok'); + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'sentry-trace': `${incomingTraceId}-1234567890abcdef-1` }, + }).then(res => res.text()); + + await close(); + + expect(observedTraceId).toBe(incomingTraceId); + }); +}); From 19d37d8890e81b753d6f66fcc625a236a0a87fbd Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 30 Jul 2026 14:53:12 +0200 Subject: [PATCH 2/6] feat(bun): Instrument http module on bun --- packages/bun/src/sdk.ts | 2 ++ .../test/integrations/bunHttpServer.test.ts | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 1d23447ed43d..9411e1ad47f0 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -26,6 +26,7 @@ import { channelIntegrations, isOrchestrionInjected } from '@sentry/server-utils import { bunServerIntegration } from './integrations/bunserver'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; +import { bunHttpServerIntegration } from './integrations/bunHttpServer'; /** * The orchestrion channel-subscriber integrations, listening on the diagnostics @@ -88,6 +89,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { processSessionIntegration(), // Bun Specific bunServerIntegration(), + bunHttpServerIntegration(), ]; } diff --git a/packages/bun/test/integrations/bunHttpServer.test.ts b/packages/bun/test/integrations/bunHttpServer.test.ts index 9511c8eee836..e15b2d485f18 100644 --- a/packages/bun/test/integrations/bunHttpServer.test.ts +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -1,8 +1,7 @@ import http from 'node:http'; -import { getCurrentScope } from '@sentry/core'; +import { getTraceData } from '@sentry/core'; import { beforeAll, describe, expect, test } from 'bun:test'; -import { init } from '../../src'; -import { instrumentBunHttpServer } from '../../src/integrations/bunHttpServer'; +import { bunHttpServerIntegration, init } from '../../src'; async function startServer(handler: http.RequestListener): Promise<{ port: number; close: () => Promise }> { const server = http.createServer(handler); @@ -15,6 +14,11 @@ async function startServer(handler: http.RequestListener): Promise<{ port: numbe }; } +/** Read the trace id the SDK would propagate for the current request. Works in both OTel and non-OTel modes. */ +function currentTraceId(): string | undefined { + return getTraceData()['sentry-trace']?.split('-')[0]; +} + describe('Bun HTTP Server Integration', () => { beforeAll(() => { init({ @@ -22,16 +26,15 @@ describe('Bun HTTP Server Integration', () => { tracesSampleRate: 0, // Avoid sending anything to Sentry transport: () => ({ send: async () => ({}), flush: async () => true }), + integrations: [bunHttpServerIntegration({ spans: false })], }); - // Only performs isolation + trace reset, no span creation (Next.js emits its own spans). - instrumentBunHttpServer({ spans: false }); }); test('isolates each incoming request with a distinct trace id', async () => { - const traceIds: string[] = []; + const traceIds: Array = []; const { port, close } = await startServer((_req, res) => { - traceIds.push(getCurrentScope().getPropagationContext().traceId); + traceIds.push(currentTraceId()); res.end('ok'); }); @@ -51,7 +54,7 @@ describe('Bun HTTP Server Integration', () => { let observedTraceId: string | undefined; const { port, close } = await startServer((_req, res) => { - observedTraceId = getCurrentScope().getPropagationContext().traceId; + observedTraceId = currentTraceId(); res.end('ok'); }); From aa90c89c7c1f9c6c202d1b1cd499ce818768260e Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 30 Jul 2026 15:02:02 +0200 Subject: [PATCH 3/6] test(bun): Cover span emission in bunHttpServerIntegration Adds an assertion that an `http.server` span is created for incoming requests when spans are enabled, alongside the existing isolation and trace-continuation coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/integrations/bunHttpServer.test.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/bun/test/integrations/bunHttpServer.test.ts b/packages/bun/test/integrations/bunHttpServer.test.ts index e15b2d485f18..59b6ab1f56cc 100644 --- a/packages/bun/test/integrations/bunHttpServer.test.ts +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -1,5 +1,5 @@ import http from 'node:http'; -import { getTraceData } from '@sentry/core'; +import { getActiveSpan, getTraceData, spanToJSON } from '@sentry/core'; import { beforeAll, describe, expect, test } from 'bun:test'; import { bunHttpServerIntegration, init } from '../../src'; @@ -23,13 +23,32 @@ describe('Bun HTTP Server Integration', () => { beforeAll(() => { init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 0, + tracesSampleRate: 1.0, // Avoid sending anything to Sentry transport: () => ({ send: async () => ({}), flush: async () => true }), - integrations: [bunHttpServerIntegration({ spans: false })], + integrations: [bunHttpServerIntegration()], }); }); + test('creates an http.server span for incoming requests', async () => { + let span: ReturnType | undefined; + + const { port, close } = await startServer((_req, res) => { + const activeSpan = getActiveSpan(); + span = activeSpan ? spanToJSON(activeSpan) : undefined; + res.end('ok'); + }); + + await fetch(`http://localhost:${port}/users?id=123`).then(res => res.text()); + + await close(); + + expect(span).toBeDefined(); + expect(span?.op).toBe('http.server'); + expect(span?.description).toBe('GET /users'); + expect(span?.data['sentry.origin']).toBe('auto.http.server'); + }); + test('isolates each incoming request with a distinct trace id', async () => { const traceIds: Array = []; From 0166d24454c684a30fe01ce24a49123890251bab Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 31 Jul 2026 08:36:17 +0200 Subject: [PATCH 4/6] Apply suggestions from code review Co-authored-by: isaacs --- packages/bun/test/integrations/bunHttpServer.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bun/test/integrations/bunHttpServer.test.ts b/packages/bun/test/integrations/bunHttpServer.test.ts index 59b6ab1f56cc..83aaed4f4789 100644 --- a/packages/bun/test/integrations/bunHttpServer.test.ts +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -26,7 +26,6 @@ describe('Bun HTTP Server Integration', () => { tracesSampleRate: 1.0, // Avoid sending anything to Sentry transport: () => ({ send: async () => ({}), flush: async () => true }), - integrations: [bunHttpServerIntegration()], }); }); From d3b91f397245141812ee9d3ffe2f78fd1713a0cc Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 31 Jul 2026 09:02:03 +0200 Subject: [PATCH 5/6] fix options --- packages/bun/src/integrations/bunHttpServer.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/bun/src/integrations/bunHttpServer.ts b/packages/bun/src/integrations/bunHttpServer.ts index cb5cea764500..12647be55454 100644 --- a/packages/bun/src/integrations/bunHttpServer.ts +++ b/packages/bun/src/integrations/bunHttpServer.ts @@ -95,13 +95,7 @@ export function instrumentBunHttpServer(options: BunHttpServerOptions = {}): voi return; } - const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions({ - spans: options.spans ?? true, - sessions: options.sessions ?? true, - sessionFlushingDelayMS: options.sessionFlushingDelayMS ?? 60_000, - maxRequestBodySize: options.maxRequestBodySize ?? 'medium', - ignoreRequestBody: options.ignoreRequestBody, - }); + const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions(options); // Track which servers we have already handed to core, so we instrument each server exactly once. // After core instruments a server it installs its own `emit` on the instance, which shadows this From 5c0580de2ce7d5b056741783a4da7ca1153321b5 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 31 Jul 2026 09:07:05 +0200 Subject: [PATCH 6/6] fixes --- .../bun/src/integrations/bunHttpServer.ts | 35 +++++++++++++++++-- .../test/integrations/bunHttpServer.test.ts | 2 +- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/bun/src/integrations/bunHttpServer.ts b/packages/bun/src/integrations/bunHttpServer.ts index 12647be55454..6cf95b37ab35 100644 --- a/packages/bun/src/integrations/bunHttpServer.ts +++ b/packages/bun/src/integrations/bunHttpServer.ts @@ -1,6 +1,7 @@ +import { errorMonitor } from 'node:events'; import http from 'node:http'; import https from 'node:https'; -import type { IntegrationFn } from '@sentry/core'; +import type { HttpIncomingMessage, HttpServerResponse, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration, getHttpServerSubscriptions, HTTP_ON_SERVER_REQUEST } from '@sentry/core'; const INTEGRATION_NAME = 'BunHttpServer' as const; @@ -43,6 +44,31 @@ interface BunHttpServerOptions { * @default 'medium' */ maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + + /** + * Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`. + * + * The `urlPath` param consists of the URL path and query string (if any) of the incoming request. + */ + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; + + /** + * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. + * + * @default true + */ + ignoreStaticAssets?: boolean; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, but before it is recorded. + */ + onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; + + /** + * A hook that can be used to mutate the span one last time when the response is finished. + */ + onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } let hasPatched = false; @@ -95,7 +121,12 @@ export function instrumentBunHttpServer(options: BunHttpServerOptions = {}): voi return; } - const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions(options); + const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions({ + ...options, + // Pass the real `errorMonitor` symbol so core observes `'error'` events without consuming + // them — otherwise it would swallow errors before they reach user-supplied `'error'` handlers. + errorMonitor, + }); // Track which servers we have already handed to core, so we instrument each server exactly once. // After core instruments a server it installs its own `emit` on the instance, which shadows this diff --git a/packages/bun/test/integrations/bunHttpServer.test.ts b/packages/bun/test/integrations/bunHttpServer.test.ts index 83aaed4f4789..1834f4eb9f15 100644 --- a/packages/bun/test/integrations/bunHttpServer.test.ts +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -1,7 +1,7 @@ import http from 'node:http'; import { getActiveSpan, getTraceData, spanToJSON } from '@sentry/core'; import { beforeAll, describe, expect, test } from 'bun:test'; -import { bunHttpServerIntegration, init } from '../../src'; +import { init } from '../../src'; async function startServer(handler: http.RequestListener): Promise<{ port: number; close: () => Promise }> { const server = http.createServer(handler);