Skip to content

Commit b8b33f4

Browse files
committed
WIP WIP
1 parent 08fb932 commit b8b33f4

File tree

3 files changed

+263
-9
lines changed

3 files changed

+263
-9
lines changed

packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts

Lines changed: 254 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
/* eslint-disable max-lines */
22
import type { ChannelListener } from 'node:diagnostics_channel';
33
import { subscribe, unsubscribe } from 'node:diagnostics_channel';
4+
import { errorMonitor } from 'node:events';
45
import type * as http from 'node:http';
56
import type * as https from 'node:https';
67
import type { EventEmitter } from 'node:stream';
7-
import { context, propagation } from '@opentelemetry/api';
8+
import { context, propagation, SpanStatusCode, trace } from '@opentelemetry/api';
89
import { isTracingSuppressed } from '@opentelemetry/core';
910
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
1011
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';
1224
import {
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';
3148
import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry';
3249
import { DEBUG_BUILD } from '../../debug-build';
3350
import { mergeBaggageHeaders } from '../../utils/baggage';
34-
import { getRequestUrl } from '../../utils/getRequestUrl';
3551

3652
const INSTRUMENTATION_NAME = '@sentry/instrumentation-http';
3753

3854
type Http = typeof http;
3955
type Https = typeof https;
56+
type IncomingHttpHeaders = http.IncomingHttpHeaders;
57+
type OutgoingHttpHeaders = http.OutgoingHttpHeaders;
4058

4159
export 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+
}

packages/node-core/src/integrations/http/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) =>
117117
...options,
118118
extractIncomingTraceFromHeader: true,
119119
propagateTraceInOutgoingRequests: true,
120+
createSpansForOutgoingRequests: true,
120121
});
121122
},
122123
processEvent(event) {

packages/node/src/integrations/http/index.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import type { HttpInstrumentationConfig } from '@opentelemetry/instrumentation-h
44
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
55
import type { Span } from '@sentry/core';
66
import { defineIntegration, getClient, hasSpansEnabled } from '@sentry/core';
7-
import type { HTTPModuleRequestIncomingMessage, NodeClient } from '@sentry/node-core';
7+
import type { HTTPModuleRequestIncomingMessage, NodeClient, SentryHttpInstrumentationOptions } from '@sentry/node-core';
88
import {
9-
type SentryHttpInstrumentationOptions,
109
addOriginToSpan,
1110
generateInstrumentOnce,
1211
getRequestUrl,
@@ -19,6 +18,8 @@ const INTEGRATION_NAME = 'Http';
1918

2019
const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http';
2120

21+
const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = NODE_VERSION.major >= 22;
22+
2223
interface HttpOptions {
2324
/**
2425
* Whether breadcrumbs should be recorded for outgoing requests.
@@ -200,9 +201,9 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) =>
200201
// If spans are not instrumented, it means the HttpInstrumentation has not been added
201202
// In that case, we want to handle incoming trace extraction ourselves
202203
extractIncomingTraceFromHeader: !instrumentSpans,
203-
// If spans are not instrumented, it means the HttpInstrumentation has not been added
204-
// In that case, we want to handle trace propagation ourselves
205-
propagateTraceInOutgoingRequests: !instrumentSpans,
204+
// on older versions, this is handled by the Otel instrumentation
205+
propagateTraceInOutgoingRequests: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL,
206+
createSpansForOutgoingRequests: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL,
206207
});
207208

208209
// This is the "regular" OTEL instrumentation that emits spans
@@ -257,6 +258,8 @@ function getConfigWithDefaults(options: Partial<HttpOptions> = {}): HttpInstrume
257258
...options.instrumentation?._experimentalConfig,
258259

259260
disableIncomingRequestInstrumentation: options.disableIncomingRequestSpans,
261+
// This is handled by the SentryHttpInstrumentation on Node 22+
262+
disableOutgoingRequestInstrumentation: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL,
260263

261264
ignoreOutgoingRequestHook: request => {
262265
const url = getRequestUrl(request);

0 commit comments

Comments
 (0)