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, ...)
@@ -169,6 +194,11 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
169194 this . _onOutgoingRequestCreated ( data . request ) ;
170195 } ) satisfies ChannelListener ;
171196
197+ const onHttpClientRequestStart = ( ( _data : unknown ) => {
198+ const data = _data as { request : http . ClientRequest } ;
199+ this . _onOutgoingRequestStart ( data . request ) ;
200+ } ) satisfies ChannelListener ;
201+
172202 const wrap = < T extends Http | Https > ( moduleExports : T ) : T => {
173203 if ( hasRegisteredHandlers ) {
174204 return moduleExports ;
@@ -187,7 +217,10 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
187217 // Before that, outgoing requests are not patched
188218 // and trace headers are not propagated, sadly.
189219 if ( this . getConfig ( ) . propagateTraceInOutgoingRequests ) {
220+ subscribe ( 'http.client.request.start' , onHttpClientRequestStart ) ;
190221 subscribe ( 'http.client.request.created' , onHttpClientRequestCreated ) ;
222+ } else {
223+ // TODO: monkey patch this on older node versions :sad:
191224 }
192225
193226 return moduleExports ;
@@ -198,6 +231,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
198231 unsubscribe ( 'http.client.response.finish' , onHttpClientResponseFinish ) ;
199232 unsubscribe ( 'http.client.request.error' , onHttpClientRequestError ) ;
200233 unsubscribe ( 'http.client.request.created' , onHttpClientRequestCreated ) ;
234+ unsubscribe ( 'http.client.request.start' , onHttpClientRequestStart ) ;
201235 } ;
202236
203237 /**
@@ -214,6 +248,116 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
214248 ] ;
215249 }
216250
251+ /**
252+ * This is triggered when an outgoing request starts.
253+ * It has access to the request object, and can mutate it before the request is sent.
254+ */
255+ private _onOutgoingRequestStart ( request : http . ClientRequest ) : void {
256+ DEBUG_BUILD && debug . log ( INSTRUMENTATION_NAME , 'Handling started outgoing request' ) ;
257+
258+ const _spans = this . getConfig ( ) . spans ;
259+ const spansEnabled = typeof _spans === 'undefined' ? true : _spans ;
260+
261+ const shouldIgnore = this . _ignoreOutgoingRequestsMap . get ( request ) ?? this . _shouldIgnoreOutgoingRequest ( request ) ;
262+ this . _ignoreOutgoingRequestsMap . set ( request , shouldIgnore ) ;
263+
264+ if ( spansEnabled && ! shouldIgnore ) {
265+ this . _startSpanForOutgoingRequest ( request ) ;
266+ }
267+ }
268+
269+ /**
270+ * Start a span for an outgoing request.
271+ * The span wraps the callback of the request, and ends when the response is finished.
272+ */
273+ private _startSpanForOutgoingRequest ( request : http . ClientRequest ) : void {
274+ // We monkey-patch `req.once('response'), which is used to trigger the callback of the request
275+ // eslint-disable-next-line @typescript-eslint/unbound-method, deprecation/deprecation
276+ const originalOnce = request . once ;
277+
278+ const [ name , attributes ] = _getOutgoingRequestSpanData ( request ) ;
279+
280+ const span = startInactiveSpan ( {
281+ name,
282+ attributes,
283+ onlyIfParent : true ,
284+ } ) ;
285+
286+ const newOnce = new Proxy ( originalOnce , {
287+ apply ( target , thisArg , args : Parameters < typeof originalOnce > ) {
288+ const [ event ] = args ;
289+ if ( event !== 'response' ) {
290+ return target . apply ( thisArg , args ) ;
291+ }
292+
293+ const parentContext = context . active ( ) ;
294+ const requestContext = trace . setSpan ( parentContext , span ) ;
295+
296+ context . with ( requestContext , ( ) => {
297+ return target . apply ( thisArg , args ) ;
298+ } ) ;
299+ } ,
300+ } ) ;
301+
302+ // eslint-disable-next-line deprecation/deprecation
303+ request . once = newOnce ;
304+
305+ /**
306+ * Determines if the request has errored or the response has ended/errored.
307+ */
308+ let responseFinished = false ;
309+
310+ const endSpan = ( status : SpanStatus ) : void => {
311+ if ( responseFinished ) {
312+ return ;
313+ }
314+ responseFinished = true ;
315+
316+ span . setStatus ( status ) ;
317+ span . end ( ) ;
318+ } ;
319+
320+ request . prependListener ( 'response' , response => {
321+ if ( request . listenerCount ( 'response' ) <= 1 ) {
322+ response . resume ( ) ;
323+ }
324+
325+ context . bind ( context . active ( ) , response ) ;
326+
327+ const additionalAttributes = _getOutgoingRequestEndedSpanData ( response ) ;
328+ span . setAttributes ( additionalAttributes ) ;
329+
330+ const endHandler = ( forceError : boolean = false ) : void => {
331+ this . _diag . debug ( 'outgoingRequest on end()' ) ;
332+
333+ const status =
334+ // eslint-disable-next-line deprecation/deprecation
335+ forceError || typeof response . statusCode !== 'number' || ( response . aborted && ! response . complete )
336+ ? { code : SpanStatusCode . ERROR }
337+ : getSpanStatusFromHttpCode ( response . statusCode ) ;
338+
339+ endSpan ( status ) ;
340+ } ;
341+
342+ response . on ( 'end' , ( ) => {
343+ endHandler ( ) ;
344+ } ) ;
345+ response . on ( errorMonitor , error => {
346+ this . _diag . debug ( 'outgoingRequest on response error()' , error ) ;
347+ endHandler ( true ) ;
348+ } ) ;
349+ } ) ;
350+
351+ // Fallback if proper response end handling above fails
352+ request . on ( 'close' , ( ) => {
353+ endSpan ( { code : SpanStatusCode . UNSET } ) ;
354+ } ) ;
355+ request . on ( errorMonitor , error => {
356+ this . _diag . debug ( 'outgoingRequest on request error()' , error ) ;
357+ endSpan ( { code : SpanStatusCode . ERROR } ) ;
358+ } ) ;
359+ }
360+
217361 /**
218362 * This is triggered when an outgoing request finishes.
219363 * It has access to the final request and response objects.
@@ -661,3 +805,103 @@ const clientToRequestSessionAggregatesMap = new Map<
661805 Client ,
662806 { [ timestampRoundedToSeconds : string ] : { exited : number ; crashed : number ; errored : number } }
663807> ( ) ;
808+
809+ function _getOutgoingRequestSpanData ( request : http . ClientRequest ) : [ string , SpanAttributes ] {
810+ const url = getRequestUrl ( request ) ;
811+
812+ const [ name , attributes ] = getHttpSpanDetailsFromUrlObject (
813+ parseStringToURLObject ( url ) ,
814+ 'client' ,
815+ 'auto.http.otel.http' ,
816+ request ,
817+ ) ;
818+
819+ const userAgent = request . getHeader ( 'user-agent' ) ;
820+
821+ return [
822+ name ,
823+ {
824+ [ SEMANTIC_ATTRIBUTE_SENTRY_OP ] : 'http.client' ,
825+ 'otel.kind' : 'CLIENT' ,
826+ [ ATTR_USER_AGENT_ORIGINAL ] : userAgent ,
827+ [ ATTR_URL_FULL ] : url ,
828+ 'http.url' : url ,
829+ 'http.method' : request . method ,
830+ 'http.target' : request . path || '/' ,
831+ 'net.peer.name' : request . host ,
832+ 'http.host' : request . getHeader ( 'host' ) ,
833+ ...attributes ,
834+ } ,
835+ ] ;
836+ }
837+
838+ function getRequestUrl ( request : http . ClientRequest ) : string {
839+ const hostname = request . getHeader ( 'host' ) || request . host ;
840+ const protocol = request . protocol ;
841+ const path = request . path ;
842+
843+ return `${ protocol } //${ hostname } ${ path } ` ;
844+ }
845+
846+ function _getOutgoingRequestEndedSpanData ( response : http . IncomingMessage ) : SpanAttributes {
847+ const { statusCode, statusMessage, httpVersion, socket } = response ;
848+
849+ const transport = httpVersion . toUpperCase ( ) !== 'QUIC' ? 'ip_tcp' : 'ip_udp' ;
850+
851+ const additionalAttributes : SpanAttributes = {
852+ [ ATTR_HTTP_RESPONSE_STATUS_CODE ] : statusCode ,
853+ [ ATTR_NETWORK_PROTOCOL_VERSION ] : httpVersion ,
854+ 'http.flavor' : httpVersion ,
855+ [ ATTR_NETWORK_TRANSPORT ] : transport ,
856+ 'net.transport' : transport ,
857+ [ 'http.status_text' ] : statusMessage ?. toUpperCase ( ) ,
858+ 'http.status_code' : statusCode ,
859+ ...getResponseContentLengthAttributes ( response ) ,
860+ } ;
861+
862+ if ( socket ) {
863+ const { remoteAddress, remotePort } = socket ;
864+
865+ additionalAttributes [ ATTR_NETWORK_PEER_ADDRESS ] = remoteAddress ;
866+ additionalAttributes [ ATTR_NETWORK_PEER_PORT ] = remotePort ;
867+ additionalAttributes [ 'net.peer.ip' ] = remoteAddress ;
868+ additionalAttributes [ 'net.peer.port' ] = remotePort ;
869+ }
870+
871+ return additionalAttributes ;
872+ }
873+
874+ function getResponseContentLengthAttributes ( response : http . IncomingMessage ) : SpanAttributes {
875+ const length = getContentLength ( response . headers ) ;
876+ if ( length == null ) {
877+ return { } ;
878+ }
879+
880+ if ( isCompressed ( response . headers ) ) {
881+ // eslint-disable-next-line deprecation/deprecation
882+ return { [ SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH ] : length } ;
883+ } else {
884+ // eslint-disable-next-line deprecation/deprecation
885+ return { [ SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED ] : length } ;
886+ }
887+ }
888+
889+ function getContentLength ( headers : http . OutgoingHttpHeaders ) : number | undefined {
890+ const contentLengthHeader = headers [ 'content-length' ] ;
891+ if ( typeof contentLengthHeader !== 'string' ) {
892+ return contentLengthHeader ;
893+ }
894+
895+ const contentLength = parseInt ( contentLengthHeader , 10 ) ;
896+ if ( isNaN ( contentLength ) ) {
897+ return undefined ;
898+ }
899+
900+ return contentLength ;
901+ }
902+
903+ function isCompressed ( headers : OutgoingHttpHeaders | IncomingHttpHeaders ) : boolean {
904+ const encoding = headers [ 'content-encoding' ] ;
905+
906+ return ! ! encoding && encoding !== 'identity' ;
907+ }
0 commit comments