Skip to content
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-express/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,12 @@ provider.register({
});

registerInstrumentations({
instrumentations: [new UndiciInstrumentation(), new HttpInstrumentation()],
instrumentations: [
new UndiciInstrumentation({
headersToSpanAttributes: {
responseHeaders: ['content-length'],
},
}),
new HttpInstrumentation(),
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
11 changes: 8 additions & 3 deletions packages/node/src/integrations/node-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import type { NodeClientOptions } from '../types';

const INTEGRATION_NAME = 'NodeFetch';

interface NodeFetchOptions extends Pick<UndiciInstrumentationConfig, 'requestHook' | 'responseHook'> {
interface NodeFetchOptions extends Pick<
UndiciInstrumentationConfig,
'requestHook' | 'responseHook' | 'headersToSpanAttributes'
> {
/**
* Whether breadcrumbs should be recorded for requests.
* Defaults to true
Expand Down Expand Up @@ -54,7 +57,7 @@ const instrumentOtelNodeFetch = generateInstrumentOnce(
INTEGRATION_NAME,
UndiciInstrumentation,
(options: NodeFetchOptions) => {
return getConfigWithDefaults(options);
return _getConfigWithDefaults(options);
},
);

Expand Down Expand Up @@ -110,7 +113,8 @@ function _shouldInstrumentSpans(options: NodeFetchOptions, clientOptions: Partia
: !clientOptions.skipOpenTelemetrySetup && hasSpansEnabled(clientOptions);
}

function getConfigWithDefaults(options: Partial<NodeFetchOptions> = {}): UndiciInstrumentationConfig {
/** Exported only for tests. */
export function _getConfigWithDefaults(options: Partial<NodeFetchOptions> = {}): UndiciInstrumentationConfig {
const instrumentationConfig = {
requireParentforSpans: false,
ignoreRequestHook: request => {
Expand Down Expand Up @@ -140,6 +144,7 @@ function getConfigWithDefaults(options: Partial<NodeFetchOptions> = {}): UndiciI
},
requestHook: options.requestHook,
responseHook: options.responseHook,
headersToSpanAttributes: options.headersToSpanAttributes,
} satisfies UndiciInstrumentationConfig;

return instrumentationConfig;
Expand Down
25 changes: 25 additions & 0 deletions packages/node/test/integrations/node-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading