1
1
/* eslint-disable max-lines */
2
2
import type { ChannelListener } from 'node:diagnostics_channel' ;
3
3
import { subscribe , unsubscribe } from 'node:diagnostics_channel' ;
4
+ import { errorMonitor } from 'node:events' ;
4
5
import type * as http from 'node:http' ;
5
6
import type * as https from 'node:https' ;
6
7
import type { EventEmitter } from 'node:stream' ;
7
- import { context , propagation } from '@opentelemetry/api' ;
8
+ import { context , propagation , SpanStatusCode , trace } from '@opentelemetry/api' ;
8
9
import { isTracingSuppressed } from '@opentelemetry/core' ;
9
10
import type { InstrumentationConfig } from '@opentelemetry/instrumentation' ;
10
11
import { 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' ;
12
24
import {
13
25
addBreadcrumb ,
14
26
addNonEnumerableProperty ,
@@ -17,26 +29,32 @@ import {
17
29
getBreadcrumbLogLevelFromHttpStatusCode ,
18
30
getClient ,
19
31
getCurrentScope ,
32
+ getHttpSpanDetailsFromUrlObject ,
20
33
getIsolationScope ,
21
34
getSanitizedUrlString ,
35
+ getSpanStatusFromHttpCode ,
22
36
getTraceData ,
23
37
httpRequestToRequestData ,
24
38
isError ,
25
39
LRUMap ,
40
+ parseStringToURLObject ,
26
41
parseUrl ,
27
42
SDK_VERSION ,
43
+ SEMANTIC_ATTRIBUTE_SENTRY_OP ,
44
+ startInactiveSpan ,
28
45
stripUrlQueryAndFragment ,
29
46
withIsolationScope ,
30
47
} from '@sentry/core' ;
31
48
import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry' ;
32
49
import { DEBUG_BUILD } from '../../debug-build' ;
33
50
import { mergeBaggageHeaders } from '../../utils/baggage' ;
34
- import { getRequestUrl } from '../../utils/getRequestUrl' ;
35
51
36
52
const INSTRUMENTATION_NAME = '@sentry/instrumentation-http' ;
37
53
38
54
type Http = typeof http ;
39
55
type Https = typeof https ;
56
+ type IncomingHttpHeaders = http . IncomingHttpHeaders ;
57
+ type OutgoingHttpHeaders = http . OutgoingHttpHeaders ;
40
58
41
59
export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
42
60
/**
@@ -46,6 +64,13 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
46
64
*/
47
65
breadcrumbs ?: boolean ;
48
66
67
+ /**
68
+ * Whether to create spans for outgoing requests.
69
+ *
70
+ * @default `true`
71
+ */
72
+ spans ?: boolean ;
73
+
49
74
/**
50
75
* Whether to extract the trace ID from the `sentry-trace` header for incoming requests.
51
76
* 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 & {
64
89
*/
65
90
propagateTraceInOutgoingRequests ?: boolean ;
66
91
92
+ /**
93
+ * If spans for outgoing requests should be created.
94
+ *
95
+ * @default `false``
96
+ */
97
+ createSpansForOutgoingRequests ?: boolean ;
98
+
67
99
/**
68
100
* Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`.
69
101
* For the scope of this instrumentation, this callback only controls breadcrumb creation.
@@ -169,6 +201,11 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
169
201
this . _onOutgoingRequestCreated ( data . request ) ;
170
202
} ) satisfies ChannelListener ;
171
203
204
+ const onHttpClientRequestStart = ( ( _data : unknown ) => {
205
+ const data = _data as { request : http . ClientRequest } ;
206
+ this . _onOutgoingRequestStart ( data . request ) ;
207
+ } ) satisfies ChannelListener ;
208
+
172
209
const wrap = < T extends Http | Https > ( moduleExports : T ) : T => {
173
210
if ( hasRegisteredHandlers ) {
174
211
return moduleExports ;
@@ -183,13 +220,15 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
183
220
// In this case, `http.client.response.finish` is not triggered
184
221
subscribe ( 'http.client.request.error' , onHttpClientRequestError ) ;
185
222
223
+ if ( this . getConfig ( ) . createSpansForOutgoingRequests ) {
224
+ subscribe ( 'http.client.request.start' , onHttpClientRequestStart ) ;
225
+ }
186
226
// NOTE: This channel only exist since Node 22
187
227
// Before that, outgoing requests are not patched
188
228
// and trace headers are not propagated, sadly.
189
229
if ( this . getConfig ( ) . propagateTraceInOutgoingRequests ) {
190
230
subscribe ( 'http.client.request.created' , onHttpClientRequestCreated ) ;
191
231
}
192
-
193
232
return moduleExports ;
194
233
} ;
195
234
@@ -198,6 +237,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
198
237
unsubscribe ( 'http.client.response.finish' , onHttpClientResponseFinish ) ;
199
238
unsubscribe ( 'http.client.request.error' , onHttpClientRequestError ) ;
200
239
unsubscribe ( 'http.client.request.created' , onHttpClientRequestCreated ) ;
240
+ unsubscribe ( 'http.client.request.start' , onHttpClientRequestStart ) ;
201
241
} ;
202
242
203
243
/**
@@ -214,6 +254,116 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
214
254
] ;
215
255
}
216
256
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
+
217
367
/**
218
368
* This is triggered when an outgoing request finishes.
219
369
* It has access to the final request and response objects.
@@ -661,3 +811,103 @@ const clientToRequestSessionAggregatesMap = new Map<
661
811
Client ,
662
812
{ [ timestampRoundedToSeconds : string ] : { exited : number ; crashed : number ; errored : number } }
663
813
> ( ) ;
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