From f2c2bc4dd01bddac86b756eba98a0250316b1160 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 13 Jul 2026 14:56:09 -0400 Subject: [PATCH 1/4] feat(tracing): Promote trace_lifecycle and ignore_spans to top-level options Expose `trace_lifecycle` and `ignore_spans` as top-level `sentry_sdk.init()` parameters instead of requiring them under `_experiments`. The `_experiments` keys remain supported for backwards compatibility, but the top-level value now takes precedence: when both sources are set, `has_span_streaming_enabled` and `is_ignored_span` respect the top-level `trace_lifecycle` and fall back to `_experiments` only when it is unset. Add a validation warning when `ignore_spans` is set but span streaming is not enabled, since the option only takes effect in stream mode. Update the span-streaming docstrings to reference the new top-level configuration. Fixes PY-2609 Fixes #6820 --- sentry_sdk/client.py | 6 + sentry_sdk/consts.py | 8 + sentry_sdk/traces.py | 4 +- sentry_sdk/tracing_utils.py | 17 +- tests/test_client.py | 23 ++ tests/tracing/test_span_streaming.py | 323 ++++++++++++++++++++------- 6 files changed, 294 insertions(+), 87 deletions(-) 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..57e929dee7 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -113,7 +113,15 @@ 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_at_top_level = options.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 is_enabled_at_top_level + + return is_enabled_in_experiment_config def should_truncate_gen_ai_input(options: "Optional[dict[str, Any]]") -> bool: @@ -1647,7 +1655,12 @@ 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 or 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..599f908f5a 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,152 @@ 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"}, + }, + True, + ), + ({"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" + + +@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 From 4b2973714459fc36f48514c010f5a7df87bbbb40 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 13 Jul 2026 15:04:52 -0400 Subject: [PATCH 2/4] small cleanup --- sentry_sdk/tracing_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 57e929dee7..dc8d5784bd 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -113,13 +113,12 @@ def has_span_streaming_enabled(options: "Optional[dict[str, Any]]") -> bool: if options is None: return False - is_enabled_at_top_level = options.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 is_enabled_at_top_level + return options.get("trace_lifecycle") == "stream" return is_enabled_in_experiment_config From b96d65a64eb1ae2a90303b767f064e3783cce2b4 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 13 Jul 2026 15:06:01 -0400 Subject: [PATCH 3/4] fix test --- tests/tracing/test_span_streaming.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 599f908f5a..7cf520eeff 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1740,7 +1740,7 @@ def test_default_attributes(sentry_init, capture_envelopes): "trace_lifecycle": "static", "_experiments": {"trace_lifecycle": "stream"}, }, - True, + False, ), ({"trace_lifecycle": "static"}, False), ({"_experiments": {"trace_lifecycle": "static"}}, False), From 30cdadee444e2705aaad8628a0da6cf621499e7d Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 13 Jul 2026 15:39:31 -0400 Subject: [PATCH 4/4] fix(tracing): Respect explicit empty top-level ignore_spans An explicit top-level ignore_spans=[] was treated as unset, letting _experiments rules leak through. Use an is-not-None check so top-level config wins whenever provided, including an empty list meant to disable ignoring. --- sentry_sdk/tracing_utils.py | 6 +++++- tests/tracing/test_span_streaming.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index dc8d5784bd..8cb1cbe3ae 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1659,7 +1659,11 @@ def is_ignored_span(name: str, attributes: "Optional[Attributes]") -> bool: "ignore_spans" ) - ignore_spans = is_ignored_at_top_level or is_ignored_in_experiment_config + 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/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 7cf520eeff..35c8ec69b2 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1828,6 +1828,30 @@ def test_ignore_spans_top_level_with_trace_lifecycle_in_experiments( 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"), [