From 9c68295724408310f308e1dec82a5d576e479856 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 8 Aug 2025 10:24:30 +0200 Subject: [PATCH 01/17] feat(node): Avoid OTEL instrumentation for outgoing requests on Node 22+ Registers diagnostics channels for outgoing requests on Node >= 22 that takes care of creating spans, rather than relying on OTEL instrumentation. --- .../http/SentryHttpInstrumentation.ts | 282 +++++++++++++++++- packages/node-core/src/utils/baggage.ts | 13 +- packages/node/src/integrations/http.ts | 9 +- 3 files changed, 285 insertions(+), 19 deletions(-) diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index f8a10b0a1f8b..e01be6779ca4 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,15 +1,35 @@ import type { ChannelListener } from 'node:diagnostics_channel'; import { subscribe, unsubscribe } from 'node:diagnostics_channel'; +import { errorMonitor } from 'node:events'; import type * as http from 'node:http'; import type * as https from 'node:https'; -import { context } from '@opentelemetry/api'; +import { context, SpanStatusCode, trace } from '@opentelemetry/api'; import { isTracingSuppressed } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import type { Span } from '@sentry/core'; -import { debug, LRUMap, SDK_VERSION } from '@sentry/core'; +import { + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_NETWORK_PEER_ADDRESS, + ATTR_NETWORK_PEER_PORT, + ATTR_NETWORK_PROTOCOL_VERSION, + ATTR_NETWORK_TRANSPORT, + ATTR_URL_FULL, + ATTR_USER_AGENT_ORIGINAL, + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH, + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED, +} from '@opentelemetry/semantic-conventions'; +import type { Span, SpanAttributes, SpanStatus } from '@sentry/core'; +import { + debug, + getHttpSpanDetailsFromUrlObject, + getSpanStatusFromHttpCode, + LRUMap, + parseStringToURLObject, + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + startInactiveSpan, +} from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; -import { getRequestUrl } from '../../utils/getRequestUrl'; import { INSTRUMENTATION_NAME } from './constants'; import { addRequestBreadcrumb, @@ -19,6 +39,8 @@ import { type Http = typeof http; type Https = typeof https; +type IncomingHttpHeaders = http.IncomingHttpHeaders; +type OutgoingHttpHeaders = http.OutgoingHttpHeaders; export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** @@ -28,6 +50,13 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ breadcrumbs?: boolean; + /** + * Whether to create spans for outgoing requests. + * + * @default `true` + */ + spans?: boolean; + /** * Whether to propagate Sentry trace headers in outgoing requests. * By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled) @@ -37,6 +66,13 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ propagateTraceInOutgoingRequests?: boolean; + /** + * If spans for outgoing requests should be created. + * + * @default `false`` + */ + createSpansForOutgoingRequests?: boolean; + /** * Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. * For the scope of this instrumentation, this callback only controls breadcrumb creation. @@ -51,11 +87,6 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { // All options below do not do anything anymore in this instrumentation, and will be removed in the future. // They are only kept here for backwards compatibility - the respective functionality is now handled by the httpServerIntegration/httpServerSpansIntegration. - /** - * @deprecated This no longer does anything. - */ - spans?: boolean; - /** * @depreacted This no longer does anything. */ @@ -111,14 +142,17 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { }; /** - * This custom HTTP instrumentation is used to isolate incoming requests and annotate them with additional information. - * It does not emit any spans. + * This custom HTTP instrumentation handles outgoing HTTP requests. * - * The reason this is isolated from the OpenTelemetry instrumentation is that users may overwrite this, - * which would lead to Sentry not working as expected. + * It provides: + * - Breadcrumbs for all outgoing requests + * - Trace propagation headers (when enabled) + * - Span creation for outgoing requests (when createSpansForOutgoingRequests is enabled) + * + * Span creation requires Node 22+ and uses diagnostic channels to avoid monkey-patching. + * By default, this is only enabled in the node SDK, not in node-core or other runtime SDKs. * * Important note: Contrary to other OTEL instrumentation, this one cannot be unwrapped. - * It only does minimal things though and does not emit any spans. * * This is heavily inspired & adapted from: * https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts @@ -155,6 +189,11 @@ export class SentryHttpInstrumentation extends InstrumentationBase { + const data = _data as { request: http.ClientRequest }; + this._onOutgoingRequestStart(data.request); + }) satisfies ChannelListener; + const wrap = (moduleExports: T): T => { if (hasRegisteredHandlers) { return moduleExports; @@ -168,13 +207,15 @@ export class SentryHttpInstrumentation extends InstrumentationBase) { + const [event] = args; + if (event !== 'response') { + return target.apply(thisArg, args); + } + + const parentContext = context.active(); + const requestContext = trace.setSpan(parentContext, span); + + context.with(requestContext, () => { + return target.apply(thisArg, args); + }); + }, + }); + + // eslint-disable-next-line deprecation/deprecation + request.once = newOnce; + + /** + * Determines if the request has errored or the response has ended/errored. + */ + let responseFinished = false; + + const endSpan = (status: SpanStatus): void => { + if (responseFinished) { + return; + } + responseFinished = true; + + span.setStatus(status); + span.end(); + }; + + request.prependListener('response', response => { + if (request.listenerCount('response') <= 1) { + response.resume(); + } + + context.bind(context.active(), response); + + const additionalAttributes = _getOutgoingRequestEndedSpanData(response); + span.setAttributes(additionalAttributes); + + const endHandler = (forceError: boolean = false): void => { + this._diag.debug('outgoingRequest on end()'); + + const status = + // eslint-disable-next-line deprecation/deprecation + forceError || typeof response.statusCode !== 'number' || (response.aborted && !response.complete) + ? { code: SpanStatusCode.ERROR } + : getSpanStatusFromHttpCode(response.statusCode); + + endSpan(status); + }; + + response.on('end', () => { + endHandler(); + }); + response.on(errorMonitor, error => { + this._diag.debug('outgoingRequest on response error()', error); + endHandler(true); + }); + }); + + // Fallback if proper response end handling above fails + request.on('close', () => { + endSpan({ code: SpanStatusCode.UNSET }); + }); + request.on(errorMonitor, error => { + this._diag.debug('outgoingRequest on request error()', error); + endSpan({ code: SpanStatusCode.ERROR }); + }); + } + /** * This is triggered when an outgoing request finishes. * It has access to the final request and response objects. @@ -251,3 +403,103 @@ export class SentryHttpInstrumentation extends InstrumentationBase( existing: Existing, @@ -19,10 +24,12 @@ export function mergeBaggageHeaders { - if (!mergedBaggageEntries[key]) { + // Sentry-specific keys always take precedence from new baggage + // Non-Sentry keys only added if not already present + if (key.startsWith('sentry-') || !mergedBaggageEntries[key]) { mergedBaggageEntries[key] = value; } }); diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index e6c48a6bd550..5ecffdf9a76b 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -20,6 +20,8 @@ const INTEGRATION_NAME = 'Http'; const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http'; +const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = NODE_VERSION.major >= 22; + interface HttpOptions { /** * Whether breadcrumbs should be recorded for outgoing requests. @@ -240,7 +242,9 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => const sentryHttpInstrumentationOptions = { breadcrumbs: options.breadcrumbs, - propagateTraceInOutgoingRequests: !useOtelHttpInstrumentation, + propagateTraceInOutgoingRequests: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL || !useOtelHttpInstrumentation, + createSpansForOutgoingRequests: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL, + spans: options.spans, ignoreOutgoingRequests: options.ignoreOutgoingRequests, } satisfies SentryHttpInstrumentationOptions; @@ -263,6 +267,9 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => function getConfigWithDefaults(options: Partial = {}): HttpInstrumentationConfig { const instrumentationConfig = { + // This is handled by the SentryHttpInstrumentation on Node 22+ + disableOutgoingRequestInstrumentation: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL, + ignoreOutgoingRequestHook: request => { const url = getRequestUrl(request); From 1dad5741edce4a75e149d398f739e9f4d5003e6b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Sun, 14 Dec 2025 19:42:28 +0100 Subject: [PATCH 02/17] Add unit tests for mergeBaggageHeaders --- packages/node-core/test/utils/baggage.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/node-core/test/utils/baggage.test.ts diff --git a/packages/node-core/test/utils/baggage.test.ts b/packages/node-core/test/utils/baggage.test.ts new file mode 100644 index 000000000000..aae5c48d6068 --- /dev/null +++ b/packages/node-core/test/utils/baggage.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { mergeBaggageHeaders } from '../../src/utils/baggage'; + +describe('mergeBaggageHeaders', () => { + it('returns new baggage when existing is undefined', () => { + const result = mergeBaggageHeaders(undefined, 'foo=bar'); + expect(result).toBe('foo=bar'); + }); + + it('returns existing baggage when new baggage is empty', () => { + const result = mergeBaggageHeaders('foo=bar', ''); + expect(result).toBe('foo=bar'); + }); + + it('returns existing baggage when new baggage is invalid', () => { + const result = mergeBaggageHeaders('foo=bar', 'invalid'); + expect(result).toBe('foo=bar'); + }); + + it('handles empty existing baggage', () => { + const result = mergeBaggageHeaders('', 'foo=bar,sentry-release=1.0.0'); + expect(result).toBe('foo=bar,sentry-release=1.0.0'); + }); + + it('preserves existing non-Sentry entries', () => { + const result = mergeBaggageHeaders('foo=bar,other=vendor', 'foo=newvalue,third=party'); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('third=party'); + expect(entries).not.toContain('foo=newvalue'); + }); + + it('overwrites existing Sentry entries with new ones', () => { + const result = mergeBaggageHeaders( + 'sentry-release=1.0.0,sentry-environment=prod', + 'sentry-release=2.0.0,sentry-environment=staging', + ); + + const entries = result?.split(','); + expect(entries).toContain('sentry-release=2.0.0'); + expect(entries).toContain('sentry-environment=staging'); + expect(entries).not.toContain('sentry-release=1.0.0'); + expect(entries).not.toContain('sentry-environment=prod'); + }); + + it('merges Sentry and non-Sentry entries correctly', () => { + const result = mergeBaggageHeaders('foo=bar,sentry-release=1.0.0,other=vendor', 'sentry-release=2.0.0,third=party'); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('third=party'); + expect(entries).toContain('sentry-release=2.0.0'); + expect(entries).not.toContain('sentry-release=1.0.0'); + }); + + it('handles third-party baggage with Sentry entries', () => { + const result = mergeBaggageHeaders( + 'other=vendor,foo=bar,third=party,sentry-release=9.9.9,sentry-environment=staging,sentry-sample_rate=0.54,last=item', + 'sentry-release=2.1.0,sentry-environment=myEnv', + ); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('last=item'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('third=party'); + expect(entries).toContain('sentry-environment=myEnv'); + expect(entries).toContain('sentry-release=2.1.0'); + expect(entries).toContain('sentry-sample_rate=0.54'); + expect(entries).not.toContain('sentry-environment=staging'); + expect(entries).not.toContain('sentry-release=9.9.9'); + }); + + it('adds new Sentry entries when they do not exist', () => { + const result = mergeBaggageHeaders('foo=bar,other=vendor', 'sentry-release=1.0.0,sentry-environment=prod'); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('sentry-release=1.0.0'); + expect(entries).toContain('sentry-environment=prod'); + }); + + it('handles array-type existing baggage', () => { + const result = mergeBaggageHeaders(['foo=bar', 'other=vendor'], 'sentry-release=1.0.0'); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('sentry-release=1.0.0'); + }); + + it('preserves order of existing entries', () => { + const result = mergeBaggageHeaders('first=1,second=2,third=3', 'fourth=4'); + expect(result).toBe('first=1,second=2,third=3,fourth=4'); + }); + + it('handles complex scenario with multiple Sentry keys', () => { + const result = mergeBaggageHeaders( + 'foo=bar,sentry-release=old,sentry-environment=old,other=vendor', + 'sentry-release=new,sentry-environment=new,sentry-transaction=test,new=entry', + ); + + const entries = result?.split(','); + expect(entries).toContain('foo=bar'); + expect(entries).toContain('other=vendor'); + expect(entries).toContain('sentry-release=new'); + expect(entries).toContain('sentry-environment=new'); + expect(entries).toContain('sentry-transaction=test'); + expect(entries).toContain('new=entry'); + expect(entries).not.toContain('sentry-release=old'); + expect(entries).not.toContain('sentry-environment=old'); + }); + + it('matches OTEL propagation.inject() behavior for Sentry keys', () => { + const result = mergeBaggageHeaders( + 'sentry-trace_id=abc123,sentry-sampled=false,non-sentry=keep', + 'sentry-trace_id=xyz789,sentry-sampled=true', + ); + + const entries = result?.split(','); + expect(entries).toContain('sentry-trace_id=xyz789'); + expect(entries).toContain('sentry-sampled=true'); + expect(entries).toContain('non-sentry=keep'); + expect(entries).not.toContain('sentry-trace_id=abc123'); + expect(entries).not.toContain('sentry-sampled=false'); + }); +}); From 81ba2cfead640440a14ec2a4aac0b482239a3fd8 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Sun, 14 Dec 2025 20:23:05 +0100 Subject: [PATCH 03/17] Bump size limits --- .size-limit.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 00b4bdbfd4d8..ce278f1a947e 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -231,7 +231,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '52 KB', + limit: '56 KB', }, // Node SDK (ESM) { @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '161 KB', + limit: '162 KB', }, { name: '@sentry/node - without tracing', @@ -270,7 +270,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '111 KB', + limit: '112 KB', }, ]; From 52cdf7dc51ec81add4cd4cd633e3195266547f85 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Sun, 14 Dec 2025 20:34:57 +0100 Subject: [PATCH 04/17] Update `spans` and `createSpansForOutgoingRequests` options docs --- .../http/SentryHttpInstrumentation.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index e01be6779ca4..d60c9b9d48f3 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -50,13 +50,6 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ breadcrumbs?: boolean; - /** - * Whether to create spans for outgoing requests. - * - * @default `true` - */ - spans?: boolean; - /** * Whether to propagate Sentry trace headers in outgoing requests. * By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled) @@ -67,12 +60,26 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { propagateTraceInOutgoingRequests?: boolean; /** - * If spans for outgoing requests should be created. + * Whether to enable the capability to create spans for outgoing requests via diagnostic channels. + * This controls whether the instrumentation subscribes to the `http.client.request.start` channel. + * If enabled, spans will only be created if the `spans` option is also enabled (default: true). + * + * This is a feature flag that should be enabled by SDKs when the runtime supports it (Node 22+). + * Individual users should not need to configure this directly. * - * @default `false`` + * @default `false` */ createSpansForOutgoingRequests?: boolean; + /** + * Whether to create spans for outgoing requests (user preference). + * This only takes effect if `createSpansForOutgoingRequests` is also enabled. + * If `createSpansForOutgoingRequests` is not enabled, this option is ignored. + * + * @default `true` + */ + spans?: boolean; + /** * Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. * For the scope of this instrumentation, this callback only controls breadcrumb creation. From a79842078d8d50805152ccc6a0d2acacf5f563f0 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Sun, 14 Dec 2025 20:41:00 +0100 Subject: [PATCH 05/17] Simplify spansEnabled --- .../src/integrations/http/SentryHttpInstrumentation.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index d60c9b9d48f3..f63e61b5c6b2 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -254,8 +254,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase Date: Sun, 14 Dec 2025 20:48:19 +0100 Subject: [PATCH 06/17] Ensure we return the value when patching 'response' events --- .../src/integrations/http/SentryHttpInstrumentation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index f63e61b5c6b2..38b34269af8d 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -291,7 +291,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase { + return context.with(requestContext, () => { return target.apply(thisArg, args); }); }, From c6df808ad96b7f2bfcddb04efd0a316bbd69ab72 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 13:45:43 +0100 Subject: [PATCH 07/17] stricter node version scope to ensure node 22.12.0+ --- packages/node/src/integrations/http.ts | 13 +++++++++---- packages/node/test/integrations/http.test.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 9a6ac8ba8638..f4aa1ecfba65 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -26,7 +26,12 @@ const INTEGRATION_NAME = 'Http'; const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http'; -const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = NODE_VERSION.major >= 22; +// The `http.client.request.created` diagnostics channel, needed for trace propagation, +// was added in Node 22.12.0 (backported from 23.2.0). Earlier 22.x versions don't have it. +const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = + (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || + (NODE_VERSION.major === 23 && NODE_VERSION.minor >= 2) || + NODE_VERSION.major >= 24; interface HttpOptions { /** @@ -194,9 +199,9 @@ export function _shouldUseOtelHttpInstrumentation( return false; } - // IMPORTANT: We only disable span instrumentation when spans are not enabled _and_ we are on Node 22+, - // as otherwise the necessary diagnostics channel is not available yet - if (!hasSpansEnabled(clientOptions) && NODE_VERSION.major >= 22) { + // IMPORTANT: We only disable span instrumentation when spans are not enabled _and_ we are on a Node version + // that fully supports the necessary diagnostics channels for trace propagation + if (!hasSpansEnabled(clientOptions) && FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL) { return false; } diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts index aa7b3331fef1..d02bc12393c6 100644 --- a/packages/node/test/integrations/http.test.ts +++ b/packages/node/test/integrations/http.test.ts @@ -17,7 +17,7 @@ describe('httpIntegration', () => { expect(actual).toBe(expected); }); - conditionalTest({ min: 22 })('returns false without tracesSampleRate on Node >=22', () => { + conditionalTest({ min: 22 })('returns false without tracesSampleRate on Node >=22.12', () => { const actual = _shouldUseOtelHttpInstrumentation({}, {}); expect(actual).toBe(false); }); From c12e32eccb72756695d1d202e6f816d277f3f44a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 13:52:36 +0100 Subject: [PATCH 08/17] Bump size limit --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 8e3f12f27d4a..fa91643e4100 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -326,7 +326,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '162 KB', + limit: '163 KB', }, { name: '@sentry/node - without tracing', From 3ef20333ea6b1848375b390b2bc95806ee49a158 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 14:24:38 +0100 Subject: [PATCH 09/17] dedupe getRequestUrl for CLientRequests into a shared getClientRequestUrl --- .../integrations/http/SentryHttpInstrumentation.ts | 13 +++---------- .../src/integrations/http/outgoing-requests.ts | 1 + .../src/light/integrations/httpIntegration.ts | 3 ++- packages/node-core/src/utils/outgoingHttpRequest.ts | 7 +++++-- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index 38b34269af8d..0454cfcfb090 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -34,6 +34,7 @@ import { INSTRUMENTATION_NAME } from './constants'; import { addRequestBreadcrumb, addTracePropagationHeadersToOutgoingRequest, + getClientRequestUrl, getRequestOptions, } from './outgoing-requests'; @@ -405,13 +406,13 @@ export class SentryHttpInstrumentation extends InstrumentationBase, ): void { - const url = getRequestUrl(request); + const url = getClientRequestUrl(request); const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {}; const headersToAdd = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) @@ -146,7 +146,10 @@ export function getRequestOptions(request: ClientRequest): RequestOptions { }; } -function getRequestUrl(request: ClientRequest): string { +/** + * + */ +export function getClientRequestUrl(request: ClientRequest): string { const hostname = request.getHeader('host') || request.host; const protocol = request.protocol; const path = request.path; From bdcf180141678cea55814eb44f01bd664201b701 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 15:07:36 +0100 Subject: [PATCH 10/17] ensure outgoing trace propagation has correct span in context --- .../http/SentryHttpInstrumentation.ts | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index 0454cfcfb090..b6239b9d7f8c 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -62,10 +62,9 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** * Whether to enable the capability to create spans for outgoing requests via diagnostic channels. - * This controls whether the instrumentation subscribes to the `http.client.request.start` channel. * If enabled, spans will only be created if the `spans` option is also enabled (default: true). * - * This is a feature flag that should be enabled by SDKs when the runtime supports it (Node 22+). + * This is a feature flag that should be enabled by SDKs when the runtime supports it (Node 22.12+). * Individual users should not need to configure this directly. * * @default `false` @@ -197,11 +196,6 @@ export class SentryHttpInstrumentation extends InstrumentationBase { - const data = _data as { request: http.ClientRequest }; - this._onOutgoingRequestStart(data.request); - }) satisfies ChannelListener; - const wrap = (moduleExports: T): T => { if (hasRegisteredHandlers) { return moduleExports; @@ -215,13 +209,10 @@ export class SentryHttpInstrumentation extends InstrumentationBase { + addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); + }); + } + } else if (shouldPropagate) { + addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); + } } /** From 177b26f6a4fcbad7c8895c09cc8b1d401e15a166 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 15:43:29 +0100 Subject: [PATCH 11/17] Bump size limits --- .size-limit.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index fa91643e4100..e04db2e575ce 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -317,7 +317,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '56 KB', + limit: '57 KB', }, // Node SDK (ESM) { @@ -326,14 +326,14 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '163 KB', + limit: '168 KB', }, { name: '@sentry/node - without tracing', path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '95 KB', + limit: '96 KB', ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -356,7 +356,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '112 KB', + limit: '113 KB', }, ]; From 8c7d414f2f85b9eadeff62323bc9334ae86133a2 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 18:29:06 +0100 Subject: [PATCH 12/17] Forward user instrumentation hooks to SentryHttpInstrumentation --- .../http/SentryHttpInstrumentation.ts | 18 ++++++++++++++++++ packages/node/src/integrations/http.ts | 14 ++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index b6239b9d7f8c..dae68955010d 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import type { ChannelListener } from 'node:diagnostics_channel'; import { subscribe, unsubscribe } from 'node:diagnostics_channel'; import { errorMonitor } from 'node:events'; @@ -91,6 +92,18 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ ignoreOutgoingRequests?: (url: string, request: http.RequestOptions) => boolean; + /** + * Hooks for outgoing request spans, called when `createSpansForOutgoingRequests` is enabled. + * These mirror the OTEL HttpInstrumentation hooks for backwards compatibility. + */ + outgoingRequestHook?: (span: Span, request: http.ClientRequest) => void; + outgoingResponseHook?: (span: Span, response: http.IncomingMessage) => void; + outgoingRequestApplyCustomAttributes?: ( + span: Span, + request: http.ClientRequest, + response: http.IncomingMessage, + ) => void; + // All options below do not do anything anymore in this instrumentation, and will be removed in the future. // They are only kept here for backwards compatibility - the respective functionality is now handled by the httpServerIntegration/httpServerSpansIntegration. @@ -255,6 +268,8 @@ export class SentryHttpInstrumentation extends InstrumentationBase) { const [event] = args; @@ -299,6 +314,9 @@ export class SentryHttpInstrumentation extends InstrumentationBase { this._diag.debug('outgoingRequest on end()'); diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index f4aa1ecfba65..5509978c9f2b 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -257,6 +257,20 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => createSpansForOutgoingRequests: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL, spans: options.spans, ignoreOutgoingRequests: options.ignoreOutgoingRequests, + outgoingRequestHook: (span, request) => { + // Sanitize data URLs to prevent long base64 strings in span attributes + const url = getRequestUrl(request); + if (url.startsWith('data:')) { + const sanitizedUrl = stripDataUrlContent(url); + span.setAttribute('http.url', sanitizedUrl); + span.setAttribute(SEMANTIC_ATTRIBUTE_URL_FULL, sanitizedUrl); + span.updateName(`${request.method || 'GET'} ${sanitizedUrl}`); + } + + options.instrumentation?.requestHook?.(span, request); + }, + outgoingResponseHook: options.instrumentation?.responseHook, + outgoingRequestApplyCustomAttributes: options.instrumentation?.applyCustomAttributesOnSpan, } satisfies SentryHttpInstrumentationOptions; // This is Sentry-specific instrumentation for outgoing request breadcrumbs & trace propagation From 5fc2a89bd4c64c8c1906d7da7a7574b9360b8dc3 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 20:40:29 +0100 Subject: [PATCH 13/17] Fix trace propagation when outgoing request has no parent span --- .../src/integrations/http/SentryHttpInstrumentation.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index dae68955010d..9972669d79b1 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -391,12 +391,16 @@ export class SentryHttpInstrumentation extends InstrumentationBase { addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); }); + } else if (shouldPropagate) { + addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); } } else if (shouldPropagate) { addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); From 54dae22a488d182d5acac79869e97cd9dd8790cf Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 21:13:52 +0100 Subject: [PATCH 14/17] Add test asserting consistent trace ID across outgoing requests without tracing --- .../suites/tracing/requests/http-no-tracing/test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts index 4f6593f82e34..b804352a95c6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts @@ -5,18 +5,23 @@ import { createEsmAndCjsTests } from '../../../../utils/runner'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('outgoing http requests are correctly instrumented with tracing disabled', async () => { - expect.assertions(11); + expect.assertions(12); + + let traceIdFromV0: string | undefined; const [SERVER_URL, closeTestServer] = await createTestServer() .get('/api/v0', headers => { expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); expect(headers['baggage']).toEqual(expect.any(String)); + traceIdFromV0 = headers['sentry-trace']?.split('-')[0]; }) .get('/api/v1', headers => { expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); expect(headers['baggage']).toEqual(expect.any(String)); + // Both requests should share the same trace ID from the scope's propagation context + expect(headers['sentry-trace']?.split('-')[0]).toEqual(traceIdFromV0); }) .get('/api/v2', headers => { expect(headers['baggage']).toBeUndefined(); From 4ef95fd35c524f7d881629c99a310fdd7d77a02c Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 21:47:33 +0100 Subject: [PATCH 15/17] Accept ClientRequest in getRequestUrl type signature --- packages/node-core/src/utils/getRequestUrl.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/node-core/src/utils/getRequestUrl.ts b/packages/node-core/src/utils/getRequestUrl.ts index 5005224f59e0..73ddd33b447b 100644 --- a/packages/node-core/src/utils/getRequestUrl.ts +++ b/packages/node-core/src/utils/getRequestUrl.ts @@ -1,7 +1,11 @@ -import type { RequestOptions } from 'node:http'; - -/** Build a full URL from request options. */ -export function getRequestUrl(requestOptions: RequestOptions): string { +/** Build a full URL from request options or a ClientRequest. */ +export function getRequestUrl(requestOptions: { + protocol?: string | null; + hostname?: string | null; + host?: string | null; + port?: string | number | null; + path?: string | null; +}): string { const protocol = requestOptions.protocol || ''; const hostname = requestOptions.hostname || requestOptions.host || ''; // Don't log standard :80 (http) and :443 (https) ports to reduce the noise From 9c09bfdf16d8cce11ea0c835f05ff6d5703e47f5 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 23:26:09 +0100 Subject: [PATCH 16/17] Remove trace ID consistency assertion from http-no-tracing test The assertion that both outgoing requests share the same trace ID only holds on Node 22+ (diagnostics channel path). On Node <22, OTEL creates separate spans per request, each with their own trace ID. --- .../suites/tracing/requests/http-no-tracing/test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts index b804352a95c6..4f6593f82e34 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts @@ -5,23 +5,18 @@ import { createEsmAndCjsTests } from '../../../../utils/runner'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('outgoing http requests are correctly instrumented with tracing disabled', async () => { - expect.assertions(12); - - let traceIdFromV0: string | undefined; + expect.assertions(11); const [SERVER_URL, closeTestServer] = await createTestServer() .get('/api/v0', headers => { expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); expect(headers['baggage']).toEqual(expect.any(String)); - traceIdFromV0 = headers['sentry-trace']?.split('-')[0]; }) .get('/api/v1', headers => { expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); expect(headers['baggage']).toEqual(expect.any(String)); - // Both requests should share the same trace ID from the scope's propagation context - expect(headers['sentry-trace']?.split('-')[0]).toEqual(traceIdFromV0); }) .get('/api/v2', headers => { expect(headers['baggage']).toBeUndefined(); From f8e3cadcdf56f9122acd5203a0e51ed3a4fffd32 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 23:43:11 +0100 Subject: [PATCH 17/17] Fix getContentLength to accept IncomingHttpHeaders --- .../src/integrations/http/SentryHttpInstrumentation.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index 9972669d79b1..b25d32138aa9 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -499,11 +499,14 @@ function getResponseContentLengthAttributes(response: http.IncomingMessage): Spa } } -function getContentLength(headers: http.OutgoingHttpHeaders): number | undefined { +function getContentLength(headers: http.OutgoingHttpHeaders | http.IncomingHttpHeaders): number | undefined { const contentLengthHeader = headers['content-length']; - if (typeof contentLengthHeader !== 'string') { + if (typeof contentLengthHeader === 'number') { return contentLengthHeader; } + if (typeof contentLengthHeader !== 'string') { + return undefined; + } const contentLength = parseInt(contentLengthHeader, 10); if (isNaN(contentLength)) {