-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
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
Changes from all commits
c94fdaa
67c5dbd
aaa3dbf
9d1ba89
cd76d6a
8128d79
fee6cc0
956ef49
15e9446
1834a71
3931a20
3dd7943
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
||
/** | ||
* Instruments outgoing HTTP requests made with the `undici` package via | ||
* Node's `diagnostics_channel` API. | ||
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} | ||
|
||
|
@@ -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(); | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l (and more a question than a suggestion): There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Going to keep this as is for the test, we can come back and refactor all instances of |
||
} 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l: Let's add a test to cover the case where |
||
|
||
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); | ||
}); | ||
} |
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); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds painful :/