Skip to content

Commit a66e597

Browse files
mydeaandreiborza
authored andcommitted
WIP WIP
1 parent 2e7a29e commit a66e597

2 files changed

Lines changed: 267 additions & 11 deletions

File tree

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

Lines changed: 259 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,35 @@
11
import type { ChannelListener } from 'node:diagnostics_channel';
22
import { subscribe, unsubscribe } from 'node:diagnostics_channel';
3+
import { errorMonitor } from 'node:events';
34
import type * as http from 'node:http';
45
import type * as https from 'node:https';
5-
import { context } from '@opentelemetry/api';
6+
import { context, SpanStatusCode, trace } from '@opentelemetry/api';
67
import { isTracingSuppressed } from '@opentelemetry/core';
78
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
89
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
9-
import type { Span } from '@sentry/core';
10-
import { debug, LRUMap, SDK_VERSION } from '@sentry/core';
10+
import {
11+
ATTR_HTTP_RESPONSE_STATUS_CODE,
12+
ATTR_NETWORK_PEER_ADDRESS,
13+
ATTR_NETWORK_PEER_PORT,
14+
ATTR_NETWORK_PROTOCOL_VERSION,
15+
ATTR_NETWORK_TRANSPORT,
16+
ATTR_URL_FULL,
17+
ATTR_USER_AGENT_ORIGINAL,
18+
SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH,
19+
SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED,
20+
} from '@opentelemetry/semantic-conventions';
21+
import type { SpanAttributes, SpanStatus } from '@sentry/core';
22+
import {
23+
debug,
24+
getHttpSpanDetailsFromUrlObject,
25+
getSpanStatusFromHttpCode,
26+
LRUMap,
27+
parseStringToURLObject,
28+
SDK_VERSION,
29+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
30+
startInactiveSpan,
31+
} from '@sentry/core';
1132
import { DEBUG_BUILD } from '../../debug-build';
12-
import { getRequestUrl } from '../../utils/getRequestUrl';
1333
import { INSTRUMENTATION_NAME } from './constants';
1434
import {
1535
addRequestBreadcrumb,
@@ -19,6 +39,8 @@ import {
1939

2040
type Http = typeof http;
2141
type Https = typeof https;
42+
type IncomingHttpHeaders = http.IncomingHttpHeaders;
43+
type OutgoingHttpHeaders = http.OutgoingHttpHeaders;
2244

2345
export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
2446
/**
@@ -28,6 +50,13 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
2850
*/
2951
breadcrumbs?: boolean;
3052

53+
/**
54+
* Whether to create spans for outgoing requests.
55+
*
56+
* @default `true`
57+
*/
58+
spans?: boolean;
59+
3160
/**
3261
* Whether to propagate Sentry trace headers in outgoing requests.
3362
* By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled)
@@ -37,6 +66,13 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
3766
*/
3867
propagateTraceInOutgoingRequests?: boolean;
3968

69+
/**
70+
* If spans for outgoing requests should be created.
71+
*
72+
* @default `false``
73+
*/
74+
createSpansForOutgoingRequests?: boolean;
75+
4076
/**
4177
* Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`.
4278
* For the scope of this instrumentation, this callback only controls breadcrumb creation.
@@ -51,11 +87,6 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
5187
// All options below do not do anything anymore in this instrumentation, and will be removed in the future.
5288
// They are only kept here for backwards compatibility - the respective functionality is now handled by the httpServerIntegration/httpServerSpansIntegration.
5389

54-
/**
55-
* @deprecated This no longer does anything.
56-
*/
57-
spans?: boolean;
58-
5990
/**
6091
* @depreacted This no longer does anything.
6192
*/
@@ -155,6 +186,11 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
155186
this._onOutgoingRequestCreated(data.request);
156187
}) satisfies ChannelListener;
157188

