diff --git a/CHANGELOG.md b/CHANGELOG.md index d65296eaca89..cbffe5f09835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ 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, and @hafzism. 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(bun, cloudflare, deno, vercel-edge): Bound the record of spans waiting for their outgoing `fetch` to settle. A request whose promise never settles reports no end, so its entry was never released and the record grew for the lifetime of the process. +- 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 022cf4383495..7c3569e2b68a 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -210,5 +210,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 e347cbc9fdab..bb5c2a211cf3 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..aefc8b30a080 --- /dev/null +++ b/packages/core/src/integrations/fetch.ts @@ -0,0 +1,242 @@ +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; +} + +/** + * Upper bound on spans waiting for their request to settle. + * + * `instrumentFetchRequest` adds an entry when a request starts and deletes it when the request + * ends. A request whose promise never settles never reports an end, so its entry has no other way + * out. Without a cap those orphans accumulate for the lifetime of the process. + * + * The sweep runs once per this many starts rather than on every request, so the record holds at + * most twice this many entries between sweeps. + */ +const MAX_PENDING_SPANS = 1000; + +/** + * Drops the oldest entries once there are more than {@link MAX_PENDING_SPANS}. + * + * A record iterates integer-like keys first and every other key in insertion order. Span ids are + * 16 hex characters, so even an all-digit one is far above the largest array index and can never + * be integer-like: the entries at the head are always the oldest. Dropping one only loses the + * timing of a request that never finished. + */ +function dropOrphanedSpans(spans: Record): void { + const ids = Object.keys(spans); + + for (let i = 0; i < ids.length - MAX_PENDING_SPANS; i++) { + // oxlint-disable-next-line typescript/no-dynamic-delete + delete spans[ids[i] as string]; + } +} + +/** + * 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(); + + // Counting requests since the last sweep keeps `Object.keys` off the per-request path. + let startsSinceSweep = 0; + + 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 (!handlerData.endTimestamp && ++startsSinceSweep >= MAX_PENDING_SPANS) { + startsSinceSweep = 0; + dropOrphanedSpans(spans); + } + + 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..ef6ff03f9b59 --- /dev/null +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -0,0 +1,297 @@ +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('bounds the pending-span record when requests never settle', () => { + // `instrumentFetchRequest` runs for real here, so the record the spy captured is the live one. + const handler = setupIntegration(fetchIntegration(), client); + + // Start events only: no end event ever arrives, so nothing deletes these entries. + for (let i = 0; i < 5000; i++) { + handler({ + fetchData: { url: `http://my-website.com/${i}`, method: 'GET' }, + args: [`http://my-website.com/${i}`], + startTimestamp: Date.now(), + }); + } + + const pendingSpans = instrumentFetchRequestSpy.mock.calls[0]![3]; + + // The cap is 1000, swept once every 1000 starts, so at most 2000 entries survive. + expect(Object.keys(pendingSpans).length).toBeLessThanOrEqual(2000); + }); + + 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 0a7369513677..e92c7828885f 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'); }); });