Skip to content

Commit 1e2a35e

Browse files
committed
fix(node): Ensure httpIntegration propagates traces
We used to rely on the existence of another `httpIntegration`
1 parent 9ce4ad1 commit 1e2a35e

File tree

3 files changed

+232
-10
lines changed

3 files changed

+232
-10
lines changed
Lines changed: 61 additions & 0 deletions
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+
}
Lines changed: 95 additions & 0 deletions
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/node/src/integrations/http/SentryHttpInstrumentation.ts

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,35 @@ 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,
1921
parseUrl,
2022
stripUrlQueryAndFragment,
2123
withIsolationScope,
2224
withScope,
2325
} from '@sentry/core';
26+
import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry';
2427
import { DEBUG_BUILD } from '../../debug-build';
2528
import { getRequestUrl } from '../../utils/getRequestUrl';
2629
import { getRequestInfo } from './vendor/getRequestInfo';
2730

2831
type Http = typeof http;
2932
type Https = typeof https;
3033

34+
type RequestArgs =
35+
// eslint-disable-next-line @typescript-eslint/ban-types
36+
| [url: string | URL, options?: RequestOptions, callback?: Function]
37+
// eslint-disable-next-line @typescript-eslint/ban-types
38+
| [options: RequestOptions, callback?: Function];
39+
3140
type SentryHttpInstrumentationOptions = InstrumentationConfig & {
3241
/**
3342
* Whether breadcrumbs should be recorded for requests.
@@ -80,8 +89,11 @@ const MAX_BODY_BYTE_LENGTH = 1024 * 1024;
8089
* https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts
8190
*/
8291
export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpInstrumentationOptions> {
92+
private _propagationDecisionMap: LRUMap<string, boolean>;
93+
8394
public constructor(config: SentryHttpInstrumentationOptions = {}) {
8495
super('@sentry/instrumentation-http', VERSION, config);
96+
this._propagationDecisionMap = new LRUMap<string, boolean>(100);
8597
}
8698

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

211-
// Making a copy to avoid mutating the original args array
212223
// We need to access and reconstruct the request options object passed to `ignoreOutgoingRequests`
213224
// so that it matches what Otel instrumentation passes to `ignoreOutgoingRequestHook`.
214225
// @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;
226+
const requestArgs = [...args] as RequestArgs;
227+
const options = requestArgs[0];
228+
const extraOptions = typeof requestArgs[1] === 'object' ? requestArgs[1] : undefined;
218229

219-
const extraOptions =
220-
typeof argsCopy[0] === 'object' && (typeof options === 'string' || options instanceof URL)
221-
? (argsCopy.shift() as http.RequestOptions)
222-
: undefined;
230+
const { optionsParsed, origin, pathname } = getRequestInfo(instrumentation._diag, options, extraOptions);
231+
const url = getAbsoluteUrl(origin, pathname);
223232

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

226-
const request = original.apply(this, args) as ReturnType<typeof http.request>;
235+
const request = original.apply(this, [optionsParsed, ...requestArgs.slice(1)]) as ReturnType<
236+
typeof http.request
237+
>;
227238

228239
request.prependListener('response', (response: http.IncomingMessage) => {
229240
const _breadcrumbs = instrumentation.getConfig().breadcrumbs;
@@ -457,6 +468,41 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope):
457468
}
458469
}
459470

471+
/**
472+
* Mutates the passed in `options` and adds `sentry-trace` / `baggage` headers, if they are not already set.
473+
*/
474+
function addSentryHeadersToRequestOptions(
475+
url: string,
476+
options: RequestOptions,
477+
propagationDecisionMap: LRUMap<string, boolean>,
478+
): void {
479+
// Manually add the trace headers, if it applies
480+
// Note: We do not use `propagation.inject()` here, because our propagator relies on an active span
481+
// Which we do not have in this case
482+
const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets;
483+
const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap)
484+
? getTraceData()
485+
: undefined;
486+
487+
if (!addedHeaders) {
488+
return;
489+
}
490+
491+
if (!options.headers) {
492+
options.headers = {};
493+
}
494+
const headers = options.headers;
495+
496+
Object.entries(addedHeaders).forEach(([k, v]) => {
497+
// We do not want to overwrite existing headers here
498+
// If the core UndiciInstrumentation is registered, it will already have set the headers
499+
// We do not want to add any then
500+
if (!headers[k]) {
501+
headers[k] = v;
502+
}
503+
});
504+
}
505+
460506
/**
461507
* Starts a session and tracks it in the context of a given isolation scope.
462508
* When the passed response is finished, the session is put into a task and is
@@ -531,3 +577,23 @@ const clientToRequestSessionAggregatesMap = new Map<
531577
Client,
532578
{ [timestampRoundedToSeconds: string]: { exited: number; crashed: number; errored: number } }
533579
>();
580+
581+
function getAbsoluteUrl(origin: string, path: string = '/'): string {
582+
try {
583+
const url = new URL(path, origin);
584+
return url.toString();
585+
} catch {
586+
// fallback: Construct it on our own
587+
const url = `${origin}`;
588+
589+
if (url.endsWith('/') && path.startsWith('/')) {
590+
return `${url}${path.slice(1)}`;
591+
}
592+
593+
if (!url.endsWith('/') && !path.startsWith('/')) {
594+
return `${url}/${path.slice(1)}`;
595+
}
596+
597+
return `${url}${path}`;
598+
}
599+
}

0 commit comments

Comments
 (0)