|
1 |
| -/* eslint-disable max-lines */ |
2 | 1 | import type * as http from 'node:http';
|
3 | 2 | import type { IncomingMessage, RequestOptions } from 'node:http';
|
4 | 3 | import type * as https from 'node:https';
|
5 | 4 | import type { EventEmitter } from 'node:stream';
|
| 5 | +/* eslint-disable max-lines */ |
6 | 6 | import { VERSION } from '@opentelemetry/core';
|
7 | 7 | import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
|
8 | 8 | import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
|
9 | 9 | import type { AggregationCounts, Client, RequestEventData, SanitizedRequestData, Scope } from '@sentry/core';
|
10 | 10 | import {
|
| 11 | + LRUMap, |
11 | 12 | addBreadcrumb,
|
12 | 13 | generateSpanId,
|
13 | 14 | getBreadcrumbLogLevelFromHttpStatusCode,
|
14 | 15 | getClient,
|
15 | 16 | getIsolationScope,
|
16 | 17 | getSanitizedUrlString,
|
| 18 | + getTraceData, |
17 | 19 | httpRequestToRequestData,
|
18 | 20 | logger,
|
| 21 | + objectToBaggageHeader, |
| 22 | + parseBaggageHeader, |
19 | 23 | parseUrl,
|
20 | 24 | stripUrlQueryAndFragment,
|
21 | 25 | withIsolationScope,
|
22 | 26 | withScope,
|
23 | 27 | } from '@sentry/core';
|
| 28 | +import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; |
24 | 29 | import { DEBUG_BUILD } from '../../debug-build';
|
25 | 30 | import { getRequestUrl } from '../../utils/getRequestUrl';
|
26 | 31 | import { getRequestInfo } from './vendor/getRequestInfo';
|
27 | 32 |
|
28 | 33 | type Http = typeof http;
|
29 | 34 | type Https = typeof https;
|
30 | 35 |
|
| 36 | +type RequestArgs = |
| 37 | + // eslint-disable-next-line @typescript-eslint/ban-types |
| 38 | + | [url: string | URL, options?: RequestOptions, callback?: Function] |
| 39 | + // eslint-disable-next-line @typescript-eslint/ban-types |
| 40 | + | [options: RequestOptions, callback?: Function]; |
| 41 | + |
31 | 42 | type SentryHttpInstrumentationOptions = InstrumentationConfig & {
|
32 | 43 | /**
|
33 | 44 | * Whether breadcrumbs should be recorded for requests.
|
@@ -80,8 +91,11 @@ const MAX_BODY_BYTE_LENGTH = 1024 * 1024;
|
80 | 91 | * https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts
|
81 | 92 | */
|
82 | 93 | export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpInstrumentationOptions> {
|
| 94 | + private _propagationDecisionMap: LRUMap<string, boolean>; |
| 95 | + |
83 | 96 | public constructor(config: SentryHttpInstrumentationOptions = {}) {
|
84 | 97 | super('@sentry/instrumentation-http', VERSION, config);
|
| 98 | + this._propagationDecisionMap = new LRUMap<string, boolean>(100); |
85 | 99 | }
|
86 | 100 |
|
87 | 101 | /** @inheritdoc */
|
@@ -208,22 +222,21 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
|
208 | 222 | return function outgoingRequest(this: unknown, ...args: unknown[]): http.ClientRequest {
|
209 | 223 | instrumentation._diag.debug('http instrumentation for outgoing requests');
|
210 | 224 |
|
211 |
| - // Making a copy to avoid mutating the original args array |
212 | 225 | // We need to access and reconstruct the request options object passed to `ignoreOutgoingRequests`
|
213 | 226 | // so that it matches what Otel instrumentation passes to `ignoreOutgoingRequestHook`.
|
214 | 227 | // @see https://github.com/open-telemetry/opentelemetry-js/blob/7293e69c1e55ca62e15d0724d22605e61bd58952/experimental/packages/opentelemetry-instrumentation-http/src/http.ts#L756-L789
|
215 |
| - const argsCopy = [...args]; |
216 |
| - |
217 |
| - const options = argsCopy.shift() as URL | http.RequestOptions | string; |
| 228 | + const requestArgs = [...args] as RequestArgs; |
| 229 | + const options = requestArgs[0]; |
| 230 | + const extraOptions = typeof requestArgs[1] === 'object' ? requestArgs[1] : undefined; |
218 | 231 |
|
219 |
| - const extraOptions = |
220 |
| - typeof argsCopy[0] === 'object' && (typeof options === 'string' || options instanceof URL) |
221 |
| - ? (argsCopy.shift() as http.RequestOptions) |
222 |
| - : undefined; |
| 232 | + const { optionsParsed, origin, pathname } = getRequestInfo(instrumentation._diag, options, extraOptions); |
| 233 | + const url = getAbsoluteUrl(origin, pathname); |
223 | 234 |
|
224 |
| - const { optionsParsed } = getRequestInfo(instrumentation._diag, options, extraOptions); |
| 235 | + addSentryHeadersToRequestOptions(url, optionsParsed, instrumentation._propagationDecisionMap); |
225 | 236 |
|
226 |
| - const request = original.apply(this, args) as ReturnType<typeof http.request>; |
| 237 | + const request = original.apply(this, [optionsParsed, ...requestArgs.slice(1)]) as ReturnType< |
| 238 | + typeof http.request |
| 239 | + >; |
227 | 240 |
|
228 | 241 | request.prependListener('response', (response: http.IncomingMessage) => {
|
229 | 242 | const _breadcrumbs = instrumentation.getConfig().breadcrumbs;
|
@@ -457,6 +470,44 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope):
|
457 | 470 | }
|
458 | 471 | }
|
459 | 472 |
|
| 473 | +/** |
| 474 | + * Mutates the passed in `options` and adds `sentry-trace` / `baggage` headers, if they are not already set. |
| 475 | + */ |
| 476 | +function addSentryHeadersToRequestOptions( |
| 477 | + url: string, |
| 478 | + options: RequestOptions, |
| 479 | + propagationDecisionMap: LRUMap<string, boolean>, |
| 480 | +): void { |
| 481 | + // Manually add the trace headers, if it applies |
| 482 | + // Note: We do not use `propagation.inject()` here, because our propagator relies on an active span |
| 483 | + // Which we do not have in this case |
| 484 | + const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets; |
| 485 | + const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) |
| 486 | + ? getTraceData() |
| 487 | + : undefined; |
| 488 | + |
| 489 | + if (!addedHeaders) { |
| 490 | + return; |
| 491 | + } |
| 492 | + |
| 493 | + if (!options.headers) { |
| 494 | + options.headers = {}; |
| 495 | + } |
| 496 | + const headers = options.headers; |
| 497 | + |
| 498 | + const { 'sentry-trace': sentryTrace, baggage } = addedHeaders; |
| 499 | + |
| 500 | + // We do not want to overwrite existing header here, if it was already set |
| 501 | + if (sentryTrace && !headers['sentry-trace']) { |
| 502 | + headers['sentry-trace'] = sentryTrace; |
| 503 | + } |
| 504 | + |
| 505 | + // For baggage, we make sure to merge this into a possibly existing header |
| 506 | + if (baggage) { |
| 507 | + headers['baggage'] = mergeBaggageHeaders(headers['baggage'], baggage); |
| 508 | + } |
| 509 | +} |
| 510 | + |
460 | 511 | /**
|
461 | 512 | * Starts a session and tracks it in the context of a given isolation scope.
|
462 | 513 | * When the passed response is finished, the session is put into a task and is
|
@@ -531,3 +582,49 @@ const clientToRequestSessionAggregatesMap = new Map<
|
531 | 582 | Client,
|
532 | 583 | { [timestampRoundedToSeconds: string]: { exited: number; crashed: number; errored: number } }
|
533 | 584 | >();
|
| 585 | + |
| 586 | +function getAbsoluteUrl(origin: string, path: string = '/'): string { |
| 587 | + try { |
| 588 | + const url = new URL(path, origin); |
| 589 | + return url.toString(); |
| 590 | + } catch { |
| 591 | + // fallback: Construct it on our own |
| 592 | + const url = `${origin}`; |
| 593 | + |
| 594 | + if (url.endsWith('/') && path.startsWith('/')) { |
| 595 | + return `${url}${path.slice(1)}`; |
| 596 | + } |
| 597 | + |
| 598 | + if (!url.endsWith('/') && !path.startsWith('/')) { |
| 599 | + return `${url}/${path.slice(1)}`; |
| 600 | + } |
| 601 | + |
| 602 | + return `${url}${path}`; |
| 603 | + } |
| 604 | +} |
| 605 | + |
| 606 | +function mergeBaggageHeaders( |
| 607 | + existing: string | string[] | number | undefined, |
| 608 | + baggage: string, |
| 609 | +): string | string[] | number | undefined { |
| 610 | + if (!existing) { |
| 611 | + return baggage; |
| 612 | + } |
| 613 | + |
| 614 | + const existingBaggageEntries = parseBaggageHeader(existing); |
| 615 | + const newBaggageEntries = parseBaggageHeader(baggage); |
| 616 | + |
| 617 | + if (!newBaggageEntries) { |
| 618 | + return existing; |
| 619 | + } |
| 620 | + |
| 621 | + // Existing entries take precedence, ensuring order remains stable for minimal changes |
| 622 | + const mergedBaggageEntries = { ...existingBaggageEntries }; |
| 623 | + Object.entries(newBaggageEntries).forEach(([key, value]) => { |
| 624 | + if (!mergedBaggageEntries[key]) { |
| 625 | + mergedBaggageEntries[key] = value; |
| 626 | + } |
| 627 | + }); |
| 628 | + |
| 629 | + return objectToBaggageHeader(mergedBaggageEntries); |
| 630 | +} |
0 commit comments