diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-fetch/server.js b/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-fetch/server.js new file mode 100644 index 000000000000..08a350f00f7e --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-fetch/server.js @@ -0,0 +1,70 @@ +const http = require('http'); +const Sentry = require('@sentry/node-core/light'); +const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-core-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + tracePropagationTargets: [/\/api\/v0/, 'v1'], +}); + +let capturedV0 = {}; +let capturedV1 = {}; +let capturedV2 = {}; + +const targetServer = http.createServer((req, res) => { + const headers = { + 'sentry-trace': req.headers['sentry-trace'], + baggage: req.headers['baggage'], + }; + + if (req.url === '/api/v0') { + capturedV0 = headers; + } else if (req.url === '/api/v1') { + capturedV1 = headers; + } else if (req.url === '/api/v2') { + capturedV2 = headers; + } + + res.writeHead(200); + res.end('ok'); +}); + +targetServer.listen(0, () => { + const targetPort = targetServer.address().port; + const targetUrl = `http://localhost:${targetPort}`; + + const server = http.createServer(async (req, res) => { + switch (req.url) { + case '/test-auto-propagation': { + capturedV0 = {}; + capturedV1 = {}; + capturedV2 = {}; + await fetch(`${targetUrl}/api/v0`); + await fetch(`${targetUrl}/api/v1`); + await fetch(`${targetUrl}/api/v2`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ '/api/v0': capturedV0, '/api/v1': capturedV1, '/api/v2': capturedV2 })); + break; + } + case '/test-breadcrumbs': { + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + await fetch(`${targetUrl}/api/v0`); + await fetch(`${targetUrl}/api/v1`); + Sentry.captureException(new Error('foo')); + res.writeHead(200); + res.end('ok'); + break; + } + default: { + res.writeHead(404); + res.end(); + } + } + }); + + server.listen(0, () => { + sendPortToRunner(server.address().port); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-fetch/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-fetch/test.ts new file mode 100644 index 000000000000..d05095fd53e1 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-fetch/test.ts @@ -0,0 +1,79 @@ +import crypto from 'crypto'; +import { afterAll, expect, test } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +conditionalTest({ min: 22 })('light mode outgoing fetch', () => { + test('automatically propagates trace headers to outgoing fetch requests matching tracePropagationTargets', async () => { + const traceId = crypto.randomUUID().replace(/-/g, ''); + const parentSpanId = traceId.substring(0, 16); + + const runner = createRunner(__dirname, 'server.js').start(); + + const response = await runner.makeRequest>( + 'get', + '/test-auto-propagation', + { + headers: { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: `sentry-trace_id=${traceId},sentry-environment=test,sentry-public_key=public`, + }, + }, + ); + + // /api/v0 matches tracePropagationTargets - should have headers + expect(response?.['/api/v0']?.['sentry-trace']).toMatch(new RegExp(`^${traceId}-[a-f\\d]{16}-1$`)); + expect(response?.['/api/v0']?.baggage).toContain(`sentry-trace_id=${traceId}`); + + // /api/v1 matches tracePropagationTargets - should have headers + expect(response?.['/api/v1']?.['sentry-trace']).toMatch(new RegExp(`^${traceId}-[a-f\\d]{16}-1$`)); + expect(response?.['/api/v1']?.baggage).toContain(`sentry-trace_id=${traceId}`); + + // /api/v2 does NOT match tracePropagationTargets - should NOT have headers + expect(response?.['/api/v2']?.['sentry-trace']).toBeUndefined(); + expect(response?.['/api/v2']?.baggage).toBeUndefined(); + }); + + test('creates breadcrumbs for outgoing fetch requests', async () => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + event: event => { + const breadcrumbs = event.breadcrumbs || []; + const httpBreadcrumbs = breadcrumbs.filter(b => b.category === 'http'); + + expect(httpBreadcrumbs.length).toBe(2); + + expect(httpBreadcrumbs[0]).toEqual( + expect.objectContaining({ + category: 'http', + type: 'http', + data: expect.objectContaining({ + 'http.method': 'GET', + status_code: 200, + }), + }), + ); + + expect(httpBreadcrumbs[1]).toEqual( + expect.objectContaining({ + category: 'http', + type: 'http', + data: expect.objectContaining({ + 'http.method': 'GET', + status_code: 200, + }), + }), + ); + }, + }) + .start(); + + await runner.makeRequest('get', '/test-breadcrumbs'); + + await runner.completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-http/server.js b/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-http/server.js new file mode 100644 index 000000000000..4aa52daaeb53 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-http/server.js @@ -0,0 +1,98 @@ +const http = require('http'); +const Sentry = require('@sentry/node-core/light'); +const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-core-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + tracePropagationTargets: [/\/api\/v0/, 'v1'], + beforeBreadcrumb(breadcrumb, hint) { + breadcrumb.data = breadcrumb.data || {}; + const req = hint?.request; + if (req?.path) { + breadcrumb.data.ADDED_PATH = req.path; + } + return breadcrumb; + }, +}); + +function makeHttpRequest(url) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const req = http.request( + { + hostname: urlObj.hostname, + port: urlObj.port, + path: urlObj.pathname, + method: 'GET', + }, + res => { + res.on('data', () => {}); + res.on('end', () => resolve()); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +let capturedV0 = {}; +let capturedV1 = {}; +let capturedV2 = {}; + +const targetServer = http.createServer((req, res) => { + const headers = { + 'sentry-trace': req.headers['sentry-trace'], + baggage: req.headers['baggage'], + }; + + if (req.url === '/api/v0') { + capturedV0 = headers; + } else if (req.url === '/api/v1') { + capturedV1 = headers; + } else if (req.url === '/api/v2') { + capturedV2 = headers; + } + + res.writeHead(200); + res.end('ok'); +}); + +targetServer.listen(0, () => { + const targetPort = targetServer.address().port; + const targetUrl = `http://localhost:${targetPort}`; + + const server = http.createServer(async (req, res) => { + switch (req.url) { + case '/test-auto-propagation': { + capturedV0 = {}; + capturedV1 = {}; + capturedV2 = {}; + await makeHttpRequest(`${targetUrl}/api/v0`); + await makeHttpRequest(`${targetUrl}/api/v1`); + await makeHttpRequest(`${targetUrl}/api/v2`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ '/api/v0': capturedV0, '/api/v1': capturedV1, '/api/v2': capturedV2 })); + break; + } + case '/test-breadcrumbs': { + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + await makeHttpRequest(`${targetUrl}/api/v0`); + await makeHttpRequest(`${targetUrl}/api/v1`); + Sentry.captureException(new Error('foo')); + res.writeHead(200); + res.end('ok'); + break; + } + default: { + res.writeHead(404); + res.end(); + } + } + }); + + server.listen(0, () => { + sendPortToRunner(server.address().port); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-http/test.ts b/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-http/test.ts new file mode 100644 index 000000000000..feab13894ada --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/light-mode/outgoing-http/test.ts @@ -0,0 +1,79 @@ +import crypto from 'crypto'; +import { afterAll, expect, test } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +conditionalTest({ min: 22 })('light mode outgoing http', () => { + test('automatically propagates trace headers to outgoing http requests matching tracePropagationTargets', async () => { + const traceId = crypto.randomUUID().replace(/-/g, ''); + const parentSpanId = traceId.substring(0, 16); + + const runner = createRunner(__dirname, 'server.js').start(); + + const response = await runner.makeRequest>( + 'get', + '/test-auto-propagation', + { + headers: { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: `sentry-trace_id=${traceId},sentry-environment=test,sentry-public_key=public`, + }, + }, + ); + + // /api/v0 matches tracePropagationTargets - should have headers + expect(response?.['/api/v0']?.['sentry-trace']).toMatch(new RegExp(`^${traceId}-[a-f\\d]{16}-1$`)); + expect(response?.['/api/v0']?.baggage).toContain(`sentry-trace_id=${traceId}`); + + // /api/v1 matches tracePropagationTargets - should have headers + expect(response?.['/api/v1']?.['sentry-trace']).toMatch(new RegExp(`^${traceId}-[a-f\\d]{16}-1$`)); + expect(response?.['/api/v1']?.baggage).toContain(`sentry-trace_id=${traceId}`); + + // /api/v2 does NOT match tracePropagationTargets - should NOT have headers + expect(response?.['/api/v2']?.['sentry-trace']).toBeUndefined(); + expect(response?.['/api/v2']?.baggage).toBeUndefined(); + }); + + test('creates breadcrumbs for outgoing http requests', async () => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + event: event => { + const breadcrumbs = event.breadcrumbs || []; + const httpBreadcrumbs = breadcrumbs.filter(b => b.category === 'http'); + + expect(httpBreadcrumbs.length).toBe(2); + + expect(httpBreadcrumbs[0]).toEqual( + expect.objectContaining({ + category: 'http', + type: 'http', + data: expect.objectContaining({ + 'http.method': 'GET', + status_code: 200, + }), + }), + ); + + expect(httpBreadcrumbs[1]).toEqual( + expect.objectContaining({ + category: 'http', + type: 'http', + data: expect.objectContaining({ + 'http.method': 'GET', + status_code: 200, + }), + }), + ); + }, + }) + .start(); + + await runner.makeRequest('get', '/test-breadcrumbs'); + + await runner.completed(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30ace1803b1a..7f262e4762b3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,6 +92,7 @@ export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnSco export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; export { getTraceData } from './utils/traceData'; +export { shouldPropagateTraceForUrl } from './utils/tracePropagationTargets'; export { getTraceMetaTags } from './utils/meta'; export { debounce } from './utils/debounce'; export { diff --git a/packages/core/src/utils/tracePropagationTargets.ts b/packages/core/src/utils/tracePropagationTargets.ts new file mode 100644 index 000000000000..6c4f9c88c62e --- /dev/null +++ b/packages/core/src/utils/tracePropagationTargets.ts @@ -0,0 +1,34 @@ +import type { CoreOptions as Options } from '../types-hoist/options'; +import { debug } from './debug-logger'; +import type { LRUMap } from './lru'; +import { stringMatchesSomePattern } from './string'; + +const NOT_PROPAGATED_MESSAGE = + '[Tracing] Not injecting trace data for url because it does not match tracePropagationTargets:'; + +/** + * Check if a given URL should be propagated to or not. + * If no url is defined, or no trace propagation targets are defined, this will always return `true`. + * You can also optionally provide a decision map, to cache decisions and avoid repeated regex lookups. + */ +export function shouldPropagateTraceForUrl( + url: string | undefined, + tracePropagationTargets: Options['tracePropagationTargets'], + decisionMap?: LRUMap, +): boolean { + if (typeof url !== 'string' || !tracePropagationTargets) { + return true; + } + + const cachedDecision = decisionMap?.get(url); + if (cachedDecision !== undefined) { + !cachedDecision && debug.log(NOT_PROPAGATED_MESSAGE, url); + return cachedDecision; + } + + const decision = stringMatchesSomePattern(url, tracePropagationTargets); + decisionMap?.set(url, decision); + + !decision && debug.log(NOT_PROPAGATED_MESSAGE, url); + return decision; +} diff --git a/packages/node-core/src/integrations/http/outgoing-requests.ts b/packages/node-core/src/integrations/http/outgoing-requests.ts index 49726c1f4937..2d34d3948a78 100644 --- a/packages/node-core/src/integrations/http/outgoing-requests.ts +++ b/packages/node-core/src/integrations/http/outgoing-requests.ts @@ -1,159 +1,5 @@ -import type { LRUMap, SanitizedRequestData } from '@sentry/core'; -import { - addBreadcrumb, - debug, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - getSanitizedUrlString, - getTraceData, - isError, - parseUrl, -} from '@sentry/core'; -import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; -import type { ClientRequest, IncomingMessage, RequestOptions } from 'http'; -import { DEBUG_BUILD } from '../../debug-build'; -import { mergeBaggageHeaders } from '../../utils/baggage'; -import { INSTRUMENTATION_NAME } from './constants'; - -/** Add a breadcrumb for outgoing requests. */ -export function addRequestBreadcrumb(request: ClientRequest, response: IncomingMessage | undefined): 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, - }, - ); -} - -/** - * Add trace propagation headers to an outgoing request. - * This must be called _before_ the request is sent! - */ -// eslint-disable-next-line complexity -export function addTracePropagationHeadersToOutgoingRequest( - request: ClientRequest, - propagationDecisionMap: LRUMap, -): void { - const url = getRequestUrl(request); - - // Manually add the trace headers, if it applies - // Note: We do not use `propagation.inject()` here, because our propagator relies on an active span - // Which we do not have in this case - const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {}; - const headersToAdd = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) - ? getTraceData({ propagateTraceparent }) - : undefined; - - if (!headersToAdd) { - return; - } - - const { 'sentry-trace': sentryTrace, baggage, traceparent } = headersToAdd; - - // We do not want to overwrite existing header here, if it was already set - if (sentryTrace && !request.getHeader('sentry-trace')) { - try { - request.setHeader('sentry-trace', sentryTrace); - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Added sentry-trace header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - INSTRUMENTATION_NAME, - 'Failed to add sentry-trace header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - - if (traceparent && !request.getHeader('traceparent')) { - try { - request.setHeader('traceparent', traceparent); - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Added traceparent header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - INSTRUMENTATION_NAME, - 'Failed to add traceparent header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - - if (baggage) { - // For baggage, we make sure to merge this into a possibly existing header - const newBaggage = mergeBaggageHeaders(request.getHeader('baggage'), baggage); - if (newBaggage) { - try { - request.setHeader('baggage', newBaggage); - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Added baggage header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - INSTRUMENTATION_NAME, - 'Failed to add baggage header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - } -} - -function getBreadcrumbData(request: ClientRequest): Partial { - try { - // `request.host` does not contain the port, but the host header does - const host = request.getHeader('host') || request.host; - const url = new URL(request.path, `${request.protocol}//${host}`); - 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 {}; - } -} - -/** Convert an outgoing request to request options. */ -export function getRequestOptions(request: ClientRequest): RequestOptions { - return { - method: request.method, - protocol: request.protocol, - host: request.host, - hostname: request.host, - path: request.path, - headers: request.getHeaders(), - }; -} - -function getRequestUrl(request: ClientRequest): string { - const hostname = request.getHeader('host') || request.host; - const protocol = request.protocol; - const path = request.path; - - return `${protocol}//${hostname}${path}`; -} +export { + addRequestBreadcrumb, + addTracePropagationHeadersToOutgoingRequest, + getRequestOptions, +} from '../../utils/outgoingHttpRequest'; diff --git a/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts b/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts index f3bb8ca1e15a..cb972353cabf 100644 --- a/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts +++ b/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts @@ -2,29 +2,16 @@ import { context } from '@opentelemetry/api'; import { isTracingSuppressed } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase } from '@opentelemetry/instrumentation'; -import type { SanitizedRequestData } from '@sentry/core'; -import { - addBreadcrumb, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - getSanitizedUrlString, - getTraceData, - LRUMap, - parseUrl, - SDK_VERSION, -} from '@sentry/core'; -import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; +import { LRUMap, SDK_VERSION } from '@sentry/core'; import * as diagch from 'diagnostics_channel'; import { NODE_MAJOR, NODE_MINOR } from '../../nodeVersion'; -import { mergeBaggageHeaders } from '../../utils/baggage'; +import { + addFetchRequestBreadcrumb, + addTracePropagationHeadersToFetchRequest, + getAbsoluteUrl, +} from '../../utils/outgoingFetchRequest'; import type { UndiciRequest, UndiciResponse } from './types'; -const SENTRY_TRACE_HEADER = 'sentry-trace'; -const SENTRY_BAGGAGE_HEADER = 'baggage'; - -// For baggage, we make sure to merge this into a possibly existing header -const BAGGAGE_HEADER_REGEX = /baggage: (.*)\r\n/; - export type SentryNodeFetchInstrumentationOptions = InstrumentationConfig & { /** * Whether breadcrumbs should be recorded for requests. @@ -114,7 +101,6 @@ export class SentryNodeFetchInstrumentation extends InstrumentationBase header === SENTRY_BAGGAGE_HEADER); - if (baggage && existingBaggagePos === -1) { - requestHeaders.push(SENTRY_BAGGAGE_HEADER, baggage); - } else if (baggage) { - const existingBaggage = requestHeaders[existingBaggagePos + 1]; - const merged = mergeBaggageHeaders(existingBaggage, baggage); - if (merged) { - requestHeaders[existingBaggagePos + 1] = merged; - } - } - } else { - const requestHeaders = request.headers; - // We do not want to overwrite existing header here, if it was already set - if (sentryTrace && !requestHeaders.includes(`${SENTRY_TRACE_HEADER}:`)) { - request.headers += `${SENTRY_TRACE_HEADER}: ${sentryTrace}\r\n`; - } - - if (traceparent && !requestHeaders.includes('traceparent:')) { - request.headers += `traceparent: ${traceparent}\r\n`; - } - - const existingBaggage = request.headers.match(BAGGAGE_HEADER_REGEX)?.[1]; - if (baggage && !existingBaggage) { - request.headers += `${SENTRY_BAGGAGE_HEADER}: ${baggage}\r\n`; - } else if (baggage) { - const merged = mergeBaggageHeaders(existingBaggage, baggage); - if (merged) { - request.headers = request.headers.replace(BAGGAGE_HEADER_REGEX, `baggage: ${merged}\r\n`); - } - } - } + addTracePropagationHeadersToFetchRequest(request, this._propagationDecisionMap); } /** @@ -215,7 +138,7 @@ export class SentryNodeFetchInstrumentation extends InstrumentationBase { - try { - const url = getAbsoluteUrl(request.origin, request.path); - const parsedUrl = parseUrl(url); - - 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 {}; - } -} - -function getAbsoluteUrl(origin: string, path: string = '/'): string { - try { - const url = new URL(path, origin); - return url.toString(); - } catch { - // fallback: Construct it on our own - 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-core/src/light/index.ts b/packages/node-core/src/light/index.ts index 55ba169dfc42..828b721375e7 100644 --- a/packages/node-core/src/light/index.ts +++ b/packages/node-core/src/light/index.ts @@ -2,7 +2,8 @@ export { LightNodeClient } from './client'; export { init, getDefaultIntegrations, initWithoutDefaultIntegrations } from './sdk'; export { setAsyncLocalStorageAsyncContextStrategy } from './asyncLocalStorageStrategy'; -export { httpServerIntegration } from './integrations/httpServerIntegration'; +export { httpIntegration } from './integrations/httpIntegration'; +export { nativeNodeFetchIntegration } from './integrations/nativeNodeFetchIntegration'; // Common exports shared with the main entry point export * from '../common-exports'; diff --git a/packages/node-core/src/light/integrations/httpServerIntegration.ts b/packages/node-core/src/light/integrations/httpIntegration.ts similarity index 56% rename from packages/node-core/src/light/integrations/httpServerIntegration.ts rename to packages/node-core/src/light/integrations/httpIntegration.ts index 4e8cd4c9c267..c1a19f21d2fc 100644 --- a/packages/node-core/src/light/integrations/httpServerIntegration.ts +++ b/packages/node-core/src/light/integrations/httpIntegration.ts @@ -1,6 +1,6 @@ import type { ChannelListener } from 'node:diagnostics_channel'; import { subscribe } from 'node:diagnostics_channel'; -import type { IncomingMessage, RequestOptions, Server } from 'node:http'; +import type { ClientRequest, IncomingMessage, RequestOptions, Server } from 'node:http'; import type { Integration, IntegrationFn } from '@sentry/core'; import { continueTrace, @@ -9,19 +9,25 @@ import { getCurrentScope, getIsolationScope, httpRequestToRequestData, + LRUMap, stripUrlQueryAndFragment, withIsolationScope, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { patchRequestToCaptureBody } from '../../utils/captureRequestBody'; +import { + addRequestBreadcrumb, + addTracePropagationHeadersToOutgoingRequest, + getRequestOptions, +} from '../../utils/outgoingHttpRequest'; import type { LightNodeClient } from '../client'; -const INTEGRATION_NAME = 'Http.Server'; +const INTEGRATION_NAME = 'Http'; // We keep track of emit functions we wrapped, to avoid double wrapping const wrappedEmitFns = new WeakSet(); -export interface HttpServerIntegrationOptions { +export interface HttpIntegrationOptions { /** * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. @@ -46,43 +52,74 @@ export interface HttpServerIntegrationOptions { * @default 'medium' */ maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + + /** + * Whether breadcrumbs should be recorded for outgoing requests. + * + * @default `true` + */ + breadcrumbs?: boolean; + + /** + * Do not capture breadcrumbs or propagate trace headers for outgoing HTTP requests to URLs + * where the given callback returns `true`. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. + * @param request Contains the {@type RequestOptions} object used to make the outgoing request. + */ + ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; } -const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => { +const _httpIntegration = ((options: HttpIntegrationOptions = {}) => { const _options = { maxRequestBodySize: options.maxRequestBodySize ?? 'medium', ignoreRequestBody: options.ignoreRequestBody, + breadcrumbs: options.breadcrumbs ?? true, + ignoreOutgoingRequests: options.ignoreOutgoingRequests, }; + const propagationDecisionMap = new LRUMap(100); + const ignoreOutgoingRequestsMap = new WeakMap(); + return { name: INTEGRATION_NAME, setupOnce() { const onHttpServerRequestStart = ((_data: unknown) => { const data = _data as { server: Server }; - instrumentServer(data.server, _options); }) satisfies ChannelListener; + const onHttpClientRequestCreated = ((_data: unknown) => { + const data = _data as { request: ClientRequest }; + onOutgoingRequestCreated(data.request, _options, propagationDecisionMap, ignoreOutgoingRequestsMap); + }) satisfies ChannelListener; + + const onHttpClientResponseFinish = ((_data: unknown) => { + const data = _data as { request: ClientRequest; response: IncomingMessage }; + onOutgoingRequestFinish(data.request, data.response, _options, ignoreOutgoingRequestsMap); + }) satisfies ChannelListener; + + const onHttpClientRequestError = ((_data: unknown) => { + const data = _data as { request: ClientRequest }; + onOutgoingRequestFinish(data.request, undefined, _options, ignoreOutgoingRequestsMap); + }) satisfies ChannelListener; + subscribe('http.server.request.start', onHttpServerRequestStart); + subscribe('http.client.request.created', onHttpClientRequestCreated); + subscribe('http.client.response.finish', onHttpClientResponseFinish); + subscribe('http.client.request.error', onHttpClientRequestError); }, }; }) satisfies IntegrationFn; /** - * This integration handles request isolation and trace continuation for incoming http requests - * in light mode (without OpenTelemetry). - * - * This is a lightweight alternative to the OpenTelemetry-based httpServerIntegration. - * It uses Node's native AsyncLocalStorage for scope isolation and Sentry's continueTrace for trace propagation. - * - * Note: This integration requires Node.js 22+ (for http.server.request.start diagnostics channel). + * This integration handles incoming and outgoing HTTP requests in light mode (without OpenTelemetry). * - * @see {@link ../../integrations/http/httpServerIntegration.ts} for the OpenTelemetry-based version + * It uses Node's native diagnostics channels (Node.js 22+) for request isolation, + * trace propagation, and breadcrumb creation. */ -export const httpServerIntegration = _httpServerIntegration as ( - options?: HttpServerIntegrationOptions, -) => Integration & { - name: 'Http.Server'; +export const httpIntegration = _httpIntegration as (options?: HttpIntegrationOptions) => Integration & { + name: 'Http'; setupOnce: () => void; }; @@ -172,3 +209,58 @@ function instrumentServer( wrappedEmitFns.add(newEmit); server.emit = newEmit; } + +function onOutgoingRequestCreated( + request: ClientRequest, + options: { ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean }, + propagationDecisionMap: LRUMap, + ignoreOutgoingRequestsMap: WeakMap, +): void { + const shouldIgnore = shouldIgnoreOutgoingRequest(request, options); + ignoreOutgoingRequestsMap.set(request, shouldIgnore); + + if (shouldIgnore) { + return; + } + + addTracePropagationHeadersToOutgoingRequest(request, propagationDecisionMap); +} + +function onOutgoingRequestFinish( + request: ClientRequest, + response: IncomingMessage | undefined, + options: { + breadcrumbs: boolean; + ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; + }, + ignoreOutgoingRequestsMap: WeakMap, +): void { + if (!options.breadcrumbs) { + return; + } + + // Note: We cannot rely on the map being set by `onOutgoingRequestCreated`, because that channel + // only exists since Node 22 + const shouldIgnore = ignoreOutgoingRequestsMap.get(request) ?? shouldIgnoreOutgoingRequest(request, options); + + if (shouldIgnore) { + return; + } + + addRequestBreadcrumb(request, response); +} + +/** Check if the given outgoing request should be ignored. */ +function shouldIgnoreOutgoingRequest( + request: ClientRequest, + options: { ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean }, +): boolean { + const { ignoreOutgoingRequests } = options; + + if (!ignoreOutgoingRequests) { + return false; + } + + const url = `${request.protocol}//${request.getHeader('host') || request.host}${request.path}`; + return ignoreOutgoingRequests(url, getRequestOptions(request)); +} diff --git a/packages/node-core/src/light/integrations/nativeNodeFetchIntegration.ts b/packages/node-core/src/light/integrations/nativeNodeFetchIntegration.ts new file mode 100644 index 000000000000..171eb6c255c6 --- /dev/null +++ b/packages/node-core/src/light/integrations/nativeNodeFetchIntegration.ts @@ -0,0 +1,117 @@ +import type { ChannelListener } from 'node:diagnostics_channel'; +import { subscribe } from 'node:diagnostics_channel'; +import type { Integration, IntegrationFn } from '@sentry/core'; +import { LRUMap } from '@sentry/core'; +import type { UndiciRequest, UndiciResponse } from '../../integrations/node-fetch/types'; +import { + addFetchRequestBreadcrumb, + addTracePropagationHeadersToFetchRequest, + getAbsoluteUrl, +} from '../../utils/outgoingFetchRequest'; + +const INTEGRATION_NAME = 'NodeFetch'; + +export interface NativeNodeFetchIntegrationOptions { + /** + * Whether breadcrumbs should be recorded for requests. + * + * @default `true` + */ + breadcrumbs?: boolean; + + /** + * Do not capture breadcrumbs or inject headers for outgoing fetch requests to URLs + * where the given callback returns `true`. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. + */ + ignoreOutgoingRequests?: (url: string) => boolean; +} + +const _nativeNodeFetchIntegration = ((options: NativeNodeFetchIntegrationOptions = {}) => { + const _options = { + breadcrumbs: options.breadcrumbs ?? true, + ignoreOutgoingRequests: options.ignoreOutgoingRequests, + }; + + const propagationDecisionMap = new LRUMap(100); + const ignoreOutgoingRequestsMap = new WeakMap(); + + return { + name: INTEGRATION_NAME, + setupOnce() { + const onRequestCreated = ((_data: unknown) => { + const data = _data as { request: UndiciRequest }; + onUndiciRequestCreated(data.request, _options, propagationDecisionMap, ignoreOutgoingRequestsMap); + }) satisfies ChannelListener; + + const onResponseHeaders = ((_data: unknown) => { + const data = _data as { request: UndiciRequest; response: UndiciResponse }; + onUndiciResponseHeaders(data.request, data.response, _options, ignoreOutgoingRequestsMap); + }) satisfies ChannelListener; + + subscribe('undici:request:create', onRequestCreated); + subscribe('undici:request:headers', onResponseHeaders); + }, + }; +}) satisfies IntegrationFn; + +/** + * This integration handles outgoing fetch (undici) requests in light mode (without OpenTelemetry). + * It propagates trace headers and creates breadcrumbs for responses. + */ +export const nativeNodeFetchIntegration = _nativeNodeFetchIntegration as ( + options?: NativeNodeFetchIntegrationOptions, +) => Integration & { + name: 'NodeFetch'; + setupOnce: () => void; +}; + +function onUndiciRequestCreated( + request: UndiciRequest, + options: { ignoreOutgoingRequests?: (url: string) => boolean }, + propagationDecisionMap: LRUMap, + ignoreOutgoingRequestsMap: WeakMap, +): void { + const shouldIgnore = shouldIgnoreRequest(request, options); + ignoreOutgoingRequestsMap.set(request, shouldIgnore); + + if (shouldIgnore) { + return; + } + + addTracePropagationHeadersToFetchRequest(request, propagationDecisionMap); +} + +function onUndiciResponseHeaders( + request: UndiciRequest, + response: UndiciResponse, + options: { breadcrumbs: boolean }, + ignoreOutgoingRequestsMap: WeakMap, +): void { + if (!options.breadcrumbs) { + return; + } + + const shouldIgnore = ignoreOutgoingRequestsMap.get(request); + if (shouldIgnore) { + return; + } + + addFetchRequestBreadcrumb(request, response); +} + +/** Check if the given outgoing request should be ignored. */ +function shouldIgnoreRequest( + request: UndiciRequest, + options: { ignoreOutgoingRequests?: (url: string) => boolean }, +): boolean { + const { ignoreOutgoingRequests } = options; + + if (!ignoreOutgoingRequests) { + return false; + } + + const url = getAbsoluteUrl(request.origin, request.path); + return ignoreOutgoingRequests(url); +} diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts index acbea4649ceb..228a5c754c30 100644 --- a/packages/node-core/src/light/sdk.ts +++ b/packages/node-core/src/light/sdk.ts @@ -33,7 +33,8 @@ import { envToBool } from '../utils/envToBool'; import { getSpotlightConfig } from '../utils/spotlight'; import { setAsyncLocalStorageAsyncContextStrategy } from './asyncLocalStorageStrategy'; import { LightNodeClient } from './client'; -import { httpServerIntegration } from './integrations/httpServerIntegration'; +import { httpIntegration } from './integrations/httpIntegration'; +import { nativeNodeFetchIntegration } from './integrations/nativeNodeFetchIntegration'; /** * Get default integrations for the Light Node-Core SDK. @@ -50,9 +51,8 @@ export function getDefaultIntegrations(): Integration[] { systemErrorIntegration(), // Native Wrappers consoleIntegration(), - // HTTP Server (automatic request isolation, requires Node.js 22+) - httpServerIntegration(), - // Note: httpIntegration() and nativeNodeFetchIntegration() are not included in light mode as they require OpenTelemetry + httpIntegration(), + nativeNodeFetchIntegration(), // Global Handlers onUncaughtExceptionIntegration(), onUnhandledRejectionIntegration(), diff --git a/packages/node-core/src/utils/outgoingFetchRequest.ts b/packages/node-core/src/utils/outgoingFetchRequest.ts new file mode 100644 index 000000000000..ed405f0b19d3 --- /dev/null +++ b/packages/node-core/src/utils/outgoingFetchRequest.ts @@ -0,0 +1,164 @@ +import type { LRUMap, SanitizedRequestData } from '@sentry/core'; +import { + addBreadcrumb, + getBreadcrumbLogLevelFromHttpStatusCode, + getClient, + getSanitizedUrlString, + getTraceData, + parseUrl, + shouldPropagateTraceForUrl, +} from '@sentry/core'; +import type { UndiciRequest, UndiciResponse } from '../integrations/node-fetch/types'; +import { mergeBaggageHeaders } from './baggage'; + +const SENTRY_TRACE_HEADER = 'sentry-trace'; +const SENTRY_BAGGAGE_HEADER = 'baggage'; + +// For baggage, we make sure to merge this into a possibly existing header +const BAGGAGE_HEADER_REGEX = /baggage: (.*)\r\n/; + +/** + * Add trace propagation headers to an outgoing fetch/undici request. + * + * Checks if the request URL matches trace propagation targets, + * then injects sentry-trace, traceparent, and baggage headers. + */ +// eslint-disable-next-line complexity +export function addTracePropagationHeadersToFetchRequest( + request: UndiciRequest, + propagationDecisionMap: LRUMap, +): void { + const url = getAbsoluteUrl(request.origin, request.path); + + // Manually add the trace headers, if it applies + // Note: We do not use `propagation.inject()` here, because our propagator relies on an active span + // Which we do not have in this case + // The propagator _may_ overwrite this, but this should be fine as it is the same data + const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {}; + const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) + ? getTraceData({ propagateTraceparent }) + : undefined; + + if (!addedHeaders) { + return; + } + + const { 'sentry-trace': sentryTrace, baggage, traceparent } = addedHeaders; + + // We do not want to overwrite existing headers here + // If the core UndiciInstrumentation is registered, it will already have set the headers + // We do not want to add any then + if (Array.isArray(request.headers)) { + const requestHeaders = request.headers; + + // We do not want to overwrite existing header here, if it was already set + if (sentryTrace && !requestHeaders.includes(SENTRY_TRACE_HEADER)) { + requestHeaders.push(SENTRY_TRACE_HEADER, sentryTrace); + } + + if (traceparent && !requestHeaders.includes('traceparent')) { + requestHeaders.push('traceparent', traceparent); + } + + // For baggage, we make sure to merge this into a possibly existing header + const existingBaggagePos = requestHeaders.findIndex(header => header === SENTRY_BAGGAGE_HEADER); + if (baggage && existingBaggagePos === -1) { + requestHeaders.push(SENTRY_BAGGAGE_HEADER, baggage); + } else if (baggage) { + const existingBaggage = requestHeaders[existingBaggagePos + 1]; + const merged = mergeBaggageHeaders(existingBaggage, baggage); + if (merged) { + requestHeaders[existingBaggagePos + 1] = merged; + } + } + } else { + const requestHeaders = request.headers; + // We do not want to overwrite existing header here, if it was already set + if (sentryTrace && !requestHeaders.includes(`${SENTRY_TRACE_HEADER}:`)) { + request.headers += `${SENTRY_TRACE_HEADER}: ${sentryTrace}\r\n`; + } + + if (traceparent && !requestHeaders.includes('traceparent:')) { + request.headers += `traceparent: ${traceparent}\r\n`; + } + + const existingBaggage = request.headers.match(BAGGAGE_HEADER_REGEX)?.[1]; + if (baggage && !existingBaggage) { + request.headers += `${SENTRY_BAGGAGE_HEADER}: ${baggage}\r\n`; + } else if (baggage) { + const merged = mergeBaggageHeaders(existingBaggage, baggage); + if (merged) { + request.headers = request.headers.replace(BAGGAGE_HEADER_REGEX, `baggage: ${merged}\r\n`); + } + } + } +} + +/** Add a breadcrumb for an outgoing fetch/undici request. */ +export function addFetchRequestBreadcrumb(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 = getAbsoluteUrl(request.origin, request.path); + const parsedUrl = parseUrl(url); + + 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 {}; + } +} + +/** Get the absolute URL from an origin and path. */ +export function getAbsoluteUrl(origin: string, path: string = '/'): string { + try { + const url = new URL(path, origin); + return url.toString(); + } catch { + // fallback: Construct it on our own + 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-core/src/utils/outgoingHttpRequest.ts b/packages/node-core/src/utils/outgoingHttpRequest.ts new file mode 100644 index 000000000000..5292018e31ef --- /dev/null +++ b/packages/node-core/src/utils/outgoingHttpRequest.ts @@ -0,0 +1,155 @@ +import type { LRUMap, SanitizedRequestData } from '@sentry/core'; +import { + addBreadcrumb, + debug, + getBreadcrumbLogLevelFromHttpStatusCode, + getClient, + getSanitizedUrlString, + getTraceData, + isError, + parseUrl, + shouldPropagateTraceForUrl, +} from '@sentry/core'; +import type { ClientRequest, IncomingMessage, RequestOptions } from 'http'; +import { DEBUG_BUILD } from '../debug-build'; +import { mergeBaggageHeaders } from './baggage'; + +const LOG_PREFIX = '@sentry/instrumentation-http'; + +/** Add a breadcrumb for outgoing requests. */ +export function addRequestBreadcrumb(request: ClientRequest, response: IncomingMessage | undefined): 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, + }, + ); +} + +/** + * Add trace propagation headers to an outgoing request. + * This must be called _before_ the request is sent! + */ +// eslint-disable-next-line complexity +export function addTracePropagationHeadersToOutgoingRequest( + request: ClientRequest, + propagationDecisionMap: LRUMap, +): void { + const url = getRequestUrl(request); + + const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {}; + const headersToAdd = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) + ? getTraceData({ propagateTraceparent }) + : undefined; + + if (!headersToAdd) { + return; + } + + const { 'sentry-trace': sentryTrace, baggage, traceparent } = headersToAdd; + + if (sentryTrace && !request.getHeader('sentry-trace')) { + try { + request.setHeader('sentry-trace', sentryTrace); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added sentry-trace header to outgoing request'); + } catch (error) { + DEBUG_BUILD && + debug.error( + LOG_PREFIX, + 'Failed to add sentry-trace header to outgoing request:', + isError(error) ? error.message : 'Unknown error', + ); + } + } + + if (traceparent && !request.getHeader('traceparent')) { + try { + request.setHeader('traceparent', traceparent); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added traceparent header to outgoing request'); + } catch (error) { + DEBUG_BUILD && + debug.error( + LOG_PREFIX, + 'Failed to add traceparent header to outgoing request:', + isError(error) ? error.message : 'Unknown error', + ); + } + } + + if (baggage) { + const newBaggage = mergeBaggageHeaders(request.getHeader('baggage'), baggage); + if (newBaggage) { + try { + request.setHeader('baggage', newBaggage); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added baggage header to outgoing request'); + } catch (error) { + DEBUG_BUILD && + debug.error( + LOG_PREFIX, + 'Failed to add baggage header to outgoing request:', + isError(error) ? error.message : 'Unknown error', + ); + } + } + } +} + +function getBreadcrumbData(request: ClientRequest): Partial { + try { + // `request.host` does not contain the port, but the host header does + const host = request.getHeader('host') || request.host; + const url = new URL(request.path, `${request.protocol}//${host}`); + 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 {}; + } +} + +/** Convert an outgoing request to request options. */ +export function getRequestOptions(request: ClientRequest): RequestOptions { + return { + method: request.method, + protocol: request.protocol, + host: request.host, + hostname: request.host, + path: request.path, + headers: request.getHeaders(), + }; +} + +function getRequestUrl(request: ClientRequest): string { + const hostname = request.getHeader('host') || request.host; + const protocol = request.protocol; + const path = request.path; + + return `${protocol}//${hostname}${path}`; +} diff --git a/packages/node-core/test/light/integrations/httpIntegration.test.ts b/packages/node-core/test/light/integrations/httpIntegration.test.ts new file mode 100644 index 000000000000..15abfed1e2e8 --- /dev/null +++ b/packages/node-core/test/light/integrations/httpIntegration.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as Sentry from '../../../src/light'; +import { httpIntegration } from '../../../src/light/integrations/httpIntegration'; +import { cleanupLightSdk } from '../../helpers/mockLightSdkInit'; + +describe('Light Mode | httpIntegration', () => { + afterEach(() => { + cleanupLightSdk(); + }); + + describe('integration configuration', () => { + it('has correct integration name', () => { + const integration = httpIntegration(); + expect(integration.name).toBe('Http'); + }); + + it('accepts options', () => { + const integration = httpIntegration({ + breadcrumbs: false, + maxRequestBodySize: 'small', + ignoreOutgoingRequests: (_url: string) => false, + ignoreRequestBody: (_url: string) => false, + }); + + expect(integration.name).toBe('Http'); + }); + + it('has setupOnce method', () => { + const integration = httpIntegration(); + expect(typeof integration.setupOnce).toBe('function'); + }); + }); + + describe('export from light mode', () => { + it('exports httpIntegration', () => { + expect(Sentry.httpIntegration).toBeDefined(); + expect(typeof Sentry.httpIntegration).toBe('function'); + }); + + it('httpIntegration creates an integration with correct name', () => { + const integration = Sentry.httpIntegration(); + expect(integration.name).toBe('Http'); + }); + }); +}); diff --git a/packages/node-core/test/light/integrations/nativeNodeFetchIntegration.test.ts b/packages/node-core/test/light/integrations/nativeNodeFetchIntegration.test.ts new file mode 100644 index 000000000000..b349273ea9c3 --- /dev/null +++ b/packages/node-core/test/light/integrations/nativeNodeFetchIntegration.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as Sentry from '../../../src/light'; +import { nativeNodeFetchIntegration } from '../../../src/light/integrations/nativeNodeFetchIntegration'; +import { cleanupLightSdk } from '../../helpers/mockLightSdkInit'; + +describe('Light Mode | nativeNodeFetchIntegration', () => { + afterEach(() => { + cleanupLightSdk(); + }); + + describe('integration configuration', () => { + it('has correct integration name', () => { + const integration = nativeNodeFetchIntegration(); + expect(integration.name).toBe('NodeFetch'); + }); + + it('accepts options', () => { + const integration = nativeNodeFetchIntegration({ + breadcrumbs: false, + ignoreOutgoingRequests: (_url: string) => false, + }); + + expect(integration.name).toBe('NodeFetch'); + }); + + it('has setupOnce method', () => { + const integration = nativeNodeFetchIntegration(); + expect(typeof integration.setupOnce).toBe('function'); + }); + }); + + describe('export from light mode', () => { + it('exports nativeNodeFetchIntegration', () => { + expect(Sentry.nativeNodeFetchIntegration).toBeDefined(); + expect(typeof Sentry.nativeNodeFetchIntegration).toBe('function'); + }); + + it('nativeNodeFetchIntegration creates an integration with correct name', () => { + const integration = Sentry.nativeNodeFetchIntegration(); + expect(integration.name).toBe('NodeFetch'); + }); + }); +}); diff --git a/packages/node-core/test/light/sdk.test.ts b/packages/node-core/test/light/sdk.test.ts index 95f4dc5b4f42..8b0ef03700d9 100644 --- a/packages/node-core/test/light/sdk.test.ts +++ b/packages/node-core/test/light/sdk.test.ts @@ -93,11 +93,18 @@ describe('Light Mode | SDK', () => { expect(integrationNames).toContain('OnUnhandledRejection'); }); - it('includes Http.Server integration for request isolation', () => { + it('includes Http integration for request isolation and outgoing trace propagation', () => { const integrations = Sentry.getDefaultIntegrations(); const integrationNames = integrations.map(i => i.name); - expect(integrationNames).toContain('Http.Server'); + expect(integrationNames).toContain('Http'); + }); + + it('includes NodeFetch integration for outgoing fetch trace propagation', () => { + const integrations = Sentry.getDefaultIntegrations(); + const integrationNames = integrations.map(i => i.name); + + expect(integrationNames).toContain('NodeFetch'); }); }); diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index 458fc7f2ac94..2b5873658706 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -2,7 +2,7 @@ import type { Baggage, Context, Span, SpanContext, TextMapGetter, TextMapSetter import { context, INVALID_TRACEID, propagation, trace, TraceFlags } from '@opentelemetry/api'; import { isTracingSuppressed, W3CBaggagePropagator } from '@opentelemetry/core'; import { ATTR_URL_FULL, SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions'; -import type { Client, continueTrace, DynamicSamplingContext, Options, Scope } from '@sentry/core'; +import type { Client, continueTrace, DynamicSamplingContext, Scope } from '@sentry/core'; import { baggageHeaderToDynamicSamplingContext, debug, @@ -18,8 +18,8 @@ import { propagationContextFromHeaders, SENTRY_BAGGAGE_KEY_PREFIX, shouldContinueTrace, + shouldPropagateTraceForUrl, spanToJSON, - stringMatchesSomePattern, } from '@sentry/core'; import { SENTRY_BAGGAGE_HEADER, SENTRY_TRACE_HEADER, SENTRY_TRACE_STATE_URL } from './constants'; import { DEBUG_BUILD } from './debug-build'; @@ -124,35 +124,8 @@ export class SentryPropagator extends W3CBaggagePropagator { } } -const NOT_PROPAGATED_MESSAGE = - '[Tracing] Not injecting trace data for url because it does not match tracePropagationTargets:'; - -/** - * Check if a given URL should be propagated to or not. - * If no url is defined, or no trace propagation targets are defined, this will always return `true`. - * You can also optionally provide a decision map, to cache decisions and avoid repeated regex lookups. - */ -export function shouldPropagateTraceForUrl( - url: string | undefined, - tracePropagationTargets: Options['tracePropagationTargets'], - decisionMap?: LRUMap, -): boolean { - if (typeof url !== 'string' || !tracePropagationTargets) { - return true; - } - - const cachedDecision = decisionMap?.get(url); - if (cachedDecision !== undefined) { - DEBUG_BUILD && !cachedDecision && debug.log(NOT_PROPAGATED_MESSAGE, url); - return cachedDecision; - } - - const decision = stringMatchesSomePattern(url, tracePropagationTargets); - decisionMap?.set(url, decision); - - DEBUG_BUILD && !decision && debug.log(NOT_PROPAGATED_MESSAGE, url); - return decision; -} +// Re-exported from @sentry/core for backwards compatibility +export { shouldPropagateTraceForUrl } from '@sentry/core'; /** * Get propagation injection data for the given context.