189+
const onHttpClientRequestStart = ((_data: unknown) => {
190+
const data = _data as { request: http.ClientRequest };
191+
this._onOutgoingRequestStart(data.request);
192+
}) satisfies ChannelListener;
193+
158194
const wrap = <T extends Http | Https>(moduleExports: T): T => {
159195
if (hasRegisteredHandlers) {
160196
return moduleExports;
@@ -168,20 +204,23 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
168204
// In this case, `http.client.response.finish` is not triggered
169205
subscribe('http.client.request.error', onHttpClientRequestError);
170206

207+
if (this.getConfig().createSpansForOutgoingRequests) {
208+
subscribe('http.client.request.start', onHttpClientRequestStart);
209+
}
171210
// NOTE: This channel only exist since Node 22
172211
// Before that, outgoing requests are not patched
173212
// and trace headers are not propagated, sadly.
174213
if (this.getConfig().propagateTraceInOutgoingRequests) {
175214
subscribe('http.client.request.created', onHttpClientRequestCreated);
176215
}
177-
178216
return moduleExports;
179217
};
180218

181219
const unwrap = (): void => {
182220
unsubscribe('http.client.response.finish', onHttpClientResponseFinish);
183221
unsubscribe('http.client.request.error', onHttpClientRequestError);
184222
unsubscribe('http.client.request.created', onHttpClientRequestCreated);
223+
unsubscribe('http.client.request.start', onHttpClientRequestStart);
185224
};
186225

187226
/**
@@ -198,6 +237,116 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
198237
];
199238
}
200239

240+
/**
241+
* This is triggered when an outgoing request starts.
242+
* It has access to the request object, and can mutate it before the request is sent.
243+
*/
244+
private _onOutgoingRequestStart(request: http.ClientRequest): void {
245+
DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling started outgoing request');
246+
247+
const _spans = this.getConfig().spans;
248+
const spansEnabled = typeof _spans === 'undefined' ? true : _spans;
249+
250+
const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request);
251+
this._ignoreOutgoingRequestsMap.set(request, shouldIgnore);
252+
253+
if (spansEnabled && !shouldIgnore) {
254+
this._startSpanForOutgoingRequest(request);
255+
}
256+
}
257+
258+
/**
259+
* Start a span for an outgoing request.
260+
* The span wraps the callback of the request, and ends when the response is finished.
261+
*/
262+
private _startSpanForOutgoingRequest(request: http.ClientRequest): void {
263+
// We monkey-patch `req.once('response'), which is used to trigger the callback of the request
264+
// eslint-disable-next-line @typescript-eslint/unbound-method, deprecation/deprecation
265+
const originalOnce = request.once;
266+
267+
const [name, attributes] = _getOutgoingRequestSpanData(request);
268+
269+
const span = startInactiveSpan({
270+
name,
271+
attributes,
272+
onlyIfParent: true,
273+
});
274+
275+
const newOnce = new Proxy(originalOnce, {
276+
apply(target, thisArg, args: Parameters<typeof originalOnce>) {
277+
const [event] = args;
278+
if (event !== 'response') {
279+
return target.apply(thisArg, args);
280+
}
281+
282+
const parentContext = context.active();
283+
const requestContext = trace.setSpan(parentContext, span);
284+
285+
context.with(requestContext, () => {
286+
return target.apply(thisArg, args);
287+
});
288+
},
289+
});
290+
291+
// eslint-disable-next-line deprecation/deprecation
292+
request.once = newOnce;
293+
294+
/**
295+
* Determines if the request has errored or the response has ended/errored.
296+
*/
297+
let responseFinished = false;
298+
299+
const endSpan = (status: SpanStatus): void => {
300+
if (responseFinished) {
301+
return;
302+
}
303+
responseFinished = true;
304+
305+
span.setStatus(status);
306+
span.end();
307+
};
308+
309+
request.prependListener('response', response => {
310+
if (request.listenerCount('response') <= 1) {
311+
response.resume();
312+
}
313+
314+
context.bind(context.active(), response);
315+
316+
const additionalAttributes = _getOutgoingRequestEndedSpanData(response);
317+
span.setAttributes(additionalAttributes);
318+
319+
const endHandler = (forceError: boolean = false): void => {
320+
this._diag.debug('outgoingRequest on end()');
321+
322+
const status =
323+
// eslint-disable-next-line deprecation/deprecation
324+
forceError || typeof response.statusCode !== 'number' || (response.aborted && !response.complete)
325+
? { code: SpanStatusCode.ERROR }
326+
: getSpanStatusFromHttpCode(response.statusCode);
327+
328+
endSpan(status);
329+
};
330+
331+
response.on('end', () => {
332+
endHandler();
333+
});
334+
response.on(errorMonitor, error => {
335+
this._diag.debug('outgoingRequest on response error()', error);
336+
endHandler(true);
337+
});
338+
});
339+
340+
// Fallback if proper response end handling above fails
341+
request.on('close', () => {
342+
endSpan({ code: SpanStatusCode.UNSET });
343+
});
344+
request.on(errorMonitor, error => {
345+
this._diag.debug('outgoingRequest on request error()', error);
346+
endSpan({ code: SpanStatusCode.ERROR });
347+
});
348+
}
349+
201350
/**
202351
* This is triggered when an outgoing request finishes.
203352
* It has access to the final request and response objects.
@@ -251,3 +400,103 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
251400
return ignoreOutgoingRequests(url, options);
252401
}
253402
}
403+
404+
function _getOutgoingRequestSpanData(request: http.ClientRequest): [string, SpanAttributes] {
405+
const url = getRequestUrl(request);
406+
407+
const [name, attributes] = getHttpSpanDetailsFromUrlObject(
408+
parseStringToURLObject(url),
409+
'client',
410+
'auto.http.otel.http',
411+
request,
412+
);
413+
414+
const userAgent = request.getHeader('user-agent');
415+
416+
return [
417+
name,
418+
{
419+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client',
420+
'otel.kind': 'CLIENT',
421+
[ATTR_USER_AGENT_ORIGINAL]: userAgent,
422+
[ATTR_URL_FULL]: url,
423+
'http.url': url,
424+
'http.method': request.method,
425+
'http.target': request.path || '/',
426+
'net.peer.name': request.host,
427+
'http.host': request.getHeader('host'),
428+
...attributes,
429+
},
430+
];
431+
}
432+
433+
function getRequestUrl(request: http.ClientRequest): string {
434+
const hostname = request.getHeader('host') || request.host;
435+
const protocol = request.protocol;
436+
const path = request.path;
437+
438+
return `${protocol}//${hostname}${path}`;
439+
}
440+
441+
function _getOutgoingRequestEndedSpanData(response: http.IncomingMessage): SpanAttributes {
442+
const { statusCode, statusMessage, httpVersion, socket } = response;
443+
444+
const transport = httpVersion.toUpperCase() !== 'QUIC' ? 'ip_tcp' : 'ip_udp';
445+
446+
const additionalAttributes: SpanAttributes = {
447+
[ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode,
448+
[ATTR_NETWORK_PROTOCOL_VERSION]: httpVersion,
449+
'http.flavor': httpVersion,
450+
[ATTR_NETWORK_TRANSPORT]: transport,
451+
'net.transport': transport,
452+
['http.status_text']: statusMessage?.toUpperCase(),
453+
'http.status_code': statusCode,
454+
...getResponseContentLengthAttributes(response),
455+
};
456+
457+
if (socket) {
458+
const { remoteAddress, remotePort } = socket;
459+
460+
additionalAttributes[ATTR_NETWORK_PEER_ADDRESS] = remoteAddress;
461+
additionalAttributes[ATTR_NETWORK_PEER_PORT] = remotePort;
462+
additionalAttributes['net.peer.ip'] = remoteAddress;
463+
additionalAttributes['net.peer.port'] = remotePort;
464+
}
465+
466+
return additionalAttributes;
467+
}
468+
469+
function getResponseContentLengthAttributes(response: http.IncomingMessage): SpanAttributes {
470+
const length = getContentLength(response.headers);
471+
if (length == null) {
472+
return {};
473+
}
474+
475+
if (isCompressed(response.headers)) {
476+
// eslint-disable-next-line deprecation/deprecation
477+
return { [SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH]: length };
478+
} else {
479+
// eslint-disable-next-line deprecation/deprecation
480+
return { [SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED]: length };
481+
}
482+
}
483+
484+
function getContentLength(headers: http.OutgoingHttpHeaders): number | undefined {
485+
const contentLengthHeader = headers['content-length'];
486+
if (typeof contentLengthHeader !== 'string') {
487+
return contentLengthHeader;
488+
}
489+
490+
const contentLength = parseInt(contentLengthHeader, 10);
491+
if (isNaN(contentLength)) {
492+
return undefined;
493+
}
494+
495+
return contentLength;
496+
}
497+
498+
function isCompressed(headers: OutgoingHttpHeaders | IncomingHttpHeaders): boolean {
499+
const encoding = headers['content-encoding'];
500+
501+
return !!encoding && encoding !== 'identity';
502+
}

packages/node/src/integrations/http.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const INTEGRATION_NAME = 'Http';
2020

2121
const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http';
2222

23+
const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = NODE_VERSION.major >= 22;
24+
2325
interface HttpOptions {
2426
/**
2527
* Whether breadcrumbs should be recorded for outgoing requests.
@@ -240,7 +242,9 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) =>
240242

241243
const sentryHttpInstrumentationOptions = {
242244
breadcrumbs: options.breadcrumbs,
243-
propagateTraceInOutgoingRequests: !useOtelHttpInstrumentation,
245+
propagateTraceInOutgoingRequests: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL || !useOtelHttpInstrumentation,
246+
createSpansForOutgoingRequests: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL,
247+
spans: options.spans,
244248
ignoreOutgoingRequests: options.ignoreOutgoingRequests,
245249
} satisfies SentryHttpInstrumentationOptions;
246250

@@ -263,6 +267,9 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) =>
263267

264268
function getConfigWithDefaults(options: Partial<HttpOptions> = {}): HttpInstrumentationConfig {
265269
const instrumentationConfig = {
270+
// This is handled by the SentryHttpInstrumentation on Node 22+
271+
disableOutgoingRequestInstrumentation: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL,
272+
266273
ignoreOutgoingRequestHook: request => {
267274
const url = getRequestUrl(request);
268275

0 commit comments

Comments
 (0)