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
46 changes: 46 additions & 0 deletions dev-packages/bun-integration-tests/suites/fetch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as Sentry from '@sentry/bun';

// The target server the instrumented app makes outgoing fetch requests to. It
// echoes back the headers it received so the test can assert on trace propagation.
const targetServer = Bun.serve({
port: 0,
fetch(request) {
const headers = Object.fromEntries(request.headers.entries());
return Response.json({ headers });
},
});

const targetUrl = `http://localhost:${targetServer.port}`;

Sentry.init({
traceLifecycle: 'static',
environment: 'production',
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
// Only the `/allowed` path is a propagation target (matching is substring-based),
// so requests to `/disallowed` below must NOT receive sentry-trace/baggage headers.
tracePropagationTargets: [`${targetUrl}/allowed`],
});

const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url);

if (url.pathname === '/outgoing-fetch') {
const response = await fetch(`${targetUrl}/allowed`);
const data = await response.json();
return Response.json(data);
}

if (url.pathname === '/outgoing-fetch-disallowed') {
const response = await fetch(`${targetUrl}/disallowed`);
const data = await response.json();
return Response.json(data);
}

return new Response('Hello from Bun!');
},
});

process.send?.(JSON.stringify({ event: 'READY', port: server.port }));
78 changes: 78 additions & 0 deletions dev-packages/bun-integration-tests/suites/fetch/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Envelope, TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../runner';

function getTransaction(envelope: Envelope): TransactionEvent {
return envelope[1][0][1] as TransactionEvent;
}

it('creates an http.client span for outgoing fetch requests', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const transaction = getTransaction(envelope);

expect(transaction.transaction).toBe('GET /outgoing-fetch');

const httpClientSpan = transaction.spans?.find(span => span.op === 'http.client');

expect(httpClientSpan).toBeDefined();
expect(httpClientSpan).toMatchObject({
op: 'http.client',
origin: 'auto.http.fetch',
description: expect.stringMatching(/^GET http:\/\/localhost:\d+\/allowed$/),
data: expect.objectContaining({
'http.method': 'GET',
type: 'fetch',
}),
});
})
.start(signal);

await runner.makeRequest('get', '/outgoing-fetch');
await runner.completed();
});

it('propagates sentry-trace and baggage headers to allowed outgoing fetch requests', async ({ signal }) => {
const runner = createRunner(__dirname).start(signal);

const response = await runner.makeRequest<{ headers: Record<string, string> }>('get', '/outgoing-fetch');

const traceId = response?.headers['sentry-trace']?.split('-')[0];

expect(response?.headers['sentry-trace']).toMatch(/^[\da-f]{32}-[\da-f]{16}-1$/);
expect(response?.headers.baggage).toContain('sentry-environment=production');
expect(response?.headers.baggage).toContain(`sentry-trace_id=${traceId}`);
});

it('does not propagate headers to outgoing fetch requests outside tracePropagationTargets', async ({ signal }) => {
const runner = createRunner(__dirname).start(signal);

const response = await runner.makeRequest<{ headers: Record<string, string> }>('get', '/outgoing-fetch-disallowed');

expect(response?.headers['sentry-trace']).toBeUndefined();
expect(response?.headers.baggage).toBeUndefined();
});

it('records a breadcrumb for outgoing fetch requests', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const transaction = getTransaction(envelope);

const fetchBreadcrumb = transaction.breadcrumbs?.find(
breadcrumb => breadcrumb.category === 'fetch' && (breadcrumb.data?.url as string)?.includes('/allowed'),
);

expect(fetchBreadcrumb).toMatchObject({
category: 'fetch',
type: 'http',
data: expect.objectContaining({
method: 'GET',
status_code: 200,
}),
});
})
.start(signal);

await runner.makeRequest('get', '/outgoing-fetch');
await runner.completed();
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ test('Includes sentry-trace and baggage in response headers', async ({ baseURL }
expect(baggage).toContain('sentry-trace_id=');
});

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

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

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

test.fixme('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => {
test('Propagates trace for outgoing fetch to external allowed URL', async ({ baseURL }) => {
const inboundTransactionPromise = waitForTransaction('elysia-bun', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' &&
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,4 +1,5 @@
import * as Sentry from '@sentry/nextjs';
import { bunServerIntegration, fetchIntegration } from '@sentry/bun';

Sentry.init({
traceLifecycle: 'static',
Expand All @@ -8,4 +9,8 @@ Sentry.init({
tracesSampleRate: 1.0,
dataCollection: { userInfo: true },
tracePropagationTargets: ['http://localhost:3030/propagation/test-outgoing-fetch/check'],
integrations: [
// Adding bun-specific integration here
fetchIntegration(),
],
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

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

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

expect(httpClientSpan).toBeDefined();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const NODE_EXPORTS_IGNORE = [
'preloadOpenTelemetry',
// Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration)
'_INTERNAL_normalizeCollectionInterval',
// not exported by bun
'nativeNodeFetchIntegration',
];

const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e));
Expand Down
2 changes: 1 addition & 1 deletion packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export {
httpIntegration,
httpServerIntegration,
httpServerSpansIntegration,
nativeNodeFetchIntegration,
onUncaughtExceptionIntegration,
onUnhandledRejectionIntegration,
openAIIntegration,
Expand Down Expand Up @@ -199,5 +198,6 @@ export {
initWithoutDefaultIntegrations,
} from './sdk';
export { bunServerIntegration } from './integrations/bunserver';
export { fetchIntegration } from './integrations/fetch';
export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics';
export { makeFetchTransport } from './transports';
Loading
Loading