diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index dad2e8c517..e3e8c16f90 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -1,3 +1,9 @@ +try: + from re import Pattern +except ImportError: + # 3.6 + from typing import Pattern + from typing import TYPE_CHECKING, TypeVar, Union @@ -361,3 +367,11 @@ class SDKInfo(TypedDict): class TextPart(TypedDict): type: Literal["text"] content: str + + IgnoreSpansName = Union[str, Pattern[str]] + IgnoreSpansContext = TypedDict( + "IgnoreSpansContext", + {"name": IgnoreSpansName, "attributes": Attributes}, + total=False, + ) + IgnoreSpansConfig = list[Union[IgnoreSpansName, IgnoreSpansContext]] diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index e1cc00e156..eb077ef62f 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -52,6 +52,7 @@ class CompressionAlgo(Enum): Event, EventProcessor, Hint, + IgnoreSpansConfig, Log, MeasurementUnit, Metric, @@ -83,6 +84,7 @@ class CompressionAlgo(Enum): "enable_metrics": Optional[bool], "before_send_metric": Optional[Callable[[Metric, Hint], Optional[Metric]]], "trace_lifecycle": Optional[Literal["static", "stream"]], + "ignore_spans": Optional[IgnoreSpansConfig], }, total=False, ) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 5dc8adaeef..a3477cb200 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -30,6 +30,7 @@ Baggage, has_tracing_enabled, has_span_streaming_enabled, + is_ignored_span, _make_sampling_decision, normalize_incoming_data, PropagationContext, @@ -1207,6 +1208,12 @@ def start_streamed_span( if parent_span is None: propagation_context = self.get_active_propagation_context() + if is_ignored_span(name, attributes): + return NoOpStreamedSpan( + scope=self, + unsampled_reason="ignored", + ) + sampled, sample_rate, sample_rand, outcome = _make_sampling_decision( name, attributes, @@ -1238,6 +1245,11 @@ def start_streamed_span( # This is a child span; take propagation context from the parent span with new_scope(): + if is_ignored_span(name, attributes): + return NoOpStreamedSpan( + unsampled_reason="ignored", + ) + if isinstance(parent_span, NoOpStreamedSpan): return NoOpStreamedSpan(unsampled_reason=parent_span._unsampled_reason) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 0bdb819b04..d8d1d3d712 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -11,6 +11,12 @@ from urllib.parse import quote, unquote import uuid +try: + from re import Pattern +except ImportError: + # 3.6 + from typing import Pattern + import sentry_sdk from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS, SPANTEMPLATE from sentry_sdk.utils import ( @@ -1478,6 +1484,51 @@ def _make_sampling_decision( return sampled, sample_rate, sample_rand, outcome +def is_ignored_span(name: str, attributes: "Optional[Attributes]") -> bool: + """Determine if a span fits one of the rules in ignore_spans.""" + client = sentry_sdk.get_client() + ignore_spans = (client.options.get("_experiments") or {}).get("ignore_spans") + + if not ignore_spans: + return False + + def _matches(rule: "Any", value: "Any") -> bool: + if isinstance(rule, Pattern): + if isinstance(value, str): + return bool(rule.fullmatch(value)) + else: + return False + + return rule == value + + for rule in ignore_spans: + if isinstance(rule, (str, Pattern)): + if _matches(rule, name): + return True + + elif isinstance(rule, dict) and ("name" in rule or "attributes" in rule): + name_matches = True + attributes_match = True + + attributes = attributes or {} + + if "name" in rule: + name_matches = _matches(rule["name"], name) + + if "attributes" in rule: + for attribute, value in rule["attributes"].items(): + if attribute not in attributes or not _matches( + value, attributes[attribute] + ): + attributes_match = False + break + + if name_matches and attributes_match: + return True + + return False + + # Circular imports from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index cfcd928631..7dbad08992 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1,4 +1,5 @@ import asyncio +import re import sys from typing import Any from unittest import mock @@ -6,7 +7,7 @@ import pytest import sentry_sdk -from sentry_sdk.traces import SpanStatus +from sentry_sdk.traces import NoOpStreamedSpan, SpanStatus, StreamedSpan minimum_python_38 = pytest.mark.skipif( sys.version_info < (3, 8), reason="Asyncio tests need Python >= 3.8" @@ -879,6 +880,370 @@ def test_set_span_status_on_error(sentry_init, capture_envelopes): assert span["status"] == "error" +@pytest.mark.parametrize( + ("ignore_spans", "name", "attributes", "ignored"), + [ + # no regexes + ([], "/health", {}, False), + ([{}], "/health", {}, False), + (["/health"], "/health", {}, True), + (["/health"], "/health", {"custom": "custom"}, True), + ([{"name": "/health"}], "/health", {}, True), + ([{"name": "/health"}], "/health", {"custom": "custom"}, True), + ([{"attributes": {"custom": "custom"}}], "/health", {"custom": "custom"}, True), + ([{"attributes": {"custom": "custom"}}], "/health", {}, False), + ( + [{"name": "/nothealth", "attributes": {"custom": "custom"}}], + "/health", + {"custom": "custom"}, + False, + ), + ( + [{"name": "/health", "attributes": {"custom": "notcustom"}}], + "/health", + {"custom": "custom"}, + False, + ), + ( + [{"name": "/health", "attributes": {"custom": "custom"}}], + "/health", + {"custom": "custom"}, + True, + ), + # test cases with regexes + ([re.compile("/hea.*")], "/health", {}, True), + ([re.compile("/hea.*")], "/health", {"custom": "custom"}, True), + ([{"name": re.compile("/hea.*")}], "/health", {}, True), + ([{"name": re.compile("/hea.*")}], "/health", {"custom": "custom"}, True), + ( + [{"attributes": {"custom": re.compile("c.*")}}], + "/health", + {"custom": "custom"}, + True, + ), + ([{"attributes": {"custom": re.compile("c.*")}}], "/health", {}, False), + ( + [ + { + "name": re.compile("/nothea.*"), + "attributes": {"custom": re.compile("c.*")}, + } + ], + "/health", + {"custom": "custom"}, + False, + ), + ( + [ + { + "name": re.compile("/hea.*"), + "attributes": {"custom": re.compile("notc.*")}, + } + ], + "/health", + {"custom": "custom"}, + False, + ), + ( + [ + { + "name": re.compile("/hea.*"), + "attributes": {"custom": re.compile("c.*")}, + } + ], + "/health", + {"custom": "custom"}, + True, + ), + ( + [{"attributes": {"listattr": re.compile(r"\[.*\]")}}], + "/a", + {"listattr": [1, 2, 3]}, + False, + ), + ], +) +def test_ignore_spans( + sentry_init, capture_envelopes, ignore_spans, name, attributes, ignored +): + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ignore_spans, + }, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name=name, attributes=attributes) as span: + if ignored: + assert span.sampled is False + assert isinstance(span, NoOpStreamedSpan) + else: + assert span.sampled is True + assert isinstance(span, StreamedSpan) + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + if ignored: + assert len(spans) == 0 + else: + assert len(spans) == 1 + (span,) = spans + assert span["name"] == name + + +def test_ignore_spans_basic( + sentry_init, capture_envelopes, capture_record_lost_event_calls +): + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + lost_event_calls = capture_record_lost_event_calls() + + with sentry_sdk.traces.start_span(name="ignored") as ignored_span: + assert ignored_span.sampled is False + + with sentry_sdk.traces.start_span(name="not ignored") as span: + assert span.sampled is True + + sentry_sdk.get_client().flush() + + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + assert span["name"] == "not ignored" + assert span["parent_span_id"] is None + + assert len(lost_event_calls) == 1 + assert lost_event_calls[0] == ("ignored", "span", None, 1) + + +def test_ignore_spans_ignored_segment_drops_whole_tree( + sentry_init, capture_envelopes, capture_record_lost_event_calls +): + # Ignored segments should drop the whole span tree. + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + lost_event_calls = capture_record_lost_event_calls() + + with sentry_sdk.traces.start_span(name="ignored") as ignored_span: + assert ignored_span.sampled is False + assert isinstance(ignored_span, NoOpStreamedSpan) + + with sentry_sdk.traces.start_span(name="not ignored") as span1: + assert span1.sampled is False + assert isinstance(span1, NoOpStreamedSpan) + + with sentry_sdk.traces.start_span(name="not ignored") as span2: + assert span2.sampled is False + assert isinstance(span2, NoOpStreamedSpan) + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 0 + + assert len(lost_event_calls) == 3 + for lost_event_call in lost_event_calls: + assert lost_event_call == ("ignored", "span", None, 1) + + +def test_ignore_spans_ignored_segment_drops_whole_tree_explicit_parent_span( + sentry_init, capture_envelopes, capture_record_lost_event_calls +): + # Ignored segments should drop the whole span tree. + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + lost_event_calls = capture_record_lost_event_calls() + + ignored_span = sentry_sdk.traces.start_span(name="ignored") + assert isinstance(ignored_span, NoOpStreamedSpan) + assert ignored_span.sampled is False + + span1 = sentry_sdk.traces.start_span(name="not ignored 1", parent_span=ignored_span) + assert isinstance(span1, NoOpStreamedSpan) + assert span1.sampled is False + + span2 = sentry_sdk.traces.start_span(name="not ignored 2", parent_span=ignored_span) + assert isinstance(span2, NoOpStreamedSpan) + assert span2.sampled is False + + span1.end() + span2.end() + ignored_span.end() + + sentry_sdk.get_client().flush() + + spans = envelopes_to_spans(events) + + assert len(spans) == 0 + + assert len(lost_event_calls) == 3 + for lost_event_call in lost_event_calls: + assert lost_event_call == ("ignored", "span", None, 1) + + +def test_ignore_spans_set_ignored_child_span_as_parent( + sentry_init, capture_envelopes, capture_record_lost_event_calls +): + # Ignored non-segment spans should NOT drop the whole subtree under them. + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + lost_event_calls = capture_record_lost_event_calls() + + with sentry_sdk.traces.start_span(name="segment") as segment: + assert segment.sampled is True + + with sentry_sdk.traces.start_span(name="ignored") as ignored_span1: + assert ignored_span1.sampled is False + + with sentry_sdk.traces.start_span(name="ignored") as ignored_span2: + assert ignored_span2.sampled is False + + with sentry_sdk.traces.start_span(name="child") as span: + assert span.sampled is True + assert span._parent_span_id == segment.span_id + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + (child, segment) = spans + assert segment["name"] == "segment" + assert child["name"] == "child" + assert child["parent_span_id"] == segment["span_id"] # reparented to segment + + assert len(lost_event_calls) == 2 + for lost_event_call in lost_event_calls: + assert lost_event_call == ("ignored", "span", None, 1) + + +def test_ignore_spans_set_ignored_child_span_as_parent_explicit_parent_span( + sentry_init, capture_envelopes, capture_record_lost_event_calls +): + # Ignored non-segment spans should NOT drop the whole subtree under them. + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + lost_event_calls = capture_record_lost_event_calls() + + segment = sentry_sdk.traces.start_span(name="segment") + assert not isinstance(segment, NoOpStreamedSpan) + assert segment.sampled is True + assert segment._parent_span_id is None + + ignored_span1 = sentry_sdk.traces.start_span(name="ignored", parent_span=segment) + assert isinstance(ignored_span1, NoOpStreamedSpan) + assert ignored_span1.sampled is False + + ignored_span2 = sentry_sdk.traces.start_span( + name="ignored", parent_span=ignored_span1 + ) + assert isinstance(ignored_span2, NoOpStreamedSpan) + assert ignored_span2.sampled is False + + span = sentry_sdk.traces.start_span(name="child", parent_span=ignored_span2) + assert not isinstance(span, NoOpStreamedSpan) + assert span.sampled is True + assert span._parent_span_id == segment.span_id + span.end() + + ignored_span2.end() + ignored_span1.end() + segment.end() + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + (child, segment) = spans + assert segment["name"] == "segment" + assert child["name"] == "child" + assert child["parent_span_id"] == segment["span_id"] # reparented to segment + + assert len(lost_event_calls) == 2 + for lost_event_call in lost_event_calls: + assert lost_event_call == ("ignored", "span", None, 1) + + +def test_ignore_spans_reparenting(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name="segment") as span1: + assert span1.sampled is True + assert span1._parent_span_id is None + + with sentry_sdk.traces.start_span(name="ignored") as span2: + assert span2.sampled is False + + with sentry_sdk.traces.start_span(name="child 1") as span3: + assert span3.sampled is True + assert span3._parent_span_id == span1.span_id + + with sentry_sdk.traces.start_span(name="ignored") as span4: + assert span4.sampled is False + + with sentry_sdk.traces.start_span(name="child 2") as span5: + assert span5.sampled is True + assert span5._parent_span_id == span3.span_id + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 3 + (span5, span3, span1) = spans + assert span1["name"] == "segment" + assert span3["name"] == "child 1" + assert span5["name"] == "child 2" + assert span3["parent_span_id"] == span1["span_id"] + assert span5["parent_span_id"] == span3["span_id"] + + def test_transport_format(sentry_init, capture_envelopes): sentry_init( server_name="test-server",