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..6cf95b37ab35 --- /dev/null +++ b/packages/bun/src/integrations/bunHttpServer.ts @@ -0,0 +1,158 @@ +import { errorMonitor } from 'node:events'; +import http from 'node:http'; +import https from 'node:https'; +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; + +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'; + + /** + * 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; + +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({ + ...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 + // 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/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 new file mode 100644 index 000000000000..1834f4eb9f15 --- /dev/null +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -0,0 +1,87 @@ +import http from 'node:http'; +import { getActiveSpan, getTraceData, spanToJSON } from '@sentry/core'; +import { beforeAll, describe, expect, test } from 'bun:test'; +import { init } from '../../src'; + +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())), + }; +} + +/** 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({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + // Avoid sending anything to Sentry + transport: () => ({ send: async () => ({}), flush: async () => true }), + }); + }); + + 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 = []; + + const { port, close } = await startServer((_req, res) => { + traceIds.push(currentTraceId()); + 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 = currentTraceId(); + res.end('ok'); + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'sentry-trace': `${incomingTraceId}-1234567890abcdef-1` }, + }).then(res => res.text()); + + await close(); + + expect(observedTraceId).toBe(incomingTraceId); + }); +});