Skip to content

test(node): Add tests for Undici #7628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"@types/lru-cache": "^5.1.0",
"@types/node": "~10.17.0",
"express": "^4.17.1",
"nock": "^13.0.5"
"nock": "^13.0.5",
"undici": "^5.21.0"
},
"scripts": {
"build": "run-p build:transpile build:types",
Expand Down
7 changes: 5 additions & 2 deletions packages/node/src/integrations/undici/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const DEFAULT_UNDICI_OPTIONS: UndiciOptions = {
breadcrumbs: true,
};

// Please note that you cannot use `console.log` to debug the callbacks registered to the `diagnostics_channel` API.
// To debug, you can use `writeFileSync` to write to a file:
// https://nodejs.org/api/async_hooks.html#printing-in-asynchook-callbacks

Comment on lines +36 to +39
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds painful :/

/**
* Instruments outgoing HTTP requests made with the `undici` package via
* Node's `diagnostics_channel` API.
Expand Down Expand Up @@ -89,7 +93,7 @@ export class Undici implements Integration {
const url = new URL(request.path, request.origin);
const stringUrl = url.toString();

if (isSentryRequest(stringUrl)) {
if (isSentryRequest(stringUrl) || request.__sentry__ !== undefined) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept running into this, but I'm not sure why it's happening, the spec doesn't allow for it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm this looks strange. As if the same request went twice through this channel? Anyway, I think it's fine to bail out in this case

return;
}

Expand Down Expand Up @@ -132,7 +136,6 @@ export class Undici implements Integration {
: true;

if (shouldPropagate) {
// TODO: Only do this based on tracePropagationTargets
request.addHeader('sentry-trace', span.toTraceparent());
if (span.transaction) {
const dynamicSamplingContext = span.transaction.getDynamicSamplingContext();
Expand Down
2 changes: 1 addition & 1 deletion packages/node/src/integrations/undici/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type { Span } from '@sentry/core';

// Vendored code starts here:

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

/**
* The `diagnostics_channel` module provides an API to create named channels
Expand Down
320 changes: 320 additions & 0 deletions packages/node/test/integrations/undici.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
import type { Transaction } from '@sentry/core';
import { Hub, makeMain } from '@sentry/core';
import * as http from 'http';
import type { fetch as FetchType } from 'undici';

import { NodeClient } from '../../src/client';
import { Undici } from '../../src/integrations/undici';
import { getDefaultNodeClientOptions } from '../helper/node-client-options';
import { conditionalTest } from '../utils';

const SENTRY_DSN = 'https://[email protected]/0';

let hub: Hub;
let fetch: typeof FetchType;

beforeAll(async () => {
await setupTestServer();
try {
// need to conditionally require `undici` because it's not available in Node 10
// eslint-disable-next-line @typescript-eslint/no-var-requires
fetch = require('undici').fetch;
} catch (e) {
// eslint-disable-next-line no-console
console.warn('Undici integration tests are skipped because undici is not installed.');
}
});

const DEFAULT_OPTIONS = getDefaultNodeClientOptions({
dsn: SENTRY_DSN,
tracesSampleRate: 1,
integrations: [new Undici()],
});

beforeEach(() => {
const client = new NodeClient(DEFAULT_OPTIONS);
hub = new Hub(client);
makeMain(hub);
});

afterEach(() => {
requestHeaders = {};
setTestServerOptions({ statusCode: 200 });
});

afterAll(() => {
getTestServer()?.close();
});

conditionalTest({ min: 16 })('Undici integration', () => {
it.each([
[
'simple url',
'http://localhost:18099',
undefined,
{
description: 'GET http://localhost:18099/',
op: 'http.client',
},
],
[
'url with query',
'http://localhost:18099?foo=bar',
undefined,
{
description: 'GET http://localhost:18099/',
op: 'http.client',
data: {
'http.query': '?foo=bar',
},
},
],
[
'url with POST method',
'http://localhost:18099',
{ method: 'POST' },
{
description: 'POST http://localhost:18099/',
},
],
[
'url with POST method',
'http://localhost:18099',
{ method: 'POST' },
{
description: 'POST http://localhost:18099/',
},
],
[
'url with GET as default',
'http://localhost:18099',
{ method: undefined },
{
description: 'GET http://localhost:18099/',
},
],
])('creates a span with a %s', async (_: string, request, requestInit, expected) => {
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
hub.getScope().setSpan(transaction);

await fetch(request, requestInit);

expect(transaction.spanRecorder?.spans.length).toBe(2);

const span = transaction.spanRecorder?.spans[1];
expect(span).toEqual(expect.objectContaining(expected));
});

it('creates a span with internal errors', async () => {
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
hub.getScope().setSpan(transaction);

try {
await fetch('http://a-url-that-no-exists.com');
} catch (e) {
// ignore
}

expect(transaction.spanRecorder?.spans.length).toBe(2);

const span = transaction.spanRecorder?.spans[1];
expect(span).toEqual(expect.objectContaining({ status: 'internal_error' }));
});

it('does not create a span for sentry requests', async () => {
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
hub.getScope().setSpan(transaction);

try {
await fetch(`${SENTRY_DSN}/sub/route`, {
method: 'POST',
});
Comment on lines +129 to +131
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l (and more a question than a suggestion):
This makes me wonder about our isSentryRequest check. As I mentioned yesterday, we have 5 slightly different checks for identifying a sentry request all over the code base. I'll sooner or later clean this up (after #7626) but should this check take the Http method into account? Some checks do (example) while others (like the one you're using here) don't. I'd argue it's probably not necessary to check for the method but wdyt?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All sentry requests use POST atm, so I don't see anything wrong with this for now, but the more future-proof and correct solution is to not take into account the http method (in case we do ever introduce GET methods - think something like dynamic config).

Going to keep this as is for the test, we can come back and refactor all instances of isSentryRequest later.

} catch (e) {
// ignore
}

expect(transaction.spanRecorder?.spans.length).toBe(1);
});

it('does not create a span if there is no active spans', async () => {
try {
await fetch(`${SENTRY_DSN}/sub/route`, { method: 'POST' });
} catch (e) {
// ignore
}

expect(hub.getScope().getSpan()).toBeUndefined();
});

it('does create a span if `shouldCreateSpanForRequest` is defined', async () => {
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
hub.getScope().setSpan(transaction);

const client = new NodeClient({ ...DEFAULT_OPTIONS, shouldCreateSpanForRequest: url => url.includes('yes') });
hub.bindClient(client);

await fetch('http://localhost:18099/no', { method: 'POST' });

expect(transaction.spanRecorder?.spans.length).toBe(1);

await fetch('http://localhost:18099/yes', { method: 'POST' });

expect(transaction.spanRecorder?.spans.length).toBe(2);
});

it('attaches the sentry trace and baggage headers', async () => {
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
hub.getScope().setSpan(transaction);

await fetch('http://localhost:18099', { method: 'POST' });

expect(transaction.spanRecorder?.spans.length).toBe(2);
const span = transaction.spanRecorder?.spans[1];

expect(requestHeaders['sentry-trace']).toEqual(span?.toTraceparent());
expect(requestHeaders['baggage']).toEqual(
`sentry-environment=production,sentry-transaction=test-transaction,sentry-public_key=0,sentry-trace_id=${transaction.traceId},sentry-sample_rate=1`,
);
});
Comment on lines +149 to +178
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Let's add a test to cover the case where shouldCreateSpanForRequest returns false which should lead to the tracing headers not being attached.


it('does not attach headers if `shouldCreateSpanForRequest` does not create a span', async () => {
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
hub.getScope().setSpan(transaction);

const client = new NodeClient({ ...DEFAULT_OPTIONS, shouldCreateSpanForRequest: url => url.includes('yes') });
hub.bindClient(client);

await fetch('http://localhost:18099/no', { method: 'POST' });

expect(requestHeaders['sentry-trace']).toBeUndefined();
expect(requestHeaders['baggage']).toBeUndefined();

await fetch('http://localhost:18099/yes', { method: 'POST' });

expect(requestHeaders['sentry-trace']).toBeDefined();
expect(requestHeaders['baggage']).toBeDefined();
});

it('uses tracePropagationTargets', async () => {
const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction;
hub.getScope().setSpan(transaction);

const client = new NodeClient({ ...DEFAULT_OPTIONS, tracePropagationTargets: ['/yes'] });
hub.bindClient(client);

expect(transaction.spanRecorder?.spans.length).toBe(1);

await fetch('http://localhost:18099/no', { method: 'POST' });

expect(transaction.spanRecorder?.spans.length).toBe(2);

expect(requestHeaders['sentry-trace']).toBeUndefined();
expect(requestHeaders['baggage']).toBeUndefined();

await fetch('http://localhost:18099/yes', { method: 'POST' });

expect(transaction.spanRecorder?.spans.length).toBe(3);

expect(requestHeaders['sentry-trace']).toBeDefined();
expect(requestHeaders['baggage']).toBeDefined();
});

it('adds a breadcrumb on request', async () => {
expect.assertions(1);

const client = new NodeClient({
...DEFAULT_OPTIONS,
beforeBreadcrumb: breadcrumb => {
expect(breadcrumb).toEqual({
category: 'http',
data: {
method: 'POST',
status_code: 200,
url: 'http://localhost:18099/',
},
type: 'http',
timestamp: expect.any(Number),
});
return breadcrumb;
},
});
hub.bindClient(client);

await fetch('http://localhost:18099', { method: 'POST' });
});

it('adds a breadcrumb on errored request', async () => {
expect.assertions(1);

const client = new NodeClient({
...DEFAULT_OPTIONS,
beforeBreadcrumb: breadcrumb => {
expect(breadcrumb).toEqual({
category: 'http',
data: {
method: 'GET',
url: 'http://a-url-that-no-exists.com/',
},
level: 'error',
type: 'http',
timestamp: expect.any(Number),
});
return breadcrumb;
},
});
hub.bindClient(client);

try {
await fetch('http://a-url-that-no-exists.com');
} catch (e) {
// ignore
}
});
});

interface TestServerOptions {
statusCode: number;
responseHeaders?: Record<string, string | string[] | undefined>;
}

let testServer: http.Server | undefined;

let requestHeaders: any = {};

let testServerOptions: TestServerOptions = {
statusCode: 200,
};

function setTestServerOptions(options: TestServerOptions): void {
testServerOptions = { ...options };
}

function getTestServer(): http.Server | undefined {
return testServer;
}

function setupTestServer() {
testServer = http.createServer((req, res) => {
const chunks: Buffer[] = [];

req.on('data', data => {
chunks.push(data);
});

req.on('end', () => {
requestHeaders = req.headers;
});

res.writeHead(testServerOptions.statusCode, testServerOptions.responseHeaders);
res.end();

// also terminate socket because keepalive hangs connection a bit
res.connection.end();
});

testServer.listen(18099, 'localhost');

return new Promise(resolve => {
testServer?.on('listening', resolve);
});
}
18 changes: 18 additions & 0 deletions packages/node/test/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { parseSemver } from '@sentry/utils';

/**
* Returns`describe` or `describe.skip` depending on allowed major versions of Node.
*
* @param {{ min?: number; max?: number }} allowedVersion
* @return {*} {jest.Describe}
*/
export const conditionalTest = (allowedVersion: { min?: number; max?: number }): jest.Describe => {
const NODE_VERSION = parseSemver(process.versions.node).major;
if (!NODE_VERSION) {
return describe.skip as jest.Describe;
}

return NODE_VERSION < (allowedVersion.min || -Infinity) || NODE_VERSION > (allowedVersion.max || Infinity)
? (describe.skip as jest.Describe)
: (describe as any);
};
Loading