Skip to content

Commit 08cab24

Browse files
authored
fix(node): Deduplicate sentry-trace and baggage headers on outgoing requests (#19960)
This patch fixes a bunch of closely related issues with our node fetch and http integrations for outgoing request header propagation. ### Summary: - We now dedupe sentry-trace and baggage headers more aggressively, resolving multiple scenarios where duplicated sentry headers were attached to outgoing requests - We now always prefer the first sentry tracing headers pair set onto a request. This allows users to set custom sentry headers (for whatever reason) and ensures our instrumentation doesn't overwrite itself. - We no longer mix individual `sentry-` baggage entries when merging two headers where both contain `sentry-` entries. We only take one of the two and delete the other. See PR for further details! closes #19158
1 parent 738b3e7 commit 08cab24

File tree

19 files changed

+1168
-49
lines changed

19 files changed

+1168
-49
lines changed

dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ async function assertRequests({
3939

4040
// No merged sentry trace headers
4141
expect(headers['sentry-trace']).not.toContain(',');
42+
expect(headers['sentry-trace']).toBe('12312012123120121231201212312012-1121201211212012-1');
4243

4344
// No multiple baggage entries
44-
expect(headers['baggage'].match(/sentry-release/g) ?? []).toHaveLength(1);
45+
expect(headers['baggage']).toBe('sentry-release=4.2.0');
4546
});
4647
}
4748

dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterAll, expect, test } from 'vitest';
22
import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
33
import type { TestAPIResponse } from '../server';
4+
import { extractTraceparentData } from '@sentry/core';
45

