Skip to content

Commit 4caafd8

Browse files
committed
more cleanup, fix edge case with odd headers, dedupe traceparent
1 parent e300e33 commit 4caafd8

File tree

2 files changed

+184
-33
lines changed

2 files changed

+184
-33
lines changed

packages/node-core/src/utils/outgoingFetchRequest.ts

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { mergeBaggageHeaders } from './baggage';
1313

1414
const SENTRY_TRACE_HEADER = 'sentry-trace';
1515
const SENTRY_BAGGAGE_HEADER = 'baggage';
16-
16+
const W3C_TRACEPARENT_HEADER = 'traceparent';
1717
/**
1818
* Add trace propagation headers to an outgoing fetch/undici request.
1919
*
@@ -46,14 +46,18 @@ export function addTracePropagationHeadersToFetchRequest(
4646

4747
// OTel's UndiciInstrumentation calls propagation.inject() which unconditionally
4848
// appends headers to the request. When the user also sets headers via getTraceData(),
49-
// this results in duplicate sentry-trace and baggage entries.
49+
// this results in duplicate sentry-trace and baggage (and optionally traceparent) entries.
5050
// We clean these up before applying our own logic.
51-
_deduplicateArrayHeaders(requestHeaders);
51+
_deduplicateArrayHeader(requestHeaders, SENTRY_TRACE_HEADER);
52+
_deduplicateArrayHeader(requestHeaders, SENTRY_BAGGAGE_HEADER);
53+
if (propagateTraceparent) {
54+
_deduplicateArrayHeader(requestHeaders, W3C_TRACEPARENT_HEADER);
55+
}
5256

5357
// We do not want to overwrite existing headers here
5458
// If the core UndiciInstrumentation is registered, it will already have set the headers
5559
// We do not want to add any then
56-
const hasExistingSentryTraceHeader = requestHeaders.includes(SENTRY_TRACE_HEADER);
60+
const hasExistingSentryTraceHeader = _findExistingHeaderIndex(requestHeaders, SENTRY_TRACE_HEADER) !== -1;
5761

5862
// We do not want to set any headers if we already have an existing sentry-trace header.
5963
// sentry-trace is still the source of truth, otherwise we risk mixing up baggage and sentry-trace values.
@@ -62,20 +66,20 @@ export function addTracePropagationHeadersToFetchRequest(
6266
requestHeaders.push(SENTRY_TRACE_HEADER, sentryTrace);
6367
}
6468

65-
if (traceparent && !requestHeaders.includes('traceparent')) {
69+
if (traceparent && _findExistingHeaderIndex(requestHeaders, 'traceparent') === -1) {
6670
requestHeaders.push('traceparent', traceparent);
6771
}
6872

6973
// For baggage, we make sure to merge this into a possibly existing header
70-
const existingBaggagePos = requestHeaders.findIndex(header => header === SENTRY_BAGGAGE_HEADER);
71-
if (baggage && existingBaggagePos === -1) {
74+
const existingBaggageIndex = _findExistingHeaderIndex(requestHeaders, SENTRY_BAGGAGE_HEADER);
75+
if (baggage && existingBaggageIndex === -1) {
7276
requestHeaders.push(SENTRY_BAGGAGE_HEADER, baggage);
7377
} else if (baggage) {
7478
// headers in format [key_0, value_0, key_1, value_1, ...], hence the +1 here
75-
const existingBaggage = requestHeaders[existingBaggagePos + 1];
76-
const merged = mergeBaggageHeaders(existingBaggage, baggage);
79+
const existingBaggageValue = requestHeaders[existingBaggageIndex + 1];
80+
const merged = mergeBaggageHeaders(existingBaggageValue, baggage);
7781
if (merged) {
78-
requestHeaders[existingBaggagePos + 1] = merged;
82+
requestHeaders[existingBaggageIndex + 1] = merged;
7983
}
8084
}
8185
}
@@ -108,11 +112,15 @@ function stringToArrayHeaders(requestHeaders: string): string[] {
108112
function arrayToStringHeaders(headers: string[]): string {
109113
const headerPairs: string[] = [];
110114

111-
try {
112-
for (let i = 0; i < headers.length; i += 2) {
113-
headerPairs.push(`${headers[i]}: ${headers[i + 1]}`);
115+
for (let i = 0; i < headers.length; i += 2) {
116+
const key = headers[i];
117+
const value = headers[i + 1];
118+
if (!key || value == null) {
119+
// skip falsy keys but only null/undefined values
120+
continue;
114121
}
115-
} catch {}
122+
headerPairs.push(`${key}: ${value}`);
123+
}
116124

117125
if (!headerPairs.length) {
118126
return '';
@@ -121,21 +129,6 @@ function arrayToStringHeaders(headers: string[]): string {
121129
return headerPairs.join('\r\n').concat('\r\n');
122130
}
123131

124-
/**
125-
* Remove duplicate sentry-trace and baggage headers from the request.
126-
*
127-
* OTel's UndiciInstrumentation unconditionally appends headers via propagation.inject(),
128-
* which can create duplicates when the user has already set these headers (e.g. via getTraceData()).
129-
* For sentry-trace, we keep the first occurrence (user-set).
130-
* For baggage, we merge all occurrences into one header to preserve non-sentry entries. For Sentry
131-
* entries, we keep the first occurrence.
132-
*/
133-
134-
function _deduplicateArrayHeaders(headers: string[]): void {
135-
_deduplicateArrayHeader(headers, SENTRY_TRACE_HEADER);
136-
_deduplicateArrayHeader(headers, SENTRY_BAGGAGE_HEADER);
137-
}
138-
139132
/**
140133
* For a given header name, if there are multiple entries in the [key, value, key, value, ...] array,
141134
* keep the first entry and remove the rest.
@@ -167,6 +160,15 @@ function _deduplicateArrayHeader(headers: string[], headerName: string): void {
167160
}
168161
}
169162

163+
/**
164+
* Find the index of an existing header in an array of headers.
165+
* Only take even indices, because headers are in format [key_0, value_0, key_1, value_1, ...]
166+
* otherwise we could match a header _value_ with @param name
167+
*/
168+
function _findExistingHeaderIndex(headers: string[], name: string): number {
169+
return headers.findIndex((header, i) => i % 2 === 0 && header === name);
170+
}
171+
170172
/** Add a breadcrumb for an outgoing fetch/undici request. */
171173
export function addFetchRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void {
172174
const data = getBreadcrumbData(request);

packages/node-core/test/utils/outgoingFetchRequest.test.ts

Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
1+
import type { MockedFunction } from 'vitest';
12
import { describe, beforeEach, vi, expect, it } from 'vitest';
23
import type { UndiciRequest } from '../../src/integrations/node-fetch/types';
34
import { addTracePropagationHeadersToFetchRequest } from '../../src/utils/outgoingFetchRequest';
45
import { LRUMap } from '@sentry/core';
56
import * as SentryCore from '@sentry/core';
67

7-
const mockedGetTraceData = vi.hoisted(() =>
8+
const mockedGetTraceData: MockedFunction<() => ReturnType<typeof SentryCore.getTraceData>> = vi.hoisted(() =>
89
vi.fn(() => ({
910
'sentry-trace': 'trace_id_1-span_id_1-1',
1011
baggage: 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging',
1112
})),
1213
);
1314

15+
const mockedClientGetOptions: MockedFunction<() => Partial<SentryCore.ClientOptions>> = vi.hoisted(() =>
16+
vi.fn(() => ({
17+
tracePropagationTargets: ['https://example.com'],
18+
propagateTraceparent: true,
19+
})),
20+
);
21+
1422
vi.mock('@sentry/core', async () => {
1523
const actual = await vi.importActual('@sentry/core');
1624
return {
1725
...actual,
1826
getClient: vi.fn(() => ({
19-
getOptions: vi.fn(() => ({
20-
tracePropagationTargets: ['https://example.com'],
21-
})),
27+
getOptions: mockedClientGetOptions,
2228
})),
2329
shouldPropagateTraceForUrl: () => true,
2430
getTraceData: mockedGetTraceData,
@@ -62,6 +68,31 @@ describe('addTracePropagationHeadersToFetchRequest', () => {
6268
]);
6369
});
6470

71+
it('adds sentry-trace, baggage and traceparent headers to request', () => {
72+
mockedGetTraceData.mockReturnValueOnce({
73+
'sentry-trace': 'trace_id_1-span_id_1-1',
74+
baggage: 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging',
75+
traceparent: '00-trace_id_1-span_id_1-01',
76+
});
77+
78+
const request = {
79+
headers: [] as string[],
80+
origin: 'https://some-service.com',
81+
path: '/api/test',
82+
} as UndiciRequest;
83+
84+
addTracePropagationHeadersToFetchRequest(request, new LRUMap<string, boolean>(100));
85+
86+
expect(request.headers).toEqual([
87+
'sentry-trace',
88+
'trace_id_1-span_id_1-1',
89+
'traceparent',
90+
'00-trace_id_1-span_id_1-01',
91+
'baggage',
92+
'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging',
93+
]);
94+
});
95+
6596
it('preserves non-sentry entries in existing baggage header', () => {
6697
const request = {
6798
headers: ['baggage', 'other=entry,not=sentry'],
@@ -79,6 +110,31 @@ describe('addTracePropagationHeadersToFetchRequest', () => {
79110
]);
80111
});
81112

113+
it('preserves pre-existing traceparent header', () => {
114+
mockedGetTraceData.mockReturnValueOnce({
115+
'sentry-trace': 'trace_id_1-span_id_1-1',
116+
baggage: 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging',
117+
traceparent: '00-trace_id_1-span_id_1-01',
118+
});
119+
120+
const request = {
121+
headers: ['traceparent', '00-some-other-trace_id-span_id_x-01'],
122+
origin: 'https://some-service.com',
123+
path: '/api/test',
124+
} as UndiciRequest;
125+
126+
addTracePropagationHeadersToFetchRequest(request, new LRUMap<string, boolean>(100));
127+
128+
expect(request.headers).toEqual([
129+
'traceparent',
130+
'00-some-other-trace_id-span_id_x-01',
131+
'sentry-trace',
132+
'trace_id_1-span_id_1-1',
133+
'baggage',
134+
'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging',
135+
]);
136+
});
137+
82138
describe('when sentry-trace is already set', () => {
83139
it("preserves original sentry-trace header doesn't add baggage", () => {
84140
const request = {
@@ -113,6 +169,24 @@ describe('addTracePropagationHeadersToFetchRequest', () => {
113169
'sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging',
114170
]);
115171
});
172+
173+
it("doesn't add traceparent header even if propagateTraceparent is true", () => {
174+
mockedGetTraceData.mockReturnValueOnce({
175+
'sentry-trace': 'trace_id_2-span_id_2-1',
176+
baggage: 'sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging',
177+
traceparent: '00-trace_id_2-span_id_2-01',
178+
});
179+
180+
const request = {
181+
headers: ['sentry-trace', 'trace_id_2-span_id_2-1'],
182+
origin: 'https://some-service.com',
183+
path: '/api/test',
184+
} as UndiciRequest;
185+
186+
addTracePropagationHeadersToFetchRequest(request, new LRUMap<string, boolean>(100));
187+
188+
expect(request.headers).toEqual(['sentry-trace', 'trace_id_2-span_id_2-1']);
189+
});
116190
});
117191

118192
describe('pre-existing header deduplication', () => {
@@ -142,6 +216,43 @@ describe('addTracePropagationHeadersToFetchRequest', () => {
142216
]);
143217
});
144218

219+
it('deduplicates traceparent headers if propagateTraceparent is true', () => {
220+
mockedClientGetOptions.mockReturnValueOnce({
221+
tracePropagationTargets: ['https://example.com'],
222+
propagateTraceparent: true,
223+
});
224+
225+
const request = {
226+
headers: [
227+
'sentry-trace',
228+
'user-trace_id-xyz-1',
229+
'baggage',
230+
'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user',
231+
'traceparent',
232+
'00-user-trace_id-xyz-1-01',
233+
'sentry-trace',
234+
'undici-trace_id-abc-1',
235+
'baggage',
236+
'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici',
237+
'traceparent',
238+
'00-undici-trace_id-abc-1-01',
239+
],
240+
origin: 'https://some-service.com',
241+
path: '/api/test',
242+
} as UndiciRequest;
243+
244+
addTracePropagationHeadersToFetchRequest(request, new LRUMap<string, boolean>(100));
245+
246+
expect(request.headers).toEqual([
247+
'sentry-trace',
248+
'user-trace_id-xyz-1',
249+
'baggage',
250+
'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user',
251+
'traceparent',
252+
'00-user-trace_id-xyz-1-01',
253+
]);
254+
});
255+
145256
// admittedly an unrealistic edge case but doesn't hurt to test it
146257
it("doesn't crash with incomplete headers array", () => {
147258
const request = {
@@ -215,6 +326,44 @@ describe('addTracePropagationHeadersToFetchRequest', () => {
215326
]);
216327
});
217328
});
329+
330+
it('doesn\'t mistake a header value with "sentry-trace" for a sentry-trace header', () => {
331+
const request = {
332+
headers: ['x-allow-header', 'sentry-trace'],
333+
origin: 'https://some-service.com',
334+
path: '/api/test',
335+
} as UndiciRequest;
336+
337+
addTracePropagationHeadersToFetchRequest(request, new LRUMap<string, boolean>(100));
338+
339+
expect(request.headers).toEqual([
340+
'x-allow-header',
341+
'sentry-trace',
342+
'sentry-trace',
343+
'trace_id_1-span_id_1-1',
344+
'baggage',
345+
'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging',
346+
]);
347+
});
348+
349+
it('doesn\'t mistake a header value with "baggage" for a sentry-trace header', () => {
350+
const request = {
351+
headers: ['x-allow-header', 'baggage'],
352+
origin: 'https://some-service.com',
353+
path: '/api/test',
354+
} as UndiciRequest;
355+
356+
addTracePropagationHeadersToFetchRequest(request, new LRUMap<string, boolean>(100));
357+
358+
expect(request.headers).toEqual([
359+
'x-allow-header',
360+
'baggage',
361+
'sentry-trace',
362+
'trace_id_1-span_id_1-1',
363+
'baggage',
364+
'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging',
365+
]);
366+
});
218367
});
219368

220369
describe('when headers are a string', () => {

0 commit comments

Comments
 (0)