Skip to content

Commit d3e7777

Browse files
committed
test(node): Add tests for Undici
1 parent 4c31a5c commit d3e7777

File tree

4 files changed

+334
-3
lines changed

4 files changed

+334
-3
lines changed

packages/node/src/integrations/undici/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ const DEFAULT_UNDICI_OPTIONS: UndiciOptions = {
3333
breadcrumbs: true,
3434
};
3535

36+
// Please note that you cannot use `console.log` to debug the callbacks registered to the `diagnostics_channel` API.
37+
// To debug, you can use `writeFileSync` to write to a file:
38+
// https://nodejs.org/api/async_hooks.html#printing-in-asynchook-callbacks
39+
3640
/**
3741
* Instruments outgoing HTTP requests made with the `undici` package via
3842
* Node's `diagnostics_channel` API.
@@ -89,7 +93,7 @@ export class Undici implements Integration {
8993
const url = new URL(request.path, request.origin);
9094
const stringUrl = url.toString();
9195

92-
if (isSentryRequest(stringUrl)) {
96+
if (isSentryRequest(stringUrl) || request.__sentry__ !== undefined) {
9397
return;
9498
}
9599

@@ -132,7 +136,6 @@ export class Undici implements Integration {
132136
: true;
133137

134138
if (shouldPropagate) {
135-
// TODO: Only do this based on tracePropagationTargets
136139
request.addHeader('sentry-trace', span.toTraceparent());
137140
if (span.transaction) {
138141
const dynamicSamplingContext = span.transaction.getDynamicSamplingContext();

packages/node/src/integrations/undici/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type { Span } from '@sentry/core';
2020

2121
// Vendored code starts here:
2222

23-
type ChannelListener = (message: unknown, name: string | symbol) => void;
23+
export type ChannelListener = (message: unknown, name: string | symbol) => void;
2424

2525
/**
2626
* The `diagnostics_channel` module provides an API to create named channels
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import { Transaction, getCurrentHub } from '@sentry/core';
2+
import { Hub, makeMain } from '@sentry/core';
3+
import * as http from 'http';
4+
import { fetch } from 'undici';
5+
6+
import { NodeClient } from '../../src/client';
7+
import { Undici } from '../../src/integrations/undici';
8+
import { getDefaultNodeClientOptions } from '../helper/node-client-options';
9+
import { conditionalTest } from '../utils';
10+
11+
const SENTRY_DSN = 'https://[email protected]/0';
12+
13+
let hub: Hub;
14+
15+
beforeAll(async () => {
16+
await setupTestServer();
17+
});
18+
19+
const DEFAULT_OPTIONS = getDefaultNodeClientOptions({
20+
dsn: SENTRY_DSN,
21+
tracesSampleRate: 1,
22+
integrations: [new Undici()],
23+
});
24+
25+
beforeEach(() => {
26+
const client = new NodeClient(DEFAULT_OPTIONS);
27+
hub = new Hub(client);
28+
makeMain(hub);
29+
});
30+
31+
afterEach(() => {
32+
requestHeaders = {};
33+
setTestServerOptions({ statusCode: 200 });
34+
});
35+
36+
afterAll(() => {
37+
getTestServer()?.close();
38+
});
39+
40+
conditionalTest({ min: 16 })('Undici integration', () => {
41+
it.each([
42+
[
43+
'simple url',
44+
'http://localhost:18099',
45+
undefined,
46+
{
47+
description: 'GET http://localhost:18099/',
48+
op: 'http.client',
49+
},
50+
],
51+
[
52+
'url with query',
53+
'http://localhost:18099?foo=bar',
54+
undefined,
55+
{
56+
description: 'GET http://localhost:18099/',
57+
op: 'http.client',
58+
data: {
59+
'http.query': '?foo=bar',
60+
},
61+
},
62+
],
63+
[
64+
'url with POST method',
65+
'http://localhost:18099',
66+
{ method: 'POST' },
67+
{
68+
description: 'POST http://localhost:18099/',
69+
},
70+
],
71+
[
72+
'url with POST method',
73+
'http://localhost:18099',
74+
{ method: 'POST' },
75+
{
76+
description: 'POST http://localhost:18099/',
77+
},
78+
],
79+
[
80+
'url with GET as default',
81+
'http://localhost:18099',
82+
{ method: undefined },
83+
{
84+
description: 'GET http://localhost:18099/',
85+
},
86+
],
87+
])('creates a span with a %s', async (_: string, request, requestInit, expected) => {
88+
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
89+
hub.getScope().setSpan(transaction);
90+
91+
await fetch(request, requestInit);
92+
93+
expect(transaction.spanRecorder?.spans.length).toBe(2);
94+
95+
const span = transaction.spanRecorder?.spans[1];
96+
expect(span).toEqual(expect.objectContaining(expected));
97+
});
98+
99+
it('creates a span with internal errors', async () => {
100+
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
101+
hub.getScope().setSpan(transaction);
102+
103+
try {
104+
await fetch('http://a-url-that-no-exists.com');
105+
} catch (e) {
106+
// ignore
107+
}
108+
109+
expect(transaction.spanRecorder?.spans.length).toBe(2);
110+
111+
const span = transaction.spanRecorder?.spans[1];
112+
expect(span).toEqual(expect.objectContaining({ status: 'internal_error' }));
113+
});
114+
115+
it('does not create a span for sentry requests', async () => {
116+
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
117+
hub.getScope().setSpan(transaction);
118+
119+
try {
120+
await fetch(`${SENTRY_DSN}/sub/route`, {
121+
method: 'POST',
122+
});
123+
} catch (e) {
124+
// ignore
125+
}
126+
127+
expect(transaction.spanRecorder?.spans.length).toBe(1);
128+
});
129+
130+
it('does not create a span for sentry requests', async () => {
131+
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
132+
hub.getScope().setSpan(transaction);
133+
134+
expect(transaction.spanRecorder?.spans.length).toBe(1);
135+
136+
try {
137+
await fetch(`${SENTRY_DSN}/sub/route`, {
138+
method: 'POST',
139+
});
140+
} catch (e) {
141+
// ignore
142+
}
143+
144+
expect(transaction.spanRecorder?.spans.length).toBe(1);
145+
});
146+
147+
it('does not create a span if there is no active spans', async () => {
148+
try {
149+
await fetch(`${SENTRY_DSN}/sub/route`, { method: 'POST' });
150+
} catch (e) {
151+
// ignore
152+
}
153+
154+
expect(hub.getScope().getSpan()).toBeUndefined();
155+
});
156+
157+
it('does create a span if `shouldCreateSpanForRequest` is defined', async () => {
158+
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
159+
hub.getScope().setSpan(transaction);
160+
161+
const client = new NodeClient({ ...DEFAULT_OPTIONS, shouldCreateSpanForRequest: url => url.includes('yes') });
162+
hub.bindClient(client);
163+
164+
await fetch('http://localhost:18099/no', { method: 'POST' });
165+
166+
expect(transaction.spanRecorder?.spans.length).toBe(1);
167+
168+
await fetch('http://localhost:18099/yes', { method: 'POST' });
169+
170+
expect(transaction.spanRecorder?.spans.length).toBe(2);
171+
});
172+
173+
it('attaches the sentry trace and baggage headers', async () => {
174+
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
175+
hub.getScope().setSpan(transaction);
176+
177+
await fetch('http://localhost:18099', { method: 'POST' });
178+
179+
expect(transaction.spanRecorder?.spans.length).toBe(2);
180+
const span = transaction.spanRecorder?.spans[1];
181+
182+
expect(requestHeaders['sentry-trace']).toEqual(span?.toTraceparent());
183+
expect(requestHeaders['baggage']).toEqual(
184+
`sentry-environment=production,sentry-transaction=test-transaction,sentry-public_key=0,sentry-trace_id=${transaction.traceId},sentry-sample_rate=1`,
185+
);
186+
});
187+
188+
it('uses tracePropagationTargets', async () => {
189+
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
190+
hub.getScope().setSpan(transaction);
191+
192+
const client = new NodeClient({ ...DEFAULT_OPTIONS, tracePropagationTargets: ['/yes'] });
193+
hub.bindClient(client);
194+
195+
expect(transaction.spanRecorder?.spans.length).toBe(1);
196+
197+
await fetch('http://localhost:18099/no', { method: 'POST' });
198+
199+
expect(transaction.spanRecorder?.spans.length).toBe(2);
200+
201+
expect(requestHeaders['sentry-trace']).toBeUndefined();
202+
expect(requestHeaders['baggage']).toBeUndefined();
203+
204+
await fetch('http://localhost:18099/yes', { method: 'POST' });
205+
206+
expect(transaction.spanRecorder?.spans.length).toBe(3);
207+
208+
expect(requestHeaders['sentry-trace']).toBeDefined();
209+
expect(requestHeaders['baggage']).toBeDefined();
210+
});
211+
212+
it('adds a breadcrumb on request', async () => {
213+
expect.assertions(1);
214+
215+
const client = new NodeClient({
216+
...DEFAULT_OPTIONS,
217+
beforeBreadcrumb: breadcrumb => {
218+
expect(breadcrumb).toEqual({
219+
category: 'http',
220+
data: {
221+
method: 'POST',
222+
status_code: 200,
223+
url: 'http://localhost:18099/',
224+
},
225+
type: 'http',
226+
timestamp: expect.any(Number),
227+
});
228+
return breadcrumb;
229+
},
230+
});
231+
hub.bindClient(client);
232+
233+
await fetch('http://localhost:18099', { method: 'POST' });
234+
});
235+
236+
it('adds a breadcrumb on errored request', async () => {
237+
expect.assertions(1);
238+
239+
const client = new NodeClient({
240+
...DEFAULT_OPTIONS,
241+
beforeBreadcrumb: breadcrumb => {
242+
expect(breadcrumb).toEqual({
243+
category: 'http',
244+
data: {
245+
method: 'GET',
246+
url: 'http://a-url-that-no-exists.com/',
247+
},
248+
level: 'error',
249+
type: 'http',
250+
timestamp: expect.any(Number),
251+
});
252+
return breadcrumb;
253+
},
254+
});
255+
hub.bindClient(client);
256+
257+
try {
258+
await fetch('http://a-url-that-no-exists.com');
259+
} catch (e) {
260+
// ignore
261+
}
262+
});
263+
});
264+
265+
interface TestServerOptions {
266+
statusCode: number;
267+
responseHeaders?: Record<string, string | string[] | undefined>;
268+
}
269+
270+
let testServer: http.Server | undefined;
271+
272+
let requestHeaders: any = {};
273+
274+
let testServerOptions: TestServerOptions = {
275+
statusCode: 200,
276+
};
277+
278+
function setTestServerOptions(options: TestServerOptions): void {
279+
testServerOptions = { ...options };
280+
}
281+
282+
function getTestServer(): http.Server | undefined {
283+
return testServer;
284+
}
285+
286+
function setupTestServer() {
287+
testServer = http.createServer((req, res) => {
288+
const chunks: Buffer[] = [];
289+
290+
req.on('data', data => {
291+
chunks.push(data);
292+
});
293+
294+
req.on('end', () => {
295+
requestHeaders = req.headers;
296+
});
297+
298+
res.writeHead(testServerOptions.statusCode, testServerOptions.responseHeaders);
299+
res.end();
300+
301+
// also terminate socket because keepalive hangs connection a bit
302+
res.connection.end();
303+
});
304+
305+
testServer.listen(18099, 'localhost');
306+
307+
return new Promise(resolve => {
308+
testServer?.on('listening', resolve);
309+
});
310+
}

packages/node/test/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { parseSemver } from '@sentry/utils';
2+
3+
/**
4+
* Returns`describe` or `describe.skip` depending on allowed major versions of Node.
5+
*
6+
* @param {{ min?: number; max?: number }} allowedVersion
7+
* @return {*} {jest.Describe}
8+
*/
9+
export const conditionalTest = (allowedVersion: { min?: number; max?: number }): jest.Describe => {
10+
const NODE_VERSION = parseSemver(process.versions.node).major;
11+
if (!NODE_VERSION) {
12+
return describe.skip as jest.Describe;
13+
}
14+
15+
return NODE_VERSION < (allowedVersion.min || -Infinity) || NODE_VERSION > (allowedVersion.max || Infinity)
16+
? (describe.skip as jest.Describe)
17+
: (describe as any);
18+
};

0 commit comments

Comments
 (0)