56
afterAll(() => {
67
cleanupChildProcesses();
@@ -33,7 +34,6 @@ test('should ignore sentry-values in `baggage` header of a third party vendor an
3334
'sentry-environment=myEnv',
3435
'sentry-release=2.1.0',
3536
expect.stringMatching(/sentry-sample_rand=\d+/),
36-
'sentry-sample_rate=0.54',
3737
'third=party',
3838
]);
3939
});
@@ -46,6 +46,11 @@ test('should ignore sentry-values in `baggage` header of a third party vendor an
4646
expect(response).toBeDefined();
4747

4848
const baggage = response?.test_data.baggage?.split(',').sort();
49+
const sentryTraceHeader = response?.test_data['sentry-trace'];
50+
51+
const sentryTrace = extractTraceparentData(sentryTraceHeader);
52+
53+
expect(sentryTrace?.traceId).toMatch(/^[0-9a-f]{32}$/);
4954

5055
expect(response).toMatchObject({
5156
test_data: {
@@ -63,7 +68,7 @@ test('should ignore sentry-values in `baggage` header of a third party vendor an
6368
expect.stringMatching(/sentry-sample_rand=\d+/),
6469
'sentry-sample_rate=1',
6570
'sentry-sampled=true',
66-
expect.stringMatching(/sentry-trace_id=[\da-f]{32}/),
71+
`sentry-trace_id=${sentryTrace?.traceId}`,
6772
'sentry-transaction=GET%20%2Ftest%2Fexpress',
6873
'third=party',
6974
]);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { expect } from 'vitest';
2+
import { extractTraceparentData, parseBaggageHeader, TRACEPARENT_REGEXP } from '@sentry/core';
3+
4+
export function expectNoDuplicateSentryBaggageKeys(baggage: string | string[] | undefined): void {
5+
expect(baggage).toBeDefined();
6+
const baggageStr = Array.isArray(baggage) ? baggage.join(',') : (baggage as string);
7+
const sentryEntries = baggageStr.split(',').filter(entry => entry.trim().startsWith('sentry-'));
8+
const sentryKeyNames = sentryEntries.map(entry => entry.trim().split('=')[0]);
9+
const uniqueKeyNames = [...new Set(sentryKeyNames)];
10+
expect(sentryKeyNames).toEqual(uniqueKeyNames);
11+
}
12+
13+
export function expectConsistentTraceId(headers: Record<string, string | string[] | undefined>): void {
14+
const sentryTrace = headers['sentry-trace'];
15+
expect(sentryTrace).toMatch(TRACEPARENT_REGEXP);
16+
17+
const sentryTraceData = extractTraceparentData(sentryTrace as string)!;
18+
expect(sentryTraceData.traceId).toMatch(/^[a-f\d]{32}$/);
19+
20+
const baggage = parseBaggageHeader(headers['baggage']);
21+
22+
const baggageTraceId = baggage!['sentry-trace_id'];
23+
expect(baggageTraceId).toBeDefined();
24+
expect(baggageTraceId).toMatch(/^[a-f\d]{32}$/);
25+
26+
expect(sentryTraceData.traceId).toEqual(baggageTraceId);
27+
}
28+
29+
export function expectUserSetTraceId(headers: Record<string, string | string[] | undefined>): void {
30+
const xSentryTrace = extractTraceparentData(headers['x-tracedata-sentry-trace'] as string);
31+
const sentryTrace = extractTraceparentData(headers['sentry-trace'] as string);
32+
expect(xSentryTrace?.traceId).toBe(sentryTrace?.traceId);
33+
34+
const xBaggage = parseBaggageHeader(headers['x-tracedata-baggage']);
35+
const baggage = parseBaggageHeader(headers['baggage']);
36+
expect(xBaggage).toEqual(baggage);
37+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
// explicitly not setting tracesSampleRate,
8+
transport: loggingTransport,
9+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as Sentry from '@sentry/node';
2+
import http from 'http';
3+
4+
async function run() {
5+
const traceData = Sentry.getTraceData();
6+
// fetch with manual getTraceData() headers - the core reproduction case from #19158
7+
await fetch(`${process.env.SERVER_URL}/api/fetch-custom-headers`, {
8+
headers: {
9+
...traceData,
10+
'x-tracedata-sentry-trace': traceData['sentry-trace'],
11+
'x-tracedata-baggage': traceData.baggage,
12+
},
13+
}).then(res => res.text());
14+
15+
// fetch without manual headers (baseline - auto-instrumentation only)
16+
await fetch(`${process.env.SERVER_URL}/api/fetch`).then(res => res.text());
17+
18+
// http.request with manual getTraceData() headers
19+
await new Promise((resolve, reject) => {
20+
const url = new URL(`${process.env.SERVER_URL}/api/http-custom-headers`);
21+
const req = http.request(
22+
{
23+
hostname: url.hostname,
24+
port: url.port,
25+
path: url.pathname,
26+
method: 'GET',
27+
headers: {
28+
...traceData,
29+
'x-tracedata-sentry-trace': traceData['sentry-trace'],
30+
'x-tracedata-baggage': traceData.baggage,
31+
},
32+
},
33+
res => {
34+
res.on('data', () => {});
35+
res.on('end', () => resolve());
36+
},
37+
);
38+
req.on('error', reject);
39+
req.end();
40+
});
41+
42+
Sentry.captureException(new Error('done'));
43+
}
44+
45+
run();
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { createTestServer } from '@sentry-internal/test-utils';
2+
import { describe, expect } from 'vitest';
3+
import { createEsmAndCjsTests } from '../../../../utils/runner';
4+
import { expectConsistentTraceId, expectNoDuplicateSentryBaggageKeys, expectUserSetTraceId } from '../expects';
5+
6+
describe('double baggage prevention', () => {
7+
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
8+
test('fetch with manual getTraceData() does not duplicate sentry baggage entries', async () => {
9+
const [SERVER_URL, closeTestServer] = await createTestServer()
10+
.get('/api/fetch-custom-headers', headers => {
11+
// fetch with manual getTraceData() headers
12+
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}$/));
13+
expectNoDuplicateSentryBaggageKeys(headers['baggage']);
14+
expectConsistentTraceId(headers);
15+
expectUserSetTraceId(headers);
16+
})
17+
.get('/api/fetch', headers => {
18+
// fetch without manual headers (baseline)
19+
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}$/));
20+
expectNoDuplicateSentryBaggageKeys(headers['baggage']);
21+
expectConsistentTraceId(headers);
22+
})
23+
.get('/api/http-custom-headers', headers => {
24+
// http.request with manual getTraceData() headers
25+
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}$/));
26+
expectNoDuplicateSentryBaggageKeys(headers['baggage']);
27+
expectConsistentTraceId(headers);
28+
expectUserSetTraceId(headers);
29+
})
30+
.start();
31+
32+
await createRunner()
33+
.withEnv({ SERVER_URL })
34+
.ignore('transaction')
35+
.expect({
36+
event: {
37+
exception: {
38+
values: [{ type: 'Error', value: 'done' }],
39+
},
40+
},
41+
})
42+
.start()
43+
.completed();
44+
closeTestServer();
45+
});
46+
});
47+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as Sentry from '@sentry/node';
2+
import http from 'http';
3+
4+
async function run() {
5+
const traceData = Sentry.getTraceData();
6+
// fetch with manual getTraceData() headers - the core reproduction case from #19158
7+
await fetch(`${process.env.SERVER_URL}/api/fetch-custom-headers`, {
8+
headers: {
9+
...traceData,
10+
'x-tracedata-sentry-trace': traceData['sentry-trace'],
11+
'x-tracedata-baggage': traceData.baggage,
12+
},
13+
}).then(res => res.text());
14+
15+
// fetch without manual headers (baseline - auto-instrumentation only)
16+
await fetch(`${process.env.SERVER_URL}/api/fetch`, {}).then(res => res.text());
17+
18+
// http.request with manual getTraceData() headers
19+
await new Promise((resolve, reject) => {
20+
const url = new URL(`${process.env.SERVER_URL}/api/http-custom-headers`);
21+
const req = http.request(
22+
{
23+
hostname: url.hostname,
24+
port: url.port,
25+
path: url.pathname,
26+
method: 'GET',
27+
headers: {
28+
...traceData,
29+
'x-tracedata-sentry-trace': traceData['sentry-trace'],
30+
'x-tracedata-baggage': traceData.baggage,
31+
},
32+
},
33+
res => {
34+
res.on('data', () => {});
35+
res.on('end', () => resolve());
36+
},
37+
);
38+
req.on('error', reject);
39+
req.end();
40+
});
41+
42+
Sentry.captureException(new Error('done'));
43+
}
44+
45+
run();
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { createTestServer } from '@sentry-internal/test-utils';
2+
import { describe, expect } from 'vitest';
3+
import { createEsmAndCjsTests } from '../../../../utils/runner';
4+
import { expectConsistentTraceId, expectNoDuplicateSentryBaggageKeys, expectUserSetTraceId } from '../expects';
5+
6+
describe('double baggage prevention', () => {
7+
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
8+
test('fetch with manual getTraceData() does not duplicate sentry baggage entries', async () => {
9+
const [SERVER_URL, closeTestServer] = await createTestServer()
10+
.get('/api/fetch-custom-headers', headers => {
11+
// fetch with manual getTraceData() headers
12+
expect(headers['sentry-trace']).not.toContain(',');
13+
expectNoDuplicateSentryBaggageKeys(headers['baggage']);
14+
expectConsistentTraceId(headers);
15+
expectUserSetTraceId(headers);
16+
})
17+
.get('/api/fetch', headers => {
18+
// fetch without manual headers (baseline)
19+
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}(-[01])?$/));
20+
expectNoDuplicateSentryBaggageKeys(headers['baggage']);
21+
expectConsistentTraceId(headers);
22+
})
23+
.get('/api/http-custom-headers', headers => {
24+
// http.request with manual getTraceData() headers
25+
expect(headers['sentry-trace']).not.toContain(',');
26+
expectNoDuplicateSentryBaggageKeys(headers['baggage']);
27+
expectConsistentTraceId(headers);
28+
expectUserSetTraceId(headers);
29+
})
30+
.start();
31+
32+
await createRunner()
33+
.withEnv({ SERVER_URL })
34+
.expect({
35+
event: {
36+
exception: {
37+
values: [{ type: 'Error', value: 'done' }],
38+
},
39+
},
40+
})
41+
.start()
42+
.completed();
43+
closeTestServer();
44+
});
45+
});
46+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});

0 commit comments

Comments
 (0)