-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(bun): Add bunHttpServerIntegration
#22870
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
0df122c
feat(bun): Add `bunHttpServerIntegration`
mydea 19d37d8
feat(bun): Instrument http module on bun
mydea aa90c89
test(bun): Cover span emission in bunHttpServerIntegration
mydea 0166d24
Apply suggestions from code review
mydea d3b91f3
fix options
mydea 5c0580d
fixes
mydea File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| // 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<object>(); | ||
|
|
||
| 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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> }> { | ||
| const server = http.createServer(handler); | ||
| const port = await new Promise<number>(resolve => { | ||
| server.listen(0, () => resolve((server.address() as { port: number }).port)); | ||
| }); | ||
| return { | ||
| port, | ||
| close: () => new Promise<void>(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<typeof spanToJSON> | 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<string | undefined> = []; | ||
|
|
||
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.