-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
ref(node): Streamline undici (node-fetch) instrumentation #21650
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
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 |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { createServer } from 'http'; | ||
| import { loggingTransport } from '@sentry-internal/node-integration-tests'; | ||
| import * as Sentry from '@sentry/node'; | ||
|
|
||
| Sentry.init({ | ||
| dsn: 'https://public@dsn.ingest.sentry.io/1337', | ||
| release: '1.0', | ||
| tracesSampleRate: 1.0, | ||
| transport: loggingTransport, | ||
| }); | ||
|
|
||
| // Bind and immediately release a port so we have an address that reliably refuses the connection. | ||
| // A refused outgoing request fires the `undici:request:error` channel, exercising the error path. | ||
| function getRefusedPort(): Promise<number> { | ||
| return new Promise(resolve => { | ||
| const server = createServer(); | ||
| server.listen(0, () => { | ||
| const { port } = server.address() as { port: number }; | ||
| server.close(() => resolve(port)); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| async function run(): Promise<void> { | ||
| const port = await getRefusedPort(); | ||
|
|
||
| await Sentry.startSpan({ name: 'test_transaction' }, async () => { | ||
| await fetch(`http://localhost:${port}/api/v0`).catch(() => { | ||
| // Ignore the expected connection error | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||
| run(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { expect, test } from 'vitest'; | ||
| import { createRunner } from '../../../../utils/runner'; | ||
|
|
||
| test('captures an errored span for a failed outgoing fetch request', async () => { | ||
| await createRunner(__dirname, 'scenario.ts') | ||
| .expect({ | ||
| transaction: { | ||
| transaction: 'test_transaction', | ||
| spans: expect.arrayContaining([ | ||
| expect.objectContaining({ | ||
| description: expect.stringMatching(/GET http:\/\/localhost:\d+\//), | ||
| op: 'http.client', | ||
| origin: 'auto.http.otel.node_fetch', | ||
| status: 'internal_error', | ||
| }), | ||
| ]), | ||
| }, | ||
| }) | ||
| .start() | ||
| .completed(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { loggingTransport } from '@sentry-internal/node-integration-tests'; | ||
| import * as Sentry from '@sentry/node'; | ||
|
|
||
| Sentry.init({ | ||
| dsn: 'https://public@dsn.ingest.sentry.io/1337', | ||
| release: '1.0', | ||
| tracesSampleRate: 1.0, | ||
| transport: loggingTransport, | ||
| integrations: [ | ||
| Sentry.nativeNodeFetchIntegration({ | ||
| headersToSpanAttributes: { | ||
| requestHeaders: ['x-test-header'], | ||
| responseHeaders: ['x-powered-by'], | ||
| }, | ||
| }), | ||
| ], | ||
| }); | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||
| Sentry.startSpan({ name: 'test_transaction' }, async () => { | ||
| await fetch(`${process.env.SERVER_URL}/api/v0`, { headers: { 'x-test-header': 'test-value' } }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { createTestServer } from '@sentry-internal/test-utils'; | ||
| import { expect, test } from 'vitest'; | ||
| import { createRunner } from '../../../../utils/runner'; | ||
|
|
||
| test('maps configured request & response headers to span attributes', async () => { | ||
| expect.assertions(2); | ||
|
|
||
| const [SERVER_URL, closeTestServer] = await createTestServer() | ||
| .get('/api/v0', headers => { | ||
| expect(headers['x-test-header']).toBe('test-value'); | ||
| }) | ||
| .start(); | ||
|
|
||
| await createRunner(__dirname, 'scenario.ts') | ||
| .withEnv({ SERVER_URL }) | ||
| .expect({ | ||
| transaction: { | ||
| transaction: 'test_transaction', | ||
| spans: expect.arrayContaining([ | ||
| expect.objectContaining({ | ||
| description: expect.stringMatching(/GET .*\/api\/v0/), | ||
| op: 'http.client', | ||
| origin: 'auto.http.otel.node_fetch', | ||
| data: expect.objectContaining({ | ||
| 'http.request.header.x-test-header': ['test-value'], | ||
| 'http.response.header.x-powered-by': ['Express'], | ||
| }), | ||
| }), | ||
| ]), | ||
| }, | ||
| }) | ||
| .start() | ||
| .completed(); | ||
| closeTestServer(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,13 +53,16 @@ interface NodeFetchOptions extends Pick< | |
| ignoreOutgoingRequests?: (url: string) => boolean; | ||
| } | ||
|
|
||
| const instrumentOtelNodeFetch = generateInstrumentOnce( | ||
| INTEGRATION_NAME, | ||
| UndiciInstrumentation, | ||
| (options: NodeFetchOptions) => { | ||
| return _getConfigWithDefaults(options); | ||
| }, | ||
| ); | ||
| let _undiciInstrumentation: UndiciInstrumentation | undefined; | ||
|
|
||
| // Sets up the vendored undici instrumentation (emits `http.client` spans & propagates traces). | ||
| // The module-level singleton mirrors `generateInstrumentOnce`'s "instrument once per process" behavior. | ||
| function instrumentNodeFetchSpans(options: NodeFetchOptions): void { | ||
| if (!_undiciInstrumentation) { | ||
| _undiciInstrumentation = new UndiciInstrumentation(_getConfigWithDefaults(options)); | ||
| } | ||
| _undiciInstrumentation.enable(); | ||
|
Comment on lines
+60
to
+64
Contributor
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. Bug: The Suggested FixTo allow for configuration updates, implement a Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| } | ||
|
|
||
| const instrumentSentryNodeFetch = generateInstrumentOnce( | ||
| `${INTEGRATION_NAME}.sentry`, | ||
|
|
@@ -75,14 +78,14 @@ const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { | |
| setupOnce() { | ||
| const instrumentSpans = _shouldInstrumentSpans(options, getClient<NodeClient>()?.getOptions()); | ||
|
|
||
| // This is the "regular" OTEL instrumentation that emits spans | ||
| // This is the instrumentation that emits spans & propagates traces for outgoing fetch requests | ||
| if (instrumentSpans) { | ||
| instrumentOtelNodeFetch(options); | ||
| instrumentNodeFetchSpans(options); | ||
| } | ||
|
|
||
| // This is the Sentry-specific instrumentation that creates breadcrumbs & propagates traces | ||
| // This must be registered after the OTEL one, to ensure that the core trace propagation logic takes presedence | ||
| // Otherwise, the sentry-trace header may be set multiple times | ||
| // This is the Sentry-specific instrumentation that creates breadcrumbs & propagates traces. | ||
| // It must subscribe to the diagnostics channels after the span instrumentation above, so the core | ||
| // trace propagation logic takes precedence. Otherwise, the sentry-trace header may be set multiple times. | ||
| instrumentSentryNodeFetch(options); | ||
| }, | ||
| }; | ||
|
|
@@ -116,7 +119,6 @@ function _shouldInstrumentSpans(options: NodeFetchOptions, clientOptions: Partia | |
| /** Exported only for tests. */ | ||
| export function _getConfigWithDefaults(options: Partial<NodeFetchOptions> = {}): UndiciInstrumentationConfig { | ||
| const instrumentationConfig = { | ||
| requireParentforSpans: false, | ||
| ignoreRequestHook: request => { | ||
| const url = getAbsoluteUrl(request.origin, request.path); | ||
| const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * | ||
| * NOTICE from the Sentry authors: | ||
| * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/ed97091c9890dd18e52759f2ea98e9d7593b3ae4/packages/instrumentation-undici | ||
| * - Upstream version: @opentelemetry/instrumentation-undici@0.24.0 | ||
| * - The semantic-convention constants this package emits, inlined from | ||
| * `@opentelemetry/semantic-conventions` (matching the sibling vendored dirs). | ||
| */ | ||
|
|
||
| export const ATTR_HTTP_REQUEST_METHOD = 'http.request.method' as const; | ||
| export const ATTR_HTTP_REQUEST_METHOD_ORIGINAL = 'http.request.method_original' as const; | ||
| export const ATTR_HTTP_RESPONSE_STATUS_CODE = 'http.response.status_code' as const; | ||
| export const ATTR_NETWORK_PEER_ADDRESS = 'network.peer.address' as const; | ||
| export const ATTR_NETWORK_PEER_PORT = 'network.peer.port' as const; | ||
| export const ATTR_SERVER_ADDRESS = 'server.address' as const; | ||
| export const ATTR_SERVER_PORT = 'server.port' as const; | ||
| export const ATTR_URL_FULL = 'url.full' as const; | ||
| export const ATTR_URL_PATH = 'url.path' as const; | ||
| export const ATTR_URL_QUERY = 'url.query' as const; | ||
| export const ATTR_URL_SCHEME = 'url.scheme' as const; | ||
| export const ATTR_USER_AGENT_ORIGINAL = 'user_agent.original' as const; |
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.
Undici config frozen at first init
Low Severity
UndiciInstrumentationis constructed only when_undiciInstrumentationis first created; laterinstrumentNodeFetchSpans(options)calls only invokeenable(). Updated integration options (hooks, header mapping, ignore callbacks) from a subsequent setup path are not applied to the singleton instance.Reviewed by Cursor Bugbot for commit 3962e7f. Configure here.