diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 8c3e51b14024..f9ac90e48e13 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -50,6 +50,8 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // not supported in bun: 'NodeClient', + // Doesn't have these events + 'fetchBreadcrumbsIntegration', 'childProcessIntegration', ], }, diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario-disabled-legacy.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario-disabled-legacy.ts new file mode 100644 index 000000000000..c98db3561d04 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario-disabled-legacy.ts @@ -0,0 +1,27 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [Sentry.nativeNodeFetchIntegration({ breadcrumbs: false })], + transport: loggingTransport, + tracesSampleRate: 0.0, +}); + +async function run(): Promise { + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + + // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented + await new Promise(resolve => setTimeout(resolve, 100)); + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario-disabled.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario-disabled.ts new file mode 100644 index 000000000000..1f16b59a654b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario-disabled.ts @@ -0,0 +1,28 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, + tracesSampleRate: 0.0, + fetchBreadcrumbs: false, +}); + +async function run(): Promise { + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + + // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented + await new Promise(resolve => setTimeout(resolve, 100)); + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts index c14a6ab528ac..ff8865e367ff 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts @@ -75,4 +75,62 @@ describe('outgoing fetch', () => { .completed(); closeTestServer(); }); + + test('outgoing fetch requests should not create breadcrumbs when disabled', done => { + createTestServer(done) + .start() + .then(([SERVER_URL, closeTestServer]) => { + createRunner(__dirname, 'scenario-disabled.ts') + .withEnv({ SERVER_URL }) + .ensureNoErrorOutput() + .expect({ + event: { + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + ], + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start(closeTestServer); + }); + }); + + test('outgoing fetch requests should not create breadcrumbs when legacy disabled', done => { + createTestServer(done) + .start() + .then(([SERVER_URL, closeTestServer]) => { + createRunner(__dirname, 'scenario-disabled-legacy.ts') + .withEnv({ SERVER_URL }) + .ensureNoErrorOutput() + .expect({ + event: { + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + ], + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start(closeTestServer); + }); + }); }); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index d89503eb9dfb..6c22a8c63c9b 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -77,6 +77,7 @@ export { mysql2Integration, mysqlIntegration, nativeNodeFetchIntegration, + fetchBreadcrumbsIntegration, NodeClient, nodeContextIntegration, onUncaughtExceptionIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 59465831a734..852b73aa781f 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -46,6 +46,7 @@ export { consoleIntegration, httpIntegration, nativeNodeFetchIntegration, + fetchBreadcrumbsIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, modulesIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 54ae30fb5c8c..6baba7c1a3ce 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -46,6 +46,7 @@ export { consoleIntegration, httpIntegration, nativeNodeFetchIntegration, + fetchBreadcrumbsIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, modulesIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8467f3e3727d..c87c190ea51b 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -33,6 +33,7 @@ export { dataloaderIntegration } from './integrations/tracing/dataloader'; export { amqplibIntegration } from './integrations/tracing/amqplib'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { childProcessIntegration } from './integrations/childProcess'; +export { fetchBreadcrumbsIntegration } from './integrations/fetch-breadcrumbs'; export { SentryContextManager } from './otel/contextManager'; export { generateInstrumentOnce } from './otel/instrument'; diff --git a/packages/node/src/integrations/fetch-breadcrumbs.ts b/packages/node/src/integrations/fetch-breadcrumbs.ts new file mode 100644 index 000000000000..7c1fa762f3ed --- /dev/null +++ b/packages/node/src/integrations/fetch-breadcrumbs.ts @@ -0,0 +1,121 @@ +import type { UndiciRequest, UndiciResponse } from '@opentelemetry/instrumentation-undici'; +import type { Integration, IntegrationFn, SanitizedRequestData } from '@sentry/core'; +import { + addBreadcrumb, + defineIntegration, + getBreadcrumbLogLevelFromHttpStatusCode, + getSanitizedUrlString, + parseUrl, +} from '@sentry/core'; + +import * as diagnosticsChannel from 'diagnostics_channel'; +import type { NodeClient } from '../sdk/client'; + +type OldIntegration = Integration & { breadcrumbsDisabled: boolean }; + +interface NodeFetchOptions { + /** + * Do not capture breadcrumbs for outgoing fetch requests to URLs where the given callback returns `true`. + */ + ignore?: (url: string) => boolean; +} + +const _fetchBreadcrumbsIntegration = ((options: NodeFetchOptions = {}) => { + function onRequestHeaders({ request, response }: { request: UndiciRequest; response: UndiciResponse }): void { + if (options.ignore) { + const url = getAbsoluteUrl(request.origin, request.path); + const shouldIgnore = options.ignore(url); + + if (shouldIgnore) { + return; + } + } + + addRequestBreadcrumb(request, response); + } + + return { + name: 'FetchBreadcrumbs', + setup: (client: NodeClient) => { + if (client.getOptions().fetchBreadcrumbs === false) { + return; + } + + // We need to ensure all other integrations have been setup first + setImmediate(() => { + const oldIntegration = client.getIntegrationByName('NodeFetch'); + if (oldIntegration?.breadcrumbsDisabled) { + return; + } + + diagnosticsChannel + .channel('undici:request:headers') + .subscribe(onRequestHeaders as diagnosticsChannel.ChannelListener); + }); + }, + }; +}) satisfies IntegrationFn; + +export const fetchBreadcrumbsIntegration = defineIntegration(_fetchBreadcrumbsIntegration); + +/** Add a breadcrumb for outgoing requests. */ +function addRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void { + const data = getBreadcrumbData(request); + const statusCode = response.statusCode; + const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); + + addBreadcrumb( + { + category: 'http', + data: { + status_code: statusCode, + ...data, + }, + type: 'http', + level, + }, + { + event: 'response', + request, + response, + }, + ); +} + +function getBreadcrumbData(request: UndiciRequest): Partial { + try { + const url = new URL(request.path, request.origin); + const parsedUrl = parseUrl(url.toString()); + + const data: Partial = { + url: getSanitizedUrlString(parsedUrl), + 'http.method': request.method || 'GET', + }; + + if (parsedUrl.search) { + data['http.query'] = parsedUrl.search; + } + if (parsedUrl.hash) { + data['http.fragment'] = parsedUrl.hash; + } + + return data; + } catch { + return {}; + } +} + +// Matching the behavior of the base instrumentation +function getAbsoluteUrl(origin: string, path: string = '/'): string { + const url = `${origin}`; + + if (url.endsWith('/') && path.startsWith('/')) { + return `${url}${path.slice(1)}`; + } + + if (!url.endsWith('/') && !path.startsWith('/')) { + return `${url}/${path.slice(1)}`; + } + + return `${url}${path}`; +} diff --git a/packages/node/src/integrations/node-fetch.ts b/packages/node/src/integrations/node-fetch.ts new file mode 100644 index 000000000000..8bcb8434442d --- /dev/null +++ b/packages/node/src/integrations/node-fetch.ts @@ -0,0 +1,107 @@ +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'; +import type { IntegrationFn } from '@sentry/core'; +import { + LRUMap, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + defineIntegration, + getClient, + getTraceData, + hasSpansEnabled +} from '@sentry/core'; +import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; + +interface NodeFetchOptions { + /** + * @deprecated Use `fetchBreadcrumbs` init option instead. + * ```js + * Sentry.init({ + * dsn: '__DSN__', + * fetchBreadcrumbs: false, + * }) + * ``` + * + * Whether breadcrumbs should be recorded for requests. + * + * Defaults to `true` + */ + breadcrumbs?: boolean; + + /** + * Do not capture spans or breadcrumbs for outgoing fetch requests to URLs where the given callback returns `true`. + * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. + */ + ignoreOutgoingRequests?: (url: string) => boolean; +} + +const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { + const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; + + return { + name: 'NodeFetch', + setupOnce() { + const propagationDecisionMap = new LRUMap(100); + + const instrumentation = new UndiciInstrumentation({ + requireParentforSpans: false, + ignoreRequestHook: request => { + const url = getAbsoluteUrl(request.origin, request.path); + const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url); + + if (shouldIgnore) { + return true; + } + + // If tracing is disabled, we still want to propagate traces + // So we do that manually here, matching what the instrumentation does otherwise + if (!hasSpansEnabled()) { + const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets; + const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) + ? getTraceData() + : {}; + + const requestHeaders = request.headers; + if (Array.isArray(requestHeaders)) { + Object.entries(addedHeaders).forEach(headers => requestHeaders.push(...headers)); + } else { + request.headers += Object.entries(addedHeaders) + .map(([k, v]) => `${k}: ${v}\r\n`) + .join(''); + } + + // Prevent starting a span for this request + return true; + } + + return false; + }, + startSpanHook: () => { + return { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch', + }; + }, + }); + + registerInstrumentations({ instrumentations: [instrumentation] }); + }, + // eslint-disable-next-line deprecation/deprecation + breadcrumbsDisabled: options.breadcrumbs === false, + }; +}) satisfies IntegrationFn; + +export const nativeNodeFetchIntegration = defineIntegration(_nativeNodeFetchIntegration); + +// Matching the behavior of the base instrumentation +function getAbsoluteUrl(origin: string, path: string = '/'): string { + const url = `${origin}`; + + if (url.endsWith('/') && path.startsWith('/')) { + return `${url}${path.slice(1)}`; + } + + if (!url.endsWith('/') && !path.startsWith('/')) { + return `${url}/${path.slice(1)}`; + } + + return `${url}${path}`; +} diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 7df3696c3d58..e45ad28808bb 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -23,6 +23,7 @@ import { DEBUG_BUILD } from '../debug-build'; import { childProcessIntegration } from '../integrations/childProcess'; import { nodeContextIntegration } from '../integrations/context'; import { contextLinesIntegration } from '../integrations/contextlines'; +import { fetchBreadcrumbsIntegration } from '../integrations/fetch-breadcrumbs'; import { httpIntegration } from '../integrations/http'; import { localVariablesIntegration } from '../integrations/local-variables'; import { modulesIntegration } from '../integrations/modules'; @@ -68,6 +69,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { localVariablesIntegration(), nodeContextIntegration(), childProcessIntegration(), + fetchBreadcrumbsIntegration(), processSessionIntegration(), ...getCjsOnlyIntegrations(), ]; diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 9223619522e6..79a7f6f805c7 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -65,6 +65,13 @@ export interface BaseNodeOptions { /** Sets an optional server name (device name) */ serverName?: string; + /** + * Whether breadcrumbs should be recorded for fetch and undici requests. + * + * Defaults to `true`. + */ + fetchBreadcrumbs?: boolean; + /** * Include local variables with stack traces. * diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index da00b43a4fde..e2a1e7a27c1e 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -68,6 +68,7 @@ export { mysql2Integration, mysqlIntegration, nativeNodeFetchIntegration, + fetchBreadcrumbsIntegration, NodeClient, nodeContextIntegration, onUncaughtExceptionIntegration, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index f50420fd2937..d16e90d241d4 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -70,6 +70,7 @@ export { mysql2Integration, mysqlIntegration, nativeNodeFetchIntegration, + fetchBreadcrumbsIntegration, NodeClient, nodeContextIntegration, onUncaughtExceptionIntegration,