diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 92cb42277e..abad953de3 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -382,6 +382,12 @@ def _get_options(*args: "Optional[str]", **kwargs: "Any") -> "Dict[str, Any]": stacklevel=2, ) + if rv["ignore_spans"] and not has_span_streaming_enabled(rv): + warnings.warn( + "The `ignore_spans` parameter only works when `trace_lifecycle` is set to `stream`.", + stacklevel=2, + ) + return rv diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index e81d79955e..f0531473e1 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1272,6 +1272,7 @@ def __init__( server_name: "Optional[str]" = None, shutdown_timeout: float = 2, integrations: "Sequence[sentry_sdk.integrations.Integration]" = [], # noqa: B006 + ignore_spans: "Optional[IgnoreSpansConfig]" = None, in_app_include: "List[str]" = [], # noqa: B006 in_app_exclude: "List[str]" = [], # noqa: B006 default_integrations: bool = True, @@ -1293,6 +1294,7 @@ def __init__( ca_certs: "Optional[str]" = None, propagate_traces: bool = True, traces_sample_rate: "Optional[float]" = None, + trace_lifecycle: "Optional[Literal['static', 'stream']]" = None, traces_sampler: "Optional[TracesSampler]" = None, profiles_sample_rate: "Optional[float]" = None, profiles_sampler: "Optional[TracesSampler]" = None, @@ -1757,6 +1759,12 @@ def __init__( :param stream_gen_ai_spans: When set, generative AI spans are sent in a new transport format to reduce downstream data loss. + :param trace_lifecycle: Controls how traces are sent. Set to `"stream"` to send spans as they + finish, or `"static"` to send a completed trace as a transaction event. + + :param ignore_spans: A sequence of span-matching rules. Matching spans are ignored when + `trace_lifecycle="stream"` is enabled. + :param _experiments: Dictionary of experimental, opt-in features that are not yet stable. ``data_collection`` (EXPERIMENTAL): structured configuration controlling what data integrations diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index b1b3a7ae27..9478678dc3 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -4,7 +4,7 @@ The API in this file is only meant to be used in span streaming mode. You can enable span streaming mode via -sentry_sdk.init(_experiments={"trace_lifecycle": "stream"}). +sentry_sdk.init(trace_lifecycle="stream"). """ import sys @@ -854,7 +854,7 @@ def get_current_span( Returns the currently active span on the scope if the span is a `StreamedSpan`, otherwise `None`. This function will only return a non-`None` value when the streaming trace lifecycle is enabled. - To enable the lifecycle, pass `_experiments={"trace_lifecycle": "stream"}` to `sentry.init()`. + To enable the lifecycle, pass `trace_lifecycle="stream"` to `sentry.init()`. """ scope = scope or sentry_sdk.get_current_scope() current_span = scope.streamed_span diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index fc07bf613c..8cb1cbe3ae 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -113,7 +113,14 @@ def has_span_streaming_enabled(options: "Optional[dict[str, Any]]") -> bool: if options is None: return False - return (options.get("_experiments") or {}).get("trace_lifecycle") == "stream" + is_enabled_in_experiment_config = (options.get("_experiments") or {}).get( + "trace_lifecycle" + ) == "stream" + + if options.get("trace_lifecycle") is not None: + return options.get("trace_lifecycle") == "stream" + + return is_enabled_in_experiment_config def should_truncate_gen_ai_input(options: "Optional[dict[str, Any]]") -> bool: @@ -1647,7 +1654,16 @@ def _make_sampling_decision( 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") + is_ignored_at_top_level = client.options.get("ignore_spans", None) + is_ignored_in_experiment_config = (client.options.get("_experiments") or {}).get( + "ignore_spans" + ) + + ignore_spans = ( + is_ignored_at_top_level + if is_ignored_at_top_level is not None + else is_ignored_in_experiment_config + ) if not ignore_spans: return False diff --git a/tests/test_client.py b/tests/test_client.py index 1e19fba1a4..78868e434a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,6 +4,7 @@ import subprocess import sys import time +import warnings from collections import Counter, defaultdict from collections.abc import Mapping from textwrap import dedent @@ -1524,6 +1525,28 @@ def test_enable_tracing_deprecated(sentry_init, enable_tracing): sentry_init(enable_tracing=enable_tracing) +def test_ignore_spans_warns_without_streaming(sentry_init): + with pytest.warns(UserWarning, match=r"`ignore_spans` parameter only works"): + sentry_init(ignore_spans=["/health"], trace_lifecycle="static") + + +@pytest.mark.parametrize( + "options", + [ + {"ignore_spans": ["/health"], "trace_lifecycle": "stream"}, + {"ignore_spans": ["/health"], "_experiments": {"trace_lifecycle": "stream"}}, + {}, + ], +) +def test_ignore_spans_does_not_warn(sentry_init, options): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + sentry_init(**options) + + ignore_spans_warnings = [w for w in caught if "ignore_spans" in str(w.message)] + assert ignore_spans_warnings == [] + + def make_options_transport_cls(): """Make an options transport class that captures the options passed to it.""" # We need a unique class for each test so that the options are not diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index b9cf852acd..35c8ec69b2 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1,13 +1,19 @@ import re import sys import time +import warnings from unittest import mock import pytest import sentry_sdk from sentry_sdk.profiler.continuous_profiler import get_profiler_id -from sentry_sdk.traces import NoOpStreamedSpan, SpanStatus, StreamedSpan +from sentry_sdk.traces import ( + NoOpStreamedSpan, + SpanStatus, + StreamedSpan, +) +from sentry_sdk.tracing_utils import has_span_streaming_enabled minimum_python_38 = pytest.mark.skipif( sys.version_info < (3, 8), reason="Asyncio tests need Python >= 3.8" @@ -1170,90 +1176,92 @@ def test_set_span_status_on_ignored_span(sentry_init, capture_items): assert len(spans) == 0 +IGNORE_SPANS_CASES = [ + # 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, + ), +] + + @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, - ), - ], + ("ignore_spans", "name", "attributes", "ignored"), IGNORE_SPANS_CASES ) -def test_ignore_spans( +def test_ignore_spans_set_in_experiments( sentry_init, capture_items, ignore_spans, name, attributes, ignored ): sentry_init( @@ -1713,3 +1721,176 @@ def test_default_attributes(sentry_init, capture_envelopes): "value": mock.ANY, }, } + + +@pytest.mark.parametrize( + ("options", "expected"), + [ + ({"trace_lifecycle": "stream"}, True), + ({"_experiments": {"trace_lifecycle": "stream"}}, True), + ( + { + "trace_lifecycle": "stream", + "_experiments": {"trace_lifecycle": "static"}, + }, + True, + ), + ( + { + "trace_lifecycle": "static", + "_experiments": {"trace_lifecycle": "stream"}, + }, + False, + ), + ({"trace_lifecycle": "static"}, False), + ({"_experiments": {"trace_lifecycle": "static"}}, False), + ({}, False), + ({"_experiments": {}}, False), + ({"_experiments": None}, False), + (None, False), + ], +) +def test_has_span_streaming_enabled(options, expected): + assert has_span_streaming_enabled(options) is expected + + +def test_trace_lifecycle_top_level_enables_streaming(sentry_init, capture_items): + sentry_init(traces_sample_rate=1.0, trace_lifecycle="stream") + + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="segment") as segment: + assert isinstance(segment, StreamedSpan) + + sentry_sdk.get_client().flush() + spans = [item.payload for item in items] + + assert len(spans) == 1 + assert spans[0]["name"] == "segment" + + +@pytest.mark.parametrize( + ("ignore_spans", "name", "attributes", "ignored"), IGNORE_SPANS_CASES +) +def test_ignore_spans_top_level( + sentry_init, capture_items, ignore_spans, name, attributes, ignored +): + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream", + ignore_spans=ignore_spans, + ) + + items = capture_items("span") + + 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 = [item.payload for item in items] + + if ignored: + assert len(spans) == 0 + else: + assert len(spans) == 1 + (span,) = spans + assert span["name"] == name + + +def test_ignore_spans_top_level_with_trace_lifecycle_in_experiments( + sentry_init, capture_items +): + sentry_init( + traces_sample_rate=1.0, + ignore_spans=["ignored"], + _experiments={"trace_lifecycle": "stream"}, + ) + + items = capture_items("span") + + 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 span: + assert span.sampled is True + + sentry_sdk.get_client().flush() + spans = [item.payload for item in items] + + assert len(spans) == 1 + (span,) = spans + assert span["name"] == "not ignored" + + +def test_ignore_spans_empty_top_level_overrides_experiments(sentry_init, capture_items): + # An explicit empty top-level ignore_spans should disable ignoring, + # taking precedence over any rules set in _experiments. + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream", + ignore_spans=[], + _experiments={"ignore_spans": ["ignored"]}, + ) + + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="ignored") as span: + assert span.sampled is True + assert isinstance(span, StreamedSpan) + + sentry_sdk.get_client().flush() + spans = [item.payload for item in items] + + assert len(spans) == 1 + (span,) = spans + assert span["name"] == "ignored" + + +@pytest.mark.parametrize( + ("options", "streaming_enabled"), + [ + ( + { + "trace_lifecycle": "stream", + "_experiments": {"trace_lifecycle": "static"}, + }, + True, + ), + ( + { + "trace_lifecycle": "static", + "_experiments": {"trace_lifecycle": "stream"}, + }, + False, + ), + ], +) +def test_top_level_trace_lifecycle_takes_precedence_over_experiments( + sentry_init, capture_items, options, streaming_enabled +): + sentry_init(traces_sample_rate=1.0, **options) + + items = capture_items("span") + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with sentry_sdk.traces.start_span(name="segment") as segment: + if streaming_enabled: + assert isinstance(segment, StreamedSpan) + else: + assert isinstance(segment, NoOpStreamedSpan) + + sentry_sdk.get_client().flush() + spans = [item.payload for item in items] + + if streaming_enabled: + assert len(spans) == 1 + assert spans[0]["name"] == "segment" + else: + assert len(spans) == 0