11import type { ChannelListener } from 'node:diagnostics_channel' ;
22import { subscribe , unsubscribe } from 'node:diagnostics_channel' ;
3+ import { errorMonitor } from 'node:events' ;
34import type * as http from 'node:http' ;
45import type * as https from 'node:https' ;
5- import { context } from '@opentelemetry/api' ;
6+ import { context , SpanStatusCode , trace } from '@opentelemetry/api' ;
67import { isTracingSuppressed } from '@opentelemetry/core' ;
78import type { InstrumentationConfig } from '@opentelemetry/instrumentation' ;
89import { InstrumentationBase , InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation' ;
9- import type { Span } from '@sentry/core' ;
10- import { debug , LRUMap , SDK_VERSION } from '@sentry/core' ;
10+ import {
11+ ATTR_HTTP_RESPONSE_STATUS_CODE ,
12+ ATTR_NETWORK_PEER_ADDRESS ,
13+ ATTR_NETWORK_PEER_PORT ,
14+ ATTR_NETWORK_PROTOCOL_VERSION ,
15+ ATTR_NETWORK_TRANSPORT ,
16+ ATTR_URL_FULL ,
17+ ATTR_USER_AGENT_ORIGINAL ,
18+ SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH ,
19+ SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED ,
20+ } from '@opentelemetry/semantic-conventions' ;
21+ import type { SpanAttributes , SpanStatus } from '@sentry/core' ;
22+ import {
23+ debug ,
24+ getHttpSpanDetailsFromUrlObject ,
25+ getSpanStatusFromHttpCode ,
26+ LRUMap ,
27+ parseStringToURLObject ,
28+ SDK_VERSION ,
29+ SEMANTIC_ATTRIBUTE_SENTRY_OP ,
30+ startInactiveSpan ,
31+ } from '@sentry/core' ;
1132import { DEBUG_BUILD } from '../../debug-build' ;
12- import { getRequestUrl } from '../../utils/getRequestUrl' ;
1333import { INSTRUMENTATION_NAME } from './constants' ;
1434import {
1535 addRequestBreadcrumb ,
@@ -19,6 +39,8 @@ import {
1939
2040type Http = typeof http ;
2141type Https = typeof https ;
42+ type IncomingHttpHeaders = http . IncomingHttpHeaders ;
43+ type OutgoingHttpHeaders = http . OutgoingHttpHeaders ;
2244
2345export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
2446 /**
@@ -28,6 +50,13 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
2850 */
2951 breadcrumbs ?: boolean ;
3052
53+ /**
54+ * Whether to create spans for outgoing requests.
55+ *
56+ * @default `true`
57+ */
58+ spans ?: boolean ;
59+
3160 /**
3261 * Whether to propagate Sentry trace headers in outgoing requests.
3362 * 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 & {
3766 */
3867 propagateTraceInOutgoingRequests ?: boolean ;
3968
69+ /**
70+ * If spans for outgoing requests should be created.
71+ *
72+ * @default `false``
73+ */
74+ createSpansForOutgoingRequests ?: boolean ;
75+
4076 /**
4177 * Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`.
4278 * For the scope of this instrumentation, this callback only controls breadcrumb creation.
@@ -51,11 +87,6 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
5187 // All options below do not do anything anymore in this instrumentation, and will be removed in the future.
5288 // They are only kept here for backwards compatibility - the respective functionality is now handled by the httpServerIntegration/httpServerSpansIntegration.
5389
54- /**
55- * @deprecated This no longer does anything.
56- */
57- spans ?: boolean ;
58-
5990 /**
6091 * @depreacted This no longer does anything.
6192 */
@@ -155,6 +186,11 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
155186 this . _onOutgoingRequestCreated ( data . request ) ;
156187 } ) satisfies ChannelListener ;
157188
189+ const onHttpClientRequestStart = ( ( _data : unknown ) => {
190+ const data = _data as { request : http . ClientRequest } ;
191+ this . _onOutgoingRequestStart ( data . request ) ;
192+ } ) satisfies ChannelListener ;
193+
158194 const wrap = < T extends Http | Https > ( moduleExports : T ) : T => {
159195 if ( hasRegisteredHandlers ) {
160196 return moduleExports ;
@@ -168,20 +204,23 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
168204 // In this case, `http.client.response.finish` is not triggered
169205 subscribe ( 'http.client.request.error' , onHttpClientRequestError ) ;
170206
207+ if ( this . getConfig ( ) . createSpansForOutgoingRequests ) {
208+ subscribe ( 'http.client.request.start' , onHttpClientRequestStart ) ;
209+ }
171210 // NOTE: This channel only exist since Node 22
172211 // Before that, outgoing requests are not patched
173212 // and trace headers are not propagated, sadly.
174213 if ( this . getConfig ( ) . propagateTraceInOutgoingRequests ) {
175214 subscribe ( 'http.client.request.created' , onHttpClientRequestCreated ) ;
176215 }
177-
178216 return moduleExports ;
179217 } ;
180218
181219 const unwrap = ( ) : void => {
182220 unsubscribe ( 'http.client.response.finish' , onHttpClientResponseFinish ) ;
183221 unsubscribe ( 'http.client.request.error' , onHttpClientRequestError ) ;
184222 unsubscribe ( 'http.client.request.created' , onHttpClientRequestCreated ) ;
223+ unsubscribe ( 'http.client.request.start' , onHttpClientRequestStart ) ;
185224 } ;
186225
187226 /**
@@ -198,6 +237,116 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
198237 ] ;
199238 }
200239
240+ /**
241+ * This is triggered when an outgoing request starts.
242+ * It has access to the request object, and can mutate it before the request is sent.
243+ */
244+ private _onOutgoingRequestStart ( request : http . ClientRequest ) : void {
245+ DEBUG_BUILD && debug . log ( INSTRUMENTATION_NAME , 'Handling started outgoing request' ) ;
246+
247+ const _spans = this . getConfig ( ) . spans ;
248+ const spansEnabled = typeof _spans === 'undefined' ? true : _spans ;
249+
250+ const shouldIgnore = this . _ignoreOutgoingRequestsMap . get ( request ) ?? this . _shouldIgnoreOutgoingRequest ( request ) ;
251+ this . _ignoreOutgoingRequestsMap . set ( request , shouldIgnore ) ;
252+
253+ if ( spansEnabled && ! shouldIgnore ) {
254+ this . _startSpanForOutgoingRequest ( request ) ;
255+ }
256+ }
257+
258+ /**
259+ * Start a span for an outgoing request.
260+ * The span wraps the callback of the request, and ends when the response is finished.
261+ */
262+ private _startSpanForOutgoingRequest ( request : http . ClientRequest ) : void {
263+ // We monkey-patch `req.once('response'), which is used to trigger the callback of the request
264+ // eslint-disable-next-line @typescript-eslint/unbound-method, deprecation/deprecation
265+ const originalOnce = request . once ;
266+
267+ const [ name , attributes ] = _getOutgoingRequestSpanData ( request ) ;
268+
269+ const span = startInactiveSpan ( {
270+ name,
271+ attributes,
272+ onlyIfParent : true ,
273+ } ) ;
274+
275+ const newOnce = new Proxy ( originalOnce , {
276+ apply ( target , thisArg , args : Parameters < typeof originalOnce > ) {
277+ const [ event ] = args ;
278+ if ( event !== 'response' ) {
279+ return target . apply ( thisArg , args ) ;
280+ }
281+
282+ const parentContext = context . active ( ) ;
283+ const requestContext = trace . setSpan ( parentContext , span ) ;
284+
285+ context . with ( requestContext , ( ) => {
286+ return target . apply ( thisArg , args ) ;
287+ } ) ;
288+ } ,
289+ } ) ;
290+
291+ // eslint-disable-next-line deprecation/deprecation
292+ request . once = newOnce ;
293+
294+ /**
295+ * Determines if the request has errored or the response has ended/errored.
296+ */
297+ let responseFinished = false ;
298+
299+ const endSpan = ( status : SpanStatus ) : void => {
300+ if ( responseFinished ) {
301+ return ;
302+ }
303+ responseFinished = true ;
304+
305+ span . setStatus ( status ) ;
306+ span . end ( ) ;
307+ } ;
308+
309+ request . prependListener ( 'response' , response => {
310+ if ( request . listenerCount ( 'response' ) <= 1 ) {
311+ response . resume ( ) ;
312+ }
313+
314+ context . bind ( context . active ( ) , response ) ;
315+
316+ const additionalAttributes = _getOutgoingRequestEndedSpanData ( response ) ;
317+ span . setAttributes ( additionalAttributes ) ;
318+
319+ const endHandler = ( forceError : boolean = false ) : void => {
320+ this . _diag . debug ( 'outgoingRequest on end()' ) ;
321+
322+ const status =
323+ // eslint-disable-next-line deprecation/deprecation
324+ forceError || typeof response . statusCode !== 'number' || ( response . aborted && ! response . complete )
325+ ? { code : SpanStatusCode . ERROR }
326+ : getSpanStatusFromHttpCode ( response . statusCode ) ;
327+
328+ endSpan ( status ) ;
329+ } ;
330+
331+ response . on ( 'end' , ( ) => {
332+ endHandler ( ) ;
333+ } ) ;
334+ response . on ( errorMonitor , error => {
335+ this . _diag . debug ( 'outgoingRequest on response error()' , error ) ;
336+ endHandler ( true ) ;
337+ } ) ;
338+ } ) ;
339+
340+ // Fallback if proper response end handling above fails
341+ request . on ( 'close' , ( ) => {
342+ endSpan ( { code : SpanStatusCode . UNSET } ) ;
343+ } ) ;
344+ request . on ( errorMonitor , error => {
345+ this . _diag . debug ( 'outgoingRequest on request error()' , error ) ;
346+ endSpan ( { code : SpanStatusCode . ERROR } ) ;
347+ } ) ;
348+ }
349+
201350 /**
202351 * This is triggered when an outgoing request finishes.
203352 * It has access to the final request and response objects.
@@ -251,3 +400,103 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
251400 return ignoreOutgoingRequests ( url , options ) ;
252401 }
253402}
403+
404+ function _getOutgoingRequestSpanData ( request : http . ClientRequest ) : [ string , SpanAttributes ] {
405+ const url = getRequestUrl ( request ) ;
406+
407+ const [ name , attributes ] = getHttpSpanDetailsFromUrlObject (
408+ parseStringToURLObject ( url ) ,
409+ 'client' ,
410+ 'auto.http.otel.http' ,
411+ request ,
412+ ) ;
413+
414+ const userAgent = request . getHeader ( 'user-agent' ) ;
415+
416+ return [
417+ name ,
418+ {
419+ [ SEMANTIC_ATTRIBUTE_SENTRY_OP ] : 'http.client' ,
420+ 'otel.kind' : 'CLIENT' ,
421+ [ ATTR_USER_AGENT_ORIGINAL ] : userAgent ,
422+ [ ATTR_URL_FULL ] : url ,
423+ 'http.url' : url ,
424+ 'http.method' : request . method ,
425+ 'http.target' : request . path || '/' ,
426+ 'net.peer.name' : request . host ,
427+ 'http.host' : request . getHeader ( 'host' ) ,
428+ ...attributes ,
429+ } ,
430+ ] ;
431+ }
432+
433+ function getRequestUrl ( request : http . ClientRequest ) : string {
434+ const hostname = request . getHeader ( 'host' ) || request . host ;
435+ const protocol = request . protocol ;
436+ const path = request . path ;
437+
438+ return `${ protocol } //${ hostname } ${ path } ` ;
439+ }
440+
441+ function _getOutgoingRequestEndedSpanData ( response : http . IncomingMessage ) : SpanAttributes {
442+ const { statusCode, statusMessage, httpVersion, socket } = response ;
443+
444+ const transport = httpVersion . toUpperCase ( ) !== 'QUIC' ? 'ip_tcp' : 'ip_udp' ;
445+
446+ const additionalAttributes : SpanAttributes = {
447+ [ ATTR_HTTP_RESPONSE_STATUS_CODE ] : statusCode ,
448+ [ ATTR_NETWORK_PROTOCOL_VERSION ] : httpVersion ,
449+ 'http.flavor' : httpVersion ,
450+ [ ATTR_NETWORK_TRANSPORT ] : transport ,
451+ 'net.transport' : transport ,
452+ [ 'http.status_text' ] : statusMessage ?. toUpperCase ( ) ,
453+ 'http.status_code' : statusCode ,
454+ ...getResponseContentLengthAttributes ( response ) ,
455+ } ;
456+
457+ if ( socket ) {
458+ const { remoteAddress, remotePort } = socket ;
459+
460+ additionalAttributes [ ATTR_NETWORK_PEER_ADDRESS ] = remoteAddress ;
461+ additionalAttributes [ ATTR_NETWORK_PEER_PORT ] = remotePort ;
462+ additionalAttributes [ 'net.peer.ip' ] = remoteAddress ;
463+ additionalAttributes [ 'net.peer.port' ] = remotePort ;
464+ }
465+
466+ return additionalAttributes ;
467+ }
468+
469+ function getResponseContentLengthAttributes ( response : http . IncomingMessage ) : SpanAttributes {
470+ const length = getContentLength ( response . headers ) ;
471+ if ( length == null ) {
472+ return { } ;
473+ }
474+
475+ if ( isCompressed ( response . headers ) ) {
476+ // eslint-disable-next-line deprecation/deprecation
477+ return { [ SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH ] : length } ;
478+ } else {
479+ // eslint-disable-next-line deprecation/deprecation
480+ return { [ SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED ] : length } ;
481+ }
482+ }
483+
484+ function getContentLength ( headers : http . OutgoingHttpHeaders ) : number | undefined {
485+ const contentLengthHeader = headers [ 'content-length' ] ;
486+ if ( typeof contentLengthHeader !== 'string' ) {
487+ return contentLengthHeader ;
488+ }
489+
490+ const contentLength = parseInt ( contentLengthHeader , 10 ) ;
491+ if ( isNaN ( contentLength ) ) {
492+ return undefined ;
493+ }
494+
495+ return contentLength ;
496+ }
497+
498+ function isCompressed ( headers : OutgoingHttpHeaders | IncomingHttpHeaders ) : boolean {
499+ const encoding = headers [ 'content-encoding' ] ;
500+
501+ return ! ! encoding && encoding !== 'identity' ;
502+ }
0 commit comments