Skip to content

Commit 30e1071

Browse files
Port sample_rate update to potel-base (#4069)
If the SDK uses a specific sample rate for a trace, it needs to update it in the DSC for downstream SDKs. On an incoming trace, the DSC's sample_rate is updated if: - an explicit sampling decision is forced, e.g. startTransaction(sampled: true) - the tracesSampler is invoked - the tracesSampleRate is used Closes #4028 --------- Co-authored-by: Anton Pirker <[email protected]>
1 parent 29d0819 commit 30e1071

File tree

3 files changed

+264
-17
lines changed

3 files changed

+264
-17
lines changed

sentry_sdk/integrations/opentelemetry/sampler.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,39 @@ def get_parent_sampled(parent_context, trace_id):
5050
return None
5151

5252

53+
def get_parent_sample_rate(parent_context, trace_id):
54+
# type: (Optional[SpanContext], int) -> Optional[float]
55+
if parent_context is None:
56+
return None
57+
58+
is_span_context_valid = parent_context is not None and parent_context.is_valid
59+
60+
if is_span_context_valid and parent_context.trace_id == trace_id:
61+
parent_sample_rate = parent_context.trace_state.get(TRACESTATE_SAMPLE_RATE_KEY)
62+
if parent_sample_rate is None:
63+
return None
64+
65+
try:
66+
return float(parent_sample_rate)
67+
except Exception:
68+
return None
69+
70+
return None
71+
72+
5373
def dropped_result(parent_span_context, attributes, sample_rate=None):
5474
# type: (SpanContext, Attributes, Optional[float]) -> SamplingResult
5575
# these will only be added the first time in a root span sampling decision
76+
# if sample_rate is provided, it'll be updated in trace state
5677
trace_state = parent_span_context.trace_state
5778

5879
if TRACESTATE_SAMPLED_KEY not in trace_state:
5980
trace_state = trace_state.add(TRACESTATE_SAMPLED_KEY, "false")
6081
elif trace_state.get(TRACESTATE_SAMPLED_KEY) == "deferred":
6182
trace_state = trace_state.update(TRACESTATE_SAMPLED_KEY, "false")
6283

63-
if sample_rate and TRACESTATE_SAMPLE_RATE_KEY not in trace_state:
64-
trace_state = trace_state.add(TRACESTATE_SAMPLE_RATE_KEY, str(sample_rate))
84+
if sample_rate is not None:
85+
trace_state = trace_state.update(TRACESTATE_SAMPLE_RATE_KEY, str(sample_rate))
6586

6687
is_root_span = not (
6788
parent_span_context.is_valid and not parent_span_context.is_remote
@@ -88,17 +109,18 @@ def dropped_result(parent_span_context, attributes, sample_rate=None):
88109

89110

90111
def sampled_result(span_context, attributes, sample_rate):
91-
# type: (SpanContext, Attributes, float) -> SamplingResult
112+
# type: (SpanContext, Attributes, Optional[float]) -> SamplingResult
92113
# these will only be added the first time in a root span sampling decision
114+
# if sample_rate is provided, it'll be updated in trace state
93115
trace_state = span_context.trace_state
94116

95117
if TRACESTATE_SAMPLED_KEY not in trace_state:
96118
trace_state = trace_state.add(TRACESTATE_SAMPLED_KEY, "true")
97119
elif trace_state.get(TRACESTATE_SAMPLED_KEY) == "deferred":
98120
trace_state = trace_state.update(TRACESTATE_SAMPLED_KEY, "true")
99121

100-
if TRACESTATE_SAMPLE_RATE_KEY not in trace_state:
101-
trace_state = trace_state.add(TRACESTATE_SAMPLE_RATE_KEY, str(sample_rate))
122+
if sample_rate is not None:
123+
trace_state = trace_state.update(TRACESTATE_SAMPLE_RATE_KEY, str(sample_rate))
102124

103125
return SamplingResult(
104126
Decision.RECORD_AND_SAMPLE,
@@ -142,9 +164,13 @@ def should_sample(
142164
if is_root_span:
143165
sample_rate = float(custom_sampled)
144166
if sample_rate > 0:
145-
return sampled_result(parent_span_context, attributes, sample_rate)
167+
return sampled_result(
168+
parent_span_context, attributes, sample_rate=sample_rate
169+
)
146170
else:
147-
return dropped_result(parent_span_context, attributes)
171+
return dropped_result(
172+
parent_span_context, attributes, sample_rate=sample_rate
173+
)
148174
else:
149175
logger.debug(
150176
f"[Tracing] Ignoring sampled param for non-root span {name}"
@@ -154,19 +180,27 @@ def should_sample(
154180
# Traces_sampler is responsible to check parent sampled to have full transactions.
155181
has_traces_sampler = callable(client.options.get("traces_sampler"))
156182

183+
sample_rate_to_propagate = None
184+
157185
if is_root_span and has_traces_sampler:
158186
sampling_context = create_sampling_context(
159187
name, attributes, parent_span_context, trace_id
160188
)
161189
sample_rate = client.options["traces_sampler"](sampling_context)
190+
sample_rate_to_propagate = sample_rate
162191
else:
163192
# Check if there is a parent with a sampling decision
164193
parent_sampled = get_parent_sampled(parent_span_context, trace_id)
194+
parent_sample_rate = get_parent_sample_rate(parent_span_context, trace_id)
165195
if parent_sampled is not None:
166-
sample_rate = parent_sampled
196+
sample_rate = bool(parent_sampled)
197+
sample_rate_to_propagate = (
198+
parent_sample_rate if parent_sample_rate else sample_rate
199+
)
167200
else:
168201
# Check if there is a traces_sample_rate
169202
sample_rate = client.options.get("traces_sample_rate")
203+
sample_rate_to_propagate = sample_rate
170204

171205
# If the sample rate is invalid, drop the span
172206
if not is_valid_sample_rate(sample_rate, source=self.__class__.__name__):
@@ -178,15 +212,21 @@ def should_sample(
178212
# Down-sample in case of back pressure monitor says so
179213
if is_root_span and client.monitor:
180214
sample_rate /= 2**client.monitor.downsample_factor
215+
if client.monitor.downsample_factor > 0:
216+
sample_rate_to_propagate = sample_rate
181217

182218
# Roll the dice on sample rate
183219
sample_rate = float(cast("Union[bool, float, int]", sample_rate))
184220
sampled = random.random() < sample_rate
185221

186222
if sampled:
187-
return sampled_result(parent_span_context, attributes, sample_rate)
223+
return sampled_result(
224+
parent_span_context, attributes, sample_rate=sample_rate_to_propagate
225+
)
188226
else:
189-
return dropped_result(parent_span_context, attributes, sample_rate)
227+
return dropped_result(
228+
parent_span_context, attributes, sample_rate=sample_rate_to_propagate
229+
)
190230

191231
def get_description(self) -> str:
192232
return self.__class__.__name__

sentry_sdk/integrations/opentelemetry/scope.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def _incoming_otel_span_context(self):
123123
# for twp to work, we also need to consider deferred sampling when the sampling
124124
# flag is not present, so the above TraceFlags are not sufficient
125125
if self._propagation_context.parent_sampled is None:
126-
trace_state = trace_state.add(TRACESTATE_SAMPLED_KEY, "deferred")
126+
trace_state = trace_state.update(TRACESTATE_SAMPLED_KEY, "deferred")
127127

128128
span_context = SpanContext(
129129
trace_id=int(self._propagation_context.trace_id, 16),

tests/test_dsc.py

Lines changed: 213 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
This is not tested in this file.
99
"""
1010

11+
import random
1112
from unittest import mock
1213

1314
import pytest
@@ -117,7 +118,7 @@ def test_dsc_continuation_of_trace(sentry_init, capture_envelopes):
117118

118119
assert "sample_rate" in envelope_trace_header
119120
assert type(envelope_trace_header["sample_rate"]) == str
120-
assert envelope_trace_header["sample_rate"] == "1.0"
121+
assert envelope_trace_header["sample_rate"] == "0.01337"
121122

122123
assert "sampled" in envelope_trace_header
123124
assert type(envelope_trace_header["sampled"]) == str
@@ -137,7 +138,7 @@ def test_dsc_continuation_of_trace(sentry_init, capture_envelopes):
137138

138139

139140
def test_dsc_continuation_of_trace_sample_rate_changed_in_traces_sampler(
140-
sentry_init, capture_envelopes
141+
sentry_init, capture_envelopes, monkeypatch
141142
):
142143
"""
143144
Another service calls our service and passes tracing information to us.
@@ -175,10 +176,10 @@ def my_traces_sampler(sampling_context):
175176
}
176177

177178
# We continue the incoming trace and start a new transaction
178-
with mock.patch("sentry_sdk.tracing_utils.Random.uniform", return_value=0.125):
179-
with sentry_sdk.continue_trace(incoming_http_headers):
180-
with sentry_sdk.start_span(name="foo"):
181-
pass
179+
monkeypatch.setattr(random, "random", lambda: 0.125)
180+
with sentry_sdk.continue_trace(incoming_http_headers):
181+
with sentry_sdk.start_span(name="foo"):
182+
pass
182183

183184
assert len(envelopes) == 1
184185

@@ -214,6 +215,212 @@ def my_traces_sampler(sampling_context):
214215
assert envelope_trace_header["transaction"] == "bar"
215216

216217

218+
@pytest.mark.parametrize(
219+
"test_data, expected_sample_rate, expected_sampled",
220+
[
221+
# Test data:
222+
# "incoming_sample_rate":
223+
# The "sentry-sample_rate" in the incoming `baggage` header.
224+
# "incoming_sampled":
225+
# The "sentry-sampled" in the incoming `baggage` header.
226+
# "sentry_trace_header_parent_sampled":
227+
# The number at the end in the `sentry-trace` header, called "parent_sampled".
228+
# "use_local_traces_sampler":
229+
# Whether the local traces sampler is used.
230+
# "local_traces_sampler_result":
231+
# The result of the local traces sampler.
232+
# "local_traces_sample_rate":
233+
# The `traces_sample_rate` setting in the local `sentry_init` call.
234+
(
235+
{
236+
"incoming_sample_rate": 1.0,
237+
"incoming_sampled": "true",
238+
"sentry_trace_header_parent_sampled": 1,
239+
"use_local_traces_sampler": False,
240+
"local_traces_sampler_result": None,
241+
"local_traces_sample_rate": 0.7,
242+
},
243+
1.0, # expected_sample_rate
244+
"true", # expected_sampled
245+
),
246+
(
247+
{
248+
"incoming_sample_rate": 1.0,
249+
"incoming_sampled": "true",
250+
"sentry_trace_header_parent_sampled": 1,
251+
"use_local_traces_sampler": True,
252+
"local_traces_sampler_result": 0.5,
253+
"local_traces_sample_rate": 0.7,
254+
},
255+
0.5, # expected_sample_rate
256+
"true", # expected_sampled
257+
),
258+
(
259+
{
260+
"incoming_sample_rate": 1.0,
261+
"incoming_sampled": "false",
262+
"sentry_trace_header_parent_sampled": 0,
263+
"use_local_traces_sampler": False,
264+
"local_traces_sampler_result": None,
265+
"local_traces_sample_rate": 0.7,
266+
},
267+
None, # expected_sample_rate
268+
"tracing-disabled-no-transactions-should-be-sent", # expected_sampled (because the parent sampled is 0)
269+
),
270+
(
271+
{
272+
"incoming_sample_rate": 1.0,
273+
"incoming_sampled": "false",
274+
"sentry_trace_header_parent_sampled": 0,
275+
"use_local_traces_sampler": True,
276+
"local_traces_sampler_result": 0.5,
277+
"local_traces_sample_rate": 0.7,
278+
},
279+
0.5, # expected_sample_rate
280+
"false", # expected_sampled (traces sampler can override parent sampled)
281+
),
282+
(
283+
{
284+
"incoming_sample_rate": 1.0,
285+
"incoming_sampled": "true",
286+
"sentry_trace_header_parent_sampled": 1,
287+
"use_local_traces_sampler": False,
288+
"local_traces_sampler_result": None,
289+
"local_traces_sample_rate": None,
290+
},
291+
None, # expected_sample_rate
292+
"tracing-disabled-no-transactions-should-be-sent", # expected_sampled (traces_sample_rate=None disables all transaction creation)
293+
),
294+
(
295+
{
296+
"incoming_sample_rate": 1.0,
297+
"incoming_sampled": "true",
298+
"sentry_trace_header_parent_sampled": 1,
299+
"use_local_traces_sampler": True,
300+
"local_traces_sampler_result": 0.5,
301+
"local_traces_sample_rate": None,
302+
},
303+
0.5, # expected_sample_rate
304+
"true", # expected_sampled (traces sampler overrides the traces_sample_rate setting, so transactions are created)
305+
),
306+
(
307+
{
308+
"incoming_sample_rate": 1.0,
309+
"incoming_sampled": "false",
310+
"sentry_trace_header_parent_sampled": 0,
311+
"use_local_traces_sampler": False,
312+
"local_traces_sampler_result": None,
313+
"local_traces_sample_rate": None,
314+
},
315+
None, # expected_sample_rate
316+
"tracing-disabled-no-transactions-should-be-sent", # expected_sampled (traces_sample_rate=None disables all transaction creation)
317+
),
318+
(
319+
{
320+
"incoming_sample_rate": 1.0,
321+
"incoming_sampled": "false",
322+
"sentry_trace_header_parent_sampled": 0,
323+
"use_local_traces_sampler": True,
324+
"local_traces_sampler_result": 0.5,
325+
"local_traces_sample_rate": None,
326+
},
327+
0.5, # expected_sample_rate
328+
"false", # expected_sampled
329+
),
330+
(
331+
{
332+
"incoming_sample_rate": 1.0,
333+
"incoming_sampled": None,
334+
"sentry_trace_header_parent_sampled": None,
335+
"use_local_traces_sampler": False,
336+
"local_traces_sampler_result": 0.5,
337+
"local_traces_sample_rate": 0.7,
338+
},
339+
0.7, # expected_sample_rate
340+
"true", # expected_sampled
341+
),
342+
],
343+
ids=(
344+
"1 traces_sample_rate does not override incoming",
345+
"2 traces_sampler overrides incoming",
346+
"3 traces_sample_rate does not overrides incoming sample rate or parent (incoming not sampled)",
347+
"4 traces_sampler overrides incoming (incoming not sampled)",
348+
"5 forwarding incoming (traces_sample_rate not set)",
349+
"6 traces_sampler overrides incoming (traces_sample_rate not set)",
350+
"7 forwarding incoming (traces_sample_rate not set) (incoming not sampled)",
351+
"8 traces_sampler overrides incoming (traces_sample_rate not set) (incoming not sampled)",
352+
"9 traces_sample_rate overrides incoming (upstream deferred sampling decision)",
353+
),
354+
)
355+
def test_dsc_sample_rate_change(
356+
sentry_init,
357+
capture_envelopes,
358+
test_data,
359+
expected_sample_rate,
360+
expected_sampled,
361+
):
362+
"""
363+
Another service calls our service and passes tracing information to us.
364+
Our service is continuing the trace, but modifies the sample rate.
365+
The DSC in transaction envelopes should contain the updated sample rate.
366+
"""
367+
368+
def my_traces_sampler(sampling_context):
369+
return test_data["local_traces_sampler_result"]
370+
371+
init_kwargs = {
372+
"dsn": "https://[email protected]/12312012",
373+
"release": "[email protected]",
374+
"environment": "canary",
375+
}
376+
377+
if test_data["local_traces_sample_rate"]:
378+
init_kwargs["traces_sample_rate"] = test_data["local_traces_sample_rate"]
379+
380+
if test_data["use_local_traces_sampler"]:
381+
init_kwargs["traces_sampler"] = my_traces_sampler
382+
383+
sentry_init(**init_kwargs)
384+
envelopes = capture_envelopes()
385+
386+
# This is what the upstream service sends us
387+
incoming_trace_id = "771a43a4192642f0b136d5159a501700"
388+
if test_data["sentry_trace_header_parent_sampled"] is None:
389+
sentry_trace = f"{incoming_trace_id}-1234567890abcdef"
390+
else:
391+
sentry_trace = f"{incoming_trace_id}-1234567890abcdef-{test_data['sentry_trace_header_parent_sampled']}"
392+
393+
baggage = (
394+
f"sentry-trace_id={incoming_trace_id}, "
395+
f"sentry-sample_rate={str(test_data['incoming_sample_rate'])}, "
396+
f"sentry-sampled={test_data['incoming_sampled']}, "
397+
"sentry-public_key=frontendpublickey, "
398+
399+
"sentry-environment=prod, "
400+
"sentry-transaction=foo, "
401+
)
402+
incoming_http_headers = {
403+
"HTTP_SENTRY_TRACE": sentry_trace,
404+
"HTTP_BAGGAGE": baggage,
405+
}
406+
407+
# We continue the incoming trace and start a new transaction
408+
with mock.patch.object(random, "random", return_value=0.2):
409+
with sentry_sdk.continue_trace(incoming_http_headers):
410+
with sentry_sdk.start_span(name="foo"):
411+
pass
412+
413+
if expected_sampled == "tracing-disabled-no-transactions-should-be-sent":
414+
assert len(envelopes) == 0
415+
else:
416+
transaction_envelope = envelopes[0]
417+
dsc_in_envelope_header = transaction_envelope.headers["trace"]
418+
419+
assert dsc_in_envelope_header["sample_rate"] == str(expected_sample_rate)
420+
assert dsc_in_envelope_header["sampled"] == str(expected_sampled).lower()
421+
assert dsc_in_envelope_header["trace_id"] == incoming_trace_id
422+
423+
217424
def test_dsc_issue(sentry_init, capture_envelopes):
218425
"""
219426
Our service is a standalone service that does not have tracing enabled. Just uses Sentry for error reporting.

0 commit comments

Comments
 (0)