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, ...)
@@ -169,6 +194,11 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
169
194
this . _onOutgoingRequestCreated ( data . request ) ;
170
195
} ) satisfies ChannelListener ;
171
196
197
+ const onHttpClientRequestStart = ( ( _data : unknown ) => {
198
+ const data = _data as { request : http . ClientRequest } ;
199
+ this . _onOutgoingRequestStart ( data . request ) ;
200
+ } ) satisfies ChannelListener ;
201
+
172
202
const wrap = < T extends Http | Https > ( moduleExports : T ) : T => {
173
203
if ( hasRegisteredHandlers ) {
174
204
return moduleExports ;
@@ -187,7 +217,10 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
187
217
// Before that, outgoing requests are not patched
188
218
// and trace headers are not propagated, sadly.
189
219
if ( this . getConfig ( ) . propagateTraceInOutgoingRequests ) {
220
+ subscribe ( 'http.client.request.start' , onHttpClientRequestStart ) ;
190
221
subscribe ( 'http.client.request.created' , onHttpClientRequestCreated ) ;
222
+ } else {
223
+ // TODO: monkey patch this on older node versions :sad:
191
224
}
192
225
193
226
return moduleExports ;
@@ -198,6 +231,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
198
231
unsubscribe ( 'http.client.response.finish' , onHttpClientResponseFinish ) ;
199
232
unsubscribe ( 'http.client.request.error' , onHttpClientRequestError ) ;
200
233
unsubscribe ( 'http.client.request.created' , onHttpClientRequestCreated ) ;
234
+ unsubscribe ( 'http.client.request.start' , onHttpClientRequestStart ) ;
201
235
} ;
202
236
203
237
/**
@@ -214,6 +248,116 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
214
248
] ;
215
249
}
216
250
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
+
217
361
/**
218
362
* This is triggered when an outgoing request finishes.
219
363
* It has access to the final request and response objects.
@@ -661,3 +805,103 @@ const clientToRequestSessionAggregatesMap = new Map<
661
805
Client ,
662
806
{ [ timestampRoundedToSeconds : string ] : { exited : number ; crashed : number ; errored : number } }
663
807
> ( ) ;
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