Skip to content

Commit c850dfd

Browse files
committed
feat(node-core): Add outgoing fetch trace propagation to light mode
Add nativeNodeFetchIntegration for light mode that propagates sentry-trace, traceparent, and baggage headers on outgoing fetch/undici requests using node:diagnostics_channel. Also extracts shared fetch request utils (header injection, breadcrumbs, URL construction) into outgoingFetchRequest.ts to reduce duplication between light mode and full (OpenTelemetry) mode.
1 parent ef9916a commit c850dfd

File tree

9 files changed

+491
-157
lines changed

9 files changed

+491
-157
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
const http = require('http');
2+
const Sentry = require('@sentry/node-core/light');
3+
const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-core-integration-tests');
4+
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
release: '1.0',
8+
transport: loggingTransport,
9+
tracePropagationTargets: [/\/api\/v0/, 'v1'],
10+
});
11+
12+
let capturedV0 = {};
13+
let capturedV1 = {};
14+
let capturedV2 = {};
15+
16+
const targetServer = http.createServer((req, res) => {
17+
const headers = {
18+
'sentry-trace': req.headers['sentry-trace'],
19+
baggage: req.headers['baggage'],
20+
};
21+
22+
if (req.url === '/api/v0') {
23+
capturedV0 = headers;
24+
} else if (req.url === '/api/v1') {
25+
capturedV1 = headers;
26+
} else if (req.url === '/api/v2') {
27+
capturedV2 = headers;
28+
}
29+
30+
res.writeHead(200);
31+
res.end('ok');
32+
});
33+
34+
targetServer.listen(0, () => {
35+
const targetPort = targetServer.address().port;
36+
const targetUrl = `http://localhost:${targetPort}`;
37+
38+
const server = http.createServer(async (req, res) => {
39+
switch (req.url) {
40+
case '/test-auto-propagation': {
41+
capturedV0 = {};
42+
capturedV1 = {};
43+
capturedV2 = {};
44+
await fetch(`${targetUrl}/api/v0`);
45+
await fetch(`${targetUrl}/api/v1`);
46+
await fetch(`${targetUrl}/api/v2`);
47+
res.writeHead(200, { 'Content-Type': 'application/json' });
48+
res.end(JSON.stringify({ '/api/v0': capturedV0, '/api/v1': capturedV1, '/api/v2': capturedV2 }));
49+
break;
50+
}
51+
case '/test-breadcrumbs': {
52+
Sentry.addBreadcrumb({ message: 'manual breadcrumb' });
53+
await fetch(`${targetUrl}/api/v0`);
54+
await fetch(`${targetUrl}/api/v1`);
55+
Sentry.captureException(new Error('foo'));
56+
res.writeHead(200);
57+
res.end('ok');
58+
break;
59+
}
60+
default: {
61+
res.writeHead(404);
62+
res.end();
63+
}
64+
}
65+
});
66+
67+
server.listen(0, () => {
68+
sendPortToRunner(server.address().port);
69+
});
70+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import crypto from 'crypto';
2+
import { afterAll, expect, test } from 'vitest';
3+
import { conditionalTest } from '../../../utils';
4+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
5+
6+
afterAll(() => {
7+
cleanupChildProcesses();
8+
});
9+
10+
conditionalTest({ min: 22 })('light mode outgoing fetch', () => {
11+
test('automatically propagates trace headers to outgoing fetch requests matching tracePropagationTargets', async () => {
12+
const traceId = crypto.randomUUID().replace(/-/g, '');
13+
const parentSpanId = traceId.substring(0, 16);
14+
15+
const runner = createRunner(__dirname, 'server.js').start();
16+
17+
const response = await runner.makeRequest<Record<string, { 'sentry-trace'?: string; baggage?: string }>>(
18+
'get',
19+
'/test-auto-propagation',
20+
{
21+
headers: {
22+
'sentry-trace': `${traceId}-${parentSpanId}-1`,
23+
baggage: `sentry-trace_id=${traceId},sentry-environment=test,sentry-public_key=public`,
24+
},
25+
},
26+
);
27+
28+
// /api/v0 matches tracePropagationTargets - should have headers
29+
expect(response?.['/api/v0']?.['sentry-trace']).toMatch(new RegExp(`^${traceId}-[a-f\\d]{16}-1$`));
30+
expect(response?.['/api/v0']?.baggage).toContain(`sentry-trace_id=${traceId}`);
31+
32+
// /api/v1 matches tracePropagationTargets - should have headers
33+
expect(response?.['/api/v1']?.['sentry-trace']).toMatch(new RegExp(`^${traceId}-[a-f\\d]{16}-1$`));
34+
expect(response?.['/api/v1']?.baggage).toContain(`sentry-trace_id=${traceId}`);
35+
36+
// /api/v2 does NOT match tracePropagationTargets - should NOT have headers
37+
expect(response?.['/api/v2']?.['sentry-trace']).toBeUndefined();
38+
expect(response?.['/api/v2']?.baggage).toBeUndefined();
39+
});
40+
41+
test('creates breadcrumbs for outgoing fetch requests', async () => {
42+
const runner = createRunner(__dirname, 'server.js')
43+
.expect({
44+
event: event => {
45+
const breadcrumbs = event.breadcrumbs || [];
46+
const httpBreadcrumbs = breadcrumbs.filter(b => b.category === 'http');
47+
48+
expect(httpBreadcrumbs.length).toBe(2);
49+
50+
expect(httpBreadcrumbs[0]).toEqual(
51+
expect.objectContaining({
52+
category: 'http',
53+
type: 'http',
54+
data: expect.objectContaining({
55+
'http.method': 'GET',
56+
status_code: 200,
57+
}),
58+
}),
59+
);
60+
61+
expect(httpBreadcrumbs[1]).toEqual(
62+
expect.objectContaining({
63+
category: 'http',
64+
type: 'http',
65+
data: expect.objectContaining({
66+
'http.method': 'GET',
67+
status_code: 200,
68+
}),
69+
}),
70+
);
71+
},
72+
})
73+
.start();
74+
75+
await runner.makeRequest('get', '/test-breadcrumbs');
76+
77+
await runner.completed();
78+
});
79+
});

packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts

Lines changed: 8 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,16 @@ import { context } from '@opentelemetry/api';
22
import { isTracingSuppressed } from '@opentelemetry/core';
33
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
44
import { InstrumentationBase } from '@opentelemetry/instrumentation';
5-
import type { SanitizedRequestData } from '@sentry/core';
6-
import {
7-
addBreadcrumb,
8-
getBreadcrumbLogLevelFromHttpStatusCode,
9-
getClient,
10-
getSanitizedUrlString,
11-
getTraceData,
12-
LRUMap,
13-
parseUrl,
14-
SDK_VERSION,
15-
} from '@sentry/core';
16-
import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry';
5+
import { LRUMap, SDK_VERSION } from '@sentry/core';
176
import * as diagch from 'diagnostics_channel';
187
import { NODE_MAJOR, NODE_MINOR } from '../../nodeVersion';
19-
import { mergeBaggageHeaders } from '../../utils/baggage';
8+
import {
9+
addFetchRequestBreadcrumb,
10+
addTracePropagationHeadersToFetchRequest,
11+
getAbsoluteUrl,
12+
} from '../../utils/outgoingFetchRequest';
2013
import type { UndiciRequest, UndiciResponse } from './types';
2114

22-
const SENTRY_TRACE_HEADER = 'sentry-trace';
23-
const SENTRY_BAGGAGE_HEADER = 'baggage';
24-
25-
// For baggage, we make sure to merge this into a possibly existing header
26-
const BAGGAGE_HEADER_REGEX = /baggage: (.*)\r\n/;
27-
2815
export type SentryNodeFetchInstrumentationOptions = InstrumentationConfig & {
2916
/**
3017
* Whether breadcrumbs should be recorded for requests.
@@ -114,7 +101,6 @@ export class SentryNodeFetchInstrumentation extends InstrumentationBase<SentryNo
114101
* This method is called when a request is created.
115102
* You can still mutate the request here before it is sent.
116103
*/
117-
// eslint-disable-next-line complexity
118104
private _onRequestCreated({ request }: { request: UndiciRequest }): void {
119105
const config = this.getConfig();
120106
const enabled = config.enabled !== false;
@@ -132,70 +118,7 @@ export class SentryNodeFetchInstrumentation extends InstrumentationBase<SentryNo
132118
return;
133119
}
134120

135-
const url = getAbsoluteUrl(request.origin, request.path);
136-
137-
// Manually add the trace headers, if it applies
138-
// Note: We do not use `propagation.inject()` here, because our propagator relies on an active span
139-
// Which we do not have in this case
140-
// The propagator _may_ overwrite this, but this should be fine as it is the same data
141-
const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {};
142-
const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, this._propagationDecisionMap)
143-
? getTraceData({ propagateTraceparent })
144-
: undefined;
145-
146-
if (!addedHeaders) {
147-
return;
148-
}
149-
150-
const { 'sentry-trace': sentryTrace, baggage, traceparent } = addedHeaders;
151-
152-
// We do not want to overwrite existing headers here
153-
// If the core UndiciInstrumentation is registered, it will already have set the headers
154-
// We do not want to add any then
155-
if (Array.isArray(request.headers)) {
156-
const requestHeaders = request.headers;
157-
158-
// We do not want to overwrite existing header here, if it was already set
159-
if (sentryTrace && !requestHeaders.includes(SENTRY_TRACE_HEADER)) {
160-
requestHeaders.push(SENTRY_TRACE_HEADER, sentryTrace);
161-
}
162-
163-
if (traceparent && !requestHeaders.includes('traceparent')) {
164-
requestHeaders.push('traceparent', traceparent);
165-
}
166-
167-
// For baggage, we make sure to merge this into a possibly existing header
168-
const existingBaggagePos = requestHeaders.findIndex(header => header === SENTRY_BAGGAGE_HEADER);
169-
if (baggage && existingBaggagePos === -1) {
170-
requestHeaders.push(SENTRY_BAGGAGE_HEADER, baggage);
171-
} else if (baggage) {
172-
const existingBaggage = requestHeaders[existingBaggagePos + 1];
173-
const merged = mergeBaggageHeaders(existingBaggage, baggage);
174-
if (merged) {
175-
requestHeaders[existingBaggagePos + 1] = merged;
176-
}
177-
}
178-
} else {
179-
const requestHeaders = request.headers;
180-
// We do not want to overwrite existing header here, if it was already set
181-
if (sentryTrace && !requestHeaders.includes(`${SENTRY_TRACE_HEADER}:`)) {
182-
request.headers += `${SENTRY_TRACE_HEADER}: ${sentryTrace}\r\n`;
183-
}
184-
185-
if (traceparent && !requestHeaders.includes('traceparent:')) {
186-
request.headers += `traceparent: ${traceparent}\r\n`;
187-
}
188-
189-
const existingBaggage = request.headers.match(BAGGAGE_HEADER_REGEX)?.[1];
190-
if (baggage && !existingBaggage) {
191-
request.headers += `${SENTRY_BAGGAGE_HEADER}: ${baggage}\r\n`;
192-
} else if (baggage) {
193-
const merged = mergeBaggageHeaders(existingBaggage, baggage);
194-
if (merged) {
195-
request.headers = request.headers.replace(BAGGAGE_HEADER_REGEX, `baggage: ${merged}\r\n`);
196-
}
197-
}
198-
}
121+
addTracePropagationHeadersToFetchRequest(request, this._propagationDecisionMap);
199122
}
200123

201124
/**
@@ -215,7 +138,7 @@ export class SentryNodeFetchInstrumentation extends InstrumentationBase<SentryNo
215138
const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request);
216139

217140
if (breadCrumbsEnabled && !shouldIgnore) {
218-
addRequestBreadcrumb(request, response);
141+
addFetchRequestBreadcrumb(request, response);
219142
}
220143
}
221144

@@ -263,71 +186,3 @@ export class SentryNodeFetchInstrumentation extends InstrumentationBase<SentryNo
263186
return ignoreOutgoingRequests(url);
264187
}
265188
}
266-
267-
/** Add a breadcrumb for outgoing requests. */
268-
function addRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void {
269-
const data = getBreadcrumbData(request);
270-
271-
const statusCode = response.statusCode;
272-
const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode);
273-
274-
addBreadcrumb(
275-
{
276-
category: 'http',
277-
data: {
278-
status_code: statusCode,
279-
...data,
280-
},
281-
type: 'http',
282-
level,
283-
},
284-
{
285-
event: 'response',
286-
request,
287-
response,
288-
},
289-
);
290-
}
291-
292-
function getBreadcrumbData(request: UndiciRequest): Partial<SanitizedRequestData> {
293-
try {
294-
const url = getAbsoluteUrl(request.origin, request.path);
295-
const parsedUrl = parseUrl(url);
296-
297-
const data: Partial<SanitizedRequestData> = {
298-
url: getSanitizedUrlString(parsedUrl),
299-
'http.method': request.method || 'GET',
300-
};
301-
302-
if (parsedUrl.search) {
303-
data['http.query'] = parsedUrl.search;
304-
}
305-
if (parsedUrl.hash) {
306-
data['http.fragment'] = parsedUrl.hash;
307-
}
308-
309-
return data;
310-
} catch {
311-
return {};
312-
}
313-
}
314-
315-
function getAbsoluteUrl(origin: string, path: string = '/'): string {
316-
try {
317-
const url = new URL(path, origin);
318-
return url.toString();
319-
} catch {
320-
// fallback: Construct it on our own
321-
const url = `${origin}`;
322-
323-
if (url.endsWith('/') && path.startsWith('/')) {
324-
return `${url}${path.slice(1)}`;
325-
}
326-
327-
if (!url.endsWith('/') && !path.startsWith('/')) {
328-
return `${url}/${path.slice(1)}`;
329-
}
330-
331-
return `${url}${path}`;
332-
}
333-
}

packages/node-core/src/light/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { LightNodeClient } from './client';
33
export { init, getDefaultIntegrations, initWithoutDefaultIntegrations } from './sdk';
44
export { setAsyncLocalStorageAsyncContextStrategy } from './asyncLocalStorageStrategy';
55
export { httpServerIntegration } from './integrations/httpServerIntegration';
6+
export { nativeNodeFetchIntegration } from './integrations/nativeNodeFetchIntegration';
67

78
// Common exports shared with the main entry point
89
export * from '../common-exports';

0 commit comments

Comments
 (0)