diff --git a/packages/node/package.json b/packages/node/package.json index c642b2b0e8e2..0d4601e52e76 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -69,6 +69,7 @@ "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", + "@sentry/conventions": "^0.12.0", "@sentry/core": "10.61.0", "@sentry/node-core": "10.61.0", "@sentry/opentelemetry": "10.61.0", diff --git a/packages/node/src/integrations/node-fetch/index.ts b/packages/node/src/integrations/node-fetch/index.ts index 416685cd5c82..d4fce2f39b47 100644 --- a/packages/node/src/integrations/node-fetch/index.ts +++ b/packages/node/src/integrations/node-fetch/index.ts @@ -1,69 +1,13 @@ -import type { UndiciInstrumentationConfig } from './vendored/types'; -import { UndiciInstrumentation } from './vendored/undici'; +import { instrumentUndici } from './undici-instrumentation'; +import type { NodeFetchOptions } from './types'; import type { IntegrationFn } from '@sentry/core'; -import { - defineIntegration, - getClient, - hasSpansEnabled, - SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_URL_FULL, - stripDataUrlContent, -} from '@sentry/core'; +import { defineIntegration, getClient, hasSpansEnabled } from '@sentry/core'; import type { NodeClient } from '@sentry/node-core'; import { generateInstrumentOnce, SentryNodeFetchInstrumentation } from '@sentry/node-core'; import type { NodeClientOptions } from '../../types'; const INTEGRATION_NAME = 'NodeFetch'; -interface NodeFetchOptions extends Pick< - UndiciInstrumentationConfig, - 'requestHook' | 'responseHook' | 'headersToSpanAttributes' -> { - /** - * Whether breadcrumbs should be recorded for requests. - * Defaults to true - */ - breadcrumbs?: boolean; - - /** - * If set to false, do not emit any spans. - * This will ensure that the default UndiciInstrumentation from OpenTelemetry is not setup, - * only the Sentry-specific instrumentation for breadcrumbs & trace propagation is applied. - * - * If `skipOpenTelemetrySetup: true` is configured, this defaults to `false`, otherwise it defaults to `true`. - */ - spans?: boolean; - - /** - * Whether to inject trace propagation headers (sentry-trace, baggage, traceparent) into outgoing fetch requests. - * - * When set to `false`, Sentry will not inject any trace propagation headers, but will still create breadcrumbs - * (if `breadcrumbs` is enabled). This is useful when `skipOpenTelemetrySetup: true` is configured and you want - * to avoid duplicate trace headers being injected by both Sentry and OpenTelemetry's UndiciInstrumentation. - * - * @default `true` - */ - tracePropagation?: 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; -} - -let _undiciInstrumentation: UndiciInstrumentation | undefined; - -// Sets up the vendored undici instrumentation (emits `http.client` spans & propagates traces). -// The module-level singleton mirrors `generateInstrumentOnce`'s "instrument once per process" behavior. -function instrumentNodeFetchSpans(options: NodeFetchOptions): void { - if (!_undiciInstrumentation) { - _undiciInstrumentation = new UndiciInstrumentation(_getConfigWithDefaults(options)); - } - _undiciInstrumentation.enable(); -} - const instrumentSentryNodeFetch = generateInstrumentOnce( `${INTEGRATION_NAME}.sentry`, SentryNodeFetchInstrumentation, @@ -80,7 +24,12 @@ const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { // This is the instrumentation that emits spans & propagates traces for outgoing fetch requests if (instrumentSpans) { - instrumentNodeFetchSpans(options); + instrumentUndici({ + ignoreOutgoingRequests: options.ignoreOutgoingRequests, + requestHook: options.requestHook, + responseHook: options.responseHook, + headersToSpanAttributes: options.headersToSpanAttributes, + }); } // This is the Sentry-specific instrumentation that creates breadcrumbs & propagates traces. @@ -93,21 +42,6 @@ const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { 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}`; - } - - return `${url}${path}`; -} - function _shouldInstrumentSpans(options: NodeFetchOptions, clientOptions: Partial = {}): boolean { // If `spans` is passed in, it takes precedence // Else, we by default emit spans, unless `skipOpenTelemetrySetup` is set to `true` or spans are not enabled @@ -115,39 +49,3 @@ function _shouldInstrumentSpans(options: NodeFetchOptions, clientOptions: Partia ? options.spans : !clientOptions.skipOpenTelemetrySetup && hasSpansEnabled(clientOptions); } - -/** Exported only for tests. */ -export function _getConfigWithDefaults(options: Partial = {}): UndiciInstrumentationConfig { - const instrumentationConfig = { - ignoreRequestHook: request => { - const url = getAbsoluteUrl(request.origin, request.path); - const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; - const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url); - - return !!shouldIgnore; - }, - startSpanHook: request => { - const url = getAbsoluteUrl(request.origin, request.path); - - // Sanitize data URLs to prevent long base64 strings in span attributes - if (url.startsWith('data:')) { - const sanitizedUrl = stripDataUrlContent(url); - return { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch', - 'http.url': sanitizedUrl, - [SEMANTIC_ATTRIBUTE_URL_FULL]: sanitizedUrl, - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: `${request.method || 'GET'} ${sanitizedUrl}`, - }; - } - - return { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch', - }; - }, - requestHook: options.requestHook, - responseHook: options.responseHook, - headersToSpanAttributes: options.headersToSpanAttributes, - } satisfies UndiciInstrumentationConfig; - - return instrumentationConfig; -} diff --git a/packages/node/src/integrations/node-fetch/types.ts b/packages/node/src/integrations/node-fetch/types.ts new file mode 100644 index 000000000000..e2135b8ec4b1 --- /dev/null +++ b/packages/node/src/integrations/node-fetch/types.ts @@ -0,0 +1,123 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/ed97091c9890dd18e52759f2ea98e9d7593b3ae4/packages/instrumentation-undici + * - Upstream version: @opentelemetry/instrumentation-undici@0.24.0 + * - Tracking issue: https://github.com/getsentry/sentry-javascript/issues/20165 + * - Dropped the `@opentelemetry/instrumentation` `InstrumentationConfig` base (its only field used + * here, `enabled`, is unused by the Sentry integration) + */ + +import type { Span } from '@sentry/core'; + +export interface UndiciRequest { + origin: string; + method: string; + path: string; + /** + * Serialized string of headers in the form `name: value\r\n` for v5 + * Array of strings `[key1, value1, key2, value2]`, where values are + * `string | string[]` for v6 + */ + headers: string | (string | string[])[]; + /** + * Helper method to add headers (from v6) + */ + addHeader: (name: string, value: string) => void; + throwOnError: boolean; + completed: boolean; + aborted: boolean; + idempotent: boolean; + contentLength: number | null; + contentType: string | null; + // oxlint-disable-next-line typescript/no-explicit-any + body: any; +} + +export interface UndiciResponse { + headers: Buffer[]; + statusCode: number; + statusText: string; +} + +export interface RequestHookFunction { + (span: Span, request: T): void; +} + +export interface ResponseHookFunction { + (span: Span, info: { request: RequestType; response: ResponseType }): void; +} + +export interface RequestMessage { + request: UndiciRequest; +} + +export interface RequestHeadersMessage { + request: UndiciRequest; + // oxlint-disable-next-line typescript/no-explicit-any + socket: any; +} + +export interface ResponseHeadersMessage { + request: UndiciRequest; + response: UndiciResponse; +} + +export interface RequestTrailersMessage { + request: UndiciRequest; + response: UndiciResponse; +} + +export interface RequestErrorMessage { + request: UndiciRequest; + error: Error; +} + +// This package will instrument HTTP requests made through `undici` or `fetch` global API +// so it seems logical to have similar options than the HTTP instrumentation +export interface UndiciInstrumentationConfig { + /** + * 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; + /** Function for adding custom attributes before request is handled */ + requestHook?: RequestHookFunction; + /** Function called once response headers have been received */ + responseHook?: ResponseHookFunction; + /** Map the following HTTP headers to span attributes. */ + headersToSpanAttributes?: { + requestHeaders?: string[]; + responseHeaders?: string[]; + }; +} + +export interface NodeFetchOptions extends UndiciInstrumentationConfig { + /** + * Whether breadcrumbs should be recorded for requests. + * Defaults to true + */ + breadcrumbs?: boolean; + + /** + * If set to false, do not emit any spans. + * This will ensure that the default UndiciInstrumentation from OpenTelemetry is not setup, + * only the Sentry-specific instrumentation for breadcrumbs & trace propagation is applied. + * + * If `skipOpenTelemetrySetup: true` is configured, this defaults to `false`, otherwise it defaults to `true`. + */ + spans?: boolean; + + /** + * Whether to inject trace propagation headers (sentry-trace, baggage, traceparent) into outgoing fetch requests. + * + * When set to `false`, Sentry will not inject any trace propagation headers, but will still create breadcrumbs + * (if `breadcrumbs` is enabled). This is useful when `skipOpenTelemetrySetup: true` is configured and you want + * to avoid duplicate trace headers being injected by both Sentry and OpenTelemetry's UndiciInstrumentation. + * + * @default `true` + */ + tracePropagation?: boolean; +} diff --git a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts new file mode 100644 index 000000000000..32e91bf4543d --- /dev/null +++ b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts @@ -0,0 +1,448 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + * + * NOTICE from the Sentry authors: + * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/ed97091c9890dd18e52759f2ea98e9d7593b3ae4/packages/instrumentation-undici + * - Upstream version: @opentelemetry/instrumentation-undici@0.24.0 + * - Tracking issue: https://github.com/getsentry/sentry-javascript/issues/20165 + * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs + * - Dropped the OTel metrics (no MeterProvider is wired up) and the dead + * `requireParentforSpans` code path (the SDK always passes `false`) + * - Dropped the `@opentelemetry/instrumentation` base (undici reports via `diagnostics_channel`, + * so no module patching was needed) — exposed as a plain `instrumentUndici()` function that the + * integration wires up directly + */ + +/* eslint-disable max-lines */ + +import * as diagch from 'diagnostics_channel'; +import { URL } from 'url'; + +import type { Span, SpanAttributes } from '@sentry/core'; +import { + debug, + getClient, + getTraceData, + LRUMap, + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + shouldPropagateTraceForUrl, + SPAN_KIND, + SPAN_STATUS_ERROR, + startInactiveSpan, + stripDataUrlContent, + withActiveSpan, +} from '@sentry/core'; +import { + HTTP_REQUEST_METHOD, + HTTP_RESPONSE_STATUS_CODE, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + SERVER_ADDRESS, + SERVER_PORT, + URL_FULL, + URL_PATH, + URL_QUERY, + URL_SCHEME, + USER_AGENT_ORIGINAL, +} from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { + UndiciInstrumentationConfig, + UndiciRequest, + RequestErrorMessage, + RequestHeadersMessage, + RequestMessage, + RequestTrailersMessage, + ResponseHeadersMessage, +} from './types'; + +// `http.request.method_original` is not part of `@sentry/conventions`, so we keep it inline. +const ATTR_HTTP_REQUEST_METHOD_ORIGINAL = 'http.request.method_original'; + +// Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug +// We can replace this with _isInstrumented once we drop support for Node.js 18.18.0 +const _channelSubs: Array = []; +const spanFromReq = new WeakMap(); +// Caches trace-propagation decisions per URL so we don't recompute the `tracePropagationTargets` regexes per request. +const propagationDecisionMap = new LRUMap(100); + +/** + * Instrument outgoing HTTP requests made through `undici` or the global `fetch` API: emit `http.client` + * spans and propagate traces into the outgoing request headers. + * + * undici reports its request lifecycle via `diagnostics_channel`, so rather than patching any module we + * subscribe to those channels directly. This is idempotent — subsequent calls are no-ops once the + * channels have been subscribed to, and the config of the first call wins. + * + * A combination of https://github.com/elastic/apm-agent-nodejs and + * https://github.com/gadget-inc/opentelemetry-instrumentations/blob/main/packages/opentelemetry-instrumentation-undici/src/index.ts + */ +export function instrumentUndici(config: UndiciInstrumentationConfig = {}): void { + // Avoid duplicate subscriptions + if (_channelSubs.length) { + return; + } + + subscribeToChannel('undici:request:create', message => onRequestCreated(config, message as RequestMessage)); + subscribeToChannel('undici:client:sendHeaders', message => + onRequestHeaders(config, message as RequestHeadersMessage), + ); + subscribeToChannel('undici:request:headers', message => onResponseHeaders(config, message as ResponseHeadersMessage)); + subscribeToChannel('undici:request:trailers', message => onDone(message as RequestTrailersMessage)); + subscribeToChannel('undici:request:error', message => onError(message as RequestErrorMessage)); +} + +/** Replaces OTel's `safeExecuteInTheMiddle`: run `fn`, route any error to `onError`, and swallow it. */ +function safeExecute(fn: () => T, onError: (error: unknown) => void): T | undefined { + try { + return fn(); + } catch (error) { + onError(error); + return undefined; + } +} + +function subscribeToChannel( + diagnosticChannel: string, + onMessage: (message: unknown, name: string | symbol) => void, +): void { + // `diagnostics_channel` had a ref counting bug until v18.19.0. + // https://github.com/nodejs/node/pull/47520 + const [major = 0, minor = 0] = process.version + .replace('v', '') + .split('.') + .map(n => Number(n)); + const useNewSubscribe = major > 18 || (major === 18 && minor >= 19); + + if (useNewSubscribe) { + _channelSubs.push(diagch.subscribe?.(diagnosticChannel, onMessage)); + } else { + _channelSubs.push(diagch.channel(diagnosticChannel).subscribe(onMessage)); + } +} + +function parseRequestHeaders(request: UndiciRequest): Map { + const result = new Map(); + + if (Array.isArray(request.headers)) { + // headers are an array [k1, v2, k2, v2] (undici v6+) + // values could be string or a string[] for multiple values + for (let i = 0; i < request.headers.length; i += 2) { + const key = request.headers[i]; + const value = request.headers[i + 1]; + + // Key should always be a string, but the types don't know that, and let's be safe + if (typeof key === 'string' && value !== undefined) { + result.set(key.toLowerCase(), value); + } + } + } else if (typeof request.headers === 'string') { + // headers are a raw string (undici v5) + // headers could be repeated in several lines for multiple values + const headers = request.headers.split('\r\n'); + for (const line of headers) { + if (!line) { + continue; + } + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) { + // Invalid header? Probably this can't happen, but again let's be safe. + continue; + } + const key = line.substring(0, colonIndex).toLowerCase(); + const value = line.substring(colonIndex + 1).trim(); + const allValues = result.get(key); + + if (allValues && Array.isArray(allValues)) { + allValues.push(value); + } else if (allValues) { + result.set(key, [allValues, value]); + } else { + result.set(key, value); + } + } + } + return result; +} + +// This is the 1st message we receive for each request (fired after request creation). Here we will +// create the span and populate some atttributes, then link the span to the request for further +// span processing +function onRequestCreated(config: UndiciInstrumentationConfig, { request }: RequestMessage): void { + const url = getAbsoluteUrl(request.origin, request.path); + + // Ignore if: + // - the outgoing request is ignored via config + // - method is 'CONNECT' + const shouldIgnoreReq = safeExecute( + () => request.method === 'CONNECT' || !!config.ignoreOutgoingRequests?.(url), + e => e && DEBUG_BUILD && debug.error('caught ignoreOutgoingRequests error: ', e), + ); + + if (shouldIgnoreReq) { + return; + } + + let requestUrl; + try { + requestUrl = new URL(request.path, request.origin); + } catch (err) { + DEBUG_BUILD && debug.warn('could not determine url.full:', err); + // Skip instrumenting this request. + return; + } + const urlScheme = requestUrl.protocol.replace(':', ''); + const requestMethod = getRequestMethod(request.method); + const attributes: SpanAttributes = { + [HTTP_REQUEST_METHOD]: requestMethod, + [ATTR_HTTP_REQUEST_METHOD_ORIGINAL]: request.method, + [URL_FULL]: requestUrl.toString(), + [URL_PATH]: requestUrl.pathname, + [URL_QUERY]: requestUrl.search || undefined, + [URL_SCHEME]: urlScheme, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch', + }; + + // Sanitize data URLs to prevent long base64 strings in span attributes + if (url.startsWith('data:')) { + const sanitizedUrl = stripDataUrlContent(url); + attributes['http.url'] = sanitizedUrl; + attributes[URL_FULL] = sanitizedUrl; + attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME] = `${request.method || 'GET'} ${sanitizedUrl}`; + } + + const schemePorts: Record = { https: '443', http: '80' }; + const serverAddress = requestUrl.hostname; + const serverPort = requestUrl.port || schemePorts[urlScheme]; + + attributes[SERVER_ADDRESS] = serverAddress; + if (serverPort && !isNaN(Number(serverPort))) { + attributes[SERVER_PORT] = Number(serverPort); + } + + // Get user agent from headers + const headersMap = parseRequestHeaders(request); + const userAgentValues = headersMap.get('user-agent'); + + if (userAgentValues) { + // NOTE: having multiple user agents is not expected so + // we're going to take last one like `curl` does + // ref: https://curl.se/docs/manpage.html#-A + const userAgent = Array.isArray(userAgentValues) ? userAgentValues[userAgentValues.length - 1] : userAgentValues; + attributes[USER_AGENT_ORIGINAL] = userAgent; + } + + const span = startInactiveSpan({ + name: requestMethod === '_OTHER' ? 'HTTP' : requestMethod, + kind: SPAN_KIND.CLIENT, + attributes, + }); + + // Execute the request hook if defined + safeExecute( + () => config.requestHook?.(span, request), + e => e && DEBUG_BUILD && debug.error('caught requestHook error: ', e), + ); + + // Context propagation goes last so no hook can tamper the propagation headers. + // We propagate the trace data of the freshly created client span (not the active parent span) + // so downstream services are parented to the http.client span, matching the upstream behavior. + injectTracePropagationHeaders(span, request, requestUrl.toString()); + + spanFromReq.set(request, span); +} + +// This is the 2nd message we receive for each request. It is fired when connection with +// the remote is established and about to send the first byte. Here we do have info about the +// remote address and port so we can populate some `network.*` attributes into the span +function onRequestHeaders(config: UndiciInstrumentationConfig, { request, socket }: RequestHeadersMessage): void { + const span = spanFromReq.get(request); + + if (!span) { + return; + } + + const { remoteAddress, remotePort } = socket; + const spanAttributes: SpanAttributes = { + [NETWORK_PEER_ADDRESS]: remoteAddress, + [NETWORK_PEER_PORT]: remotePort, + }; + + // After hooks have been processed (which may modify request headers) + // we can collect the headers based on the configuration + if (config.headersToSpanAttributes?.requestHeaders) { + const headersToAttribs = new Set(config.headersToSpanAttributes.requestHeaders.map(n => n.toLowerCase())); + const headersMap = parseRequestHeaders(request); + + for (const [name, value] of headersMap.entries()) { + if (headersToAttribs.has(name)) { + const attrValue = Array.isArray(value) ? value : [value]; + spanAttributes[`http.request.header.${name}`] = attrValue; + } + } + } + + span.setAttributes(spanAttributes); +} + +// This is the 3rd message we get for each request and it's fired when the server +// headers are received, body may not be accessible yet. +// From the response headers we can set the status and content length +function onResponseHeaders(config: UndiciInstrumentationConfig, { request, response }: ResponseHeadersMessage): void { + const span = spanFromReq.get(request); + + if (!span) { + return; + } + + const spanAttributes: SpanAttributes = { + [HTTP_RESPONSE_STATUS_CODE]: response.statusCode, + }; + + // Execute the response hook if defined + safeExecute( + () => config.responseHook?.(span, { request, response }), + e => e && DEBUG_BUILD && debug.error('caught responseHook error: ', e), + ); + + if (config.headersToSpanAttributes?.responseHeaders) { + const headersToAttribs = new Set(); + config.headersToSpanAttributes?.responseHeaders.forEach(name => headersToAttribs.add(name.toLowerCase())); + + for (let idx = 0; idx < response.headers.length; idx = idx + 2) { + const nameBuf = response.headers[idx]; + const valueBuf = response.headers[idx + 1]; + if (nameBuf === undefined || valueBuf === undefined) { + continue; + } + const name = nameBuf.toString().toLowerCase(); + const value = valueBuf; + + if (headersToAttribs.has(name)) { + const attrName = `http.response.header.${name}`; + if (!Object.prototype.hasOwnProperty.call(spanAttributes, attrName)) { + spanAttributes[attrName] = [value.toString()]; + } else { + (spanAttributes[attrName] as string[]).push(value.toString()); + } + } + } + } + + span.setAttributes(spanAttributes); + + // The Sentry pipeline infers `ok` / `not_found` / etc. from `http.response.status_code` when the + // status is left unset, so we only need to flag erroneous responses explicitly. + if (response.statusCode >= 400) { + span.setStatus({ code: SPAN_STATUS_ERROR }); + } +} + +// This is the last event we receive if the request went without any errors +function onDone({ request }: RequestTrailersMessage): void { + const span = spanFromReq.get(request); + + if (!span) { + return; + } + + span.end(); + spanFromReq.delete(request); +} + +// This is the event we get when something is wrong in the request like +// - invalid options when calling `fetch` global API or any undici method for request +// - connectivity errors such as unreachable host +// - requests aborted through an `AbortController.signal` +// NOTE: server errors are considered valid responses and it's the lib consumer +// who should deal with that. +function onError({ request, error }: RequestErrorMessage): void { + const span = spanFromReq.get(request); + + if (!span) { + return; + } + + // NOTE: in `undici@6.3.0` when request aborted the error type changes from + // a custom error (`RequestAbortedError`) to a built-in `DOMException` carrying + // some differences: + // - `code` is from DOMEXception (ABORT_ERR: 20) + // - `message` changes + // - stacktrace is smaller and contains node internal frames + span.setStatus({ + code: SPAN_STATUS_ERROR, + message: error.message, + }); + span.end(); + spanFromReq.delete(request); +} + +// Propagate the trace data of the given (client) span into the outgoing request headers, gated by +// `tracePropagationTargets`. Mirrors what `propagation.inject()` did with the SentryPropagator, but +// via Sentry's `getTraceData()` so we stay off OpenTelemetry's propagation API. +function injectTracePropagationHeaders(span: Span, request: UndiciRequest, url: string): void { + const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() ?? {}; + + if (!shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap)) { + return; + } + + // We make the freshly created client span active so the propagated headers reference it (and not + // the parent span). Passing `{ span }` to `getTraceData()` is not enough: for an inactive span it + // resolves to the span's *captured* scope, whose active span is still the parent. + const addedHeaders = withActiveSpan(span, () => getTraceData({ propagateTraceparent })); + + for (const [k, v] of Object.entries(addedHeaders)) { + if (!v) { + continue; + } + + if (typeof request.addHeader === 'function') { + request.addHeader(k, v); + } else if (typeof request.headers === 'string') { + request.headers += `${k}: ${v}\r\n`; + } else if (Array.isArray(request.headers)) { + // undici@6.11.0 accidentally, briefly removed `request.addHeader()`. + request.headers.push(k, v); + } + } +} + +function getRequestMethod(original: string): string { + const knownMethods = { + CONNECT: true, + OPTIONS: true, + HEAD: true, + GET: true, + POST: true, + PUT: true, + PATCH: true, + DELETE: true, + TRACE: true, + // QUERY from https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/ + QUERY: true, + }; + + if (original.toUpperCase() in knownMethods) { + return original.toUpperCase(); + } + + return '_OTHER'; +} + +// 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}`; + } + + return `${url}${path}`; +} diff --git a/packages/node/src/integrations/node-fetch/vendored/internal-types.ts b/packages/node/src/integrations/node-fetch/vendored/internal-types.ts deleted file mode 100644 index 687ee9c43cfe..000000000000 --- a/packages/node/src/integrations/node-fetch/vendored/internal-types.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - * - * NOTICE from the Sentry authors: - * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/ed97091c9890dd18e52759f2ea98e9d7593b3ae4/packages/instrumentation-undici - * - Upstream version: @opentelemetry/instrumentation-undici@0.24.0 - * - Tracking issue: https://github.com/getsentry/sentry-javascript/issues/20165 - */ - -import type { UndiciRequest, UndiciResponse } from './types'; - -export interface ListenerRecord { - name: string; - unsubscribe: () => void; -} - -export interface RequestMessage { - request: UndiciRequest; -} - -export interface RequestHeadersMessage { - request: UndiciRequest; - socket: any; -} - -export interface ResponseHeadersMessage { - request: UndiciRequest; - response: UndiciResponse; -} - -export interface RequestTrailersMessage { - request: UndiciRequest; - response: UndiciResponse; -} - -export interface RequestErrorMessage { - request: UndiciRequest; - error: Error; -} diff --git a/packages/node/src/integrations/node-fetch/vendored/semconv.ts b/packages/node/src/integrations/node-fetch/vendored/semconv.ts deleted file mode 100644 index 882cf2281487..000000000000 --- a/packages/node/src/integrations/node-fetch/vendored/semconv.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - * - * NOTICE from the Sentry authors: - * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/ed97091c9890dd18e52759f2ea98e9d7593b3ae4/packages/instrumentation-undici - * - Upstream version: @opentelemetry/instrumentation-undici@0.24.0 - * - The semantic-convention constants this package emits, inlined from - * `@opentelemetry/semantic-conventions` (matching the sibling vendored dirs). - */ - -export const ATTR_HTTP_REQUEST_METHOD = 'http.request.method' as const; -export const ATTR_HTTP_REQUEST_METHOD_ORIGINAL = 'http.request.method_original' as const; -export const ATTR_HTTP_RESPONSE_STATUS_CODE = 'http.response.status_code' as const; -export const ATTR_NETWORK_PEER_ADDRESS = 'network.peer.address' as const; -export const ATTR_NETWORK_PEER_PORT = 'network.peer.port' as const; -export const ATTR_SERVER_ADDRESS = 'server.address' as const; -export const ATTR_SERVER_PORT = 'server.port' as const; -export const ATTR_URL_FULL = 'url.full' as const; -export const ATTR_URL_PATH = 'url.path' as const; -export const ATTR_URL_QUERY = 'url.query' as const; -export const ATTR_URL_SCHEME = 'url.scheme' as const; -export const ATTR_USER_AGENT_ORIGINAL = 'user_agent.original' as const; diff --git a/packages/node/src/integrations/node-fetch/vendored/types.ts b/packages/node/src/integrations/node-fetch/vendored/types.ts deleted file mode 100644 index 9bcb90f08c8e..000000000000 --- a/packages/node/src/integrations/node-fetch/vendored/types.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - * - * NOTICE from the Sentry authors: - * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/ed97091c9890dd18e52759f2ea98e9d7593b3ae4/packages/instrumentation-undici - * - Upstream version: @opentelemetry/instrumentation-undici@0.24.0 - * - Tracking issue: https://github.com/getsentry/sentry-javascript/issues/20165 - */ - -import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; -import type { Span, SpanAttributes } from '@sentry/core'; - -export interface UndiciRequest { - origin: string; - method: string; - path: string; - /** - * Serialized string of headers in the form `name: value\r\n` for v5 - * Array of strings `[key1, value1, key2, value2]`, where values are - * `string | string[]` for v6 - */ - headers: string | (string | string[])[]; - /** - * Helper method to add headers (from v6) - */ - addHeader: (name: string, value: string) => void; - throwOnError: boolean; - completed: boolean; - aborted: boolean; - idempotent: boolean; - contentLength: number | null; - contentType: string | null; - body: any; -} - -export interface UndiciResponse { - headers: Buffer[]; - statusCode: number; - statusText: string; -} - -export interface IgnoreRequestFunction { - (request: T): boolean; -} - -export interface RequestHookFunction { - (span: Span, request: T): void; -} - -export interface ResponseHookFunction { - (span: Span, info: { request: RequestType; response: ResponseType }): void; -} - -export interface StartSpanHookFunction { - (request: T): SpanAttributes; -} - -// This package will instrument HTTP requests made through `undici` or `fetch` global API -// so it seems logical to have similar options than the HTTP instrumentation -export interface UndiciInstrumentationConfig< - RequestType = UndiciRequest, - ResponseType = UndiciResponse, -> extends InstrumentationConfig { - /** Not trace all outgoing requests that matched with custom function */ - ignoreRequestHook?: IgnoreRequestFunction; - /** Function for adding custom attributes before request is handled */ - requestHook?: RequestHookFunction; - /** Function called once response headers have been received */ - responseHook?: ResponseHookFunction; - /** Function for adding custom attributes before a span is started */ - startSpanHook?: StartSpanHookFunction; - /** Map the following HTTP headers to span attributes. */ - headersToSpanAttributes?: { - requestHeaders?: string[]; - responseHeaders?: string[]; - }; -} diff --git a/packages/node/src/integrations/node-fetch/vendored/undici.ts b/packages/node/src/integrations/node-fetch/vendored/undici.ts deleted file mode 100644 index 814613921a20..000000000000 --- a/packages/node/src/integrations/node-fetch/vendored/undici.ts +++ /dev/null @@ -1,452 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - * - * NOTICE from the Sentry authors: - * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/ed97091c9890dd18e52759f2ea98e9d7593b3ae4/packages/instrumentation-undici - * - Upstream version: @opentelemetry/instrumentation-undici@0.24.0 - * - Tracking issue: https://github.com/getsentry/sentry-javascript/issues/20165 - * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs - * - Dropped the OTel metrics (no MeterProvider is wired up) and the dead - * `requireParentforSpans` code path (the SDK always passes `false`) - * - Dropped the `@opentelemetry/instrumentation` base (undici reports via `diagnostics_channel`, - * so no module patching was needed) — now a plain class wired up directly by the integration - */ - -import * as diagch from 'diagnostics_channel'; -import { URL } from 'url'; - -import type { Span, SpanAttributes } from '@sentry/core'; -import { - debug, - getClient, - getTraceData, - LRUMap, - shouldPropagateTraceForUrl, - SPAN_KIND, - SPAN_STATUS_ERROR, - startInactiveSpan, - withActiveSpan, -} from '@sentry/core'; -import { DEBUG_BUILD } from '../../../debug-build'; -import { - ATTR_HTTP_REQUEST_METHOD, - ATTR_HTTP_REQUEST_METHOD_ORIGINAL, - ATTR_HTTP_RESPONSE_STATUS_CODE, - ATTR_NETWORK_PEER_ADDRESS, - ATTR_NETWORK_PEER_PORT, - ATTR_SERVER_ADDRESS, - ATTR_SERVER_PORT, - ATTR_URL_FULL, - ATTR_URL_PATH, - ATTR_URL_QUERY, - ATTR_URL_SCHEME, - ATTR_USER_AGENT_ORIGINAL, -} from './semconv'; - -import type { - ListenerRecord, - RequestErrorMessage, - RequestHeadersMessage, - RequestMessage, - RequestTrailersMessage, - ResponseHeadersMessage, -} from './internal-types'; -import type { UndiciInstrumentationConfig, UndiciRequest } from './types'; - -/** Replaces OTel's `safeExecuteInTheMiddle`: run `fn`, route any error to `onError`, and swallow it. */ -function safeExecute(fn: () => T, onError: (error: unknown) => void): T | undefined { - try { - return fn(); - } catch (error) { - onError(error); - return undefined; - } -} - -// A combination of https://github.com/elastic/apm-agent-nodejs and -// https://github.com/gadget-inc/opentelemetry-instrumentations/blob/main/packages/opentelemetry-instrumentation-undici/src/index.ts -// -// Not an OTel `InstrumentationBase` (undici reports via `diagnostics_channel`, not module patching); -// the integration wires this up directly via `enable()` / `disable()`. -export class UndiciInstrumentation { - // Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for unsubscribing. - private _channelSubs: Array = []; - private _spanFromReq = new WeakMap(); - // Caches trace-propagation decisions per URL so we don't recompute the `tracePropagationTargets` regexes per request. - private _propagationDecisionMap = new LRUMap(100); - private _config: UndiciInstrumentationConfig; - - constructor(config: UndiciInstrumentationConfig = {}) { - this._config = config; - } - - public disable(): void { - this._channelSubs.forEach(sub => sub.unsubscribe()); - this._channelSubs.length = 0; - } - - /** Subscribe to the undici diagnostics channels (idempotent). */ - public enable(): void { - // Avoid duplicate subscriptions - if (this._channelSubs.length > 0) { - return; - } - - this.subscribeToChannel('undici:request:create', this.onRequestCreated.bind(this)); - this.subscribeToChannel('undici:client:sendHeaders', this.onRequestHeaders.bind(this)); - this.subscribeToChannel('undici:request:headers', this.onResponseHeaders.bind(this)); - this.subscribeToChannel('undici:request:trailers', this.onDone.bind(this)); - this.subscribeToChannel('undici:request:error', this.onError.bind(this)); - } - - private subscribeToChannel( - diagnosticChannel: string, - onMessage: (message: any, name: string | symbol) => void, - ): void { - // `diagnostics_channel` had a ref counting bug until v18.19.0. - // https://github.com/nodejs/node/pull/47520 - const [major = 0, minor = 0] = process.version - .replace('v', '') - .split('.') - .map(n => Number(n)); - const useNewSubscribe = major > 18 || (major === 18 && minor >= 19); - - let unsubscribe: () => void; - if (useNewSubscribe) { - diagch.subscribe?.(diagnosticChannel, onMessage); - unsubscribe = () => diagch.unsubscribe?.(diagnosticChannel, onMessage); - } else { - const channel = diagch.channel(diagnosticChannel); - channel.subscribe(onMessage); - unsubscribe = () => channel.unsubscribe(onMessage); - } - - this._channelSubs.push({ - name: diagnosticChannel, - unsubscribe, - }); - } - - private parseRequestHeaders(request: UndiciRequest): Map { - const result = new Map(); - - if (Array.isArray(request.headers)) { - // headers are an array [k1, v2, k2, v2] (undici v6+) - // values could be string or a string[] for multiple values - for (let i = 0; i < request.headers.length; i += 2) { - const key = request.headers[i]; - const value = request.headers[i + 1]; - - // Key should always be a string, but the types don't know that, and let's be safe - if (typeof key === 'string' && value !== undefined) { - result.set(key.toLowerCase(), value); - } - } - } else if (typeof request.headers === 'string') { - // headers are a raw string (undici v5) - // headers could be repeated in several lines for multiple values - const headers = request.headers.split('\r\n'); - for (const line of headers) { - if (!line) { - continue; - } - const colonIndex = line.indexOf(':'); - if (colonIndex === -1) { - // Invalid header? Probably this can't happen, but again let's be safe. - continue; - } - const key = line.substring(0, colonIndex).toLowerCase(); - const value = line.substring(colonIndex + 1).trim(); - const allValues = result.get(key); - - if (allValues && Array.isArray(allValues)) { - allValues.push(value); - } else if (allValues) { - result.set(key, [allValues, value]); - } else { - result.set(key, value); - } - } - } - return result; - } - - // This is the 1st message we receive for each request (fired after request creation). Here we will - // create the span and populate some atttributes, then link the span to the request for further - // span processing - private onRequestCreated({ request }: RequestMessage): void { - // Ignore if: - // - instrumentation is disabled - // - ignored by config - // - method is 'CONNECT' - const config = this._config; - const enabled = config.enabled !== false; - const shouldIgnoreReq = safeExecute( - () => !enabled || request.method === 'CONNECT' || config.ignoreRequestHook?.(request), - e => e && DEBUG_BUILD && debug.error('caught ignoreRequestHook error: ', e), - ); - - if (shouldIgnoreReq) { - return; - } - - let requestUrl; - try { - requestUrl = new URL(request.path, request.origin); - } catch (err) { - DEBUG_BUILD && debug.warn('could not determine url.full:', err); - // Skip instrumenting this request. - return; - } - const urlScheme = requestUrl.protocol.replace(':', ''); - const requestMethod = this.getRequestMethod(request.method); - const attributes: SpanAttributes = { - [ATTR_HTTP_REQUEST_METHOD]: requestMethod, - [ATTR_HTTP_REQUEST_METHOD_ORIGINAL]: request.method, - [ATTR_URL_FULL]: requestUrl.toString(), - [ATTR_URL_PATH]: requestUrl.pathname, - [ATTR_URL_QUERY]: requestUrl.search, - [ATTR_URL_SCHEME]: urlScheme, - }; - - const schemePorts: Record = { https: '443', http: '80' }; - const serverAddress = requestUrl.hostname; - const serverPort = requestUrl.port || schemePorts[urlScheme]; - - attributes[ATTR_SERVER_ADDRESS] = serverAddress; - if (serverPort && !isNaN(Number(serverPort))) { - attributes[ATTR_SERVER_PORT] = Number(serverPort); - } - - // Get user agent from headers - const headersMap = this.parseRequestHeaders(request); - const userAgentValues = headersMap.get('user-agent'); - - if (userAgentValues) { - // NOTE: having multiple user agents is not expected so - // we're going to take last one like `curl` does - // ref: https://curl.se/docs/manpage.html#-A - const userAgent = Array.isArray(userAgentValues) ? userAgentValues[userAgentValues.length - 1] : userAgentValues; - attributes[ATTR_USER_AGENT_ORIGINAL] = userAgent; - } - - // Get attributes from the hook if present - const hookAttributes = safeExecute( - () => config.startSpanHook?.(request), - e => e && DEBUG_BUILD && debug.error('caught startSpanHook error: ', e), - ); - if (hookAttributes) { - Object.entries(hookAttributes).forEach(([key, val]) => { - attributes[key] = val; - }); - } - - const span = startInactiveSpan({ - name: requestMethod === '_OTHER' ? 'HTTP' : requestMethod, - kind: SPAN_KIND.CLIENT, - attributes, - }); - - // Execute the request hook if defined - safeExecute( - () => config.requestHook?.(span, request), - e => e && DEBUG_BUILD && debug.error('caught requestHook error: ', e), - ); - - // Context propagation goes last so no hook can tamper the propagation headers. - // We propagate the trace data of the freshly created client span (not the active parent span) - // so downstream services are parented to the http.client span, matching the upstream behavior. - this.injectTracePropagationHeaders(span, request, requestUrl.toString()); - - this._spanFromReq.set(request, span); - } - - // This is the 2nd message we receive for each request. It is fired when connection with - // the remote is established and about to send the first byte. Here we do have info about the - // remote address and port so we can populate some `network.*` attributes into the span - private onRequestHeaders({ request, socket }: RequestHeadersMessage): void { - const span = this._spanFromReq.get(request); - - if (!span) { - return; - } - - const config = this._config; - const { remoteAddress, remotePort } = socket; - const spanAttributes: SpanAttributes = { - [ATTR_NETWORK_PEER_ADDRESS]: remoteAddress, - [ATTR_NETWORK_PEER_PORT]: remotePort, - }; - - // After hooks have been processed (which may modify request headers) - // we can collect the headers based on the configuration - if (config.headersToSpanAttributes?.requestHeaders) { - const headersToAttribs = new Set(config.headersToSpanAttributes.requestHeaders.map(n => n.toLowerCase())); - const headersMap = this.parseRequestHeaders(request); - - for (const [name, value] of headersMap.entries()) { - if (headersToAttribs.has(name)) { - const attrValue = Array.isArray(value) ? value : [value]; - spanAttributes[`http.request.header.${name}`] = attrValue; - } - } - } - - span.setAttributes(spanAttributes); - } - - // This is the 3rd message we get for each request and it's fired when the server - // headers are received, body may not be accessible yet. - // From the response headers we can set the status and content length - private onResponseHeaders({ request, response }: ResponseHeadersMessage): void { - const span = this._spanFromReq.get(request); - - if (!span) { - return; - } - - const spanAttributes: SpanAttributes = { - [ATTR_HTTP_RESPONSE_STATUS_CODE]: response.statusCode, - }; - - const config = this._config; - - // Execute the response hook if defined - safeExecute( - () => config.responseHook?.(span, { request, response }), - e => e && DEBUG_BUILD && debug.error('caught responseHook error: ', e), - ); - - if (config.headersToSpanAttributes?.responseHeaders) { - const headersToAttribs = new Set(); - config.headersToSpanAttributes?.responseHeaders.forEach(name => headersToAttribs.add(name.toLowerCase())); - - for (let idx = 0; idx < response.headers.length; idx = idx + 2) { - const nameBuf = response.headers[idx]; - const valueBuf = response.headers[idx + 1]; - if (nameBuf === undefined || valueBuf === undefined) { - continue; - } - const name = nameBuf.toString().toLowerCase(); - const value = valueBuf; - - if (headersToAttribs.has(name)) { - const attrName = `http.response.header.${name}`; - if (!Object.prototype.hasOwnProperty.call(spanAttributes, attrName)) { - spanAttributes[attrName] = [value.toString()]; - } else { - (spanAttributes[attrName] as string[]).push(value.toString()); - } - } - } - } - - span.setAttributes(spanAttributes); - - // The Sentry pipeline infers `ok` / `not_found` / etc. from `http.response.status_code` when the - // status is left unset, so we only need to flag erroneous responses explicitly. - if (response.statusCode >= 400) { - span.setStatus({ code: SPAN_STATUS_ERROR }); - } - } - - // This is the last event we receive if the request went without any errors - private onDone({ request }: RequestTrailersMessage): void { - const span = this._spanFromReq.get(request); - - if (!span) { - return; - } - - span.end(); - this._spanFromReq.delete(request); - } - - // This is the event we get when something is wrong in the request like - // - invalid options when calling `fetch` global API or any undici method for request - // - connectivity errors such as unreachable host - // - requests aborted through an `AbortController.signal` - // NOTE: server errors are considered valid responses and it's the lib consumer - // who should deal with that. - private onError({ request, error }: RequestErrorMessage): void { - const span = this._spanFromReq.get(request); - - if (!span) { - return; - } - - // NOTE: in `undici@6.3.0` when request aborted the error type changes from - // a custom error (`RequestAbortedError`) to a built-in `DOMException` carrying - // some differences: - // - `code` is from DOMEXception (ABORT_ERR: 20) - // - `message` changes - // - stacktrace is smaller and contains node internal frames - span.setStatus({ - code: SPAN_STATUS_ERROR, - message: error.message, - }); - span.end(); - this._spanFromReq.delete(request); - } - - // Propagate the trace data of the given (client) span into the outgoing request headers, gated by - // `tracePropagationTargets`. Mirrors what `propagation.inject()` did with the SentryPropagator, but - // via Sentry's `getTraceData()` so we stay off OpenTelemetry's propagation API. - private injectTracePropagationHeaders(span: Span, request: UndiciRequest, url: string): void { - const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() ?? {}; - - if (!shouldPropagateTraceForUrl(url, tracePropagationTargets, this._propagationDecisionMap)) { - return; - } - - // We make the freshly created client span active so the propagated headers reference it (and not - // the parent span). Passing `{ span }` to `getTraceData()` is not enough: for an inactive span it - // resolves to the span's *captured* scope, whose active span is still the parent. - const addedHeaders = withActiveSpan(span, () => getTraceData({ propagateTraceparent })); - - const headerEntries = Object.entries(addedHeaders); - - for (let i = 0; i < headerEntries.length; i++) { - const pair = headerEntries[i]; - if (!pair) { - continue; - } - const [k, v] = pair; - if (!v) { - continue; - } - - if (typeof request.addHeader === 'function') { - request.addHeader(k, v); - } else if (typeof request.headers === 'string') { - request.headers += `${k}: ${v}\r\n`; - } else if (Array.isArray(request.headers)) { - // undici@6.11.0 accidentally, briefly removed `request.addHeader()`. - request.headers.push(k, v); - } - } - } - - private getRequestMethod(original: string): string { - const knownMethods = { - CONNECT: true, - OPTIONS: true, - HEAD: true, - GET: true, - POST: true, - PUT: true, - PATCH: true, - DELETE: true, - TRACE: true, - // QUERY from https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/ - QUERY: true, - }; - - if (original.toUpperCase() in knownMethods) { - return original.toUpperCase(); - } - - return '_OTHER'; - } -} diff --git a/packages/node/test/integrations/node-fetch.test.ts b/packages/node/test/integrations/node-fetch.test.ts deleted file mode 100644 index a627d48dc6c0..000000000000 --- a/packages/node/test/integrations/node-fetch.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { _getConfigWithDefaults } from '../../src/integrations/node-fetch'; - -describe('nativeNodeFetchIntegration', () => { - describe('_getConfigWithDefaults', () => { - it('passes headersToSpanAttributes through to the config', () => { - const config = _getConfigWithDefaults({ - headersToSpanAttributes: { - requestHeaders: ['x-custom-header'], - responseHeaders: ['content-length', 'content-type'], - }, - }); - - expect(config.headersToSpanAttributes).toEqual({ - requestHeaders: ['x-custom-header'], - responseHeaders: ['content-length', 'content-type'], - }); - }); - - it('does not set headersToSpanAttributes when not provided', () => { - const config = _getConfigWithDefaults({}); - expect(config.headersToSpanAttributes).toBeUndefined(); - }); - }); -});