Skip to content

Commit d464269

Browse files
authored
fix(node): Fix sample rand propagation for negative sampling decisions (#15045)
1 parent 4b0d06f commit d464269

File tree

6 files changed

+40
-14
lines changed

6 files changed

+40
-14
lines changed

dev-packages/node-integration-tests/suites/sample-rand-propagation/server.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const Sentry = require('@sentry/node');
44
Sentry.init({
55
dsn: 'https://[email protected]/1337',
66
transport: loggingTransport,
7-
tracesSampleRate: 1,
7+
tracesSampleRate: 0.00000001, // It's important that this is not 1, so that we also check logic for NonRecordingSpans, which is usually the edge-case
88
});
99

1010
// express must be required after Sentry is initialized

dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ test('envelope header for error event during active unsampled span is correct',
1111
environment: 'production',
1212
release: '1.0',
1313
sampled: 'false',
14+
sample_rand: expect.any(String),
1415
},
1516
},
1617
})

packages/core/src/tracing/dynamicSamplingContext.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,14 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly<Partial<
117117
// So we end up with an active span that is not sampled (neither positively nor negatively)
118118
if (hasTracingEnabled()) {
119119
dsc.sampled = String(spanIsSampled(rootSpan));
120-
dsc.sample_rand = getCapturedScopesOnSpan(rootSpan).scope?.getPropagationContext().sampleRand.toString();
120+
dsc.sample_rand =
121+
// In OTEL we store the sample rand on the trace state because we cannot access scopes for NonRecordingSpans
122+
// The Sentry OTEL SpanSampler takes care of writing the sample rand on the root span
123+
traceState?.get('sentry.sample_rand') ??
124+
// On all other platforms we can actually get the scopes from a root span (we use this as a fallback)
125+
getCapturedScopesOnSpan(rootSpan)
126+
.scope?.getPropagationContext()
127+
.sampleRand.toString();
121128
}
122129

123130
client.emit('createDsc', dsc, rootSpan);

packages/opentelemetry/src/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const SENTRY_BAGGAGE_HEADER = 'baggage';
66
export const SENTRY_TRACE_STATE_DSC = 'sentry.dsc';
77
export const SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING = 'sentry.sampled_not_recording';
88
export const SENTRY_TRACE_STATE_URL = 'sentry.url';
9+
export const SENTRY_TRACE_STATE_SAMPLE_RAND = 'sentry.sample_rand';
910

1011
export const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes');
1112

packages/opentelemetry/src/sampler.ts

+20-6
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import {
1818
logger,
1919
sampleSpan,
2020
} from '@sentry/core';
21-
import { SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, SENTRY_TRACE_STATE_URL } from './constants';
21+
import {
22+
SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING,
23+
SENTRY_TRACE_STATE_SAMPLE_RAND,
24+
SENTRY_TRACE_STATE_URL,
25+
} from './constants';
2226
import { DEBUG_BUILD } from './debug-build';
2327
import { getScopesFromContext } from './utils/contextData';
2428
import { getSamplingDecision } from './utils/getSamplingDecision';
@@ -99,11 +103,10 @@ export class SentrySampler implements Sampler {
99103

100104
const isRootSpan = !parentSpan || parentContext?.isRemote;
101105

102-
const { isolationScope, scope } = getScopesFromContext(context) ?? {};
103-
104106
// We only sample based on parameters (like tracesSampleRate or tracesSampler) for root spans (which is done in sampleSpan).
105107
// Non-root-spans simply inherit the sampling decision from their parent.
106108
if (isRootSpan) {
109+
const { isolationScope, scope } = getScopesFromContext(context) ?? {};
107110
const sampleRand = scope?.getPropagationContext().sampleRand ?? Math.random();
108111
const [sampled, sampleRate] = sampleSpan(
109112
options,
@@ -126,7 +129,7 @@ export class SentrySampler implements Sampler {
126129
DEBUG_BUILD && logger.log(`[Tracing] Not sampling span because HTTP method is '${method}' for ${spanName}`);
127130

128131
return {
129-
...wrapSamplingDecision({ decision: SamplingDecision.NOT_RECORD, context, spanAttributes }),
132+
...wrapSamplingDecision({ decision: SamplingDecision.NOT_RECORD, context, spanAttributes, sampleRand }),
130133
attributes,
131134
};
132135
}
@@ -145,6 +148,7 @@ export class SentrySampler implements Sampler {
145148
decision: sampled ? SamplingDecision.RECORD_AND_SAMPLED : SamplingDecision.NOT_RECORD,
146149
context,
147150
spanAttributes,
151+
sampleRand,
148152
}),
149153
attributes,
150154
};
@@ -196,8 +200,18 @@ export function wrapSamplingDecision({
196200
decision,
197201
context,
198202
spanAttributes,
199-
}: { decision: SamplingDecision | undefined; context: Context; spanAttributes: SpanAttributes }): SamplingResult {
200-
const traceState = getBaseTraceState(context, spanAttributes);
203+
sampleRand,
204+
}: {
205+
decision: SamplingDecision | undefined;
206+
context: Context;
207+
spanAttributes: SpanAttributes;
208+
sampleRand?: number;
209+
}): SamplingResult {
210+
let traceState = getBaseTraceState(context, spanAttributes);
211+
212+
if (sampleRand !== undefined) {
213+
traceState = traceState.set(SENTRY_TRACE_STATE_SAMPLE_RAND, `${sampleRand}`);
214+
}
201215

202216
// If the decision is undefined, we treat it as NOT_RECORDING, but we don't propagate this decision to downstream SDKs
203217
// Which is done by not setting `SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING` traceState

packages/opentelemetry/test/sampler.test.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@ describe('SentrySampler', () => {
2626
const links = undefined;
2727

2828
const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links);
29-
expect(actual).toEqual({
30-
decision: SamplingDecision.NOT_RECORD,
31-
attributes: { 'sentry.sample_rate': 0 },
32-
traceState: new TraceState().set('sentry.sampled_not_recording', '1'),
33-
});
29+
expect(actual).toEqual(
30+
expect.objectContaining({
31+
decision: SamplingDecision.NOT_RECORD,
32+
attributes: { 'sentry.sample_rate': 0 },
33+
}),
34+
);
35+
expect(actual.traceState?.get('sentry.sampled_not_recording')).toBe('1');
36+
expect(actual.traceState?.get('sentry.sample_rand')).toEqual(expect.any(String));
3437
expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1);
3538
expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'transaction');
3639

@@ -81,7 +84,7 @@ describe('SentrySampler', () => {
8184
expect(actual).toEqual({
8285
decision: SamplingDecision.RECORD_AND_SAMPLED,
8386
attributes: { 'sentry.sample_rate': 1 },
84-
traceState: new TraceState(),
87+
traceState: expect.any(TraceState),
8588
});
8689
expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0);
8790

0 commit comments

Comments
 (0)