Skip to content

Commit 2e0c776

Browse files
authored
fix(node): Fix baggage propagation (#11363)
The node integration tests (correctly!) showed that we were not propagating baggage 100% correct anymore. Turns out there were a few issues with what we did before: ## Do not freeze DSC onto span `traceState` when not continuing incoming trace The main issue was, that we did not actually set the `transaction`, `sampled` and `sample_rate` baggage entries. This was the case because we only picked these from the trace state, but we only set this fully in our own `startSpan()` APIs, not when auto instrumentation created spans. So I changed this to _not_ set the trace state in `startSpan()` APIs, but instead leave it empty _unless_ we are actually continuing an incoming trace. Whenever we are _not_ continuing an incoming trace, we'll now generate the DSC at injection-time based of the actual span data. So to summarize, we now set the `dsc` trace state in two places: 1. In the propagator, when we `extract` an incoming trace. 2. In our trace functions, when we use `forceTransaction` which we treat as continuing an incoming trace. ## No merging of existing baggage headers Another issue that came up is that we did not properly handled other `baggage` entires being set. This is because of how OTEL does injection, where it passes the `carrier` as object which we treated as `unknown` and basically ignored. This meant that if a user did e.g. `fetch({ headers: { baggage: 'my=value' }})`, we would overwrite the baggage and not keep the exiting value(s). This PR fixes this as well by ensuring we keep this around. ## Future improvements After talking with @lforst, we may be able to improve/streamline this further by getting rid of the DSC trace state. The logic could be: 1. If there is a DSC on the scope's propagation context, use that. 2. Else, use the root span We may update to this logic internally in a follow up step!
1 parent b0c9459 commit 2e0c776

File tree

17 files changed

+311
-141
lines changed

17 files changed

+311
-141
lines changed

dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-header-assign/test.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,7 @@ test('Should not propagate baggage and ignore original 3rd party baggage entries
8686
});
8787
});
8888

