Skip to content

Commit 3e31bdc

Browse files
mydeaLms24
andauthored
fix(node): Ensure httpIntegration propagates traces (#15233)
Related to #15231, I noticed that we today would not propagate traces in outgoing http requests if: 1. The user configures `httpIntegration({ spans: false })` 2. ..._or_ the user has a custom OTEL setup 3. _and_ the user does not add their own `HttpInstrumentation` Admittedly and edge case. More importantly, though, by actually adding distributed tracing information here, we are unblocked from potentially stopping to ship the http-instrumentation to users that do not need spans (and/or have a custom otel setup). --------- Co-authored-by: Lukas Stracke <[email protected]>
1 parent d9b7543 commit 3e31bdc

File tree

5 files changed

+266
-12
lines changed

5 files changed

+266
-12
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
2+
import * as Sentry from '@sentry/node';
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
tracePropagationTargets: [/\/v0/, 'v1'],
8+
integrations: [Sentry.httpIntegration({ spans: false })],
9+
transport: loggingTransport,
10+
// Ensure this gets a correct hint
11+
beforeBreadcrumb(breadcrumb, hint) {
12+
breadcrumb.data = breadcrumb.data || {};
13+
const req = hint?.request as { path?: string };
14+
breadcrumb.data.ADDED_PATH = req?.path;
15+
return breadcrumb;
16+
},
17+
});
18+
19+
import * as http from 'http';
20+
21+
async function run(): Promise<void> {
22+
Sentry.addBreadcrumb({ message: 'manual breadcrumb' });
23+
24+
await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`);
25+
await makeHttpGet(`${process.env.SERVER_URL}/api/v1`);
26+
await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`);
27+
await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`);
28+
29+
Sentry.captureException(new Error('foo'));
30+
}
31+
32+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
33+
run();
34+
35+
function makeHttpRequest(url: string): Promise<void> {
36+
return new Promise<void>(resolve => {
37+
http
38+
.request(url, httpRes => {
39+
httpRes.on('data', () => {
40+
// we don't care about data
41+
});
42+
httpRes.on('end', () => {
43+
resolve();
44+
});
45+
})
46+
.end();
47+
});
48+
}
49+
50+
function makeHttpGet(url: string): Promise<void> {
51+
return new Promise<void>(resolve => {
52+
http.get(url, httpRes => {
53+
httpRes.on('data', () => {
54+
// we don't care about data
55+
});
56+
httpRes.on('end', () => {
57+
resolve();
58+
});
59+
});
60+
});
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { createRunner } from '../../../../utils/runner';
2+
import { createTestServer } from '../../../../utils/server';
3+
4+
test('outgoing http requests are correctly instrumented with tracing & spans disabled', done => {
5+
expect.assertions(11);
6+
7+
createTestServer(done)
8+
.get('/api/v0', headers => {
9+
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/));
10+
expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000');
11+
expect(headers['baggage']).toEqual(expect.any(String));
12+
})
13+
.get('/api/v1', headers => {
14+
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/));
15+
expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000');
16+
expect(headers['baggage']).toEqual(expect.any(String));
17+
})
18+
.get('/api/v2', headers => {
19+
expect(headers['baggage']).toBeUndefined();
20+
expect(headers['sentry-trace']).toBeUndefined();
21+
})
22+
.get('/api/v3', headers => {
23+
expect(headers['baggage']).toBeUndefined();
24+
expect(headers['sentry-trace']).toBeUndefined();
25+
})
26+
.start()
27+
.then(([SERVER_URL, closeTestServer]) => {
28+
createRunner(__dirname, 'scenario.ts')
29+
.withEnv({ SERVER_URL })
30+
.ensureNoErrorOutput()
31+
.expect({
32+
event: {
33+
exception: {
34+
values: [
35+
{
36+
type: 'Error',
37+
value: 'foo',
38+
},
39+
],
40+
},
41+
breadcrumbs: [
42+
{
43+
message: 'manual breadcrumb',
44+
timestamp: expect.any(Number),
45+
},
46+
{
47+
category: 'http',
48+
data: {
49+
'http.method': 'GET',
50+
url: `${SERVER_URL}/api/v0`,
51+
status_code: 200,
52+
ADDED_PATH: '/api/v0',
53+
},
54+
timestamp: expect.any(Number),
55+
type: 'http',
56+
},
57+
{
58+
category: 'http',
59+
data: {
60+
'http.method': 'GET',
61+
url: `${SERVER_URL}/api/v1`,
62+
status_code: 200,
63+
ADDED_PATH: '/api/v1',
64+
},
65+
timestamp: expect.any(Number),
66+
type: 'http',
67+
},
68+
{
69+
category: 'http',
70+
data: {
71+
'http.method': 'GET',
72+
url: `${SERVER_URL}/api/v2`,
73+
status_code: 200,
74+
ADDED_PATH: '/api/v2',
75+
},
76+
timestamp: expect.any(Number),
77+
type: 'http',
78+
},
79+
{
80+
category: 'http',
81+
data: {
82+
'http.method': 'GET',
83+
url: `${SERVER_URL}/api/v3`,
84+
status_code: 200,
85+
ADDED_PATH: '/api/v3',
86+
},
87+
timestamp: expect.any(Number),
88+
type: 'http',
89+
},
90+
],
91+
},
92+
})
93+
.start(closeTestServer);
94+
});
95+
});

packages/core/src/utils-hoist/baggage.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ function baggageHeaderToObject(baggageHeader: string): Record<string, string> {
130130
* @returns a baggage header string, or `undefined` if the object didn't have any values, since an empty baggage header
131131
* is not spec compliant.
132132
*/
133-
function objectToBaggageHeader(object: Record<string, string>): string | undefined {
133+
export function objectToBaggageHeader(object: Record<string, string>): string | undefined {
134134
if (Object.keys(object).length === 0) {
135135
// An empty baggage header is not spec compliant: We return undefined.
136136
return undefined;

packages/core/src/utils-hoist/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export {
128128
baggageHeaderToDynamicSamplingContext,
129129
dynamicSamplingContextToSentryBaggageHeader,
130130
parseBaggageHeader,
131+
objectToBaggageHeader,
131132
} from './baggage';
132133

133134
export { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from './url';

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

+108-11
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,44 @@
1-
/* eslint-disable max-lines */
21
import type * as http from 'node:http';
32
import type { IncomingMessage, RequestOptions } from 'node:http';
43
import type * as https from 'node:https';
54
import type { EventEmitter } from 'node:stream';
5+
/* eslint-disable max-lines */
66
import { VERSION } from '@opentelemetry/core';
77
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
88
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
99
import type { AggregationCounts, Client, RequestEventData, SanitizedRequestData, Scope } from '@sentry/core';
1010
import {
11+
LRUMap,
1112
addBreadcrumb,
1213
generateSpanId,
1314
getBreadcrumbLogLevelFromHttpStatusCode,
1415
getClient,
1516
getIsolationScope,
1617
getSanitizedUrlString,
18+
getTraceData,
1719
httpRequestToRequestData,
1820
logger,
21+
objectToBaggageHeader,
22+
parseBaggageHeader,
1923
parseUrl,
2024
stripUrlQueryAndFragment,
2125
withIsolationScope,
2226
withScope,
2327
} from '@sentry/core';
28+
import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry';
2429
import { DEBUG_BUILD } from '../../debug-build';
2530
import { getRequestUrl } from '../../utils/getRequestUrl';
2631
import { getRequestInfo } from './vendor/getRequestInfo';
2732

2833
type Http = typeof http;
2934
type Https = typeof https;
3035

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+
3142
type SentryHttpInstrumentationOptions = InstrumentationConfig & {
3243
/**
3344
* Whether breadcrumbs should be recorded for requests.
@@ -80,8 +91,11 @@ const MAX_BODY_BYTE_LENGTH = 1024 * 1024;
8091
* https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts
8192
*/
8293
export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpInstrumentationOptions> {
94+
private _propagationDecisionMap: LRUMap<string, boolean>;
95+
8396
public constructor(config: SentryHttpInstrumentationOptions = {}) {
8497
super('@sentry/instrumentation-http', VERSION, config);
98+
this._propagationDecisionMap = new LRUMap<string, boolean>(100);
8599
}
86100

87101
/** @inheritdoc */
@@ -208,22 +222,21 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
208222
return function outgoingRequest(this: unknown, ...args: unknown[]): http.ClientRequest {
209223
instrumentation._diag.debug('http instrumentation for outgoing requests');
210224

211-
// Making a copy to avoid mutating the original args array
212225
// We need to access and reconstruct the request options object passed to `ignoreOutgoingRequests`
213226
// so that it matches what Otel instrumentation passes to `ignoreOutgoingRequestHook`.
214227
// @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;
218231

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);
223234

224-
const { optionsParsed } = getRequestInfo(instrumentation._diag, options, extraOptions);
235+
addSentryHeadersToRequestOptions(url, optionsParsed, instrumentation._propagationDecisionMap);
225236

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+
>;
227240

228241
request.prependListener('response', (response: http.IncomingMessage) => {
229242
const _breadcrumbs = instrumentation.getConfig().breadcrumbs;
@@ -457,6 +470,44 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope):
457470
}
458471
}
459472

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+
460511
/**
461512
* Starts a session and tracks it in the context of a given isolation scope.
462513
* When the passed response is finished, the session is put into a task and is
@@ -531,3 +582,49 @@ const clientToRequestSessionAggregatesMap = new Map<
531582
Client,
532583
{ [timestampRoundedToSeconds: string]: { exited: number; crashed: number; errored: number } }
533584
>();
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

Comments
 (0)