Skip to content

Commit 98d00aa

Browse files
mydeaclaude
andcommitted
feat(bun): Instrument outgoing fetch requests
Add a `fetchIntegration` to `@sentry/bun` that patches the global `fetch` to create `http.client` spans, record breadcrumbs, and inject trace-propagation headers. Bun implements `fetch` natively rather than through undici, so the Node SDK's `nativeNodeFetchIntegration` (which subscribes to undici's `diagnostics_channel` events) never fires. The new integration mirrors the Cloudflare approach and replaces `nativeNodeFetchIntegration` in the Bun default integrations. Also fix an inverted guard in core's `instrumentFetch`: `skipNativeFetchCheck` did the opposite of its name — passing `true` performed the browser-only native check, while the default skipped it. Only Cloudflare/Bun passed `true`, so they were the only callers that ran the check. The guard now reads `!skipNativeFetchCheck && !supportsNativeFetch()` so the parameter matches its name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2a913fd commit 98d00aa

11 files changed

Lines changed: 341 additions & 15 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as Sentry from '@sentry/bun';
2+
3+
// The target server the instrumented app makes outgoing fetch requests to. It
4+
// echoes back the headers it received so the test can assert on trace propagation.
5+
const targetServer = Bun.serve({
6+
port: 0,
7+
fetch(request) {
8+
const headers = Object.fromEntries(request.headers.entries());
9+
return Response.json({ headers });
10+
},
11+
});
12+
13+
const targetUrl = `http://localhost:${targetServer.port}`;
14+
15+
Sentry.init({
16+
traceLifecycle: 'static',
17+
environment: 'production',
18+
dsn: process.env.SENTRY_DSN,
19+
tracesSampleRate: 1.0,
20+
// Only the `/allowed` path is a propagation target (matching is substring-based),
21+
// so requests to `/disallowed` below must NOT receive sentry-trace/baggage headers.
22+
tracePropagationTargets: [`${targetUrl}/allowed`],
23+
});
24+
25+
const server = Bun.serve({
26+
port: 0,
27+
async fetch(request) {
28+
const url = new URL(request.url);
29+
30+
if (url.pathname === '/outgoing-fetch') {
31+
const response = await fetch(`${targetUrl}/allowed`);
32+
const data = await response.json();
33+
return Response.json(data);
34+
}
35+
36+
if (url.pathname === '/outgoing-fetch-disallowed') {
37+
const response = await fetch(`${targetUrl}/disallowed`);
38+
const data = await response.json();
39+
return Response.json(data);
40+
}
41+
42+
return new Response('Hello from Bun!');
43+
},
44+
});
45+
46+
process.send?.(JSON.stringify({ event: 'READY', port: server.port }));
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Envelope, TransactionEvent } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../runner';
4+
5+
function getTransaction(envelope: Envelope): TransactionEvent {
6+
return envelope[1][0][1] as TransactionEvent;
7+
}
8+
9+
it('creates an http.client span for outgoing fetch requests', async ({ signal }) => {
10+
const runner = createRunner(__dirname)
11+
.expect(envelope => {
12+
const transaction = getTransaction(envelope);
13+
14+
expect(transaction.transaction).toBe('GET /outgoing-fetch');
15+
16+
const httpClientSpan = transaction.spans?.find(span => span.op === 'http.client');
17+
18+
expect(httpClientSpan).toBeDefined();
19+
expect(httpClientSpan).toMatchObject({
20+
op: 'http.client',
21+
origin: 'auto.http.fetch',
22+
description: expect.stringMatching(/^GET http:\/\/localhost:\d+\/allowed$/),
23+
data: expect.objectContaining({
24+
'http.method': 'GET',
25+
type: 'fetch',
26+
}),
27+
});
28+
})
29+
.start(signal);
30+
31+
await runner.makeRequest('get', '/outgoing-fetch');
32+
await runner.completed();
33+
});
34+
35+
it('propagates sentry-trace and baggage headers to allowed outgoing fetch requests', async ({ signal }) => {
36+
const runner = createRunner(__dirname).start(signal);
37+
38+
const response = await runner.makeRequest<{ headers: Record<string, string> }>('get', '/outgoing-fetch');
39+
40+
const traceId = response?.headers['sentry-trace']?.split('-')[0];
41+
42+
expect(response?.headers['sentry-trace']).toMatch(/^[\da-f]{32}-[\da-f]{16}-1$/);
43+
expect(response?.headers.baggage).toContain('sentry-environment=production');
44+
expect(response?.headers.baggage).toContain(`sentry-trace_id=${traceId}`);
45+
});
46+
47+
it('does not propagate headers to outgoing fetch requests outside tracePropagationTargets', async ({ signal }) => {
48+
const runner = createRunner(__dirname).start(signal);
49+
50+
const response = await runner.makeRequest<{ headers: Record<string, string> }>('get', '/outgoing-fetch-disallowed');
51+
52+
expect(response?.headers['sentry-trace']).toBeUndefined();
53+
expect(response?.headers.baggage).toBeUndefined();
54+
});
55+
56+
it('records a breadcrumb for outgoing fetch requests', async ({ signal }) => {
57+
const runner = createRunner(__dirname)
58+
.expect(envelope => {
59+
const transaction = getTransaction(envelope);
60+
61+
const fetchBreadcrumb = transaction.breadcrumbs?.find(
62+
breadcrumb => breadcrumb.category === 'fetch' && (breadcrumb.data?.url as string)?.includes('/allowed'),
63+
);
64+
65+
expect(fetchBreadcrumb).toMatchObject({
66+
category: 'fetch',
67+
type: 'http',
68+
data: expect.objectContaining({
69+
method: 'GET',
70+
status_code: 200,
71+
}),
72+
});
73+
})
74+
.start(signal);
75+
76+
await runner.makeRequest('get', '/outgoing-fetch');
77+
await runner.completed();
78+
});

dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ test('Includes sentry-trace and baggage in response headers', async ({ baseURL }
1313
expect(baggage).toContain('sentry-trace_id=');
1414
});
1515

16-
// Bun's native fetch does not emit undici diagnostics channels,
17-
// so the nativeNodeFetchIntegration cannot inject sentry-trace/baggage headers.
18-
// These tests document the desired behavior and will pass once Bun adds support
19-
// for undici diagnostics channels or an alternative propagation mechanism is added.
16+
// Bun's native fetch does not emit undici diagnostics channels, so the
17+
// nativeNodeFetchIntegration cannot see these requests. `@sentry/bun`'s
18+
// `fetchIntegration` instead patches the global `fetch` (like Cloudflare), which
19+
// is what creates the spans and injects sentry-trace/baggage headers below.
2020

21-
test.fixme('Propagates trace for outgoing fetch requests', async ({ baseURL }) => {
21+
test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => {
2222
const id = randomUUID();
2323

2424
const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => {
@@ -64,7 +64,7 @@ test.fixme('Propagates trace for outgoing fetch requests', async ({ baseURL }) =
6464
expect(inboundTransaction.contexts?.trace?.trace_id).toBe(traceId);
6565
});
6666

67-
test.fixme('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => {
67+
test('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => {
6868
const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => {
6969
return (
7070
transactionEvent.contexts?.trace?.op === 'http.server' &&

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,4 +1,5 @@
11
import * as Sentry from '@sentry/nextjs';
2+
import { bunServerIntegration, fetchIntegration } from '@sentry/bun';
23

34
Sentry.init({
45
traceLifecycle: 'static',
@@ -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+
integrations: [
13+
// Adding bun-specific integration here
14+
fetchIntegration(),
15+
],
1116
});

dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForTransaction } from '@sentry-internal/test-utils';
33

4-
// Bun runtime does not propagate trace headers for outgoing fetch requests.
5-
// The OTel node_fetch instrumentation does not intercept Bun's native fetch,
6-
// so sentry-trace and baggage headers are not attached to outgoing requests.
7-
// This test documents the current limitation - un-skip when Bun fetch instrumentation is supported.
8-
test.skip('Propagates trace for outgoing fetch requests', async ({ baseURL, request }) => {
4+
test('Propagates trace for outgoing fetch requests', async ({ baseURL, request }) => {
95
const inboundTransactionPromise = waitForTransaction('nextjs-16-bun', transactionEvent => {
106
return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch/check';
117
});
@@ -22,8 +18,12 @@ test.skip('Propagates trace for outgoing fetch requests', async ({ baseURL, requ
2218
expect(inboundTransaction.contexts?.trace?.trace_id).toStrictEqual(expect.any(String));
2319
expect(inboundTransaction.contexts?.trace?.trace_id).toBe(outboundTransaction.contexts?.trace?.trace_id);
2420

21+
// Although we have a fetch http.client span, we propagate through Next.js and AppRouteRouteHandlers.runHandler
22+
// as that is the active span at that time - not ideal, but it's the best we can do.
2523
const httpClientSpan = outboundTransaction.spans?.find(
26-
span => span.op === 'http.client' && span.data?.['sentry.origin'] === 'auto.http.otel.node_fetch',
24+
span =>
25+
span.data?.['next.span_type'] === 'AppRouteRouteHandlers.runHandler' &&
26+
span.data?.['next.route'] === '/propagation/test-outgoing-fetch',
2727
);
2828

2929
expect(httpClientSpan).toBeDefined();

packages/bun/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,5 +199,6 @@ export {
199199
initWithoutDefaultIntegrations,
200200
} from './sdk';
201201
export { bunServerIntegration } from './integrations/bunserver';
202+
export { fetchIntegration } from './integrations/fetch';
202203
export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics';
203204
export { makeFetchTransport } from './transports';
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import type {
2+
Client,
3+
FetchBreadcrumbData,
4+
FetchBreadcrumbHint,
5+
HandlerDataFetch,
6+
IntegrationFn,
7+
Span,
8+
} from '@sentry/core';
9+
import {
10+
addBreadcrumb,
11+
addFetchInstrumentationHandler,
12+
defineIntegration,
13+
getBreadcrumbLogLevelFromHttpStatusCode,
14+
getClient,
15+
instrumentFetchRequest,
16+
isSentryRequestUrl,
17+
LRUMap,
18+
stringMatchesSomePattern,
19+
} from '@sentry/core';
20+
21+
const INTEGRATION_NAME = 'Fetch' as const;
22+
23+
const HAS_CLIENT_MAP = new WeakMap<Client, boolean>();
24+
25+
interface FetchOptions {
26+
/**
27+
* Whether breadcrumbs should be recorded for requests.
28+
* Defaults to true.
29+
*/
30+
breadcrumbs?: boolean;
31+
32+
/**
33+
* Function determining whether or not to create spans to track outgoing requests to the given URL.
34+
* By default, spans will be created for all outgoing requests.
35+
*/
36+
shouldCreateSpanForRequest?: (url: string) => boolean;
37+
}
38+
39+
const _fetchIntegration = ((options: FetchOptions = {}) => {
40+
const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs;
41+
const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest;
42+
43+
const _createSpanUrlMap = new LRUMap<string, boolean>(100);
44+
const _headersUrlMap = new LRUMap<string, boolean>(100);
45+
46+
const spans: Record<string, Span> = {};
47+
48+
/** Decides whether to attach trace data to the outgoing fetch request */
49+
function _shouldAttachTraceData(url: string): boolean {
50+
const client = getClient();
51+
52+
if (!client) {
53+
return false;
54+
}
55+
56+
const clientOptions = client.getOptions();
57+
58+
if (clientOptions.tracePropagationTargets === undefined) {
59+
return true;
60+
}
61+
62+
const cachedDecision = _headersUrlMap.get(url);
63+
if (cachedDecision !== undefined) {
64+
return cachedDecision;
65+
}
66+
67+
const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets);
68+
_headersUrlMap.set(url, decision);
69+
return decision;
70+
}
71+
72+
/** Helper that wraps shouldCreateSpanForRequest option */
73+
function _shouldCreateSpan(url: string): boolean {
74+
if (shouldCreateSpanForRequest === undefined) {
75+
return true;
76+
}
77+
78+
const cachedDecision = _createSpanUrlMap.get(url);
79+
if (cachedDecision !== undefined) {
80+
return cachedDecision;
81+
}
82+
83+
const decision = shouldCreateSpanForRequest(url);
84+
_createSpanUrlMap.set(url, decision);
85+
return decision;
86+
}
87+
88+
return {
89+
name: INTEGRATION_NAME,
90+
setupOnce() {
91+
addFetchInstrumentationHandler(handlerData => {
92+
const client = getClient();
93+
const { propagateTraceparent } = client?.getOptions() || {};
94+
if (!client || !HAS_CLIENT_MAP.get(client)) {
95+
return;
96+
}
97+
98+
if (isSentryRequestUrl(handlerData.fetchData.url, client)) {
99+
return;
100+
}
101+
102+
instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, {
103+
spanOrigin: 'auto.http.fetch',
104+
propagateTraceparent,
105+
});
106+
107+
if (breadcrumbs) {
108+
createBreadcrumb(handlerData);
109+
}
110+
// `skipNativeFetchCheck: true` — the native-fetch check is browser-only (it probes for
111+
// `[native code]` and falls back to an iframe DOM check). On Bun `fetch` may already be
112+
// wrapped by the host (e.g. Next.js) and there is no DOM, so we always patch the global.
113+
}, true);
114+
},
115+
setup(client) {
116+
HAS_CLIENT_MAP.set(client, true);
117+
},
118+
};
119+
}) satisfies IntegrationFn;
120+
121+
/**
122+
* Instruments outgoing `fetch` requests in Bun: creates spans, records breadcrumbs and
123+
* attaches trace propagation headers.
124+
*
125+
* Bun does not route `fetch` through undici's `diagnostics_channel`, so unlike Node the
126+
* `nativeNodeFetchIntegration` cannot observe these requests. This integration instead patches
127+
* the global `fetch`, mirroring the Cloudflare SDK.
128+
*/
129+
export const fetchIntegration = defineIntegration(_fetchIntegration);
130+
131+
function createBreadcrumb(handlerData: HandlerDataFetch): void {
132+
const { startTimestamp, endTimestamp } = handlerData;
133+
134+
// We only capture complete fetch requests
135+
if (!endTimestamp) {
136+
return;
137+
}
138+
139+
const breadcrumbData: FetchBreadcrumbData = {
140+
method: handlerData.fetchData.method,
141+
url: handlerData.fetchData.url,
142+
};
143+
144+
if (handlerData.error) {
145+
const hint: FetchBreadcrumbHint = {
146+
data: handlerData.error,
147+
input: handlerData.args,
148+
startTimestamp,
149+
endTimestamp,
150+
};
151+
152+
addBreadcrumb(
153+
{
154+
category: 'fetch',
155+
data: breadcrumbData,
156+
level: 'error',
157+
type: 'http',
158+
},
159+
hint,
160+
);
161+
} else {
162+
const response = handlerData.response as Response | undefined;
163+
164+
breadcrumbData.request_body_size = handlerData.fetchData.request_body_size;
165+
breadcrumbData.response_body_size = handlerData.fetchData.response_body_size;
166+
breadcrumbData.status_code = response?.status;
167+
168+
const hint: FetchBreadcrumbHint = {
169+
input: handlerData.args,
170+
response,
171+
startTimestamp,
172+
endTimestamp,
173+
};
174+
const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code);
175+
176+
addBreadcrumb(
177+
{
178+
category: 'fetch',
179+
data: breadcrumbData,
180+
type: 'http',
181+
level,
182+
},
183+
hint,
184+
);
185+
}
186+
}

0 commit comments

Comments
 (0)