diff --git a/.size-limit.js b/.size-limit.js index 4f86a9f8a2ea..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: '53 KB', + limit: '57 KB', }, // Node SDK (ESM) { @@ -326,14 +326,14 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '167 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: '111 KB', + limit: '113 KB', }, ]; diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index f8a10b0a1f8b..b25d32138aa9 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,24 +1,48 @@ +/* eslint-disable max-lines */ 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, addTracePropagationHeadersToOutgoingRequest, + getClientRequestUrl, getRequestOptions, } from './outgoing-requests'; type Http = typeof http; type Https = typeof https; +type IncomingHttpHeaders = http.IncomingHttpHeaders; +type OutgoingHttpHeaders = http.OutgoingHttpHeaders; export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** @@ -37,6 +61,26 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ propagateTraceInOutgoingRequests?: boolean; + /** + * Whether to enable the capability to create spans for outgoing requests via diagnostic channels. + * 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.12+). + * Individual users should not need to configure this directly. + * + * @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. @@ -48,13 +92,20 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ ignoreOutgoingRequests?: (url: string, request: http.RequestOptions) => boolean; - // 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. + * Hooks for outgoing request spans, called when `createSpansForOutgoingRequests` is enabled. + * These mirror the OTEL HttpInstrumentation hooks for backwards compatibility. */ - spans?: boolean; + 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. /** * @depreacted This no longer does anything. @@ -111,14 +162,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 @@ -168,13 +222,12 @@ 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); + + return 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); + + this.getConfig().outgoingResponseHook?.(span, response); + this.getConfig().outgoingRequestApplyCustomAttributes?.(span, request, response); + + 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 }); + }); + + return span; + } + /** * This is triggered when an outgoing request finishes. * It has access to the final request and response objects. @@ -219,9 +371,12 @@ export class SentryHttpInstrumentation extends InstrumentationBase { + addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); + }); + } else if (shouldPropagate) { + addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); + } + } else if (shouldPropagate) { + addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); + } } /** @@ -247,7 +422,102 @@ 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-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 diff --git a/packages/node-core/src/utils/outgoingHttpRequest.ts b/packages/node-core/src/utils/outgoingHttpRequest.ts index 5292018e31ef..7eafa941286a 100644 --- a/packages/node-core/src/utils/outgoingHttpRequest.ts +++ b/packages/node-core/src/utils/outgoingHttpRequest.ts @@ -50,7 +50,7 @@ export function addTracePropagationHeadersToOutgoingRequest( request: ClientRequest, propagationDecisionMap: LRUMap, ): 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; 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'); + }); +}); diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 7c2cadf9eb43..5509978c9f2b 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -26,6 +26,13 @@ const INTEGRATION_NAME = 'Http'; const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http'; +// 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 { /** * Whether breadcrumbs should be recorded for outgoing requests. @@ -192,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; } @@ -246,8 +253,24 @@ 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, + 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 @@ -269,6 +292,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); 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); });