Skip to content

Commit 3ab670c

Browse files
committed
feat(node): Extract Sentry-specific node-fetch instrumentation
To allow users to opt-out of using the otel instrumentation.
1 parent d76db31 commit 3ab670c

File tree

7 files changed

+468
-114
lines changed

7 files changed

+468
-114
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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.nativeNodeFetchIntegration({ spans: false })],
9+
transport: loggingTransport,
10+
});
11+
12+
async function run(): Promise<void> {
13+
// Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented
14+
await new Promise(resolve => setTimeout(resolve, 100));
15+
await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text());
16+
await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text());
17+
await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text());
18+
await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text());
19+
20+
Sentry.captureException(new Error('foo'));
21+
}
22+
23+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
24+
run();
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { createRunner } from '../../../../utils/runner';
2+
import { createTestServer } from '../../../../utils/server';
3+
4+
describe('outgoing fetch', () => {
5+
test('outgoing fetch requests are correctly instrumented with tracing & spans are disabled', done => {
6+
expect.assertions(11);
7+
8+
createTestServer(done)
9+
.get('/api/v0', headers => {
10+
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/));
11+
expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000');
12+
expect(headers['baggage']).toEqual(expect.any(String));
13+
})
14+
.get('/api/v1', headers => {
15+
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/));
16+
expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000');
17+
expect(headers['baggage']).toEqual(expect.any(String));
18+
})
19+
.get('/api/v2', headers => {
20+
expect(headers['baggage']).toBeUndefined();
21+
expect(headers['sentry-trace']).toBeUndefined();
22+
})
23+
.get('/api/v3', headers => {
24+
expect(headers['baggage']).toBeUndefined();
25+
expect(headers['sentry-trace']).toBeUndefined();
26+
})
27+
.start()
28+
.then(([SERVER_URL, closeTestServer]) => {
29+
createRunner(__dirname, 'scenario.ts')
30+
.withEnv({ SERVER_URL })
31+
.ensureNoErrorOutput()
32+
.expect({
33+
event: {
34+
exception: {
35+
values: [
36+
{
37+
type: 'Error',
38+
value: 'foo',
39+
},
40+
],
41+
},
42+
},
43+
})
44+
.start(closeTestServer);
45+
});
46+
});
47+
});
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { VERSION } from '@opentelemetry/core';
2+
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
3+
import { InstrumentationBase } from '@opentelemetry/instrumentation';
4+
import type { SanitizedRequestData } from '@sentry/core';
5+
import { LRUMap, getClient, getTraceData } from '@sentry/core';
6+
import { addBreadcrumb, getBreadcrumbLogLevelFromHttpStatusCode, getSanitizedUrlString, parseUrl } from '@sentry/core';
7+
import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry';
8+
import * as diagch from 'diagnostics_channel';
9+
import { NODE_MAJOR, NODE_MINOR } from '../../nodeVersion';
10+
import type { UndiciRequest, UndiciResponse } from './types';
11+
12+
export type SentryNodeFetchInstrumentationOptions = InstrumentationConfig & {
13+
/**
14+
* Whether breadcrumbs should be recorded for requests.
15+
*
16+
* @default `true`
17+
*/
18+
breadcrumbs?: boolean;
19+
20+
/**
21+
* Do not capture breadcrumbs for outgoing fetch requests to URLs where the given callback returns `true`.
22+
* For the scope of this instrumentation, this callback only controls breadcrumb creation.
23+
* The same option can be passed to the top-level httpIntegration where it controls both, breadcrumb and
24+
* span creation.
25+
*
26+
* @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request.
27+
*/
28+
ignoreOutgoingRequests?: (url: string) => boolean;
29+
};
30+
31+
interface ListenerRecord {
32+
name: string;
33+
unsubscribe: () => void;
34+
}
35+
36+
// TODO
37+
38+
/**
39+
* This custom node-fetch instrumentation is used to instrument outgoing fetch requests.
40+
* It does not emit any spans.
41+
*
42+
* The reason this is isolated from the OpenTelemetry instrumentation is that users may overwrite this,
43+
* which would lead to Sentry not working as expected.
44+
*
45+
* Important note: Contrary to other OTEL instrumentation, this one cannot be unwrapped.
46+
* It only does minimal things though and does not emit any spans.
47+
*
48+
* This is heavily inspired & adapted from:
49+
* https://github.com/open-telemetry/opentelemetry-js-contrib/blob/28e209a9da36bc4e1f8c2b0db7360170ed46cb80/plugins/node/instrumentation-undici/src/undici.ts
50+
*/
51+
export class SentryNodeFetchInstrumentation extends InstrumentationBase<SentryNodeFetchInstrumentationOptions> {
52+
// Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for
53+
// unsubscribing.
54+
private _channelSubs: Array<ListenerRecord>;
55+
private _propagationDecisionMap: LRUMap<string, boolean>;
56+
57+
public constructor(config: SentryNodeFetchInstrumentationOptions = {}) {
58+
super('@sentry/instrumentation-node-fetch', VERSION, config);
59+
this._channelSubs = [];
60+
this._propagationDecisionMap = new LRUMap<string, boolean>(100);
61+
}
62+
63+
/** No need to instrument files/modules. */
64+
public init(): void {
65+
return undefined;
66+
}
67+
68+
/** Disable the instrumentation. */
69+
public disable(): void {
70+
super.disable();
71+
this._channelSubs.forEach(sub => sub.unsubscribe());
72+
this._channelSubs = [];
73+
}
74+
75+
/** Enable the instrumentation. */
76+
public enable(): void {
77+
// "enabled" handling is currently a bit messy with InstrumentationBase.
78+
// If constructed with `{enabled: false}`, this `.enable()` is still called,
79+
// and `this.getConfig().enabled !== this.isEnabled()`, creating confusion.
80+
//
81+
// For now, this class will setup for instrumenting if `.enable()` is
82+
// called, but use `this.getConfig().enabled` to determine if
83+
// instrumentation should be generated. This covers the more likely common
84+
// case of config being given a construction time, rather than later via
85+
// `instance.enable()`, `.disable()`, or `.setConfig()` calls.
86+
super.enable();
87+
88+
// This method is called by the super-class constructor before ours is
89+
// called. So we need to ensure the property is initalized.
90+
this._channelSubs = this._channelSubs || [];
91+
92+
// Avoid to duplicate subscriptions
93+
if (this._channelSubs.length > 0) {
94+
return;
95+
}
96+
97+
this._subscribeToChannel('undici:request:create', this._onRequestCreated.bind(this));
98+
this._subscribeToChannel('undici:request:headers', this._onResponseHeaders.bind(this));
99+
}
100+
101+
/**
102+
* This method is called when a request is created.
103+
* You can still mutate the request here before it is sent.
104+
*/
105+
private _onRequestCreated({ request }: { request: UndiciRequest }): void {
106+
const config = this.getConfig();
107+
const enabled = config.enabled !== false;
108+
109+
if (!enabled) {
110+
return;
111+
}
112+
113+
// Add trace propagation headers
114+
const url = getAbsoluteUrl(request.origin, request.path);
115+
const _ignoreOutgoingRequests = config.ignoreOutgoingRequests;
116+
const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url);
117+
118+
if (shouldIgnore) {
119+
return;
120+
}
121+
122+
// Manually add the trace headers, if it applies
123+
// Note: We do not use `propagation.inject()` here, because our propagator relies on an active span
124+
// Which we do not have in this case
125+
// The propagator _may_ overwrite this, but this should be fine as it is the same data
126+
const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets;
127+
const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, this._propagationDecisionMap)
128+
? getTraceData()
129+
: {};
130+
131+
// We do not want to overwrite existing headers here
132+
// If the core UndiciInstrumentation is registered, it will already have set the headers
133+
// We do not want to add any then
134+
if (Array.isArray(request.headers)) {
135+
const requestHeaders = request.headers;
136+
Object.entries(addedHeaders)
137+
.filter(([k]) => {
138+
// If the header already exists, we do not want to set it again
139+
return !requestHeaders.includes(`${k}:`);
140+
})
141+
.forEach(headers => requestHeaders.push(...headers));
142+
} else {
143+
const requestHeaders = request.headers;
144+
request.headers += Object.entries(addedHeaders)
145+
.filter(([k]) => {
146+
// If the header already exists, we do not want to set it again
147+
return !requestHeaders.includes(`${k}:`);
148+
})
149+
.map(([k, v]) => `${k}: ${v}\r\n`)
150+
.join('');
151+
}
152+
}
153+
154+
/**
155+
* This method is called when a response is received.
156+
*/
157+
private _onResponseHeaders({ request, response }: { request: UndiciRequest; response: UndiciResponse }): void {
158+
const config = this.getConfig();
159+
const enabled = config.enabled !== false;
160+
161+
if (!enabled) {
162+
return;
163+
}
164+
165+
const _breadcrumbs = config.breadcrumbs;
166+
const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs;
167+
168+
const _ignoreOutgoingRequests = config.ignoreOutgoingRequests;
169+
const shouldCreateBreadcrumb =
170+
typeof _ignoreOutgoingRequests === 'function'
171+
? !_ignoreOutgoingRequests(getAbsoluteUrl(request.origin, request.path))
172+
: true;
173+
174+
if (breadCrumbsEnabled && shouldCreateBreadcrumb) {
175+
addRequestBreadcrumb(request, response);
176+
}
177+
}
178+
179+
/** Subscribe to a diagnostics channel. */
180+
private _subscribeToChannel(
181+
diagnosticChannel: string,
182+
onMessage: (message: unknown, name: string | symbol) => void,
183+
): void {
184+
// `diagnostics_channel` had a ref counting bug until v18.19.0.
185+
// https://github.com/nodejs/node/pull/47520
186+
const useNewSubscribe = NODE_MAJOR > 18 || (NODE_MAJOR === 18 && NODE_MINOR >= 19);
187+
188+
let unsubscribe: () => void;
189+
if (useNewSubscribe) {
190+
diagch.subscribe?.(diagnosticChannel, onMessage);
191+
unsubscribe = () => diagch.unsubscribe?.(diagnosticChannel, onMessage);
192+
} else {
193+
const channel = diagch.channel(diagnosticChannel);
194+
channel.subscribe(onMessage);
195+
unsubscribe = () => channel.unsubscribe(onMessage);
196+
}
197+
198+
this._channelSubs.push({
199+
name: diagnosticChannel,
200+
unsubscribe,
201+
});
202+
}
203+
}
204+
205+
/** Add a breadcrumb for outgoing requests. */
206+
function addRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void {
207+
const data = getBreadcrumbData(request);
208+
209+
const statusCode = response.statusCode;
210+
const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode);
211+
212+
addBreadcrumb(
213+
{
214+
category: 'http',
215+
data: {
216+
status_code: statusCode,
217+
...data,
218+
},
219+
type: 'http',
220+
level,
221+
},
222+
{
223+
event: 'response',
224+
request,
225+
response,
226+
},
227+
);
228+
}
229+
230+
function getBreadcrumbData(request: UndiciRequest): Partial<SanitizedRequestData> {
231+
try {
232+
const url = getAbsoluteUrl(request.origin, request.path);
233+
const parsedUrl = parseUrl(url);
234+
235+
const data: Partial<SanitizedRequestData> = {
236+
url: getSanitizedUrlString(parsedUrl),
237+
'http.method': request.method || 'GET',
238+
};
239+
240+
if (parsedUrl.search) {
241+
data['http.query'] = parsedUrl.search;
242+
}
243+
if (parsedUrl.hash) {
244+
data['http.fragment'] = parsedUrl.hash;
245+
}
246+
247+
return data;
248+
} catch {
249+
return {};
250+
}
251+
}
252+
253+
function getAbsoluteUrl(origin: string, path: string = '/'): string {
254+
try {
255+
const url = new URL(path, origin);
256+
return url.toString();
257+
} catch {
258+
// fallback: Construct it on our own
259+
const url = `${origin}`;
260+
261+
if (url.endsWith('/') && path.startsWith('/')) {
262+
return `${url}${path.slice(1)}`;
263+
}
264+
265+
if (!url.endsWith('/') && !path.startsWith('/')) {
266+
return `${url}/${path.slice(1)}`;
267+
}
268+
269+
return `${url}${path}`;
270+
}
271+
}

0 commit comments

Comments
 (0)