From a4d2283af3c860ba12e7383180b8f1d23828ca2e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 11 Mar 2026 19:47:40 +0100 Subject: [PATCH] feat(node): Expose `headersToSpanAttributes` option on `nativeNodeFetchIntegration` Allows users to configure which HTTP request/response headers are captured as span attributes on fetch/undici spans. This was previously captured automatically for `content-length` but is now opt-in since [@opentelemetry/instrumentation-unidici@0.22.0](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/instrumentation-undici/CHANGELOG.md#0220-2026-02-16). --- CHANGELOG.md | 19 ++++++++++++ .../test-applications/node-express/src/app.ts | 13 +++++++++ .../node-express/tests/transactions.test.ts | 29 +++++++++++++++++++ .../src/instrument.ts | 9 +++++- .../tests/transactions.test.ts | 4 +++ packages/node/src/integrations/node-fetch.ts | 11 +++++-- .../node/test/integrations/node-fetch.test.ts | 25 ++++++++++++++++ 7 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 packages/node/test/integrations/node-fetch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1052530c9caf..cf916274650e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(node): Expose `headersToSpanAttributes` option on `nativeNodeFetchIntegration()` ([#19770](https://github.com/getsentry/sentry-javascript/pull/19770))** + + Response headers like `http.response.header.content-length` were previously captured automatically on outgoing + fetch spans but are now opt-in since `@opentelemetry/instrumentation-undici@0.22.0`. You can now configure which + headers to capture via the `headersToSpanAttributes` option. + + ```js + Sentry.init({ + integrations: [ + Sentry.nativeNodeFetchIntegration({ + headersToSpanAttributes: { + requestHeaders: ['x-custom-header'], + responseHeaders: ['content-length', 'content-type'], + }, + }), + ], + }); + ``` + - **feat(nestjs): Instrument `@nestjs/schedule` decorators ([#19735](https://github.com/getsentry/sentry-javascript/pull/19735))** Automatically capture exceptions thrown in `@Cron`, `@Interval`, and `@Timeout` decorated methods. diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts index 2a7cccf238cc..dc755f95d062 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts @@ -14,6 +14,13 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, enableLogs: true, + integrations: [ + Sentry.nativeNodeFetchIntegration({ + headersToSpanAttributes: { + responseHeaders: ['content-length'], + }, + }), + ], }); import { TRPCError, initTRPC } from '@trpc/server'; @@ -59,6 +66,12 @@ app.get('/test-transaction', function (_req, res) { res.send({ status: 'ok' }); }); + +app.get('/test-outgoing-fetch', async function (_req, res) { + const response = await fetch('http://localhost:3030/test-success'); + const data = await response.json(); + res.send(data); +}); app.get('/test-error', async function (req, res) { const exceptionId = Sentry.captureException(new Error('This is an error')); diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts index ce809b6ccdee..7784d7fbe3fe 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts @@ -216,6 +216,35 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) }); }); +test('Outgoing fetch spans include response headers when headersToSpanAttributes is configured', async ({ + baseURL, +}) => { + const transactionEventPromise = waitForTransaction('node-express', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-outgoing-fetch' + ); + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + // Find the outgoing fetch span (http.client operation from undici instrumentation) + const fetchSpan = spans.find( + span => span.op === 'http.client' && span.description?.includes('localhost:3030/test-success'), + ); + + expect(fetchSpan).toBeDefined(); + expect(fetchSpan?.data).toEqual( + expect.objectContaining({ + 'http.response.header.content-length': [expect.any(String)], + }), + ); +}); + test('Extracts HTTP request headers as span attributes', async ({ baseURL }) => { const transactionEventPromise = waitForTransaction('node-express', transactionEvent => { return ( diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts index 96d2472be497..ea9b6ae57545 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts @@ -38,5 +38,12 @@ provider.register({ }); registerInstrumentations({ - instrumentations: [new UndiciInstrumentation(), new HttpInstrumentation()], + instrumentations: [ + new UndiciInstrumentation({ + headersToSpanAttributes: { + responseHeaders: ['content-length'], + }, + }), + new HttpInstrumentation(), + ], }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts index 7ae23a38f288..9823d0dc6b12 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts @@ -75,6 +75,10 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { { key: 'network.peer.address', value: { stringValue: expect.any(String) } }, { key: 'network.peer.port', value: { intValue: 3030 } }, { key: 'http.response.status_code', value: { intValue: 200 } }, + { + key: 'http.response.header.content-length', + value: { arrayValue: { values: [{ stringValue: expect.any(String) }] } }, + }, ]), droppedAttributesCount: 0, events: [], diff --git a/packages/node/src/integrations/node-fetch.ts b/packages/node/src/integrations/node-fetch.ts index 2a1e1cac9098..74bfff2dab47 100644 --- a/packages/node/src/integrations/node-fetch.ts +++ b/packages/node/src/integrations/node-fetch.ts @@ -16,7 +16,10 @@ import type { NodeClientOptions } from '../types'; const INTEGRATION_NAME = 'NodeFetch'; -interface NodeFetchOptions extends Pick { +interface NodeFetchOptions extends Pick< + UndiciInstrumentationConfig, + 'requestHook' | 'responseHook' | 'headersToSpanAttributes' +> { /** * Whether breadcrumbs should be recorded for requests. * Defaults to true @@ -54,7 +57,7 @@ const instrumentOtelNodeFetch = generateInstrumentOnce( INTEGRATION_NAME, UndiciInstrumentation, (options: NodeFetchOptions) => { - return getConfigWithDefaults(options); + return _getConfigWithDefaults(options); }, ); @@ -110,7 +113,8 @@ function _shouldInstrumentSpans(options: NodeFetchOptions, clientOptions: Partia : !clientOptions.skipOpenTelemetrySetup && hasSpansEnabled(clientOptions); } -function getConfigWithDefaults(options: Partial = {}): UndiciInstrumentationConfig { +/** Exported only for tests. */ +export function _getConfigWithDefaults(options: Partial = {}): UndiciInstrumentationConfig { const instrumentationConfig = { requireParentforSpans: false, ignoreRequestHook: request => { @@ -140,6 +144,7 @@ function getConfigWithDefaults(options: Partial = {}): UndiciI }, requestHook: options.requestHook, responseHook: options.responseHook, + headersToSpanAttributes: options.headersToSpanAttributes, } satisfies UndiciInstrumentationConfig; return instrumentationConfig; diff --git a/packages/node/test/integrations/node-fetch.test.ts b/packages/node/test/integrations/node-fetch.test.ts new file mode 100644 index 000000000000..a627d48dc6c0 --- /dev/null +++ b/packages/node/test/integrations/node-fetch.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { _getConfigWithDefaults } from '../../src/integrations/node-fetch'; + +describe('nativeNodeFetchIntegration', () => { + describe('_getConfigWithDefaults', () => { + it('passes headersToSpanAttributes through to the config', () => { + const config = _getConfigWithDefaults({ + headersToSpanAttributes: { + requestHeaders: ['x-custom-header'], + responseHeaders: ['content-length', 'content-type'], + }, + }); + + expect(config.headersToSpanAttributes).toEqual({ + requestHeaders: ['x-custom-header'], + responseHeaders: ['content-length', 'content-type'], + }); + }); + + it('does not set headersToSpanAttributes when not provided', () => { + const config = _getConfigWithDefaults({}); + expect(config.headersToSpanAttributes).toBeUndefined(); + }); + }); +});