diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 33593224ad33..ef6a32b6970b 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -109,6 +109,7 @@ export { DenoClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { denoServeIntegration } from './integrations/deno-serve'; export type { DenoServeIntegrationOptions } from './integrations/deno-serve'; +export { fetchIntegration } from './integrations/fetch'; export { denoHttpIntegration } from './integrations/http'; export type { DenoHttpIntegrationOptions } from './integrations/http'; diff --git a/packages/deno/src/integrations/fetch.ts b/packages/deno/src/integrations/fetch.ts new file mode 100644 index 000000000000..e3b948f5f11c --- /dev/null +++ b/packages/deno/src/integrations/fetch.ts @@ -0,0 +1,87 @@ +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(); + +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; + +/** + * Instruments outgoing `fetch` requests in Deno by creating spans and attaching trace propagation headers. + * The separate breadcrumbs integration continues to record fetch breadcrumbs. + */ +export const fetchIntegration = defineIntegration(_fetchIntegration); diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 07d5e8366184..0f9e56095fd5 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -18,6 +18,7 @@ import { breadcrumbsIntegration } from './integrations/breadcrumbs'; import { denoContextIntegration } from './integrations/context'; import { contextLinesIntegration } from './integrations/contextlines'; import { denoServeIntegration } from './integrations/deno-serve'; +import { fetchIntegration } from './integrations/fetch'; import { denoHttpIntegration } from './integrations/http'; import { globalHandlersIntegration } from './integrations/globalhandlers'; import { normalizePathsIntegration } from './integrations/normalizepaths'; @@ -39,6 +40,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { breadcrumbsIntegration(), denoContextIntegration(), denoServeIntegration(), + fetchIntegration(), denoHttpIntegration(), contextLinesIntegration(), normalizePathsIntegration(), diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index 4ce1aea47cd0..5a21ad183511 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -55,6 +55,7 @@ snapshot[`captureMessage 1`] = ` "Breadcrumbs", "DenoContext", "DenoServe", + "Fetch", "DenoHttp", "ContextLines", "NormalizePaths", @@ -162,6 +163,7 @@ snapshot[`captureMessage twice 1`] = ` "Breadcrumbs", "DenoContext", "DenoServe", + "Fetch", "DenoHttp", "ContextLines", "NormalizePaths", @@ -276,6 +278,7 @@ snapshot[`captureMessage twice 2`] = ` "Breadcrumbs", "DenoContext", "DenoServe", + "Fetch", "DenoHttp", "ContextLines", "NormalizePaths", diff --git a/packages/deno/test/deno-fetch.test.ts b/packages/deno/test/deno-fetch.test.ts new file mode 100644 index 000000000000..053eaaa2f026 --- /dev/null +++ b/packages/deno/test/deno-fetch.test.ts @@ -0,0 +1,107 @@ +/// + +import type { Event, TransactionEvent } from '@sentry/core'; +import { getMainCarrier } from '@sentry/core'; +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'; + +function resetGlobals(): void { + getMainCarrier().__SENTRY__ = undefined; +} + +function withTimeout(promise: Promise, ms: number, description: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description} after ${ms}ms`)), ms); + }); + + return Promise.race([promise, timeout]).finally(() => { + if (timer !== undefined) { + clearTimeout(timer); + } + }); +} + +Deno.test({ + name: 'fetchIntegration: included in default integrations', + fn() { + resetGlobals(); + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; + const names = client.getOptions().integrations.map(integration => integration.name); + + assert(names.includes('Fetch'), `Fetch should be a default integration, got ${names.join(', ')}`); + }, +}); + +Deno.test({ + name: 'fetchIntegration: creates a child span, propagates trace headers, and preserves a single fetch breadcrumb', + async fn() { + resetGlobals(); + + const abortController = new AbortController(); + let onListen: ((value: unknown) => void) | undefined; + const listening = new Promise(resolve => (onListen = resolve)); + let receivedHeaders: Headers | undefined; + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen, hostname: '127.0.0.1' }, request => { + receivedHeaders = request.headers; + return new Response('ok'); + }); + await listening; + + try { + const url = `http://127.0.0.1:${server.addr.port}/downstream`; + let resolveTransaction: ((event: TransactionEvent) => void) | undefined; + const transaction = new Promise(resolve => (resolveTransaction = resolve)); + let resolveEvent: ((event: Event) => void) | undefined; + const capturedEvent = new Promise(resolve => (resolveEvent = resolve)); + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + tracePropagationTargets: [url], + beforeSendTransaction(event) { + if (event.transaction === 'parent') { + resolveTransaction?.(event); + } + return null; + }, + beforeSend(event) { + resolveEvent?.(event); + return null; + }, + }); + + await startSpan({ name: 'parent', op: 'test' }, async () => { + const response = await fetch(url); + assertEquals(await response.text(), 'ok'); + }); + + const parent = await withTimeout(transaction, 5_000, 'parent transaction'); + const httpClientSpan = parent.spans?.find(span => span.op === 'http.client'); + assertExists(httpClientSpan); + + assertExists(receivedHeaders); + const sentryTrace = receivedHeaders.get('sentry-trace'); + const baggage = receivedHeaders.get('baggage'); + assertExists(sentryTrace); + assertExists(baggage); + assertEquals(sentryTrace.split('-')[0], parent.contexts?.trace?.trace_id); + assertEquals(sentryTrace.split('-')[1], httpClientSpan.span_id); + assert(baggage.includes(`sentry-trace_id=${parent.contexts?.trace?.trace_id}`)); + + 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, + ); + assertEquals(fetchBreadcrumbs?.length, 1); + } finally { + abortController.abort(); + await server.finished; + } + }, +});