Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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)

AltStyle によって変換されたページ (->オリジナル) /