8000 test(node): Add tests for Undici (#7628) · bertho-zero/sentry-javascript@32675e8 · GitHub
[go: up one dir, main page]

Skip to content

Commit 32675e8

Browse files
authored
test(node): Add tests for Undici (getsentry#7628)
1 parent f033ede commit 32675e8

File tree

6 files changed

+353
-4
lines changed

6 files changed

+353
-4
lines changed

packages/node/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"@types/lru-cache": "^5.1.0",
3232
"@types/node": "~10.17.0",
3333
"express": "^4.17.1",
34-
"nock": "^13.0.5"
34+
"nock": "^13.0.5",
35+
"undici": "^5.21.0"
3536
},
3637
"scripts": {
3738
"build": "run-p build:transpile build:types",

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

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)
0