Skip to content

Commit 67942ee

Browse files
committed
WIP WIP
1 parent 08fb932 commit 67942ee

File tree

2 files changed

+254
-8
lines changed

2 files changed

+254
-8
lines changed

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

Lines changed: 247 additions & 3 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, ...)
@@ -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+
}

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

Lines changed: 7 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,8 @@ 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,
206206
});
207207

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

259259
disableIncomingRequestInstrumentation: options.disableIncomingRequestSpans,
260+
// This is handled by the SentryHttpInstrumentation on Node 22+
261+
disableOutgoingRequestInstrumentation: !FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL,
260262

261263
ignoreOutgoingRequestHook: request => {
262264
const url = getRequestUrl(request);

0 commit comments

Comments
 (0)