@@ -5,19 +5,24 @@ import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
55import { InstrumentationBase , InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation' ;
66import type { AggregationCounts , Client , RequestEventData , SanitizedRequestData , Scope } from '@sentry/core' ;
77import {
8+ LRUMap ,
89 addBreadcrumb ,
910 generateSpanId ,
1011 getBreadcrumbLogLevelFromHttpStatusCode ,
1112 getClient ,
1213 getCurrentScope ,
1314 getIsolationScope ,
1415 getSanitizedUrlString ,
16+ getTraceData ,
1517 httpRequestToRequestData ,
1618 logger ,
19+ objectToBaggageHeader ,
20+ parseBaggageHeader ,
1721 parseUrl ,
1822 stripUrlQueryAndFragment ,
1923 withIsolationScope ,
2024} from '@sentry/core' ;
25+ import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry' ;
2126import type * as http from 'node:http' ;
2227import type { IncomingMessage , RequestOptions } from 'node:http' ;
2328import type * as https from 'node:https' ;
@@ -30,6 +35,12 @@ import { getRequestInfo } from './vendor/getRequestInfo';
3035type Http = typeof http ;
3136type Https = typeof https ;
3237
38+ type RequestArgs =
39+ // eslint-disable-next-line @typescript-eslint/ban-types
40+ | [ url : string | URL , options ?: RequestOptions , callback ?: Function ]
41+ // eslint-disable-next-line @typescript-eslint/ban-types
42+ | [ options : RequestOptions , callback ?: Function ] ;
43+
3344export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
3445 /**
3546 * Whether breadcrumbs should be recorded for requests.
@@ -47,6 +58,15 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
4758 */
4859 extractIncomingTraceFromHeader ?: boolean ;
4960
61+ /**
62+ * Whether to propagate Sentry trace headers in ougoing requests.
63+ * By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled, ...)
64+ * then this instrumentation can take over.
65+ *
66+ * @default `false`
67+ */
68+ propagateTraceInOutgoingRequests ?: boolean ;
69+
5070 /**
5171 * Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`.
5272 * For the scope of this instrumentation, this callback only controls breadcrumb creation.
@@ -91,8 +111,11 @@ const MAX_BODY_BYTE_LENGTH = 1024 * 1024;
91111 * https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts
92112 */
93113export class SentryHttpInstrumentation extends InstrumentationBase < SentryHttpInstrumentationOptions > {
114+ private _propagationDecisionMap : LRUMap < string , boolean > ;
115+
94116 public constructor ( config : SentryHttpInstrumentationOptions = { } ) {
95117 super ( '@sentry/instrumentation-http' , VERSION , config ) ;
118+ this . _propagationDecisionMap = new LRUMap < string , boolean > ( 100 ) ;
96119 }
97120
98121 /** @inheritdoc */
@@ -232,18 +255,25 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
232255 // We need to access and reconstruct the request options object passed to `ignoreOutgoingRequests`
233256 // so that it matches what Otel instrumentation passes to `ignoreOutgoingRequestHook`.
234257 // @see https://github.com/open-telemetry/opentelemetry-js/blob/7293e69c1e55ca62e15d0724d22605e61bd58952/experimental/packages/opentelemetry-instrumentation-http/src/http.ts#L756-L789
235- const argsCopy = [ ...args ] ;
258+ const argsCopy = [ ...args ] as RequestArgs ;
236259
237- const options = argsCopy . shift ( ) as URL | http . RequestOptions | string ;
260+ const options = argsCopy [ 0 ] ;
261+ const extraOptions = typeof argsCopy [ 1 ] === 'object' ? argsCopy [ 1 ] : undefined ;
238262
239- const extraOptions =
240- typeof argsCopy [ 0 ] === 'object' && ( typeof options === 'string' || options instanceof URL )
241- ? ( argsCopy . shift ( ) as http . RequestOptions )
242- : undefined ;
263+ const { optionsParsed, origin, pathname } = getRequestInfo ( instrumentation . _diag , options , extraOptions ) ;
264+ const url = getAbsoluteUrl ( origin , pathname ) ;
243265
244- const { optionsParsed } = getRequestInfo ( instrumentation . _diag , options , extraOptions ) ;
266+ // This will be undefined if there are no changed headers
267+ const mergedHeaders = instrumentation . getConfig ( ) . propagateTraceInOutgoingRequests
268+ ? getMergedHeadersForRequestOptions ( url , optionsParsed , instrumentation . _propagationDecisionMap )
269+ : undefined ;
245270
246- const request = original . apply ( this , args ) as ReturnType < typeof http . request > ;
271+ // If we are not proapgating traces, we skip touching the args for the request at all
272+ const request = mergedHeaders
273+ ? ( original . apply ( this , getOutgoingRequestArgsWithHeaders ( argsCopy , mergedHeaders ) ) as ReturnType <
274+ typeof http . request
275+ > )
276+ : ( original . apply ( this , args ) as ReturnType < typeof http . request > ) ;
247277
248278 request . prependListener ( 'response' , ( response : http . IncomingMessage ) => {
249279 const _breadcrumbs = instrumentation . getConfig ( ) . breadcrumbs ;
@@ -515,3 +545,118 @@ const clientToRequestSessionAggregatesMap = new Map<
515545 Client ,
516546 { [ timestampRoundedToSeconds : string ] : { exited : number ; crashed : number ; errored : number } }
517547> ( ) ;
548+
549+ /**
550+ * If there are any headers to be added for this request, this will return the full merged headers object.
551+ * Else, it will return void.
552+ */
553+ function getMergedHeadersForRequestOptions (
554+ url : string ,
555+ options : RequestOptions ,
556+ propagationDecisionMap : LRUMap < string , boolean > ,
557+ ) : void | http . OutgoingHttpHeaders {
558+ // Manually add the trace headers, if it applies
559+ // Note: We do not use `propagation.inject()` here, because our propagator relies on an active span
560+ // Which we do not have in this case
561+ const tracePropagationTargets = getClient ( ) ?. getOptions ( ) . tracePropagationTargets ;
562+ const addedHeaders = shouldPropagateTraceForUrl ( url , tracePropagationTargets , propagationDecisionMap )
563+ ? getTraceData ( )
564+ : undefined ;
565+
566+ if ( ! addedHeaders ) {
567+ return ;
568+ }
569+
570+ const headers = options . headers || { } ;
571+
572+ const { 'sentry-trace' : sentryTrace , baggage } = addedHeaders ;
573+
574+ // We do not want to overwrite existing header here, if it was already set
575+ if ( sentryTrace && ! headers [ 'sentry-trace' ] ) {
576+ headers [ 'sentry-trace' ] = sentryTrace ;
577+ }
578+
579+ // For baggage, we make sure to merge this into a possibly existing header
580+ if ( baggage ) {
581+ headers [ 'baggage' ] = mergeBaggageHeaders ( headers [ 'baggage' ] , baggage ) ;
582+ }
583+
584+ return headers ;
585+ }
586+
587+ function getAbsoluteUrl ( origin : string , path : string = '/' ) : string {
588+ try {
589+ const url = new URL ( path , origin ) ;
590+ return url . toString ( ) ;
591+ } catch {
592+ // fallback: Construct it on our own
593+ const url = `${ origin } ` ;
594+
595+ if ( url . endsWith ( '/' ) && path . startsWith ( '/' ) ) {
596+ return `${ url } ${ path . slice ( 1 ) } ` ;
597+ }
598+
599+ if ( ! url . endsWith ( '/' ) && ! path . startsWith ( '/' ) ) {
600+ return `${ url } /${ path . slice ( 1 ) } ` ;
601+ }
602+
603+ return `${ url } ${ path } ` ;
604+ }
605+ }
606+
607+ function mergeBaggageHeaders (
608+ existing : string | string [ ] | number | undefined ,
609+ baggage : string ,
610+ ) : string | string [ ] | number | undefined {
611+ if ( ! existing ) {
612+ return baggage ;
613+ }
614+
615+ const existingBaggageEntries = parseBaggageHeader ( existing ) ;
616+ const newBaggageEntries = parseBaggageHeader ( baggage ) ;
617+
618+ if ( ! newBaggageEntries ) {
619+ return existing ;
620+ }
621+
622+ // Existing entries take precedence, ensuring order remains stable for minimal changes
623+ const mergedBaggageEntries = { ...existingBaggageEntries } ;
624+ Object . entries ( newBaggageEntries ) . forEach ( ( [ key , value ] ) => {
625+ if ( ! mergedBaggageEntries [ key ] ) {
626+ mergedBaggageEntries [ key ] = value ;
627+ }
628+ } ) ;
629+
630+ return objectToBaggageHeader ( mergedBaggageEntries ) ;
631+ }
632+
633+ function getOutgoingRequestArgsWithHeaders ( originalArgs : RequestArgs , headers : http . OutgoingHttpHeaders ) : RequestArgs {
634+ const argsCopy = [ ...originalArgs ] as RequestArgs ;
635+
636+ const arg1 = argsCopy [ 0 ] ;
637+
638+ // If the first argument is a string or URL, we need to merge the headers into the options object, which is optional
639+ if ( typeof arg1 === 'string' || arg1 instanceof URL ) {
640+ const arg2 = argsCopy [ 1 ] ;
641+
642+ // If the second argument is an object, we just overwrite the headers there
643+ if ( typeof arg2 === 'object' ) {
644+ argsCopy [ 1 ] = {
645+ ...arg2 ,
646+ headers,
647+ } ;
648+ return argsCopy ;
649+ }
650+
651+ // Else, we need to insert a new object as second argument and insert the headers there
652+ argsCopy . splice ( 1 , 0 , { headers } ) ;
653+ return argsCopy ;
654+ }
655+
656+ // If the first argument is an object, we just overwrite the headers there
657+ argsCopy [ 0 ] = {
658+ ...arg1 ,
659+ headers,
660+ } ;
661+ return argsCopy ;
662+ }
0 commit comments