89-
// TODO(v8): Fix this test
90-
// eslint-disable-next-line jest/no-disabled-tests
91-
test.skip('Should populate and propagate sentry baggage if sentry-trace header does not exist', async () => {
89+
test('Should populate and propagate sentry baggage if sentry-trace header does not exist', async () => {
9290
const runner = createRunner(__dirname, '..', 'server.ts').start();
9391

9492
const response = await runner.makeRequest<TestAPIResponse>('get', '/test/express');
@@ -105,9 +103,7 @@ test.skip('Should populate and propagate sentry baggage if sentry-trace header d
105103
});
106104
});
107105

108-
// TODO(v8): Fix this test
109-
// eslint-disable-next-line jest/no-disabled-tests
110-
test.skip('Should populate Sentry and ignore 3rd party content if sentry-trace header does not exist', async () => {
106+
test('Should populate Sentry and ignore 3rd party content if sentry-trace header does not exist', async () => {
111107
const runner = createRunner(__dirname, '..', 'server.ts').start();
112108

113109
const response = await runner.makeRequest<TestAPIResponse>('get', '/test/express', {

dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-header-out/server.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,8 @@ Sentry.setUser({ id: 'user123' });
2626
app.use(cors());
2727

2828
app.get('/test/express', (_req, res) => {
29-
// eslint-disable-next-line deprecation/deprecation
30-
const transaction = Sentry.getCurrentScope().getTransaction();
31-
const traceId = transaction?.spanContext().traceId;
29+
const span = Sentry.getActiveSpan();
30+
const traceId = span?.spanContext().traceId;
3231
const headers = http.get('http://somewhere.not.sentry/').getHeaders();
3332
if (traceId) {
3433
headers['baggage'] = (headers['baggage'] as string).replace(traceId, '__SENTRY_TRACE_ID__');

dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-header-out/test.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,28 @@ afterAll(() => {
55
cleanupChildProcesses();
66
});
77

8-
// TODO(v8): Fix this test
9-
// eslint-disable-next-line jest/no-disabled-tests
10-
test.skip('should attach a baggage header to an outgoing request.', async () => {
8+
test('should attach a baggage header to an outgoing request.', async () => {
119
const runner = createRunner(__dirname, 'server.ts').start();
1210

1311
const response = await runner.makeRequest<TestAPIResponse>('get', '/test/express');
1412

1513
expect(response).toBeDefined();
14+
15+
const baggage = response?.test_data.baggage?.split(',').sort();
16+
17+
expect(baggage).toEqual([
18+
'sentry-environment=prod',
19+
'sentry-public_key=public',
20+
'sentry-release=1.0',
21+
'sentry-sample_rate=1',
22+
'sentry-sampled=true',
23+
'sentry-trace_id=__SENTRY_TRACE_ID__',
24+
'sentry-transaction=GET%20%2Ftest%2Fexpress',
25+
]);
26+
1627
expect(response).toMatchObject({
1728
test_data: {
1829
host: 'somewhere.not.sentry',
19-
baggage:
20-
'sentry-environment=prod,sentry-release=1.0,sentry-public_key=public' +
21-
',sentry-trace_id=__SENTRY_TRACE_ID__,sentry-sample_rate=1,sentry-transaction=GET%20%2Ftest%2Fexpress' +
22-
',sentry-sampled=true',
2330
},
2431
});
2532
});

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

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ afterAll(() => {
55
cleanupChildProcesses();
66
});
77

8-
// TODO(v8): Fix this test
9-
// eslint-disable-next-line jest/no-disabled-tests
10-
test.skip('should ignore sentry-values in `baggage` header of a third party vendor and overwrite them with incoming DSC', async () => {
8+
test('should ignore sentry-values in `baggage` header of a third party vendor and overwrite them with incoming DSC', async () => {
119
const runner = createRunner(__dirname, 'server.ts').start();
1210

1311
const response = await runner.makeRequest<TestAPIResponse>('get', '/test/express', {
@@ -16,34 +14,52 @@ test.skip('should ignore sentry-values in `baggage` header of a third party vend
1614
});
1715

1816
expect(response).toBeDefined();
17+
18+
const baggage = response?.test_data.baggage?.split(',').sort();
19+
1920
expect(response).toMatchObject({
2021
test_data: {
2122
host: 'somewhere.not.sentry',
22-
baggage: [
23-
'other=vendor,foo=bar,third=party,sentry-release=9.9.9,sentry-environment=staging,sentry-sample_rate=0.54,last=item',
24-
'sentry-release=2.1.0,sentry-environment=myEnv',
25-
],
2623
},
2724
});
25+
26+
expect(baggage).toEqual([
27+
'foo=bar',
28+
'last=item',
29+
'other=vendor',
30+
'sentry-environment=myEnv',
31+
'sentry-release=2.1.0',
32+
'sentry-sample_rate=0.54',
33+
'third=party',
34+
]);
2835
});
2936

30-
// TODO(v8): Fix this test
31-
// eslint-disable-next-line jest/no-disabled-tests
32-
test.skip('should ignore sentry-values in `baggage` header of a third party vendor and overwrite them with new DSC', async () => {
37+
test('should ignore sentry-values in `baggage` header of a third party vendor and overwrite them with new DSC', async () => {
3338
const runner = createRunner(__dirname, 'server.ts').start();
3439

3540
const response = await runner.makeRequest<TestAPIResponse>('get', '/test/express');
3641

3742
expect(response).toBeDefined();
43+
44+
const baggage = response?.test_data.baggage?.split(',').sort();
45+
3846
expect(response).toMatchObject({
3947
test_data: {
4048
host: 'somewhere.not.sentry',
41-
baggage: [
42-
'other=vendor,foo=bar,third=party,sentry-release=9.9.9,sentry-environment=staging,sentry-sample_rate=0.54,last=item',
43-
expect.stringMatching(
44-
/sentry-environment=prod,sentry-release=1\.0,sentry-public_key=public,sentry-trace_id=[0-9a-f]{32},sentry-sample_rate=1,sentry-transaction=GET%20%2Ftest%2Fexpress/,
45-
),
46-
],
4749
},
4850
});
51+
52+
expect(baggage).toEqual([
53+
'foo=bar',
54+
'last=item',
55+
'other=vendor',
56+
'sentry-environment=prod',
57+
'sentry-public_key=public',
58+
'sentry-release=1.0',
59+
'sentry-sample_rate=1',
60+
'sentry-sampled=true',
61+
expect.stringMatching(/sentry-trace_id=[0-9a-f]{32}/),
62+
'sentry-transaction=GET%20%2Ftest%2Fexpress',
63+
'third=party',
64+
]);
4965
});

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ afterAll(() => {
55
cleanupChildProcesses();
66
});
77

8-
// TODO(v8): Fix this test
9-
// eslint-disable-next-line jest/no-disabled-tests
10-
test.skip('should merge `baggage` header of a third party vendor with the Sentry DSC baggage items', async () => {
8+
test('should merge `baggage` header of a third party vendor with the Sentry DSC baggage items', async () => {
119
const runner = createRunner(__dirname, 'server.ts').start();
1210

1311
const response = await runner.makeRequest<TestAPIResponse>('get', '/test/express', {
@@ -19,7 +17,7 @@ test.skip('should merge `baggage` header of a third party vendor with the Sentry
1917
expect(response).toMatchObject({
2018
test_data: {
2119
host: 'somewhere.not.sentry',
22-
baggage: ['other=vendor,foo=bar,third=party', 'sentry-release=2.0.0,sentry-environment=myEnv'],
20+
baggage: 'other=vendor,foo=bar,third=party,sentry-release=2.0.0,sentry-environment=myEnv',
2321
},
2422
});
2523
});

dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-transaction-name/server.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { loggingTransport } from '@sentry-internal/node-integration-tests';
2-
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
32
import * as Sentry from '@sentry/node';
43

54
export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } };
@@ -31,10 +30,6 @@ Sentry.setUser({ id: 'user123' });
3130
app.use(cors());
3231

3332
app.get('/test/express', (_req, res) => {
34-
// eslint-disable-next-line deprecation/deprecation
35-
const transaction = Sentry.getCurrentScope().getTransaction();
36-
transaction?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
37-
3833
const headers = http.get('http://somewhere.not.sentry/').getHeaders();
3934
// Responding with the headers outgoing request headers back to the assertions.
4035
res.send({ test_data: headers });

dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-transaction-name/test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ afterAll(() => {
55
cleanupChildProcesses();
66
});
77

8-
// TODO(v8): Fix this test
9-
// eslint-disable-next-line jest/no-disabled-tests
10-
test.skip('Includes transaction in baggage if the transaction name is parameterized', async () => {
8+
test('Includes transaction in baggage if the transaction name is parameterized', async () => {
119
const runner = createRunner(__dirname, 'server.ts').start();
1210

1311
const response = await runner.makeRequest<TestAPIResponse>('get', '/test/express');

dev-packages/node-integration-tests/suites/tracing/spans/scenario.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,21 @@ import * as http from 'http';
1212

1313
// eslint-disable-next-line @typescript-eslint/no-floating-promises
1414
Sentry.startSpan({ name: 'test_transaction' }, async () => {
15-
http.get(`${process.env.SERVER_URL}/api/v0`);
16-
http.get(`${process.env.SERVER_URL}/api/v1`);
17-
18-
// Give it a tick to resolve...
19-
await new Promise(resolve => setTimeout(resolve, 100));
15+
await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`);
16+
await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`);
2017
});
18+
19+
function makeHttpRequest(url: string): Promise<void> {
20+
return new Promise<void>(resolve => {
21+
http
22+
.request(url, httpRes => {
23+
httpRes.on('data', () => {
24+
// we don't care about data
25+
});
26+
httpRes.on('end', () => {
27+
resolve();
28+
});
29+
})
30+
.end();
31+
});
32+
}

packages/core/test/lib/utils/spanUtils.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ describe('getRootSpan', () => {
234234
expect(getRootSpan(root)).toBe(root);
235235
});
236236

237-
it('returns the root span of a child span xxx', () => {
237+
it('returns the root span of a child span', () => {
238238
startSpan({ name: 'outer' }, root => {
239239
startSpan({ name: 'inner' }, inner => {
240240
expect(getRootSpan(inner)).toBe(root);

packages/opentelemetry/src/propagator.ts

Lines changed: 37 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type { Baggage, Context, SpanContext, TextMapGetter, TextMapSetter } from '@opentelemetry/api';
1+
import type { Baggage, Context, Span, SpanContext, TextMapGetter, TextMapSetter } from '@opentelemetry/api';
22
import { context } from '@opentelemetry/api';
33
import { TraceFlags, propagation, trace } from '@opentelemetry/api';
44
import { TraceState, W3CBaggagePropagator, isTracingSuppressed } from '@opentelemetry/core';
55
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
66
import type { continueTrace } from '@sentry/core';
7+
import { getRootSpan } from '@sentry/core';
78
import { spanToJSON } from '@sentry/core';
89
import { getClient, getCurrentScope, getDynamicSamplingContextFromClient, getIsolationScope } from '@sentry/core';
910
import type { DynamicSamplingContext, Options, PropagationContext } from '@sentry/types';
@@ -14,6 +15,7 @@ import {
1415
dynamicSamplingContextToSentryBaggageHeader,
1516
generateSentryTraceHeader,
1617
logger,
18+
parseBaggageHeader,
1719
propagationContextFromHeaders,
1820
stringMatchesSomePattern,
1921
} from '@sentry/utils';
@@ -27,18 +29,27 @@ import {
2729
} from './constants';
2830
import { DEBUG_BUILD } from './debug-build';
2931
import { getScopesFromContext, setScopesOnContext } from './utils/contextData';
32+
import { getDynamicSamplingContextFromSpan } from './utils/dynamicSamplingContext';
33+
import { getSamplingDecision } from './utils/getSamplingDecision';
3034
import { setIsSetup } from './utils/setupCheck';
3135

3236
/** Get the Sentry propagation context from a span context. */
33-
export function getPropagationContextFromSpanContext(spanContext: SpanContext): PropagationContext {
37+
export function getPropagationContextFromSpan(span: Span): PropagationContext {
38+
const spanContext = span.spanContext();
3439
const { traceId, spanId, traceState } = spanContext;
3540

41+
// When we have a dsc trace state, it means this came from the incoming trace
42+
// Then this takes presedence over the root span
3643
const dscString = traceState ? traceState.get(SENTRY_TRACE_STATE_DSC) : undefined;
37-
const dsc = dscString ? baggageHeaderToDynamicSamplingContext(dscString) : undefined;
44+
const traceStateDsc = dscString ? baggageHeaderToDynamicSamplingContext(dscString) : undefined;
45+
3846
const parentSpanId = traceState ? traceState.get(SENTRY_TRACE_STATE_PARENT_SPAN_ID) : undefined;
3947

4048
const sampled = getSamplingDecision(spanContext);
4149

50+
// No trace state? --> Take DSC from root span
51+
const dsc = traceStateDsc || getDynamicSamplingContextFromSpan(getRootSpan(span));
52+
4253
return {
4354
traceId,
4455
spanId,
@@ -85,10 +96,21 @@ export class SentryPropagator extends W3CBaggagePropagator {
8596
return;
8697
}
8798

99+
const existingBaggageHeader = getExistingBaggage(carrier);
88100
let baggage = propagation.getBaggage(context) || propagation.createBaggage({});
89101

90102
const { dynamicSamplingContext, traceId, spanId, sampled } = getInjectionData(context);
91103

104+
if (existingBaggageHeader) {
105+
const baggageEntries = parseBaggageHeader(existingBaggageHeader);
106+
107+
if (baggageEntries) {
108+
Object.entries(baggageEntries).forEach(([key, value]) => {
109+
baggage = baggage.setEntry(key, { value });
110+
});
111+
}
112+
}
113+
92114
if (dynamicSamplingContext) {
93115
baggage = Object.entries(dynamicSamplingContext).reduce<Baggage>((b, [dscKey, dscValue]) => {
94116
if (dscValue) {
@@ -196,7 +218,8 @@ function getInjectionData(context: Context): {
196218
// If we have a local span, we can just pick everything from it
197219
if (span && !spanIsRemote) {
198220
const spanContext = span.spanContext();
199-
const propagationContext = getPropagationContextFromSpanContext(spanContext);
221+
222+
const propagationContext = getPropagationContextFromSpan(span);
200223
const dynamicSamplingContext = getDynamicSamplingContext(propagationContext, spanContext.traceId);
201224
return {
202225
dynamicSamplingContext,
@@ -220,9 +243,9 @@ function getInjectionData(context: Context): {
220243
}
221244

222245
// Else, we look at the remote span context
223-
const spanContext = trace.getSpanContext(context);
224-
if (spanContext) {
225-
const propagationContext = getPropagationContextFromSpanContext(spanContext);
246+
if (span) {
247+
const spanContext = span.spanContext();
248+
const propagationContext = getPropagationContextFromSpan(span);
226249
const dynamicSamplingContext = getDynamicSamplingContext(propagationContext, spanContext.traceId);
227250

228251
return {
@@ -301,40 +324,12 @@ export function continueTraceAsRemoteSpan<T>(
301324
return context.with(ctxWithSpanContext, callback);
302325
}
303326

304-
/**
305-
* OpenTelemetry only knows about SAMPLED or NONE decision,
306-
* but for us it is important to differentiate between unset and unsampled.
307-
*
308-
* Both of these are identified as `traceFlags === TracegFlags.NONE`,
309-
* but we additionally look at a special trace state to differentiate between them.
310-
*/
311-
export function getSamplingDecision(spanContext: SpanContext): boolean | undefined {
312-
const { traceFlags, traceState } = spanContext;
313-
314-
const sampledNotRecording = traceState ? traceState.get(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING) === '1' : false;
315-
316-
// If trace flag is `SAMPLED`, we interpret this as sampled
317-
// If it is `NONE`, it could mean either it was sampled to be not recorder, or that it was not sampled at all
318-
// For us this is an important difference, sow e look at the SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING
319-
// to identify which it is
320-
if (traceFlags === TraceFlags.SAMPLED) {
321-
return true;
322-
}
323-
324-
if (sampledNotRecording) {
325-
return false;
326-
}
327-
328-
// Fall back to DSC as a last resort, that may also contain `sampled`...
329-
const dscString = traceState ? traceState.get(SENTRY_TRACE_STATE_DSC) : undefined;
330-
const dsc = dscString ? baggageHeaderToDynamicSamplingContext(dscString) : undefined;
331-
332-
if (dsc?.sampled === 'true') {
333-
return true;
334-
}
335-
if (dsc?.sampled === 'false') {
336-
return false;
327+
/** Try to get the existing baggage header so we can merge this in. */
328+
function getExistingBaggage(carrier: unknown): string | undefined {
329+
try {
330+
const baggage = (carrier as Record<string, string | string[]>)[SENTRY_BAGGAGE_HEADER];
331+
return Array.isArray(baggage) ? baggage.join(',') : baggage;
332+
} catch {
333+
return undefined;
337334
}
338-
339-
return undefined;
340335
}

0 commit comments

Comments
 (0)