Skip to content

Commit c5fcb3e

Browse files
committed
ref: Add sampling to span first
1 parent 45372c1 commit c5fcb3e

3 files changed

Lines changed: 127 additions & 4 deletions

File tree

sentry_sdk/scope.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
Baggage,
3131
has_tracing_enabled,
3232
has_span_streaming_enabled,
33+
make_sampling_decision,
3334
normalize_incoming_data,
3435
PropagationContext,
3536
)
@@ -1199,6 +1200,20 @@ def start_streamed_span(
11991200
if parent_span is None:
12001201
propagation_context = self.get_active_propagation_context()
12011202

1203+
sampled, sample_rate, sample_rand, outcome = make_sampling_decision(
1204+
name,
1205+
attributes,
1206+
self,
1207+
)
1208+
if sampled is False:
1209+
return NoOpStreamedSpan(
1210+
scope=self,
1211+
unsampled_reason=outcome,
1212+
)
1213+
1214+
if sample_rate is not None:
1215+
self._update_sample_rate(sample_rate)
1216+
12021217
return StreamedSpan(
12031218
name=name,
12041219
attributes=attributes,
@@ -1214,7 +1229,7 @@ def start_streamed_span(
12141229
# This is a child span; take propagation context from the parent span
12151230
with new_scope():
12161231
if isinstance(parent_span, NoOpStreamedSpan):
1217-
return NoOpStreamedSpan()
1232+
return NoOpStreamedSpan(unsampled_reason=parent_span._unsampled_reason)
12181233

12191234
return StreamedSpan(
12201235
name=name,
@@ -1227,6 +1242,15 @@ def start_streamed_span(
12271242
parent_sampled=parent_span.sampled,
12281243
)
12291244

1245+
def _update_sample_rate(self, sample_rate: float) -> None:
1246+
# If we had to adjust the sample rate when setting the sampling decision
1247+
# for a span, it needs to be updated in the propagation context too
1248+
propagation_context = self.get_active_propagation_context()
1249+
baggage = propagation_context.baggage
1250+
1251+
if baggage is not None:
1252+
baggage.sentry_items["sample_rate"] = str(sample_rate)
1253+
12301254
def continue_trace(
12311255
self,
12321256
environ_or_headers: "Dict[str, Any]",

sentry_sdk/traces.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -438,13 +438,16 @@ class NoOpStreamedSpan(StreamedSpan):
438438
__slots__ = (
439439
"_scope",
440440
"_previous_span_on_scope",
441+
"_unsampled_reason",
441442
)
442443

443444
def __init__(
444445
self,
446+
unsampled_reason: "Optional[str]" = None,
445447
scope: "Optional[sentry_sdk.Scope]" = None,
446448
) -> None:
447449
self._scope = scope # type: ignore[assignment]
450+
self._unsampled_reason = unsampled_reason
448451

449452
self._start()
450453

@@ -468,10 +471,16 @@ def _start(self) -> None:
468471
self._previous_span_on_scope = old_span
469472

470473
def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None:
471-
if self._scope is None:
472-
return
474+
client = sentry_sdk.get_client()
475+
if client.is_active() and client.transport:
476+
logger.debug("Discarding span because sampled = False")
477+
client.transport.record_lost_event(
478+
reason=self._unsampled_reason or "sample_rate",
479+
data_category="span",
480+
quantity=1,
481+
)
473482

474-
if not hasattr(self, "_previous_span_on_scope"):
483+
if self._scope is None or not hasattr(self, "_previous_span_on_scope"):
475484
return
476485

477486
with capture_internal_exceptions():

sentry_sdk/tracing_utils.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
to_string,
2424
try_convert,
2525
is_sentry_url,
26+
is_valid_sample_rate,
2627
_is_external_source,
2728
_is_in_project_root,
2829
_module_in_list,
@@ -41,6 +42,8 @@
4142

4243
from types import FrameType
4344

45+
from sentry_sdk._types import Attributes
46+
4447

4548
SENTRY_TRACE_REGEX = re.compile(
4649
"^[ \t]*" # whitespace
@@ -1379,6 +1382,93 @@ def add_sentry_baggage_to_headers(
13791382
)
13801383

13811384

1385+
def make_sampling_decision(
1386+
name: str,
1387+
attributes: "Optional[Attributes]",
1388+
scope: "sentry_sdk.Scope",
1389+
) -> "tuple[bool, Optional[float], Optional[float], Optional[str]]":
1390+
"""
1391+
Decide whether a span should be sampled.
1392+
1393+
Returns a tuple with:
1394+
1. the sampling decision
1395+
2. the effective sample rate
1396+
3. the sample rand
1397+
4. the reason for not sampling the span, if unsampled
1398+
"""
1399+
client = sentry_sdk.get_client()
1400+
1401+
if not has_tracing_enabled(client.options):
1402+
return False, None, None, None
1403+
1404+
propagation_context = scope.get_active_propagation_context()
1405+
1406+
sample_rand = None
1407+
if propagation_context.baggage is not None:
1408+
sample_rand = propagation_context.baggage._sample_rand()
1409+
if sample_rand is None:
1410+
sample_rand = _generate_sample_rand(propagation_context.trace_id)
1411+
1412+
sampling_context = {
1413+
"name": name,
1414+
"trace_id": propagation_context.trace_id,
1415+
"parent_span_id": propagation_context.parent_span_id,
1416+
"parent_sampled": propagation_context.parent_sampled,
1417+
"attributes": attributes or {},
1418+
}
1419+
1420+
# If there's a traces_sampler, use that; otherwise use traces_sample_rate
1421+
traces_sampler_defined = callable(client.options.get("traces_sampler"))
1422+
if traces_sampler_defined:
1423+
sample_rate = client.options["traces_sampler"](sampling_context)
1424+
else:
1425+
if sampling_context["parent_sampled"] is not None:
1426+
sample_rate = sampling_context["parent_sampled"]
1427+
else:
1428+
sample_rate = client.options["traces_sample_rate"]
1429+
1430+
# Validate whether the sample_rate we got is actually valid. Since
1431+
# traces_sampler is user-provided, it could return anything.
1432+
if not is_valid_sample_rate(sample_rate, source="Tracing"):
1433+
logger.warning(f"[Tracing] Discarding {name} because of invalid sample rate.")
1434+
return False, None, None, None
1435+
1436+
sample_rate = float(sample_rate)
1437+
1438+
# Adjust sample rate if we're under backpressure
1439+
if client.monitor:
1440+
sample_rate /= 2**client.monitor.downsample_factor
1441+
1442+
outcome: "Optional[str]" = None
1443+
1444+
if not sample_rate:
1445+
if traces_sampler_defined:
1446+
reason = "traces_sampler returned 0 or False"
1447+
else:
1448+
reason = "traces_sample_rate is set to 0"
1449+
1450+
logger.debug(f"[Tracing] Discarding {name} because {reason}")
1451+
if client.monitor and client.monitor.downsample_factor > 0:
1452+
outcome = "backpressure"
1453+
else:
1454+
outcome = "sample_rate"
1455+
1456+
return False, 0.0, None, outcome
1457+
1458+
sampled = sample_rand < sample_rate
1459+
1460+
if sampled:
1461+
logger.debug(f"[Tracing] Starting {name}")
1462+
outcome = None
1463+
else:
1464+
logger.debug(
1465+
f"[Tracing] Discarding {name} because it's not included in the random sample (sampling rate = {sample_rate})"
1466+
)
1467+
outcome = "sample_rate"
1468+
1469+
return sampled, sample_rate, sample_rand, outcome
1470+
1471+
13821472
# Circular imports
13831473
from sentry_sdk.tracing import (
13841474
BAGGAGE_HEADER_NAME,

0 commit comments

Comments
 (0)