From 3b254be34cae77b9f127bf1f94a5a56d19d9cbff Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 11 Sep 2026 10:31:28 -0700 Subject: [PATCH 1/5] feat(core): consolidate 4 fetch integrations Consolidate the nearly (but not exactly!) identical fetch integrations used by deno, bun, cloudflare, and vercel-edge. The functionality is placed in `@sentry/core` rather than `@sentry/server-utils` in order to avoid leaking node internals where they don't belong. closes JS-3667 closes #24344 --- CHANGELOG.md | 5 + packages/bun/src/index.ts | 1 + packages/bun/src/integrations/fetch.ts | 166 +---------- packages/bun/test/integrations/fetch.test.ts | 100 +++++++ packages/cloudflare/src/index.ts | 1 + packages/cloudflare/src/integrations/fetch.ts | 166 +---------- .../test/integrations/fetch.test.ts | 227 +++----------- packages/core/src/index.ts | 2 + packages/core/src/integrations/fetch.ts | 205 +++++++++++++ .../core/test/lib/integrations/fetch.test.ts | 278 ++++++++++++++++++ packages/deno/src/index.ts | 2 +- packages/deno/src/integrations/breadcrumbs.ts | 103 ++----- packages/deno/src/integrations/fetch.ts | 91 +----- packages/deno/src/integrations/http.ts | 3 + packages/deno/test/deno-fetch.test.ts | 82 +++++- packages/vercel-edge/src/index.ts | 1 + .../src/integrations/wintercg-fetch.ts | 164 +---------- .../vercel-edge/test/wintercg-fetch.test.ts | 227 +++----------- 18 files changed, 783 insertions(+), 1041 deletions(-) create mode 100644 packages/bun/test/integrations/fetch.test.ts create mode 100644 packages/core/src/integrations/fetch.ts create mode 100644 packages/core/test/lib/integrations/fetch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d56b31c419f..ca11c4299441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehaprasad-dev, @JealousGx, @Jxxunnn, @eddie333016, @davidmurdoch, @yashschandra, @atharv-sys32, @AG0708, @birkskyum, @mkly, @mcbbugu, @suhailopensource, @zkasuran, @mohd-akram, @RealBhupesh, @halillusion, @psang39, @hafzism, @JosephDoUrden and @Tyagiquamar. Thank you for your contributions! +- feat(core): Add `createFetchIntegration`, the shared implementation behind the global-`fetch` integrations in `@sentry/bun`, `@sentry/cloudflare`, `@sentry/deno` and `@sentry/vercel-edge`. Those four packages carried four copies of it; they now share one. Two changes come out of that: + - All four gain a `tracePropagation` option (default `true`). Turn it off to stop injecting `sentry-trace` and `baggage` without also turning off spans. To scope propagation to specific URLs, keep using `tracePropagationTargets` in the client options. + - Integration options now follow the client. Previously a second `Sentry.init()` in the same process silently reused the options of the first one. +- fix(vercel-edge): `winterCGFetchIntegration` now honors the client's `propagateTraceparent` option. It was the one copy of the fetch integration that never forwarded it, so the `traceparent` header was never sent. +- feat(deno)!: Fetch breadcrumbs are now recorded by `fetchIntegration` rather than `breadcrumbsIntegration`, matching the other runtime SDKs. Disable them with `fetchIntegration({ breadcrumbs: false })`. `breadcrumbsIntegration({ fetch: false })` is deprecated, no longer has any effect, and will be removed in a future major version. - feat(core): Accept a `CollectBehavior` shorthand for `dataCollection.httpHeaders`. Passing `true`, `false`, `{ allow: [...] }` or `{ deny: [...] }` now applies to both request and response headers; `{ request, response }` still controls each direction independently. - feat(langchain)!: Emit `gen_ai.pipeline.name` instead of `langchain.chain.name` on LangChain chain spans. The attribute is omitted when the chain is unnamed. - feat(deno)!: Rename several default integrations to match the other SDKs ([#22404](https://github.com/getsentry/sentry-javascript/pull/22404)). The `deno*Integration` exports are kept as deprecated aliases. If you were relying on the names (for example, to disable them), then note that these have changed: diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index caf4e1116045..98e4d20698d6 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -212,5 +212,6 @@ export { bunServerIntegration } from './integrations/bunserver'; export type { BunServerIntegrationOptions } from './integrations/bunserver'; export { bunHttpServerIntegration } from './integrations/bunHttpServer'; export { fetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; 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 index b908ccaf2e25..21e01ccb2307 100644 --- a/packages/bun/src/integrations/fetch.ts +++ b/packages/bun/src/integrations/fetch.ts @@ -1,166 +1,10 @@ -import type { - Client, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, - Span, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} 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; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - /** 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; +import { createFetchIntegration } from '@sentry/core'; /** * 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, - ); - } -} +export const fetchIntegration = createFetchIntegration({ + name: 'Fetch', + spanOrigin: 'auto.http.fetch', +}); diff --git a/packages/bun/test/integrations/fetch.test.ts b/packages/bun/test/integrations/fetch.test.ts new file mode 100644 index 000000000000..a201d7462c89 --- /dev/null +++ b/packages/bun/test/integrations/fetch.test.ts @@ -0,0 +1,100 @@ +import http from 'node:http'; +import type { TransactionEvent } from '@sentry/core'; +import { getCurrentScope, getIsolationScope, startSpan } from '@sentry/core'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { init } from '../../src'; + +async function startServer( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, +): Promise<{ port: number; close: () => Promise }> { + const server = http.createServer(handler); + const port = await new Promise(resolve => { + server.listen(0, () => resolve((server.address() as { port: number }).port)); + }); + return { + port, + close: () => new Promise(resolve => server.close(() => resolve())), + }; +} + +const transactions: TransactionEvent[] = []; + +/** Bind on the real completion signal so a "never arrives" regression fails instead of hanging. */ +function waitForTransaction(name: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out waiting for the "${name}" transaction`)), 5000); + const poll = setInterval(() => { + const found = transactions.find(event => event.transaction === name); + if (found) { + clearTimeout(timer); + clearInterval(poll); + resolve(found); + } + }, 10); + }); +} + +function header(headers: http.IncomingHttpHeaders | undefined, name: string): string | undefined { + const value = headers?.[name]; + return Array.isArray(value) ? value[0] : value; +} + +describe('fetchIntegration', () => { + beforeAll(() => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + traceLifecycle: 'static', + beforeSendTransaction(event) { + transactions.push(event); + return null; + }, + transport: () => ({ send: async () => ({}), flush: async () => true }), + }); + }); + + afterAll(() => { + getCurrentScope().setClient(undefined); + }); + + test('creates an http.client span and propagates trace headers', async () => { + let received: http.IncomingHttpHeaders | undefined; + const { port, close } = await startServer((req, res) => { + received = req.headers; + res.end('ok'); + }); + + await startSpan({ name: 'parent', op: 'test' }, async () => { + await fetch(`http://localhost:${port}/downstream`).then(res => res.text()); + }); + + const parent = await waitForTransaction('parent'); + await close(); + + const clientSpan = parent.spans?.find(span => span.op === 'http.client'); + expect(clientSpan).toBeDefined(); + expect(clientSpan?.origin).toBe('auto.http.fetch'); + + const traceId = parent.contexts?.trace?.trace_id; + const sentryTrace = header(received, 'sentry-trace'); + expect(sentryTrace).toBeDefined(); + expect(sentryTrace!.split('-')[0]).toBe(traceId!); + expect(sentryTrace!.split('-')[1]).toBe(clientSpan!.span_id!); + expect(header(received, 'baggage')).toContain(`sentry-trace_id=${traceId}`); + }); + + test('records exactly one fetch breadcrumb', async () => { + const { port, close } = await startServer((_req, res) => res.end('ok')); + const url = `http://localhost:${port}/crumb`; + + getIsolationScope().clearBreadcrumbs(); + await fetch(url).then(res => res.text()); + await close(); + + const crumbs = getIsolationScope() + .getScopeData() + .breadcrumbs.filter(crumb => crumb.category === 'fetch' && crumb.data?.url === url); + + expect(crumbs).toHaveLength(1); + }); +}); diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index f75e4c429e78..4f56a0349800 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -120,6 +120,7 @@ export { _INTERNAL_wrapRequestHandler, getDefaultIntegrations } from './sdk'; export { httpServerIntegration } from './integrations/httpServer'; export { fetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; export { spotlightIntegration } from './integrations/spotlight'; export { openTelemetryIntegration, diff --git a/packages/cloudflare/src/integrations/fetch.ts b/packages/cloudflare/src/integrations/fetch.ts index 585377e9d76d..68d0be8466b0 100644 --- a/packages/cloudflare/src/integrations/fetch.ts +++ b/packages/cloudflare/src/integrations/fetch.ts @@ -1,165 +1,9 @@ -import type { - Client, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, - Span, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'Fetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -export interface Options { - /** - * 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: Partial = {}) => { - 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; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - /** 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(); - const { propagateTraceparent } = client?.getOptions() || {}; - if (!client || !HAS_CLIENT_MAP.get(client)) { - return; - } - - 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; +import { createFetchIntegration } from '@sentry/core'; /** * Creates spans and attaches tracing headers to fetch requests. */ -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, - ); - } -} +export const fetchIntegration = createFetchIntegration({ + name: 'Fetch', + spanOrigin: 'auto.http.fetch', +}); diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts index c2cdda44d182..a57361234e2b 100644 --- a/packages/cloudflare/test/integrations/fetch.test.ts +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -1,210 +1,49 @@ -import type { HandlerDataFetch, Integration } from '@sentry/core'; -import * as sentryCore from '@sentry/core'; -import { createStackParser } from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TransactionEvent } from '@sentry/core'; +import { createStackParser, setCurrentClient, startSpan } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { CloudflareClient } from '../../src/client'; import { fetchIntegration } from '../../src/integrations/fetch'; +import { getDefaultIntegrations } from '../../src/sdk'; -class FakeClient extends CloudflareClient { - public getIntegrationByName(name: string): T | undefined { - return name === 'Fetch' ? (fetchIntegration() as T) : undefined; - } -} - -const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryCore, 'addFetchInstrumentationHandler'); -const instrumentFetchRequestSpy = vi.spyOn(sentryCore, 'instrumentFetchRequest'); -const addBreadcrumbSpy = vi.spyOn(sentryCore, 'addBreadcrumb'); +// The behavior lives in `createFetchIntegration` and is covered by +// `packages/core/test/lib/integrations/fetch.test.ts`. This only pins the wiring. +describe('fetchIntegration', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); -describe('WinterCGFetch instrumentation', () => { - let client: FakeClient; + it('is named `Fetch` and is enabled by default', () => { + expect(fetchIntegration().name).toBe('Fetch'); + expect(getDefaultIntegrations({}).map(integration => integration.name)).toContain('Fetch'); + }); - beforeEach(() => { - vi.clearAllMocks(); + it('creates `http.client` spans with the `auto.http.fetch` origin', async () => { + vi.stubGlobal('fetch', () => Promise.resolve(new Response('ok'))); - client = new FakeClient({ + const transactions: TransactionEvent[] = []; + const client = new CloudflareClient({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1, - integrations: [], - transport: () => ({ - send: () => Promise.resolve({}), - flush: () => Promise.resolve(true), - }), - tracePropagationTargets: ['http://my-website.com/'], + traceLifecycle: 'static', + integrations: [fetchIntegration()], stackParser: createStackParser(), - }); - - vi.spyOn(sentryCore, 'getClient').mockImplementation(() => client); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( - startHandlerData, - expect.any(Function), - expect.any(Function), - expect.any(Object), - { spanOrigin: 'auto.http.fetch' }, - ); - - const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); - expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); - - // tracePropagationTargets match regardless of casing - expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); - expect(shouldAttachTraceData('https://WWW.3RD-PARTY-WEBSITE.at/')).toBe(false); - - expect(shouldCreateSpan('http://my-website.com/')).toBe(true); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); - }); - - it('should not instrument if client is not setup', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration(); - integration.setupOnce!(); - // integration.setup!(client) is not called! - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests to Sentry', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=123', method: 'POST' }, - args: ['https://dsn.ingest.sentry.io/1337?sentry_key=123'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should properly apply the `shouldCreateSpanForRequest` option', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration({ - shouldCreateSpanForRequest(url) { - return url === 'http://only-acceptable-url.com/'; + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + beforeSendTransaction(event) { + transactions.push(event); + return null; }, }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldCreateSpan('http://only-acceptable-url.com/')).toBe(true); - expect(shouldCreateSpan('http://my-website.com/')).toBe(false); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(false); - }); - - it('should create a breadcrumb for an outgoing request', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + setCurrentClient(client); + client.init(); - const integration = fetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(addBreadcrumbSpy).toBeCalledWith( - { - category: 'fetch', - data: { - method: 'POST', - status_code: 201, - url: 'http://my-website.com/', - }, - type: 'http', - }, - { - endTimestamp, - input: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' }, - startTimestamp, - }, - ); - }); - - it('should not create a breadcrumb for an outgoing request if `breadcrumbs: false` is set', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration({ breadcrumbs: false }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; + await startSpan({ name: 'parent', op: 'test' }, async () => { + await fetch('http://my-website.com/').then(response => response.text()); + }); - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); + const parent = transactions.find(event => event.transaction === 'parent'); + const clientSpan = parent?.spans?.find(span => span.op === 'http.client'); - expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + expect(clientSpan).toBeDefined(); + expect(clientSpan?.origin).toBe('auto.http.fetch'); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index efe39a7e1cf3..0146e82a11fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -164,6 +164,8 @@ export { featureFlagsIntegration } from './integrations/featureFlags'; export { growthbookIntegration } from './integrations/featureFlags'; export { conversationIdIntegration } from './integrations/conversationId'; export { spanStreamingIntegration } from './integrations/spanStreaming'; +export { createFetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from './integrations/fetch'; export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) // Therefore: diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts new file mode 100644 index 000000000000..80e0f0808a7e --- /dev/null +++ b/packages/core/src/integrations/fetch.ts @@ -0,0 +1,205 @@ +import { addBreadcrumb } from '../breadcrumbs'; +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import { instrumentFetchRequest } from '../fetch'; +import { defineIntegration } from '../integration'; +import { addFetchInstrumentationHandler } from '../instrument/fetch'; +import type { FetchBreadcrumbData, FetchBreadcrumbHint } from '../types/breadcrumb'; +import type { HandlerDataFetch } from '../types/instrument'; +import type { Integration, IntegrationFn } from '../types/integration'; +import type { Span, SpanOrigin } from '../types/span'; +import { getBreadcrumbLogLevelFromHttpStatusCode } from '../utils/breadcrumb-log-level'; +import { isSentryRequestUrl } from '../utils/isSentryRequestUrl'; +import { LRUMap } from '../utils/lru'; +import { shouldPropagateTraceForUrl } from '../utils/tracePropagationTargets'; + +export interface FetchIntegrationOptions { + /** + * Whether breadcrumbs should be recorded for requests. + * + * @default `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; + + /** + * Whether to inject trace propagation headers (`sentry-trace`, `baggage`) into outgoing requests. + * + * To scope propagation to specific URLs, configure `tracePropagationTargets` in the client options + * instead. Turn this off only to suppress propagation entirely, for example alongside + * `shouldCreateSpanForRequest`, which suppresses the span but not the headers. + * + * Covers the global `fetch` only. A runtime that also instruments another HTTP client switches + * that one separately, for example `denoHttpIntegration({ tracePropagation: false })`. + * + * @default `true` + */ + tracePropagation?: boolean; +} + +interface CreateFetchIntegrationOptions { + /** Integration name, e.g. `'Fetch'`. */ + name: string; + + /** Span origin for the `http.client` spans this integration creates. */ + spanOrigin: SpanOrigin; +} + +interface ClientConfig { + breadcrumbs: boolean; + shouldCreateSpan: (url: string) => boolean; + shouldAttachTraceData: (url: string) => boolean; +} + +/** + * Builds an integration that instruments the global `fetch` function: creates `http.client` spans, + * records breadcrumbs, and attaches trace propagation headers. + * + * Runtimes that patch the global `fetch` (Bun, Cloudflare Workers, Deno, Vercel Edge) differ only in + * the integration name and span origin, so they all share this implementation. Node is not one of + * them: it instruments undici through diagnostics channels instead. Neither is the browser, whose + * fetch tracing is driven by `browserTracingIntegration` and shares its span map with XHR. + */ +export function createFetchIntegration({ + name, + spanOrigin, +}: CreateFetchIntegrationOptions): (options?: FetchIntegrationOptions) => Integration { + // Shared by every instance of this integration, because `setupOnce` runs once per process: the + // handler it registers must be able to end a span that a different instance started. + const spans: Record = {}; + + // Keyed by client rather than captured in the instance closure, so that a second `init()` uses its + // own options instead of silently inheriting the first one's. + const configs = new WeakMap(); + + const integration = ((options: FetchIntegrationOptions = {}) => { + return { + name, + setupOnce() { + addFetchInstrumentationHandler(handlerData => { + const client = getClient(); + const config = client && configs.get(client); + + if (!client || !config) { + return; + } + + if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + return; + } + + const { propagateTraceparent } = client.getOptions(); + instrumentFetchRequest(handlerData, config.shouldCreateSpan, config.shouldAttachTraceData, spans, { + spanOrigin, + propagateTraceparent, + }); + + if (config.breadcrumbs) { + createBreadcrumb(handlerData); + } + }); + }, + setup(client) { + configs.set(client, resolveConfig(client, options)); + }, + }; + }) satisfies IntegrationFn; + + return defineIntegration(integration); +} + +function resolveConfig(client: Client, options: FetchIntegrationOptions): ClientConfig { + const { breadcrumbs = true, shouldCreateSpanForRequest, tracePropagation = true } = options; + + const createSpanUrlMap = new LRUMap(100); + const headersUrlMap = new LRUMap(100); + + return { + breadcrumbs, + + shouldCreateSpan(url) { + 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; + }, + + shouldAttachTraceData(url) { + if (!tracePropagation) { + return false; + } + + return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, headersUrlMap); + }, + }; +} + +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/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts new file mode 100644 index 000000000000..93fa943db8d8 --- /dev/null +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -0,0 +1,278 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as breadcrumbsModule from '../../../src/breadcrumbs'; +import * as currentScopesModule from '../../../src/currentScopes'; +import * as fetchModule from '../../../src/fetch'; +import { createFetchIntegration } from '../../../src/integrations/fetch'; +import * as instrumentFetchModule from '../../../src/instrument/fetch'; +import type { HandlerDataFetch } from '../../../src/types/instrument'; +import type { Integration } from '../../../src/types/integration'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +const fetchIntegration = createFetchIntegration({ name: 'Fetch', spanOrigin: 'auto.http.fetch' }); + +class FakeClient extends TestClient { + public getIntegrationByName(name: string): T | undefined { + return name === 'Fetch' ? (fetchIntegration() as T) : undefined; + } +} + +const addFetchInstrumentationHandlerSpy = vi.spyOn(instrumentFetchModule, 'addFetchInstrumentationHandler'); +const instrumentFetchRequestSpy = vi.spyOn(fetchModule, 'instrumentFetchRequest'); +const addBreadcrumbSpy = vi.spyOn(breadcrumbsModule, 'addBreadcrumb'); + +function makeClient(options: Partial[0]> = {}): FakeClient { + return new FakeClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + tracePropagationTargets: ['http://my-website.com/'], + ...options, + }), + ); +} + +/** Registers the integration against `client` and returns the handler it installed. */ +function setupIntegration( + integration: ReturnType, + client: FakeClient, +): (handlerData: HandlerDataFetch) => void { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => () => undefined); + integration.setupOnce!(); + integration.setup!(client); + + const [handler] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + expect(handler).toBeDefined(); + return handler; +} + +const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), +}; + +describe('createFetchIntegration', () => { + let client: FakeClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = makeClient(); + vi.spyOn(currentScopesModule, 'getClient').mockImplementation(() => client); + }); + + it('calls `instrumentFetchRequest` for outgoing fetch requests', () => { + const handler = setupIntegration(fetchIntegration(), client); + handler(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + { spanOrigin: 'auto.http.fetch', propagateTraceparent: undefined }, + ); + + const [, , shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; + + expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); + expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); + // tracePropagationTargets match regardless of casing + expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); + }); + + it('uses the span origin it was created with', () => { + const winterCGFetchIntegration = createFetchIntegration({ + name: 'WinterCGFetch', + spanOrigin: 'auto.http.wintercg_fetch', + }); + + const handler = setupIntegration(winterCGFetchIntegration(), client); + handler(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + expect.objectContaining({ spanOrigin: 'auto.http.wintercg_fetch' }), + ); + }); + + it('forwards the client `propagateTraceparent` option', () => { + client = makeClient({ propagateTraceparent: true }); + const handler = setupIntegration(fetchIntegration(), client); + handler(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + expect.objectContaining({ propagateTraceparent: true }), + ); + }); + + it('does not instrument if the client is not set up', () => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => () => undefined); + const integration = fetchIntegration(); + integration.setupOnce!(); + // no `setup(client)` call + + const [handler] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + handler!(startHandlerData); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + }); + + it('does not instrument outgoing requests to Sentry', () => { + const handler = setupIntegration(fetchIntegration(), client); + handler({ + fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=public', method: 'POST' }, + args: ['https://dsn.ingest.sentry.io/1337?sentry_key=public'], + startTimestamp: Date.now(), + }); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + + it('applies the `shouldCreateSpanForRequest` option', () => { + const handler = setupIntegration( + fetchIntegration({ shouldCreateSpanForRequest: url => url === 'http://only-this-one.com/' }), + client, + ); + handler(startHandlerData); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; + + expect(shouldCreateSpan('http://only-this-one.com/')).toBe(true); + expect(shouldCreateSpan('http://my-website.com/')).toBe(false); + }); + + it('attaches trace data by default', () => { + const handler = setupIntegration(fetchIntegration(), client); + handler(startHandlerData); + + const [, , shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); + }); + + it('attaches no trace data when `tracePropagation: false` is set', () => { + const handler = setupIntegration(fetchIntegration({ tracePropagation: false }), client); + handler(startHandlerData); + + const [, , shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldAttachTraceData('http://my-website.com/')).toBe(false); + }); + + it('still creates spans when `tracePropagation: false` is set', () => { + const handler = setupIntegration(fetchIntegration({ tracePropagation: false }), client); + handler(startHandlerData); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldCreateSpan('http://my-website.com/')).toBe(true); + }); + + it('creates a breadcrumb for an outgoing request', () => { + const handler = setupIntegration(fetchIntegration(), client); + + const startTimestamp = Date.now(); + const endTimestamp = startTimestamp + 100; + const response = { status: 200 } as Response; + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST', request_body_size: 10, response_body_size: 20 }, + args: ['http://my-website.com/'], + startTimestamp, + endTimestamp, + response, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + { + category: 'fetch', + data: { + method: 'POST', + url: 'http://my-website.com/', + request_body_size: 10, + response_body_size: 20, + status_code: 200, + }, + type: 'http', + }, + { + input: ['http://my-website.com/'], + response, + startTimestamp, + endTimestamp, + }, + ); + }); + + it('creates an error-level breadcrumb for a failed request', () => { + const handler = setupIntegration(fetchIntegration(), client); + + const error = new Error('kaboom'); + const startTimestamp = Date.now(); + const endTimestamp = startTimestamp + 100; + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp, + endTimestamp, + error, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + { + category: 'fetch', + data: { method: 'POST', url: 'http://my-website.com/' }, + level: 'error', + type: 'http', + }, + { + data: error, + input: ['http://my-website.com/'], + startTimestamp, + endTimestamp, + }, + ); + }); + + it('creates no breadcrumb when `breadcrumbs: false` is set', () => { + const handler = setupIntegration(fetchIntegration({ breadcrumbs: false }), client); + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + + it('uses each client own options when a second client is set up', () => { + // `setupOnce` runs once per process, so the handler must read the options of whichever client + // is current rather than the ones captured by the first instance. + const handler = setupIntegration(fetchIntegration({ breadcrumbs: false }), client); + + const secondClient = makeClient(); + fetchIntegration({ breadcrumbs: true, shouldCreateSpanForRequest: () => false }).setup!(secondClient); + vi.spyOn(currentScopesModule, 'getClient').mockImplementation(() => secondClient); + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldCreateSpan('http://my-website.com/')).toBe(false); + expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 0225063baeed..4d8585d9a04e 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -110,7 +110,7 @@ export { getDefaultIntegrations, init } from './sdk'; export { denoServeIntegration } from './integrations/deno-serve'; export type { DenoServeIntegrationOptions } from './integrations/deno-serve'; export { fetchIntegration } from './integrations/fetch'; -export type { FetchOptions } from './integrations/fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; export { denoHttpIntegration } from './integrations/http'; export type { DenoHttpIntegrationOptions } from './integrations/http'; diff --git a/packages/deno/src/integrations/breadcrumbs.ts b/packages/deno/src/integrations/breadcrumbs.ts index b7ac83f23ef0..72deaa9a7491 100644 --- a/packages/deno/src/integrations/breadcrumbs.ts +++ b/packages/deno/src/integrations/breadcrumbs.ts @@ -1,18 +1,9 @@ -import type { - Client, - Event as SentryEvent, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataConsole, - HandlerDataFetch, - IntegrationFn, -} from '@sentry/core'; +import type { Client, Event as SentryEvent, HandlerDataConsole, IntegrationFn } from '@sentry/core'; import { addBreadcrumb, addConsoleInstrumentationHandler, - addFetchInstrumentationHandler, + debug, defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, getClient, getEventDescription, safeJoin, @@ -21,8 +12,14 @@ import { interface BreadcrumbsOptions { console: boolean; - fetch: boolean; sentry: boolean; + + /** + * @deprecated Fetch breadcrumbs are recorded by `fetchIntegration`. Disable them with + * `fetchIntegration({ breadcrumbs: false })` instead. This option no longer has any effect and + * will be removed in a future major version. + */ + fetch: boolean; } const INTEGRATION_NAME = 'Breadcrumbs' as const; @@ -46,8 +43,11 @@ const _breadcrumbsIntegration = ((options: Partial = {}) => if (_options.console) { addConsoleInstrumentationHandler(_getConsoleBreadcrumbHandler(client)); } - if (_options.fetch) { - addFetchInstrumentationHandler(_getFetchBreadcrumbHandler(client)); + // oxlint-disable-next-line typescript/no-deprecated + if (!_options.fetch) { + debug.warn( + 'breadcrumbsIntegration({ fetch: false }) no longer has any effect. Fetch breadcrumbs are recorded by fetchIntegration; disable them with fetchIntegration({ breadcrumbs: false }).', + ); } if (_options.sentry) { client.on('beforeSendEvent', _getSentryBreadcrumbHandler(client)); @@ -57,7 +57,9 @@ const _breadcrumbsIntegration = ((options: Partial = {}) => }) satisfies IntegrationFn; /** - * Adds a breadcrumbs for console, fetch, and sentry events. + * Adds breadcrumbs for console and sentry events. + * + * Fetch breadcrumbs come from `fetchIntegration`. * * Enabled by default in the Deno SDK. * @@ -130,74 +132,3 @@ function _getConsoleBreadcrumbHandler(client: Client): (handlerData: HandlerData }); }; } - -/** - * Creates breadcrumbs from fetch API calls - */ -function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFetch) => void { - return function _fetchBreadcrumb(handlerData: HandlerDataFetch): void { - if (getClient() !== client) { - return; - } - - const { startTimestamp, endTimestamp } = handlerData; - - // We only capture complete fetch requests - if (!endTimestamp) { - return; - } - - if (handlerData.fetchData.url.match(/sentry_key/) && handlerData.fetchData.method === 'POST') { - // We will not create breadcrumbs for fetch requests that contain `sentry_key` (internal sentry requests) - 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/deno/src/integrations/fetch.ts b/packages/deno/src/integrations/fetch.ts index 17d82072ec00..c873aab979da 100644 --- a/packages/deno/src/integrations/fetch.ts +++ b/packages/deno/src/integrations/fetch.ts @@ -1,87 +1,10 @@ -import type { Client, IntegrationFn, Span } from '@sentry/core'; -import { - addFetchInstrumentationHandler, - defineIntegration, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'Fetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -export interface FetchOptions { - /** - * 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 shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; - - const _createSpanUrlMap = new LRUMap(100); - const _headersUrlMap = new LRUMap(100); - - const spans: Record = {}; - - function _shouldAttachTraceData(url: string): boolean { - const client = getClient(); - - if (!client) { - return false; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - 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; - } - - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { - return; - } - - const { propagateTraceparent } = client.getOptions(); - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { - spanOrigin: 'auto.http.fetch', - propagateTraceparent, - }); - }); - }, - setup(client) { - HAS_CLIENT_MAP.set(client, true); - }, - }; -}) satisfies IntegrationFn; +import { createFetchIntegration } from '@sentry/core'; /** - * Instruments outgoing `fetch` requests in Deno by creating spans and attaching trace propagation headers. - * The separate breadcrumbs integration records fetch breadcrumbs. + * Instruments outgoing `fetch` requests in Deno: creates spans, records breadcrumbs and + * attaches trace propagation headers. */ -export const fetchIntegration = defineIntegration(_fetchIntegration); +export const fetchIntegration = createFetchIntegration({ + name: 'Fetch', + spanOrigin: 'auto.http.fetch', +}); diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts index f4cfeee81d17..15a5270a6bb7 100644 --- a/packages/deno/src/integrations/http.ts +++ b/packages/deno/src/integrations/http.ts @@ -51,6 +51,9 @@ export interface DenoHttpIntegrationOptions { * When set to `false`, Sentry will not inject any trace propagation headers, but will still create breadcrumbs * (if `breadcrumbs` is enabled). * + * Covers `node:http` requests only. Outgoing `fetch` has its own switch, + * `fetchIntegration({ tracePropagation: false })`. + * * @default `true` */ tracePropagation?: boolean; diff --git a/packages/deno/test/deno-fetch.test.ts b/packages/deno/test/deno-fetch.test.ts index f744c751fc2f..8f13905183a0 100644 --- a/packages/deno/test/deno-fetch.test.ts +++ b/packages/deno/test/deno-fetch.test.ts @@ -6,7 +6,8 @@ import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import type { DenoClient } from '../build/esm/index.js'; -import { captureMessage, init, startSpan } from '../build/esm/index.js'; +import { breadcrumbsIntegration, captureMessage, init, startSpan } from '../build/esm/index.js'; +import { makeTestTransport } from './transport.ts'; function resetGlobals(): void { getMainCarrier().__SENTRY__ = undefined; @@ -105,3 +106,82 @@ Deno.test({ } }, }); + +Deno.test({ + name: 'fetchIntegration: emits an http.client span under the default (streaming) trace lifecycle', + async fn() { + resetGlobals(); + + const server = Deno.serve({ port: 0, hostname: '127.0.0.1', onListen() {} }, () => new Response('ok')); + const url = `http://127.0.0.1:${server.addr.port}/streamed`; + + try { + let resolveSpan: ((name: string) => void) | undefined; + const clientSpan = new Promise(resolve => (resolveSpan = resolve)); + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + // No `traceLifecycle`: this is the default ('stream') path the other tests do not cover. + tracePropagationTargets: [url], + transport: makeTestTransport(envelope => { + for (const [header, body] of envelope[1] as [{ type: string }, Record][]) { + if (header.type !== 'span') continue; + for (const span of (body.items ?? [body]) as Record[]) { + if (span.attributes?.['sentry.op']?.value === 'http.client') { + resolveSpan?.(span.attributes['sentry.origin']?.value); + } + } + } + }), + }); + + await startSpan({ name: 'parent', op: 'test' }, async () => { + const response = await fetch(url); + assertEquals(await response.text(), 'ok'); + }); + + assertEquals(await withTimeout(clientSpan, 5_000, 'streamed http.client span'), 'auto.http.fetch'); + } finally { + await server.shutdown(); + } + }, +}); + +Deno.test({ + name: 'breadcrumbsIntegration: the deprecated `fetch` option no longer suppresses fetch breadcrumbs', + async fn() { + resetGlobals(); + + const server = Deno.serve({ port: 0, hostname: '127.0.0.1', onListen() {} }, () => new Response('ok')); + const url = `http://127.0.0.1:${server.addr.port}/still-recorded`; + + try { + let resolveEvent: ((event: Event) => void) | undefined; + const capturedEvent = new Promise(resolve => (resolveEvent = resolve)); + + init({ + dsn: 'https://username@domain/123', + // oxlint-disable-next-line typescript/no-deprecated + integrations: [breadcrumbsIntegration({ fetch: false })], + beforeSend(event) { + resolveEvent?.(event); + return null; + }, + }); + + await fetch(url).then(response => response.text()); + + captureMessage('capture fetch breadcrumb'); + const event = await withTimeout(capturedEvent, 5_000, 'event containing fetch breadcrumb'); + const fetchBreadcrumbs = event.breadcrumbs?.filter( + breadcrumb => breadcrumb.category === 'fetch' && breadcrumb.data?.url === url, + ); + + // `fetchIntegration` owns fetch breadcrumbs now, so the old switch has no effect. + assertEquals(fetchBreadcrumbs?.length, 1); + } finally { + await server.shutdown(); + } + }, +}); diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index bc9be3e2aeef..2804fc7f8384 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -117,3 +117,4 @@ export { VercelEdgeClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { winterCGFetchIntegration } from './integrations/wintercg-fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; diff --git a/packages/vercel-edge/src/integrations/wintercg-fetch.ts b/packages/vercel-edge/src/integrations/wintercg-fetch.ts index 217efe00df2d..1922782e193b 100644 --- a/packages/vercel-edge/src/integrations/wintercg-fetch.ts +++ b/packages/vercel-edge/src/integrations/wintercg-fetch.ts @@ -1,163 +1,9 @@ -import type { - Client, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, - Span, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'WinterCGFetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -export interface Options { - /** - * 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 _winterCGFetch = ((options: Partial = {}) => { - 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; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - /** 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; - } - - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { - return; - } - - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { - spanOrigin: 'auto.http.wintercg_fetch', - }); - - if (breadcrumbs) { - createBreadcrumb(handlerData); - } - }); - }, - setup(client) { - HAS_CLIENT_MAP.set(client, true); - }, - }; -}) satisfies IntegrationFn; +import { createFetchIntegration } from '@sentry/core'; /** * Creates spans and attaches tracing headers to fetch requests on WinterCG runtimes. */ -export const winterCGFetchIntegration = defineIntegration(_winterCGFetch); - -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, - ); - } -} +export const winterCGFetchIntegration = createFetchIntegration({ + name: 'WinterCGFetch', + spanOrigin: 'auto.http.wintercg_fetch', +}); diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts index 9d9ffcd755f4..85c24eb11683 100644 --- a/packages/vercel-edge/test/wintercg-fetch.test.ts +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -1,210 +1,49 @@ -import type { HandlerDataFetch, Integration } from '@sentry/core'; -import * as sentryCore from '@sentry/core'; -import { createStackParser } from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TransactionEvent } from '@sentry/core'; +import { createStackParser, setCurrentClient, startSpan } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { VercelEdgeClient } from '../src/index'; import { winterCGFetchIntegration } from '../src/integrations/wintercg-fetch'; +import { getDefaultIntegrations } from '../src/sdk'; -class FakeClient extends VercelEdgeClient { - public getIntegrationByName(name: string): T | undefined { - return name === 'WinterCGFetch' ? (winterCGFetchIntegration() as T) : undefined; - } -} - -const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryCore, 'addFetchInstrumentationHandler'); -const instrumentFetchRequestSpy = vi.spyOn(sentryCore, 'instrumentFetchRequest'); -const addBreadcrumbSpy = vi.spyOn(sentryCore, 'addBreadcrumb'); +// The behavior lives in `createFetchIntegration` and is covered by +// `packages/core/test/lib/integrations/fetch.test.ts`. This only pins the wiring. +describe('winterCGFetchIntegration', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); -describe('WinterCGFetch instrumentation', () => { - let client: FakeClient; + it('is named `Fetch` and is enabled by default', () => { + expect(winterCGFetchIntegration().name).toBe('WinterCGFetch'); + expect(getDefaultIntegrations().map(integration => integration.name)).toContain('WinterCGFetch'); + }); - beforeEach(() => { - vi.clearAllMocks(); + it('creates `http.client` spans with the `auto.http.wintercg_fetch` origin', async () => { + vi.stubGlobal('fetch', () => Promise.resolve(new Response('ok'))); - client = new FakeClient({ + const transactions: TransactionEvent[] = []; + const client = new VercelEdgeClient({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1, - integrations: [], - transport: () => ({ - send: () => Promise.resolve({}), - flush: () => Promise.resolve(true), - }), - tracePropagationTargets: ['http://my-website.com/'], + traceLifecycle: 'static', + integrations: [winterCGFetchIntegration()], stackParser: createStackParser(), - }); - - vi.spyOn(sentryCore, 'getClient').mockImplementation(() => client); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( - startHandlerData, - expect.any(Function), - expect.any(Function), - expect.any(Object), - { spanOrigin: 'auto.http.wintercg_fetch' }, - ); - - const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); - expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); - - // tracePropagationTargets match regardless of casing - expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); - expect(shouldAttachTraceData('https://WWW.3RD-PARTY-WEBSITE.at/')).toBe(false); - - expect(shouldCreateSpan('http://my-website.com/')).toBe(true); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); - }); - - it('should not instrument if client is not setup', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - // integration.setup!(client) is not called! - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests to Sentry', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=123', method: 'POST' }, - args: ['https://dsn.ingest.sentry.io/1337?sentry_key=123'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should properly apply the `shouldCreateSpanForRequest` option', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration({ - shouldCreateSpanForRequest(url) { - return url === 'http://only-acceptable-url.com/'; + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + beforeSendTransaction(event) { + transactions.push(event); + return null; }, }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldCreateSpan('http://only-acceptable-url.com/')).toBe(true); - expect(shouldCreateSpan('http://my-website.com/')).toBe(false); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(false); - }); - - it('should create a breadcrumb for an outgoing request', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + setCurrentClient(client); + client.init(); - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(addBreadcrumbSpy).toBeCalledWith( - { - category: 'fetch', - data: { - method: 'POST', - status_code: 201, - url: 'http://my-website.com/', - }, - type: 'http', - }, - { - endTimestamp, - input: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' }, - startTimestamp, - }, - ); - }); - - it('should not create a breadcrumb for an outgoing request if `breadcrumbs: false` is set', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration({ breadcrumbs: false }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; + await startSpan({ name: 'parent', op: 'test' }, async () => { + await fetch('http://my-website.com/').then(response => response.text()); + }); - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); + const parent = transactions.find(event => event.transaction === 'parent'); + const clientSpan = parent?.spans?.find(span => span.op === 'http.client'); - expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + expect(clientSpan).toBeDefined(); + expect(clientSpan?.origin).toBe('auto.http.wintercg_fetch'); }); }); From 5be3ad5f6e7fc668adecafe9c232ac266b4372ce Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 14 Sep 2026 12:54:55 -0700 Subject: [PATCH 2/5] fixup! feat(core): consolidate 4 fetch integrations --- packages/deno/src/integrations/breadcrumbs.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/deno/src/integrations/breadcrumbs.ts b/packages/deno/src/integrations/breadcrumbs.ts index 72deaa9a7491..8fe8ac220bd5 100644 --- a/packages/deno/src/integrations/breadcrumbs.ts +++ b/packages/deno/src/integrations/breadcrumbs.ts @@ -2,7 +2,7 @@ import type { Client, Event as SentryEvent, HandlerDataConsole, IntegrationFn } import { addBreadcrumb, addConsoleInstrumentationHandler, - debug, + consoleSandbox, defineIntegration, getClient, getEventDescription, @@ -29,9 +29,17 @@ const INTEGRATION_NAME = 'Breadcrumbs' as const; * The Deno-version does not support browser-specific APIs like dom, xhr and history. */ const _breadcrumbsIntegration = ((options: Partial = {}) => { + if ('fetch' in options) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + '[Sentry] `breadcrumbsIntegration({ fetch })` is deprecated and no longer has any effect. Fetch breadcrumbs are recorded by `fetchIntegration`; disable them with `fetchIntegration({ breadcrumbs: false })`.', + ); + }); + } + const _options = { console: true, - fetch: true, sentry: true, ...options, }; @@ -43,12 +51,6 @@ const _breadcrumbsIntegration = ((options: Partial = {}) => if (_options.console) { addConsoleInstrumentationHandler(_getConsoleBreadcrumbHandler(client)); } - // oxlint-disable-next-line typescript/no-deprecated - if (!_options.fetch) { - debug.warn( - 'breadcrumbsIntegration({ fetch: false }) no longer has any effect. Fetch breadcrumbs are recorded by fetchIntegration; disable them with fetchIntegration({ breadcrumbs: false }).', - ); - } if (_options.sentry) { client.on('beforeSendEvent', _getSentryBreadcrumbHandler(client)); } From a4cc50bf9658d3a5b189bd0b5cdab7306af513e4 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 14 Sep 2026 14:18:58 -0700 Subject: [PATCH 3/5] fix(core): redact query strings in fetch instrumentation --- CHANGELOG.md | 1 + packages/core/src/integrations/fetch.ts | 13 ++++-- packages/core/src/types/breadcrumb.ts | 3 ++ .../core/test/lib/integrations/fetch.test.ts | 44 +++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca11c4299441..7b688a94504d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehap - feat(core): Add `createFetchIntegration`, the shared implementation behind the global-`fetch` integrations in `@sentry/bun`, `@sentry/cloudflare`, `@sentry/deno` and `@sentry/vercel-edge`. Those four packages carried four copies of it; they now share one. Two changes come out of that: - All four gain a `tracePropagation` option (default `true`). Turn it off to stop injecting `sentry-trace` and `baggage` without also turning off spans. To scope propagation to specific URLs, keep using `tracePropagationTargets` in the client options. - Integration options now follow the client. Previously a second `Sentry.init()` in the same process silently reused the options of the first one. +- fix(bun, cloudflare, deno, vercel-edge): Outgoing `fetch` breadcrumbs now route their URL through the data-collection filters, matching the `node:http` breadcrumb. `data.url` is sanitized (credentials stripped, query and fragment removed) and the query moves to `url.query`, where `dataCollection.urlQueryParams` applies to it. Previously the raw URL was recorded, so sensitive query values reached Sentry even with query collection turned off. - fix(vercel-edge): `winterCGFetchIntegration` now honors the client's `propagateTraceparent` option. It was the one copy of the fetch integration that never forwarded it, so the `traceparent` header was never sent. - feat(deno)!: Fetch breadcrumbs are now recorded by `fetchIntegration` rather than `breadcrumbsIntegration`, matching the other runtime SDKs. Disable them with `fetchIntegration({ breadcrumbs: false })`. `breadcrumbsIntegration({ fetch: false })` is deprecated, no longer has any effect, and will be removed in a future major version. - feat(core): Accept a `CollectBehavior` shorthand for `dataCollection.httpHeaders`. Passing `true`, `false`, `{ allow: [...] }` or `{ deny: [...] }` now applies to both request and response headers; `{ request, response }` still controls each direction independently. diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts index 80e0f0808a7e..5cf7a0489719 100644 --- a/packages/core/src/integrations/fetch.ts +++ b/packages/core/src/integrations/fetch.ts @@ -1,3 +1,4 @@ +import { URL_FRAGMENT, URL_QUERY } from '@sentry/conventions/attributes'; import { addBreadcrumb } from '../breadcrumbs'; import type { Client } from '../client'; import { getClient } from '../currentScopes'; @@ -9,9 +10,11 @@ import type { HandlerDataFetch } from '../types/instrument'; import type { Integration, IntegrationFn } from '../types/integration'; import type { Span, SpanOrigin } from '../types/span'; import { getBreadcrumbLogLevelFromHttpStatusCode } from '../utils/breadcrumb-log-level'; +import { filterCollectedUrlQuery } from '../utils/data-collection/filterCollectedUrl'; import { isSentryRequestUrl } from '../utils/isSentryRequestUrl'; import { LRUMap } from '../utils/lru'; import { shouldPropagateTraceForUrl } from '../utils/tracePropagationTargets'; +import { getSanitizedUrlString, getUrlFragment, getUrlQuery, parseUrl } from '../utils/url'; export interface FetchIntegrationOptions { /** @@ -100,7 +103,7 @@ export function createFetchIntegration({ }); if (config.breadcrumbs) { - createBreadcrumb(handlerData); + createBreadcrumb(handlerData, client); } }); }, @@ -147,7 +150,7 @@ function resolveConfig(client: Client, options: FetchIntegrationOptions): Client }; } -function createBreadcrumb(handlerData: HandlerDataFetch): void { +function createBreadcrumb(handlerData: HandlerDataFetch, client: Client): void { const { startTimestamp, endTimestamp } = handlerData; // We only capture complete fetch requests @@ -155,9 +158,13 @@ function createBreadcrumb(handlerData: HandlerDataFetch): void { return; } + const parsedUrl = parseUrl(handlerData.fetchData.url); + const breadcrumbData: FetchBreadcrumbData = { method: handlerData.fetchData.method, - url: handlerData.fetchData.url, + url: getSanitizedUrlString(parsedUrl), + [URL_QUERY]: filterCollectedUrlQuery(getUrlQuery(parsedUrl.search), client), + [URL_FRAGMENT]: getUrlFragment(parsedUrl.hash), }; if (handlerData.error) { diff --git a/packages/core/src/types/breadcrumb.ts b/packages/core/src/types/breadcrumb.ts index 391100a5a377..3eb70ac87f7d 100644 --- a/packages/core/src/types/breadcrumb.ts +++ b/packages/core/src/types/breadcrumb.ts @@ -77,10 +77,13 @@ export interface BreadcrumbHint { export interface FetchBreadcrumbData { method: string; + /** Sanitized URL: the query string and fragment live in their own fields below. */ url: string; status_code?: number; request_body_size?: number; response_body_size?: number; + 'url.query'?: string; + 'url.fragment'?: string; } export interface XhrBreadcrumbData { diff --git a/packages/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts index 93fa943db8d8..d98fc668d40d 100644 --- a/packages/core/test/lib/integrations/fetch.test.ts +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -209,6 +209,50 @@ describe('createFetchIntegration', () => { ); }); + it('sanitizes the breadcrumb URL and reports the query separately', () => { + const handler = setupIntegration(fetchIntegration(), client); + + handler({ + fetchData: { url: 'http://user:pw@my-website.com/path?q=hello&token=secret#frag', method: 'GET' }, + args: ['http://my-website.com/path?q=hello&token=secret#frag'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + url: 'http://[filtered]:[filtered]@my-website.com/path', + 'url.query': 'q=hello&token=[Filtered]', + 'url.fragment': 'frag', + }), + }), + expect.anything(), + ); + }); + + it('redacts the breadcrumb query when `dataCollection.urlQueryParams` is off', () => { + client = makeClient({ dataCollection: { urlQueryParams: false } }); + const handler = setupIntegration(fetchIntegration(), client); + + handler({ + fetchData: { url: 'http://my-website.com/path?token=secret', method: 'GET' }, + args: ['http://my-website.com/path?token=secret'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ url: 'http://my-website.com/path' }), + }), + expect.anything(), + ); + expect(addBreadcrumbSpy.mock.lastCall?.[0].data?.['url.query']).toBeUndefined(); + }); + it('creates an error-level breadcrumb for a failed request', () => { const handler = setupIntegration(fetchIntegration(), client); From 627b5cc5695d22c8985f8039be6cd70fc6b4c0b6 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 14 Sep 2026 14:41:23 -0700 Subject: [PATCH 4/5] fix: add fetch instrumentation to @sentry/core/server --- packages/core/src/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 25dac9eeb7a4..e48e0ea1de5f 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -38,3 +38,5 @@ export type { HttpServerResponse, HttpModuleExport, } from './integrations/http/types'; +export { createFetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from './integrations/fetch'; From f065fd67ae66482602c16ca8061889dff186e41d Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 14 Sep 2026 14:58:55 -0700 Subject: [PATCH 5/5] fix: empty URL should not provide the breadcrumb "undefined" --- packages/core/src/utils/url.ts | 4 +++- .../core/test/lib/integrations/fetch.test.ts | 17 +++++++++++++++++ packages/core/test/lib/utils/url.test.ts | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index 45be6373bf29..63ecad6dc4d8 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -319,7 +319,9 @@ export function getSanitizedUrlString(url: PartialURL): string { .replace(/(:80)$/, '') .replace(/(:443)$/, '') || ''; - return `${protocol ? `${protocol}://` : ''}${filteredHost}${path}`; + // `parseUrl` returns `{}` for an empty or unparseable URL, and interpolating a missing path + // would render the string 'undefined'. + return `${protocol ? `${protocol}://` : ''}${filteredHost}${path || ''}`; } /** diff --git a/packages/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts index d98fc668d40d..4c332b479fa0 100644 --- a/packages/core/test/lib/integrations/fetch.test.ts +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -253,6 +253,23 @@ describe('createFetchIntegration', () => { expect(addBreadcrumbSpy.mock.lastCall?.[0].data?.['url.query']).toBeUndefined(); }); + it('records an empty breadcrumb URL rather than the string "undefined"', () => { + const handler = setupIntegration(fetchIntegration(), client); + + handler({ + fetchData: { url: '', method: 'GET' }, + args: [''], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + error: new Error('Invalid URL'), + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ data: { method: 'GET', url: '' } }), + expect.anything(), + ); + }); + it('creates an error-level breadcrumb for a failed request', () => { const handler = setupIntegration(fetchIntegration(), client); diff --git a/packages/core/test/lib/utils/url.test.ts b/packages/core/test/lib/utils/url.test.ts index e045366955fe..ad491ea983a6 100644 --- a/packages/core/test/lib/utils/url.test.ts +++ b/packages/core/test/lib/utils/url.test.ts @@ -78,6 +78,8 @@ describe('getSanitizedUrlString', () => { ['url with port 4433', 'http://172.31.12.144:4433/test', 'http://172.31.12.144:4433/test'], ['url with port 443', 'http://172.31.12.144:443/test', 'http://172.31.12.144/test'], ['url with IP and port 80', 'http://172.31.12.144:80/test', 'http://172.31.12.144/test'], + ['empty url', '', ''], + ['unparseable url', '???', ''], ])('returns a sanitized URL for a %s', (_, rawUrl: string, sanitizedURL: string) => { const urlObject = parseUrl(rawUrl); expect(getSanitizedUrlString(urlObject)).toEqual(sanitizedURL);