11/* eslint-disable max-lines */
22import type { ChannelListener } from 'node:diagnostics_channel' ;
33import { subscribe , unsubscribe } from 'node:diagnostics_channel' ;
4+ import { errorMonitor } from 'node:events' ;
45import type * as http from 'node:http' ;
56import type * as https from 'node:https' ;
67import type { EventEmitter } from 'node:stream' ;
7- import { context , propagation } from '@opentelemetry/api' ;
8+ import { context , propagation , SpanStatusCode , trace } from '@opentelemetry/api' ;
89import { isTracingSuppressed } from '@opentelemetry/core' ;
910import type { InstrumentationConfig } from '@opentelemetry/instrumentation' ;
1011import { InstrumentationBase , InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation' ;
11- import type { AggregationCounts , Client , SanitizedRequestData , Scope } from '@sentry/core' ;
12+ import {
13+ ATTR_HTTP_RESPONSE_STATUS_CODE ,
14+ ATTR_NETWORK_PEER_ADDRESS ,
15+ ATTR_NETWORK_PEER_PORT ,
16+ ATTR_NETWORK_PROTOCOL_VERSION ,
17+ ATTR_NETWORK_TRANSPORT ,
18+ ATTR_URL_FULL ,
19+ ATTR_USER_AGENT_ORIGINAL ,
20+ SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH ,
21+ SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED ,
22+ } from '@opentelemetry/semantic-conventions' ;
23+ import type { AggregationCounts , Client , SanitizedRequestData , Scope , SpanAttributes , SpanStatus } from '@sentry/core' ;
1224import {
1325 addBreadcrumb ,
1426 addNonEnumerableProperty ,
@@ -17,26 +29,32 @@ import {
1729 getBreadcrumbLogLevelFromHttpStatusCode ,
1830 getClient ,
1931 getCurrentScope ,
32+ getHttpSpanDetailsFromUrlObject ,
2033 getIsolationScope ,
2134 getSanitizedUrlString ,
35+ getSpanStatusFromHttpCode ,
2236 getTraceData ,
2337 httpRequestToRequestData ,
2438 isError ,
2539 LRUMap ,
40+ parseStringToURLObject ,
2641 parseUrl ,
2742 SDK_VERSION ,
43+ SEMANTIC_ATTRIBUTE_SENTRY_OP ,
44+ startInactiveSpan ,
2845 stripUrlQueryAndFragment ,
2946 withIsolationScope ,
3047} from '@sentry/core' ;
3148import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry' ;
3249import { DEBUG_BUILD } from '../../debug-build' ;
3350import { mergeBaggageHeaders } from '../../utils/baggage' ;
34- import { getRequestUrl } from '../../utils/getRequestUrl' ;
3551
3652const INSTRUMENTATION_NAME = '@sentry/instrumentation-http' ;
3753
3854type Http = typeof http ;
3955type Https = typeof https ;
56+ type IncomingHttpHeaders = http . IncomingHttpHeaders ;
57+ type OutgoingHttpHeaders = http . OutgoingHttpHeaders ;
4058
4159export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
4260 /**
@@ -46,6 +64,13 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
4664 */
4765 breadcrumbs ?: boolean ;
4866
67+ /**
68+ * Whether to create spans for outgoing requests.
69+ *
70+ * @default `true`
71+ */
72+ spans ?: boolean ;
73+ 4974 /**
5075 * Whether to extract the trace ID from the `sentry-trace` header for incoming requests.
5176 * By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled, ...)
@@ -64,6 +89,13 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
6489 */
6590 propagateTraceInOutgoingRequests ?: boolean ;
6691
92+ /**
93+ * If spans for outgoing requests should be created.
94+ *
95+ * @default `false``
96+ */
97+ createSpansForOutgoingRequests ?: boolean ;
98+ 6799 /**
68100 * Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`.
69101 * For the scope of this instrumentation, this callback only controls breadcrumb creation.
@@ -169,6 +201,11 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
169201 this . _onOutgoingRequestCreated ( data . request ) ;
170202 } ) satisfies ChannelListener ;
171203
204+ const onHttpClientRequestStart = ( ( _data : unknown ) => {
205+ const data = _data as { request : http . ClientRequest } ;
206+ this . _onOutgoingRequestStart ( data . request ) ;
207+ } ) satisfies ChannelListener ;
208+ 172209 const wrap = < T extends Http | Https > ( moduleExports : T ) : T => {
173210 if ( hasRegisteredHandlers ) {
174211 return moduleExports ;
@@ -183,13 +220,15 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
183220 // In this case, `http.client.response.finish` is not triggered
184221 subscribe ( 'http.client.request.error' , onHttpClientRequestError ) ;
185222
223+ if ( this . getConfig ( ) . createSpansForOutgoingRequests ) {
224+ subscribe ( 'http.client.request.start' , onHttpClientRequestStart ) ;
225+ }
186226 // NOTE: This channel only exist since Node 22
187227 // Before that, outgoing requests are not patched
188228 // and trace headers are not propagated, sadly.
189229 if ( this . getConfig ( ) . propagateTraceInOutgoingRequests ) {
190230 subscribe ( 'http.client.request.created' , onHttpClientRequestCreated ) ;
191231 }
192- 193232 return moduleExports ;
194233 } ;
195234
@@ -198,6 +237,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
198237 unsubscribe ( 'http.client.response.finish' , onHttpClientResponseFinish ) ;
199238 unsubscribe ( 'http.client.request.error' , onHttpClientRequestError ) ;
200239 unsubscribe ( 'http.client.request.created' , onHttpClientRequestCreated ) ;
240+ unsubscribe ( 'http.client.request.start' , onHttpClientRequestStart ) ;
201241 } ;
202242
203243 /**
@@ -214,6 +254,116 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
214254 ] ;
215255 }
216256
257+ /**
258+ * This is triggered when an outgoing request starts.
259+ * It has access to the request object, and can mutate it before the request is sent.
260+ */
261+ private _onOutgoingRequestStart ( request : http . ClientRequest ) : void {
262+ DEBUG_BUILD && debug . log ( INSTRUMENTATION_NAME , 'Handling started outgoing request' ) ;
263+ 264+ const _spans = this . getConfig ( ) . spans ;
265+ const spansEnabled = typeof _spans === 'undefined' ? true : _spans ;
266+ 267+ const shouldIgnore = this . _ignoreOutgoingRequestsMap . get ( request ) ?? this . _shouldIgnoreOutgoingRequest ( request ) ;
268+ this . _ignoreOutgoingRequestsMap . set ( request , shouldIgnore ) ;
269+ 270+ if ( spansEnabled && ! shouldIgnore ) {
271+ this . _startSpanForOutgoingRequest ( request ) ;
272+ }
273+ }
274+ 275+ /**
276+ * Start a span for an outgoing request.
277+ * The span wraps the callback of the request, and ends when the response is finished.
278+ */
279+ private _startSpanForOutgoingRequest ( request : http . ClientRequest ) : void {
280+ // We monkey-patch `req.once('response'), which is used to trigger the callback of the request
281+ // eslint-disable-next-line @typescript-eslint/unbound-method, deprecation/deprecation
282+ const originalOnce = request . once ;
283+ 284+ const [ name , attributes ] = _getOutgoingRequestSpanData ( request ) ;
285+ 286+ const span = startInactiveSpan ( {
287+ name,
288+ attributes,
289+ onlyIfParent : true ,
290+ } ) ;
291+ 292+ const newOnce = new Proxy ( originalOnce , {
293+ apply ( target , thisArg , args : Parameters < typeof originalOnce > ) {
294+ const [ event ] = args ;
295+ if ( event !== 'response' ) {
296+ return target . apply ( thisArg , args ) ;
297+ }
298+ 299+ const parentContext = context . active ( ) ;
300+ const requestContext = trace . setSpan ( parentContext , span ) ;
301+ 302+ context . with ( requestContext , ( ) => {
303+ return target . apply ( thisArg , args ) ;
304+ } ) ;
305+ } ,
306+ } ) ;
307+ 308+ // eslint-disable-next-line deprecation/deprecation
309+ request . once = newOnce ;
310+ 311+ /**
312+ * Determines if the request has errored or the response has ended/errored.
313+ */
314+ let responseFinished = false ;
315+ 316+ const endSpan = ( status : SpanStatus ) : void => {
317+ if ( responseFinished ) {
318+ return ;
319+ }
320+ responseFinished = true ;
321+ 322+ span . setStatus ( status ) ;
323+ span . end ( ) ;
324+ } ;
325+ 326+ request . prependListener ( 'response' , response => {
327+ if ( request . listenerCount ( 'response' ) <= 1 ) {
328+ response . resume ( ) ;
329+ }
330+ 331+ context . bind ( context . active ( ) , response ) ;
332+ 333+ const additionalAttributes = _getOutgoingRequestEndedSpanData ( response ) ;
334+ span . setAttributes ( additionalAttributes ) ;
335+ 336+ const endHandler = ( forceError : boolean = false ) : void => {
337+ this . _diag . debug ( 'outgoingRequest on end()' ) ;
338+ 339+ const status =
340+ // eslint-disable-next-line deprecation/deprecation
341+ forceError || typeof response . statusCode !== 'number' || ( response . aborted && ! response . complete )
342+ ? { code : SpanStatusCode . ERROR }
343+ : getSpanStatusFromHttpCode ( response . statusCode ) ;
344+ 345+ endSpan ( status ) ;
346+ } ;
347+ 348+ response . on ( 'end' , ( ) => {
349+ endHandler ( ) ;
350+ } ) ;
351+ response . on ( errorMonitor , error => {
352+ this . _diag . debug ( 'outgoingRequest on response error()' , error ) ;
353+ endHandler ( true ) ;
354+ } ) ;
355+ } ) ;
356+ 357+ // Fallback if proper response end handling above fails
358+ request . on ( 'close' , ( ) => {
359+ endSpan ( { code : SpanStatusCode . UNSET } ) ;
360+ } ) ;
361+ request . on ( errorMonitor , error => {
362+ this . _diag . debug ( 'outgoingRequest on request error()' , error ) ;
363+ endSpan ( { code : SpanStatusCode . ERROR } ) ;
364+ } ) ;
365+ }
366+ 217367 /**
218368 * This is triggered when an outgoing request finishes.
219369 * It has access to the final request and response objects.
@@ -661,3 +811,103 @@ const clientToRequestSessionAggregatesMap = new Map<
661811 Client ,
662812 { [ timestampRoundedToSeconds : string ] : { exited : number ; crashed : number ; errored : number } }
663813> ( ) ;
814+ 815+ function _getOutgoingRequestSpanData ( request : http . ClientRequest ) : [ string , SpanAttributes ] {
816+ const url = getRequestUrl ( request ) ;
817+ 818+ const [ name , attributes ] = getHttpSpanDetailsFromUrlObject (
819+ parseStringToURLObject ( url ) ,
820+ 'client' ,
821+ 'auto.http.otel.http' ,
822+ request ,
823+ ) ;
824+ 825+ const userAgent = request . getHeader ( 'user-agent' ) ;
826+ 827+ return [
828+ name ,
829+ {
830+ [ SEMANTIC_ATTRIBUTE_SENTRY_OP ] : 'http.client' ,
831+ 'otel.kind' : 'CLIENT' ,
832+ [ ATTR_USER_AGENT_ORIGINAL ] : userAgent ,
833+ [ ATTR_URL_FULL ] : url ,
834+ 'http.url' : url ,
835+ 'http.method' : request . method ,
836+ 'http.target' : request . path || '/' ,
837+ 'net.peer.name' : request . host ,
838+ 'http.host' : request . getHeader ( 'host' ) ,
839+ ...attributes ,
840+ } ,
841+ ] ;
842+ }
843+ 844+ function getRequestUrl ( request : http . ClientRequest ) : string {
845+ const hostname = request . getHeader ( 'host' ) || request . host ;
846+ const protocol = request . protocol ;
847+ const path = request . path ;
848+ 849+ return `${ protocol } //${ hostname } ${ path } ` ;
850+ }
851+ 852+ function _getOutgoingRequestEndedSpanData ( response : http . IncomingMessage ) : SpanAttributes {
853+ const { statusCode, statusMessage, httpVersion, socket } = response ;
854+ 855+ const transport = httpVersion . toUpperCase ( ) !== 'QUIC' ? 'ip_tcp' : 'ip_udp' ;
856+ 857+ const additionalAttributes : SpanAttributes = {
858+ [ ATTR_HTTP_RESPONSE_STATUS_CODE ] : statusCode ,
859+ [ ATTR_NETWORK_PROTOCOL_VERSION ] : httpVersion ,
860+ 'http.flavor' : httpVersion ,
861+ [ ATTR_NETWORK_TRANSPORT ] : transport ,
862+ 'net.transport' : transport ,
863+ [ 'http.status_text' ] : statusMessage ?. toUpperCase ( ) ,
864+ 'http.status_code' : statusCode ,
865+ ...getResponseContentLengthAttributes ( response ) ,
866+ } ;
867+ 868+ if ( socket ) {
869+ const { remoteAddress, remotePort } = socket ;
870+ 871+ additionalAttributes [ ATTR_NETWORK_PEER_ADDRESS ] = remoteAddress ;
872+ additionalAttributes [ ATTR_NETWORK_PEER_PORT ] = remotePort ;
873+ additionalAttributes [ 'net.peer.ip' ] = remoteAddress ;
874+ additionalAttributes [ 'net.peer.port' ] = remotePort ;
875+ }
876+ 877+ return additionalAttributes ;
878+ }
879+ 880+ function getResponseContentLengthAttributes ( response : http . IncomingMessage ) : SpanAttributes {
881+ const length = getContentLength ( response . headers ) ;
882+ if ( length == null ) {
883+ return { } ;
884+ }
885+ 886+ if ( isCompressed ( response . headers ) ) {
887+ // eslint-disable-next-line deprecation/deprecation
888+ return { [ SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH ] : length } ;
889+ } else {
890+ // eslint-disable-next-line deprecation/deprecation
891+ return { [ SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED ] : length } ;
892+ }
893+ }
894+ 895+ function getContentLength ( headers : http . OutgoingHttpHeaders ) : number | undefined {
896+ const contentLengthHeader = headers [ 'content-length' ] ;
897+ if ( typeof contentLengthHeader !== 'string' ) {
898+ return contentLengthHeader ;
899+ }
900+ 901+ const contentLength = parseInt ( contentLengthHeader , 10 ) ;
902+ if ( isNaN ( contentLength ) ) {
903+ return undefined ;
904+ }
905+ 906+ return contentLength ;
907+ }
908+ 909+ function isCompressed ( headers : OutgoingHttpHeaders | IncomingHttpHeaders ) : boolean {
910+ const encoding = headers [ 'content-encoding' ] ;
911+ 912+ return ! ! encoding && encoding !== 'identity' ;
913+ }
0 commit comments