Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const flushMarkerMatcher = (envelope: Envelope): void => {

it('instruments sync KV operations on Durable Object storage', async ({ signal }) => {
const runner = createRunner(__dirname)
.unordered()
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent | undefined;
const spans = transactionEvent?.spans ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { bunHttpServerIntegration } from '@sentry/bun';
import * as Sentry from '@sentry/nextjs';

Sentry.init({
Expand All @@ -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 })],
});
7 changes: 0 additions & 7 deletions packages/browser/src/tracing/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
getClient,
getCurrentScope,
getDynamicSamplingContextFromSpan,
getIsolationScope,
getLocationHref,
GLOBAL_OBJ,
hasSpansEnabled,
Expand Down Expand Up @@ -554,12 +553,6 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption

maybeEndActiveSpan();

getIsolationScope().setPropagationContext({
traceId: generateTraceId(),
sampleRand: Math.random(),
propagationSpanId: hasSpansEnabled() ? undefined : generateSpanId(),
});

const scope = getCurrentScope();
scope.setPropagationContext({
traceId: generateTraceId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -937,37 +937,24 @@ describe('browserTracingIntegration', () => {
setCurrentClient(client);
client.init();

const oldIsolationScopePropCtx = getIsolationScope().getPropagationContext();
const oldCurrentScopePropCtx = getCurrentScope().getPropagationContext();

startBrowserTracingNavigationSpan(client, { name: 'test navigation span' });

const newIsolationScopePropCtx = getIsolationScope().getPropagationContext();
const newCurrentScopePropCtx = getCurrentScope().getPropagationContext();

expect(oldCurrentScopePropCtx).toEqual({
traceId: expect.stringMatching(/[a-f0-9]{32}/),
propagationSpanId: expect.stringMatching(/[a-f0-9]{16}/),
sampleRand: expect.any(Number),
});
expect(oldIsolationScopePropCtx).toEqual({
traceId: expect.stringMatching(/[a-f0-9]{32}/),
sampleRand: expect.any(Number),
});

expect(newCurrentScopePropCtx).toEqual({
traceId: expect.stringMatching(/[a-f0-9]{32}/),
propagationSpanId: expect.stringMatching(/[a-f0-9]{16}/),
sampleRand: expect.any(Number),
});
expect(newIsolationScopePropCtx).toEqual({
traceId: expect.stringMatching(/[a-f0-9]{32}/),
propagationSpanId: expect.stringMatching(/[a-f0-9]{16}/),
sampleRand: expect.any(Number),
});

expect(newIsolationScopePropCtx.traceId).not.toEqual(oldIsolationScopePropCtx.traceId);
expect(newCurrentScopePropCtx.traceId).not.toEqual(oldCurrentScopePropCtx.traceId);
expect(newIsolationScopePropCtx.propagationSpanId).not.toEqual(oldIsolationScopePropCtx.propagationSpanId);
});

it("saves the span's positive sampling decision and its DSC on the propagationContext when the span finishes", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
158 changes: 158 additions & 0 deletions packages/bun/src/integrations/bunHttpServer.ts
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,
});

// 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;
}
2 changes: 2 additions & 0 deletions packages/bun/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,6 +89,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] {
processSessionIntegration(),
// Bun Specific
bunServerIntegration(),
bunHttpServerIntegration(),
];
}

Expand Down
87 changes: 87 additions & 0 deletions packages/bun/test/integrations/bunHttpServer.test.ts
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);
});
});
2 changes: 1 addition & 1 deletion packages/cloudflare/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"message": "Do not import from `@sentry/node` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points."
},
{
"group": ["@sentry/server-utils/**"],
"group": ["@sentry/server-utils/**", "!@sentry/server-utils/no-diagnostic-channels"],
"message": "Do not import from `@sentry/server-utils` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points."
}
]
Expand Down
Loading
Loading