diff --git a/dev-packages/bun-integration-tests/suites/fetch/index.ts b/dev-packages/bun-integration-tests/suites/fetch/index.ts new file mode 100644 index 000000000000..972d6a3aba45 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/fetch/index.ts @@ -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 })); diff --git a/dev-packages/bun-integration-tests/suites/fetch/test.ts b/dev-packages/bun-integration-tests/suites/fetch/test.ts new file mode 100644 index 000000000000..114b6bbdb2aa --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/fetch/test.ts @@ -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 }>('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 }>('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(); +}); diff --git a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts index c07dea3c9dc6..c3c3bdd5b2bc 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/elysia-bun/tests/propagation.test.ts @@ -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 => { @@ -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' && 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..2f27120f55e5 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,4 +1,5 @@ import * as Sentry from '@sentry/nextjs'; +import { bunServerIntegration, fetchIntegration } from '@sentry/bun'; Sentry.init({ traceLifecycle: 'static', @@ -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(), + ], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts index 850a07bc25a2..9558a5f1c685 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts @@ -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'; }); @@ -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(); diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 079e2cfab7fc..14aa5018e226 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -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)); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4a6c25eed27d..c18d3f4e4938 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -80,7 +80,6 @@ export { httpIntegration, httpServerIntegration, httpServerSpansIntegration, - nativeNodeFetchIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, @@ -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'; diff --git a/packages/bun/src/integrations/fetch.ts b/packages/bun/src/integrations/fetch.ts new file mode 100644 index 000000000000..d002ee4875c9 --- /dev/null +++ b/packages/bun/src/integrations/fetch.ts @@ -0,0 +1,179 @@ +import type { + Client, + FetchBreadcrumbData, + FetchBreadcrumbHint, + HandlerDataFetch, + IntegrationFn, + Span, +} from '@sentry/core'; +import { + addBreadcrumb, + addFetchInstrumentationHandler, + defineIntegration, + getBreadcrumbLogLevelFromHttpStatusCode, + getClient, + instrumentFetchRequest, + isSentryRequestUrl, + LRUMap, + stringMatchesSomePattern, +} from '@sentry/core'; + +const INTEGRATION_NAME = 'Fetch' as const; + +const HAS_CLIENT_MAP = new WeakMap(); + +interface FetchOptions { + /** + * Whether breadcrumbs should be recorded for requests. + * Defaults to true. + */ + breadcrumbs?: boolean; + + /** + * Function determining whether or not to create spans to track outgoing requests to the given URL. + * By default, spans will be created for all outgoing requests. + */ + shouldCreateSpanForRequest?: (url: string) => boolean; +} + +const _fetchIntegration = ((options: FetchOptions = {}) => { + const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs; + const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; + + const _createSpanUrlMap = new LRUMap(100); + const _headersUrlMap = new LRUMap(100); + + const spans: Record = {}; + + /** Decides whether to attach trace data to the outgoing fetch request */ + function _shouldAttachTraceData(url: string): boolean { + const client = getClient(); + + if (!client) { + return false; + } + + const clientOptions = client.getOptions(); + + if (clientOptions.tracePropagationTargets === undefined) { + return true; + } + + const cachedDecision = _headersUrlMap.get(url); + if (cachedDecision !== undefined) { + return cachedDecision; + } + + const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets); + _headersUrlMap.set(url, decision); + return decision; + } + + /** Helper that wraps shouldCreateSpanForRequest option */ + function _shouldCreateSpan(url: string): boolean { + if (shouldCreateSpanForRequest === undefined) { + return true; + } + + const cachedDecision = _createSpanUrlMap.get(url); + if (cachedDecision !== undefined) { + return cachedDecision; + } + + const decision = shouldCreateSpanForRequest(url); + _createSpanUrlMap.set(url, decision); + return decision; + } + + return { + name: INTEGRATION_NAME, + setupOnce() { + addFetchInstrumentationHandler(handlerData => { + const client = getClient(); + if (!client || !HAS_CLIENT_MAP.get(client)) { + return; + } + const { propagateTraceparent } = client.getOptions(); + + if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + return; + } + + instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { + spanOrigin: 'auto.http.fetch', + propagateTraceparent, + }); + + if (breadcrumbs) { + createBreadcrumb(handlerData); + } + }); + }, + setup(client) { + HAS_CLIENT_MAP.set(client, true); + }, + }; +}) satisfies IntegrationFn; + +/** + * Instruments outgoing `fetch` requests in Bun: creates spans, records breadcrumbs and + * attaches trace propagation headers. + */ +export const fetchIntegration = defineIntegration(_fetchIntegration); + +function createBreadcrumb(handlerData: HandlerDataFetch): void { + const { startTimestamp, endTimestamp } = handlerData; + + // We only capture complete fetch requests + if (!endTimestamp) { + return; + } + + const breadcrumbData: FetchBreadcrumbData = { + method: handlerData.fetchData.method, + url: handlerData.fetchData.url, + }; + + if (handlerData.error) { + const hint: FetchBreadcrumbHint = { + data: handlerData.error, + input: handlerData.args, + startTimestamp, + endTimestamp, + }; + + addBreadcrumb( + { + category: 'fetch', + data: breadcrumbData, + level: 'error', + type: 'http', + }, + hint, + ); + } else { + const response = handlerData.response as Response | undefined; + + breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; + breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; + breadcrumbData.status_code = response?.status; + + const hint: FetchBreadcrumbHint = { + input: handlerData.args, + response, + startTimestamp, + endTimestamp, + }; + const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); + + addBreadcrumb( + { + category: 'fetch', + data: breadcrumbData, + type: 'http', + level, + }, + hint, + ); + } +} diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 1d23447ed43d..9fe43b1ccb23 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -16,7 +16,6 @@ import { httpIntegration, init as initNode, modulesIntegration, - nativeNodeFetchIntegration, nodeContextIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, @@ -24,6 +23,7 @@ import { } from '@sentry/node'; import { channelIntegrations, isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; import { bunServerIntegration } from './integrations/bunserver'; +import { fetchIntegration } from './integrations/fetch'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; @@ -77,7 +77,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { // Native Wrappers consoleIntegration(), httpIntegration(), - nativeNodeFetchIntegration(), + fetchIntegration(), // Global Handlers onUncaughtExceptionIntegration(), onUnhandledRejectionIntegration(), diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 775de5236c55..4e984da10e80 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -44,7 +44,7 @@ describe('Bun Serve Integration', () => { expect(startSpanSpy).toHaveBeenCalledTimes(1); expect(startSpanSpy).toHaveBeenLastCalledWith( { - attributes: { + attributes: expect.objectContaining({ 'sentry.origin': 'auto.http.bun.serve', 'http.request.method': 'GET', 'sentry.source': 'url', @@ -59,7 +59,7 @@ describe('Bun Serve Integration', () => { 'http.request.header.connection': 'keep-alive', 'http.request.header.host': expect.any(String), 'http.request.header.user_agent': expect.stringContaining('Bun'), - }, + }), op: 'http.server', name: 'GET /users', }, @@ -88,7 +88,7 @@ describe('Bun Serve Integration', () => { expect(startSpanSpy).toHaveBeenCalledTimes(1); expect(startSpanSpy).toHaveBeenLastCalledWith( { - attributes: { + attributes: expect.objectContaining({ 'sentry.origin': 'auto.http.bun.serve', 'http.request.method': 'POST', 'sentry.source': 'url', @@ -103,7 +103,7 @@ describe('Bun Serve Integration', () => { 'http.request.header.content_length': '0', 'http.request.header.host': expect.any(String), 'http.request.header.user_agent': expect.stringContaining('Bun'), - }, + }), op: 'http.server', name: 'POST /', }, @@ -195,6 +195,8 @@ describe('Bun Serve Integration', () => { 'http.request.header.connection': 'keep-alive', 'http.request.header.content_length': '15', 'http.request.header.host': expect.any(String), + 'http.request.header.baggage': expect.any(String), + 'http.request.header.sentry_trace': expect.any(String), }), op: 'http.server', name: 'POST /api/test', diff --git a/packages/core/src/instrument/fetch.ts b/packages/core/src/instrument/fetch.ts index b7d5d6dd0847..88998a2dc6f6 100644 --- a/packages/core/src/instrument/fetch.ts +++ b/packages/core/src/instrument/fetch.ts @@ -53,108 +53,112 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void): void { return; } - fill(GLOBAL_OBJ, 'fetch', function (originalFetch: () => void): () => void { - return function (...args: any[]): void { - // We capture the error right here and not in the Promise error callback because Safari (and probably other - // browsers too) will wipe the stack trace up to this point, only leaving us with this file which is useless. - - // NOTE: If you are a Sentry user, and you are seeing this stack frame, - // it means the error, that was caused by your fetch call did not - // have a stack trace, so the SDK backfilled the stack trace so - // you can see which fetch call failed. - const virtualError = new Error(); - - const { method, url } = parseFetchArgs(args); - const handlerData: HandlerDataFetch = { - args, - fetchData: { - method, - url, - }, - startTimestamp: timestampInSeconds() * 1000, - // // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation - virtualError, - headers: getHeadersFromFetchArgs(args), - }; - - // if there is no callback, fetch is instrumented directly - if (!onFetchResolved) { - triggerHandlers('fetch', { - ...handlerData, - }); - } + // We wrap `fetch` in a `Proxy` rather than a plain closure so that non-standard own properties some + // runtimes hang off the global `fetch` (e.g. Bun's `fetch.preconnect`) keep working - property access + // and `toString()` transparently forward to the native implementation. + fill(GLOBAL_OBJ, 'fetch', function (originalFetch: (...args: any[]) => Promise): unknown { + return new Proxy(originalFetch, { + apply(target, _thisArg, args: any[]): Promise { + // We capture the error right here and not in the Promise error callback because Safari (and probably other + // browsers too) will wipe the stack trace up to this point, only leaving us with this file which is useless. + + // NOTE: If you are a Sentry user, and you are seeing this stack frame, + // it means the error, that was caused by your fetch call did not + // have a stack trace, so the SDK backfilled the stack trace so + // you can see which fetch call failed. + const virtualError = new Error(); + + const { method, url } = parseFetchArgs(args); + const handlerData: HandlerDataFetch = { + args, + fetchData: { + method, + url, + }, + startTimestamp: timestampInSeconds() * 1000, + // // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation + virtualError, + headers: getHeadersFromFetchArgs(args), + }; + + // if there is no callback, fetch is instrumented directly + if (!onFetchResolved) { + triggerHandlers('fetch', { + ...handlerData, + }); + } + + return Reflect.apply(target, GLOBAL_OBJ, args).then( + async (response: Response) => { + if (onFetchResolved) { + onFetchResolved(response); + } else { + triggerHandlers('fetch', { + ...handlerData, + endTimestamp: timestampInSeconds() * 1000, + response, + }); + } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return originalFetch.apply(GLOBAL_OBJ, args).then( - async (response: Response) => { - if (onFetchResolved) { - onFetchResolved(response); - } else { + return response; + }, + (error: Error) => { triggerHandlers('fetch', { ...handlerData, endTimestamp: timestampInSeconds() * 1000, - response, + error, }); - } - return response; - }, - (error: Error) => { - triggerHandlers('fetch', { - ...handlerData, - endTimestamp: timestampInSeconds() * 1000, - error, - }); + if (isError(error) && error.stack === undefined) { + // NOTE: If you are a Sentry user, and you are seeing this stack frame, + // it means the error, that was caused by your fetch call did not + // have a stack trace, so the SDK backfilled the stack trace so + // you can see which fetch call failed. + error.stack = virtualError.stack; + addNonEnumerableProperty(error, 'framesToPop', 1); + } - if (isError(error) && error.stack === undefined) { - // NOTE: If you are a Sentry user, and you are seeing this stack frame, - // it means the error, that was caused by your fetch call did not - // have a stack trace, so the SDK backfilled the stack trace so - // you can see which fetch call failed. - error.stack = virtualError.stack; - addNonEnumerableProperty(error, 'framesToPop', 1); - } - - // We enhance fetch error messages with hostname information based on the configuration. - // Possible messages we handle here: - // * "Failed to fetch" (chromium) - // * "Load failed" (webkit) - // * "NetworkError when attempting to fetch resource." (firefox) - const client = getClient(); - const enhanceOption = client?.getOptions().enhanceFetchErrorMessages ?? 'always'; - const shouldEnhance = enhanceOption !== false; - - if ( - shouldEnhance && - error instanceof TypeError && - (error.message === 'Failed to fetch' || - error.message === 'Load failed' || - error.message === 'NetworkError when attempting to fetch resource.') - ) { - try { - const url = new URL(handlerData.fetchData.url); - const hostname = url.host; - - if (enhanceOption === 'always') { - // Modify the error message directly - error.message = `${error.message} (${hostname})`; - } else { - // Store hostname as non-enumerable property for Sentry-only enhancement - // This preserves the original error message for third-party packages - addNonEnumerableProperty(error, '__sentry_fetch_url_host__', hostname); + // We enhance fetch error messages with hostname information based on the configuration. + // Possible messages we handle here: + // * "Failed to fetch" (chromium) + // * "Load failed" (webkit) + // * "NetworkError when attempting to fetch resource." (firefox) + const client = getClient(); + const enhanceOption = client?.getOptions().enhanceFetchErrorMessages ?? 'always'; + const shouldEnhance = enhanceOption !== false; + + if ( + shouldEnhance && + error instanceof TypeError && + (error.message === 'Failed to fetch' || + error.message === 'Load failed' || + error.message === 'NetworkError when attempting to fetch resource.') + ) { + try { + const url = new URL(handlerData.fetchData.url); + const hostname = url.host; + + if (enhanceOption === 'always') { + // Modify the error message directly + error.message = `${error.message} (${hostname})`; + } else { + // Store hostname as non-enumerable property for Sentry-only enhancement + // This preserves the original error message for third-party packages + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', hostname); + } + } catch { + // ignore it if errors happen here } - } catch { - // ignore it if errors happen here } - } - - // NOTE: If you are a Sentry user, and you are seeing this stack frame, - // it means the sentry.javascript SDK caught an error invoking your application code. - // This is expected behavior and NOT indicative of a bug with sentry.javascript. - throw error; - }, - ); - }; + + // NOTE: If you are a Sentry user, and you are seeing this stack frame, + // it means the sentry.javascript SDK caught an error invoking your application code. + // This is expected behavior and NOT indicative of a bug with sentry.javascript. + throw error; + }, + ); + }, + }); }); } diff --git a/packages/core/test/lib/instrument/fetch.test.ts b/packages/core/test/lib/instrument/fetch.test.ts index 215b0c513ee5..fd6d1d294fe4 100644 --- a/packages/core/test/lib/instrument/fetch.test.ts +++ b/packages/core/test/lib/instrument/fetch.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from 'vitest'; -import { parseFetchArgs } from '../../../src/instrument/fetch'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { addFetchInstrumentationHandler, parseFetchArgs } from '../../../src/instrument/fetch'; +import { resetInstrumentationHandlers } from '../../../src/instrument/handlers'; +import * as isBrowserModule from '../../../src/utils/isBrowser'; +import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; describe('instrument > parseFetchArgs', () => { it.each([ @@ -53,3 +56,33 @@ describe('instrument > parseFetchArgs', () => { }); }); }); + +describe('instrument > addFetchInstrumentationHandler', () => { + const globalWithFetch = GLOBAL_OBJ as typeof GLOBAL_OBJ & { fetch?: (...args: unknown[]) => unknown }; + + afterEach(() => { + resetInstrumentationHandlers(); + vi.restoreAllMocks(); + }); + + it('preserves non-standard own properties on the global fetch (e.g. Bun `fetch.preconnect`)', () => { + // Non-browser runtime so we skip the native-fetch check and always patch + vi.spyOn(isBrowserModule, 'isBrowser').mockReturnValue(false); + + const preconnect = vi.fn(); + const originalFetch = vi.fn(() => Promise.resolve(new Response())); + (originalFetch as unknown as { preconnect: unknown }).preconnect = preconnect; + globalWithFetch.fetch = originalFetch as unknown as typeof globalWithFetch.fetch; + + try { + addFetchInstrumentationHandler(() => {}); + + // fetch was actually wrapped ... + expect(globalWithFetch.fetch).not.toBe(originalFetch); + // ... and the non-standard own property was carried over onto the wrapper + expect((globalWithFetch.fetch as unknown as { preconnect: unknown }).preconnect).toBe(preconnect); + } finally { + globalWithFetch.fetch = originalFetch as unknown as typeof globalWithFetch.fetch; + } + }); +}); diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 715c2484296e..752e07f6637b 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -58,7 +58,7 @@ export { httpIntegration, httpServerIntegration, httpServerSpansIntegration, - nativeNodeFetchIntegration, + fetchIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration,