From 4fb1f9a2a77424b7283eb632712466beec7dffc8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 19 May 2026 10:47:29 +0200 Subject: [PATCH 01/15] fix(strawberry): Fix AttributeError on graphql_span in resolve Strawberry's MiddlewareManager is cached on the schema, so resolve() runs on stale extension instances from the first request. Use sentry_sdk.start_span() instead of self.graphql_span.start_child() so resolve spans parent correctly via the scope rather than relying on instance state. Also prepend (rather than append) the Sentry extension so it wraps all other extensions as the outermost layer. Co-Authored-By: Claude Opus 4.6 --- sentry_sdk/integrations/strawberry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index c962206183..7db086d709 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -119,9 +119,9 @@ def _sentry_patched_schema_init( ] # add our extension - extensions.append( + extensions = [ SentryAsyncExtension if should_use_async_extension else SentrySyncExtension - ) + ] + extensions kwargs["extensions"] = extensions @@ -263,7 +263,7 @@ async def resolve( field_path = "{}.{}".format(info.parent_type, info.field_name) - with self.graphql_span.start_child( + with sentry_sdk.start_span( op=OP.GRAPHQL_RESOLVE, name="resolving {}".format(field_path), origin=StrawberryIntegration.origin, @@ -290,7 +290,7 @@ def resolve( field_path = "{}.{}".format(info.parent_type, info.field_name) - with self.graphql_span.start_child( + with sentry_sdk.start_span( op=OP.GRAPHQL_RESOLVE, name="resolving {}".format(field_path), origin=StrawberryIntegration.origin, From 744437238a4830407487a22bee0d8a0c34c37a3b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 19 May 2026 10:49:01 +0200 Subject: [PATCH 02/15] remove all start_child --- sentry_sdk/integrations/strawberry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 7db086d709..0ae30b2491 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -207,7 +207,7 @@ def on_operation(self) -> "Generator[None, None, None]": self.graphql_span.__exit__(None, None, None) def on_validate(self) -> "Generator[None, None, None]": - self.validation_span = self.graphql_span.start_child( + self.validation_span = sentry_sdk.start_span( op=OP.GRAPHQL_VALIDATE, name="validation", origin=StrawberryIntegration.origin, @@ -218,7 +218,7 @@ def on_validate(self) -> "Generator[None, None, None]": self.validation_span.finish() def on_parse(self) -> "Generator[None, None, None]": - self.parsing_span = self.graphql_span.start_child( + self.parsing_span = sentry_sdk.start_span( op=OP.GRAPHQL_PARSE, name="parsing", origin=StrawberryIntegration.origin, From a9571874e5434e9e28a4943eca7b2cbde98f8108 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 19 May 2026 11:37:50 +0200 Subject: [PATCH 03/15] remove all instance vars --- sentry_sdk/integrations/strawberry.py | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 0ae30b2491..6884190fd6 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -152,7 +152,7 @@ def hash_query(self, query: str) -> str: return hashlib.md5(query.encode("utf-8")).hexdigest() def on_operation(self) -> "Generator[None, None, None]": - self._operation_name = self.execution_context.operation_name + operation_name = self.execution_context.operation_name operation_type = "query" op = OP.GRAPHQL_QUERY @@ -168,13 +168,13 @@ def on_operation(self) -> "Generator[None, None, None]": op = OP.GRAPHQL_SUBSCRIPTION description = operation_type - if self._operation_name: - description += " {}".format(self._operation_name) + if operation_name: + description += " {}".format(operation_name) sentry_sdk.add_breadcrumb( category="graphql.operation", data={ - "operation_name": self._operation_name, + "operation_name": operation_name, "operation_type": operation_type, }, ) @@ -183,31 +183,31 @@ def on_operation(self) -> "Generator[None, None, None]": event_processor = _make_request_event_processor(self.execution_context) scope.add_event_processor(event_processor) - self.graphql_span = sentry_sdk.start_span( + graphql_span = sentry_sdk.start_span( op=op, name=description, origin=StrawberryIntegration.origin, ) - self.graphql_span.__enter__() + graphql_span.__enter__() - self.graphql_span.set_data("graphql.operation.type", operation_type) - self.graphql_span.set_data("graphql.operation.name", self._operation_name) + graphql_span.set_data("graphql.operation.type", operation_type) + graphql_span.set_data("graphql.operation.name", operation_name) if should_send_default_pii(): - self.graphql_span.set_data("graphql.document", self.execution_context.query) - self.graphql_span.set_data("graphql.resource_name", self._resource_name) + graphql_span.set_data("graphql.document", self.execution_context.query) + graphql_span.set_data("graphql.resource_name", self._resource_name) yield - transaction = self.graphql_span.containing_transaction + transaction = graphql_span.containing_transaction if transaction and self.execution_context.operation_name: transaction.name = self.execution_context.operation_name transaction.source = TransactionSource.COMPONENT transaction.op = op - self.graphql_span.__exit__(None, None, None) + graphql_span.__exit__(None, None, None) def on_validate(self) -> "Generator[None, None, None]": - self.validation_span = sentry_sdk.start_span( + validation_span = sentry_sdk.start_span( op=OP.GRAPHQL_VALIDATE, name="validation", origin=StrawberryIntegration.origin, @@ -215,10 +215,10 @@ def on_validate(self) -> "Generator[None, None, None]": yield - self.validation_span.finish() + validation_span.finish() def on_parse(self) -> "Generator[None, None, None]": - self.parsing_span = sentry_sdk.start_span( + parsing_span = sentry_sdk.start_span( op=OP.GRAPHQL_PARSE, name="parsing", origin=StrawberryIntegration.origin, @@ -226,7 +226,7 @@ def on_parse(self) -> "Generator[None, None, None]": yield - self.parsing_span.finish() + parsing_span.finish() def should_skip_tracing( self, From 0e313666a3f0fb454490b89c0bc8799770dbaa5e Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Tue, 19 May 2026 14:08:38 +0200 Subject: [PATCH 04/15] feat: Disable string truncation for events by default (#6290) Remove the default string limit applied when serializing envelopes by changing `max_value_length` to default to `None`. Setting `max_value_length` to `None` disables string truncation. This follows the earlier limit increase in https://github.com/getsentry/sentry-python/commit/4f9d326c86477052aa30230393497a20edb17da4. Replace `DEFAULT_MAX_VALUE_LENGTH` with the value 1024 in tests, which was the original limit. --- sentry_sdk/consts.py | 8 +- sentry_sdk/serializer.py | 2 +- sentry_sdk/utils.py | 8 +- tests/integrations/bottle/test_bottle.py | 91 ++++++++++++------- tests/integrations/falcon/test_falcon.py | 29 +++--- tests/integrations/flask/test_flask.py | 81 ++++++++++------- tests/integrations/pyramid/test_pyramid.py | 62 ++++++++----- .../sqlalchemy/test_sqlalchemy.py | 29 +++--- tests/test_client.py | 28 ++---- tests/test_serializer.py | 7 +- 10 files changed, 192 insertions(+), 153 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index d9b6e479e3..1db0b34979 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -2,11 +2,7 @@ from enum import Enum from typing import TYPE_CHECKING -# up top to prevent circular import due to integration import -# This is more or less an arbitrary large-ish value for now, so that we allow -# pretty long strings (like LLM prompts), but still have *some* upper limit -# until we verify that removing the trimming completely is safe. -DEFAULT_MAX_VALUE_LENGTH = 100_000 +DEFAULT_MAX_VALUE_LENGTH = None DEFAULT_MAX_STACK_FRAMES = 100 DEFAULT_ADD_FULL_STACK = False @@ -1233,7 +1229,7 @@ def __init__( ], functions_to_trace: "Sequence[Dict[str, str]]" = [], # noqa: B006 event_scrubber: "Optional[sentry_sdk.scrubber.EventScrubber]" = None, - max_value_length: int = DEFAULT_MAX_VALUE_LENGTH, + max_value_length: "Optional[int]" = DEFAULT_MAX_VALUE_LENGTH, enable_backpressure_handling: bool = True, error_sampler: "Optional[Callable[[Event, Hint], Union[float, bool]]]" = None, enable_db_query_source: bool = True, diff --git a/sentry_sdk/serializer.py b/sentry_sdk/serializer.py index 8cdeb1c4d6..f9afbe8fd0 100644 --- a/sentry_sdk/serializer.py +++ b/sentry_sdk/serializer.py @@ -103,7 +103,7 @@ def serialize(event: "Dict[str, Any]", **kwargs: "Any") -> "Dict[str, Any]": * Annotating the payload with the _meta field whenever trimming happens. :param max_request_body_size: If set to "always", will never trim request bodies. - :param max_value_length: The max length to strip strings to, defaults to sentry_sdk.consts.DEFAULT_MAX_VALUE_LENGTH + :param max_value_length: The max length to strip strings to, or None to disable string truncation. Defaults to None. :param is_vars: If we're serializing vars early, we want to repr() things that are JSON-serializable to make their type more apparent. For example, it's useful to see the difference between a unicode-string and a bytestring when viewing a stacktrace. :param custom_repr: A custom repr function that runs before safe_repr on the object to be serialized. If it returns None or throws internally, we will fallback to safe_repr. diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 180aff38ce..aa13a98e94 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -40,7 +40,6 @@ from sentry_sdk.consts import ( DEFAULT_ADD_FULL_STACK, DEFAULT_MAX_STACK_FRAMES, - DEFAULT_MAX_VALUE_LENGTH, EndpointType, ) @@ -754,7 +753,7 @@ def single_exception_from_error_tuple( if client_options is None: include_local_variables = True include_source_context = True - max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback + max_value_length = None # fallback custom_repr = None else: include_local_variables = client_options["include_local_variables"] @@ -1268,12 +1267,9 @@ def _get_size_in_bytes(value: str) -> "Optional[int]": def strip_string( value: str, max_length: "Optional[int]" = None ) -> "Union[AnnotatedValue, str]": - if not value: + if not value or max_length is None: return value - if max_length is None: - max_length = DEFAULT_MAX_VALUE_LENGTH - byte_size = _get_size_in_bytes(value) text_size = len(value) diff --git a/tests/integrations/bottle/test_bottle.py b/tests/integrations/bottle/test_bottle.py index 3b19dcd4a2..dd5e39a20f 100644 --- a/tests/integrations/bottle/test_bottle.py +++ b/tests/integrations/bottle/test_bottle.py @@ -9,7 +9,6 @@ from werkzeug.wrappers import Response from sentry_sdk import capture_message -from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH from sentry_sdk.integrations.bottle import BottleIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.serializer import MAX_DATABAG_BREADTH @@ -122,10 +121,17 @@ def index(): assert event["exception"]["values"][0]["mechanism"]["handled"] is False -def test_large_json_request(sentry_init, capture_events, app, get_client): - sentry_init(integrations=[BottleIntegration()], max_request_body_size="always") +@pytest.mark.parametrize("max_value_length", [1024, None]) +def test_large_json_request( + sentry_init, capture_events, app, get_client, max_value_length +): + sentry_init( + integrations=[BottleIntegration()], + max_request_body_size="always", + max_value_length=max_value_length, + ) - data = {"foo": {"bar": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)}} + data = {"foo": {"bar": "a" * (1034)}} @app.route("/", method="POST") def index(): @@ -145,15 +151,17 @@ def index(): assert response[1] == "200 OK" (event,) = events - assert event["_meta"]["request"]["data"]["foo"]["bar"] == { - "": { - "len": DEFAULT_MAX_VALUE_LENGTH + 10, - "rem": [ - ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH] - ], + + if max_value_length: + assert event["_meta"]["request"]["data"]["foo"]["bar"] == { + "": { + "len": 1034, + "rem": [["!limit", "x", 1021, 1024]], + } } - } - assert len(event["request"]["data"]["foo"]["bar"]) == DEFAULT_MAX_VALUE_LENGTH + assert len(event["request"]["data"]["foo"]["bar"]) == 1024 + else: + assert len(event["request"]["data"]["foo"]["bar"]) == 1034 @pytest.mark.parametrize("data", [{}, []], ids=["empty-dict", "empty-list"]) @@ -180,10 +188,17 @@ def index(): assert event["request"]["data"] == data -def test_medium_formdata_request(sentry_init, capture_events, app, get_client): - sentry_init(integrations=[BottleIntegration()], max_request_body_size="always") +@pytest.mark.parametrize("max_value_length", [1024, None]) +def test_medium_formdata_request( + sentry_init, capture_events, app, get_client, max_value_length +): + sentry_init( + integrations=[BottleIntegration()], + max_request_body_size="always", + max_value_length=max_value_length, + ) - data = {"foo": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)} + data = {"foo": "a" * (1034)} @app.route("/", method="POST") def index(): @@ -200,15 +215,17 @@ def index(): assert response[1] == "200 OK" (event,) = events - assert event["_meta"]["request"]["data"]["foo"] == { - "": { - "len": DEFAULT_MAX_VALUE_LENGTH + 10, - "rem": [ - ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH] - ], + + if max_value_length: + assert event["_meta"]["request"]["data"]["foo"] == { + "": { + "len": 1034, + "rem": [["!limit", "x", 1021, 1024]], + } } - } - assert len(event["request"]["data"]["foo"]) == DEFAULT_MAX_VALUE_LENGTH + assert len(event["request"]["data"]["foo"]) == 1024 + else: + assert len(event["request"]["data"]["foo"]) == 1034 @pytest.mark.parametrize("input_char", ["a", b"a"]) @@ -242,11 +259,16 @@ def index(): assert not event["request"]["data"] -def test_files_and_form(sentry_init, capture_events, app, get_client): - sentry_init(integrations=[BottleIntegration()], max_request_body_size="always") +@pytest.mark.parametrize("max_value_length", [1024, None]) +def test_files_and_form(sentry_init, capture_events, app, get_client, max_value_length): + sentry_init( + integrations=[BottleIntegration()], + max_request_body_size="always", + max_value_length=max_value_length, + ) data = { - "foo": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10), + "foo": "a" * (1034), "file": (BytesIO(b"hello"), "hello.txt"), } @@ -267,15 +289,16 @@ def index(): assert response[1] == "200 OK" (event,) = events - assert event["_meta"]["request"]["data"]["foo"] == { - "": { - "len": DEFAULT_MAX_VALUE_LENGTH + 10, - "rem": [ - ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH] - ], + if max_value_length: + assert event["_meta"]["request"]["data"]["foo"] == { + "": { + "len": 1034, + "rem": [["!limit", "x", 1021, 1024]], + } } - } - assert len(event["request"]["data"]["foo"]) == DEFAULT_MAX_VALUE_LENGTH + assert len(event["request"]["data"]["foo"]) == 1024 + else: + assert len(event["request"]["data"]["foo"]) == 1034 assert event["_meta"]["request"]["data"]["file"] == { "": { diff --git a/tests/integrations/falcon/test_falcon.py b/tests/integrations/falcon/test_falcon.py index e4cd089f3d..15122fc642 100644 --- a/tests/integrations/falcon/test_falcon.py +++ b/tests/integrations/falcon/test_falcon.py @@ -5,7 +5,6 @@ import pytest import sentry_sdk -from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH from sentry_sdk.integrations.falcon import FalconIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.utils import parse_version @@ -206,10 +205,15 @@ def on_get(self, req, resp): assert len(events) == 0 -def test_falcon_large_json_request(sentry_init, capture_events): - sentry_init(integrations=[FalconIntegration()], max_request_body_size="always") +@pytest.mark.parametrize("max_value_length", [1024, None]) +def test_falcon_large_json_request(sentry_init, capture_events, max_value_length): + sentry_init( + integrations=[FalconIntegration()], + max_request_body_size="always", + max_value_length=max_value_length, + ) - data = {"foo": {"bar": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)}} + data = {"foo": {"bar": "a" * (1034)}} class Resource: def on_post(self, req, resp): @@ -227,15 +231,16 @@ def on_post(self, req, resp): assert response.status == falcon.HTTP_200 (event,) = events - assert event["_meta"]["request"]["data"]["foo"]["bar"] == { - "": { - "len": DEFAULT_MAX_VALUE_LENGTH + 10, - "rem": [ - ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH] - ], + if max_value_length: + assert event["_meta"]["request"]["data"]["foo"]["bar"] == { + "": { + "len": 1034, + "rem": [["!limit", "x", 1021, 1024]], + } } - } - assert len(event["request"]["data"]["foo"]["bar"]) == DEFAULT_MAX_VALUE_LENGTH + assert len(event["request"]["data"]["foo"]["bar"]) == 1024 + else: + assert len(event["request"]["data"]["foo"]["bar"]) == 1034 @pytest.mark.parametrize("data", [{}, []], ids=["empty-dict", "empty-list"]) diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index 8d2d1b3c95..e5df8d790c 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -27,7 +27,6 @@ capture_message, set_tag, ) -from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.serializer import MAX_DATABAG_BREADTH @@ -247,12 +246,15 @@ def login(): assert event["user"]["id"] == str(user_id) -def test_flask_large_json_request(sentry_init, capture_events, app): +@pytest.mark.parametrize("max_value_length", [1024, None]) +def test_flask_large_json_request(sentry_init, capture_events, app, max_value_length): sentry_init( - integrations=[flask_sentry.FlaskIntegration()], max_request_body_size="always" + integrations=[flask_sentry.FlaskIntegration()], + max_request_body_size="always", + max_value_length=max_value_length, ) - data = {"foo": {"bar": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)}} + data = {"foo": {"bar": "a" * (1034)}} @app.route("/", methods=["POST"]) def index(): @@ -269,15 +271,16 @@ def index(): assert response.status_code == 200 (event,) = events - assert event["_meta"]["request"]["data"]["foo"]["bar"] == { - "": { - "len": DEFAULT_MAX_VALUE_LENGTH + 10, - "rem": [ - ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH] - ], + if max_value_length: + assert event["_meta"]["request"]["data"]["foo"]["bar"] == { + "": { + "len": 1034, + "rem": [["!limit", "x", 1021, 1024]], + } } - } - assert len(event["request"]["data"]["foo"]["bar"]) == DEFAULT_MAX_VALUE_LENGTH + assert len(event["request"]["data"]["foo"]["bar"]) == 1024 + else: + assert len(event["request"]["data"]["foo"]["bar"]) == 1034 def test_flask_session_tracking(sentry_init, capture_envelopes, app): @@ -342,12 +345,17 @@ def index(): assert event["request"]["data"] == data -def test_flask_medium_formdata_request(sentry_init, capture_events, app): +@pytest.mark.parametrize("max_value_length", [1024, None]) +def test_flask_medium_formdata_request( + sentry_init, capture_events, app, max_value_length +): sentry_init( - integrations=[flask_sentry.FlaskIntegration()], max_request_body_size="always" + integrations=[flask_sentry.FlaskIntegration()], + max_request_body_size="always", + max_value_length=max_value_length, ) - data = {"foo": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)} + data = {"foo": "a" * (1034)} @app.route("/", methods=["POST"]) def index(): @@ -368,15 +376,16 @@ def index(): assert response.status_code == 200 (event,) = events - assert event["_meta"]["request"]["data"]["foo"] == { - "": { - "len": DEFAULT_MAX_VALUE_LENGTH + 10, - "rem": [ - ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH] - ], + if max_value_length: + assert event["_meta"]["request"]["data"]["foo"] == { + "": { + "len": 1034, + "rem": [["!limit", "x", 1021, 1024]], + } } - } - assert len(event["request"]["data"]["foo"]) == DEFAULT_MAX_VALUE_LENGTH + assert len(event["request"]["data"]["foo"]) == 1024 + else: + assert len(event["request"]["data"]["foo"]) == 1034 def test_flask_formdata_request_appear_transaction_body( @@ -450,13 +459,16 @@ def index(): assert not event["request"]["data"] -def test_flask_files_and_form(sentry_init, capture_events, app): +@pytest.mark.parametrize("max_value_length", [1024, None]) +def test_flask_files_and_form(sentry_init, capture_events, app, max_value_length): sentry_init( - integrations=[flask_sentry.FlaskIntegration()], max_request_body_size="always" + integrations=[flask_sentry.FlaskIntegration()], + max_request_body_size="always", + max_value_length=max_value_length, ) data = { - "foo": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10), + "foo": "a" * (1034), "file": (BytesIO(b"hello"), "hello.txt"), } @@ -479,15 +491,16 @@ def index(): assert response.status_code == 200 (event,) = events - assert event["_meta"]["request"]["data"]["foo"] == { - "": { - "len": DEFAULT_MAX_VALUE_LENGTH + 10, - "rem": [ - ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH] - ], + if max_value_length: + assert event["_meta"]["request"]["data"]["foo"] == { + "": { + "len": 1034, + "rem": [["!limit", "x", 1021, 1024]], + } } - } - assert len(event["request"]["data"]["foo"]) == DEFAULT_MAX_VALUE_LENGTH + assert len(event["request"]["data"]["foo"]) == 1024 + else: + assert len(event["request"]["data"]["foo"]) == 1034 assert event["_meta"]["request"]["data"]["file"] == {"": {"rem": [["!raw", "x"]]}} assert not event["request"]["data"]["file"] diff --git a/tests/integrations/pyramid/test_pyramid.py b/tests/integrations/pyramid/test_pyramid.py index c82d0102f3..1c4f29eaaf 100644 --- a/tests/integrations/pyramid/test_pyramid.py +++ b/tests/integrations/pyramid/test_pyramid.py @@ -10,7 +10,6 @@ from werkzeug.test import Client from sentry_sdk import add_breadcrumb, capture_message -from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH from sentry_sdk.integrations.pyramid import PyramidIntegration from sentry_sdk.serializer import MAX_DATABAG_BREADTH from tests.conftest import unpack_werkzeug_response @@ -156,10 +155,17 @@ def test_transaction_style( assert event["transaction_info"] == {"source": expected_source} -def test_large_json_request(sentry_init, capture_events, route, get_client): - sentry_init(integrations=[PyramidIntegration()], max_request_body_size="always") +@pytest.mark.parametrize("max_value_length", [1024, None]) +def test_large_json_request( + sentry_init, capture_events, route, get_client, max_value_length +): + sentry_init( + integrations=[PyramidIntegration()], + max_request_body_size="always", + max_value_length=max_value_length, + ) - data = {"foo": {"bar": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10)}} + data = {"foo": {"bar": "a" * (1034)}} @route("/") def index(request): @@ -175,15 +181,17 @@ def index(request): client.post("/", content_type="application/json", data=json.dumps(data)) (event,) = events - assert event["_meta"]["request"]["data"]["foo"]["bar"] == { - "": { - "len": DEFAULT_MAX_VALUE_LENGTH + 10, - "rem": [ - ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH] - ], + + if max_value_length: + assert event["_meta"]["request"]["data"]["foo"]["bar"] == { + "": { + "len": 1034, + "rem": [["!limit", "x", 1021, 1024]], + } } - } - assert len(event["request"]["data"]["foo"]["bar"]) == DEFAULT_MAX_VALUE_LENGTH + assert len(event["request"]["data"]["foo"]["bar"]) == 1024 + else: + assert len(event["request"]["data"]["foo"]["bar"]) == 1034 @pytest.mark.parametrize("data", [{}, []], ids=["empty-dict", "empty-list"]) @@ -233,11 +241,18 @@ def index(request): assert event["request"]["data"] == data -def test_files_and_form(sentry_init, capture_events, route, get_client): - sentry_init(integrations=[PyramidIntegration()], max_request_body_size="always") +@pytest.mark.parametrize("max_value_length", [1024, None]) +def test_files_and_form( + sentry_init, capture_events, route, get_client, max_value_length +): + sentry_init( + integrations=[PyramidIntegration()], + max_request_body_size="always", + max_value_length=max_value_length, + ) data = { - "foo": "a" * (DEFAULT_MAX_VALUE_LENGTH + 10), + "foo": "a" * (1034), "file": (BytesIO(b"hello"), "hello.txt"), } @@ -252,15 +267,16 @@ def index(request): client.post("/", data=data) (event,) = events - assert event["_meta"]["request"]["data"]["foo"] == { - "": { - "len": DEFAULT_MAX_VALUE_LENGTH + 10, - "rem": [ - ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH] - ], + if max_value_length: + assert event["_meta"]["request"]["data"]["foo"] == { + "": { + "len": 1034, + "rem": [["!limit", "x", 1021, 1024]], + } } - } - assert len(event["request"]["data"]["foo"]) == DEFAULT_MAX_VALUE_LENGTH + assert len(event["request"]["data"]["foo"]) == 1024 + else: + assert len(event["request"]["data"]["foo"]) == 1034 assert event["_meta"]["request"]["data"]["file"] == {"": {"rem": [["!raw", "x"]]}} assert not event["request"]["data"]["file"] diff --git a/tests/integrations/sqlalchemy/test_sqlalchemy.py b/tests/integrations/sqlalchemy/test_sqlalchemy.py index eb351eead3..1f53c47444 100644 --- a/tests/integrations/sqlalchemy/test_sqlalchemy.py +++ b/tests/integrations/sqlalchemy/test_sqlalchemy.py @@ -10,7 +10,7 @@ import sentry_sdk from sentry_sdk import capture_message, start_transaction -from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH, SPANDATA +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.serializer import MAX_EVENT_BYTES from sentry_sdk.tracing_utils import record_sql_queries_supporting_streaming @@ -357,14 +357,16 @@ def test_long_sql_query_preserved( assert description.endswith("SELECT 98 UNION SELECT 99") -def test_large_event_not_truncated(sentry_init, capture_events): +@pytest.mark.parametrize("max_value_length", [1024, None]) +def test_large_event_not_truncated(sentry_init, capture_events, max_value_length): sentry_init( traces_sample_rate=1, integrations=[SqlalchemyIntegration()], + max_value_length=max_value_length, ) events = capture_events() - long_str = "x" * (DEFAULT_MAX_VALUE_LENGTH + 10) + long_str = "x" * (1034) scope = sentry_sdk.get_isolation_scope() @@ -401,18 +403,19 @@ def processor(event, hint): assert description.startswith("SELECT 0") assert description.endswith("SELECT 98 UNION SELECT 99") - # Smoke check that truncation of other fields has not changed. - assert len(event["message"]) == DEFAULT_MAX_VALUE_LENGTH + if max_value_length: + # Smoke check that truncation of other fields has not changed. + assert len(event["message"]) == 1024 - # The _meta for other truncated fields should be there as well. - assert event["_meta"]["message"] == { - "": { - "len": DEFAULT_MAX_VALUE_LENGTH + 10, - "rem": [ - ["!limit", "x", DEFAULT_MAX_VALUE_LENGTH - 3, DEFAULT_MAX_VALUE_LENGTH] - ], + # The _meta for other truncated fields should be there as well. + assert event["_meta"]["message"] == { + "": { + "len": 1034, + "rem": [["!limit", "x", 1021, 1024]], + } } - } + else: + assert len(event["message"]) == 1034 @pytest.mark.parametrize("span_streaming", [True, False]) diff --git a/tests/test_client.py b/tests/test_client.py index 54369f2b82..1e19fba1a4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -24,7 +24,7 @@ start_transaction, ) from sentry_sdk._compat import PY38 -from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, DEFAULT_MAX_VALUE_LENGTH +from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS from sentry_sdk.integrations.asyncio import AsyncioIntegration from sentry_sdk.integrations.executing import ExecutingIntegration from sentry_sdk.serializer import MAX_DATABAG_BREADTH @@ -803,19 +803,19 @@ def test_databag_depth_stripping(sentry_init, capture_events): def test_databag_string_stripping(sentry_init, capture_events): - sentry_init() + sentry_init(max_value_length=1024) events = capture_events() del events[:] try: - a = "A" * DEFAULT_MAX_VALUE_LENGTH * 10 # noqa + a = "A" * 10240 # noqa 1 / 0 except Exception: capture_exception() (event,) = events - assert len(json.dumps(event)) < DEFAULT_MAX_VALUE_LENGTH * 10 + assert len(json.dumps(event)) < 10240 def test_databag_breadth_stripping(sentry_init, capture_events): @@ -1102,25 +1102,13 @@ def test_multiple_positional_args(sentry_init): assert "Only single positional argument is expected" in str(exinfo.value) -@pytest.mark.parametrize( - "sdk_options, expected_data_length", - [ - ({}, DEFAULT_MAX_VALUE_LENGTH), - ( - {"max_value_length": DEFAULT_MAX_VALUE_LENGTH + 1000}, - DEFAULT_MAX_VALUE_LENGTH + 1000, - ), - ], -) -def test_max_value_length_option( - sentry_init, capture_events, sdk_options, expected_data_length -): - sentry_init(sdk_options) +def test_max_value_length_option(sentry_init, capture_events): + sentry_init(max_value_length=2024) events = capture_events() - capture_message("a" * (DEFAULT_MAX_VALUE_LENGTH + 2000)) + capture_message("a" * (3024)) - assert len(events[0]["message"]) == expected_data_length + assert len(events[0]["message"]) == 2024 @pytest.mark.parametrize( diff --git a/tests/test_serializer.py b/tests/test_serializer.py index 5a94ca08b9..9d75f73d96 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -2,7 +2,6 @@ import pytest -from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH from sentry_sdk.serializer import MAX_DATABAG_BREADTH, MAX_DATABAG_DEPTH, serialize try: @@ -166,12 +165,12 @@ def test_no_trimming_if_max_request_body_size_is_always(body_normalizer): assert result == data -def test_max_value_length_default(body_normalizer): - data = {"key": "a" * (DEFAULT_MAX_VALUE_LENGTH * 10)} +def test_no_value_truncation_by_default(body_normalizer): + data = {"key": "a" * (10240)} result = body_normalizer(data) - assert len(result["key"]) == DEFAULT_MAX_VALUE_LENGTH # fallback max length + assert len(result["key"]) == 10240 # fallback max length def test_max_value_length(body_normalizer): From db10de25e7e67deebac769df00567293ceb44bda Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 19 May 2026 14:25:57 +0200 Subject: [PATCH 05/15] fix(boto3): Guard setting method (#6288) ### Description Check that `request.method` is not `None` before setting it. We do this in the span streaming path but not in legacy tracing. #### Issues - Closes https://linear.app/getsentry/issue/PY-2406/guard-setting-requestmethod-in-boto3-integration - Closes https://github.com/getsentry/sentry-python/issues/6212 #### Reminders - Please add tests to validate your changes, and lint your code using `tox -e linters`. - Add GH Issue ID _&_ Linear ID (if applicable) - PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`) - For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr) --- sentry_sdk/integrations/boto3.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py index d04cc06700..b460e91d8e 100644 --- a/sentry_sdk/integrations/boto3.py +++ b/sentry_sdk/integrations/boto3.py @@ -99,7 +99,8 @@ def _sentry_request_created( span.set_tag("aws.service_id", service_id.hyphenize()) span.set_tag("aws.operation_name", operation_name) - span.set_data(SPANDATA.HTTP_METHOD, request.method) + if request.method is not None: + span.set_data(SPANDATA.HTTP_METHOD, request.method) # We do it in order for subsequent http calls/retries be # attached to this span. From c28aa61034eb41d07f32d61f6b14fa565862e779 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Tue, 19 May 2026 15:33:24 +0200 Subject: [PATCH 06/15] feat(starlite): Support span streaming (#6294) --- sentry_sdk/consts.py | 5 + sentry_sdk/integrations/starlite.py | 45 +++- tests/integrations/starlite/test_starlite.py | 246 ++++++++++++++----- 3 files changed, 220 insertions(+), 76 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 1db0b34979..c6e2e9f314 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -882,6 +882,11 @@ class SPANDATA: The messaging system's name, e.g. `kafka`, `aws_sqs` """ + MIDDLEWARE_NAME = "middleware.name" + """ + The middleware's name, e.g. `AuthenticationMiddleware` + """ + NETWORK_PROTOCOL_NAME = "network.protocol.name" """ The application layer protocol name used for the network connection. diff --git a/sentry_sdk/integrations/starlite.py b/sentry_sdk/integrations/starlite.py index 253e51b1f1..21009daa63 100644 --- a/sentry_sdk/integrations/starlite.py +++ b/sentry_sdk/integrations/starlite.py @@ -1,11 +1,12 @@ from copy import deepcopy import sentry_sdk -from sentry_sdk.consts import OP +from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( ensure_integration_enabled, event_from_exception, @@ -141,16 +142,34 @@ async def _create_span_call( receive: "Receive", send: "Send", ) -> None: - if sentry_sdk.get_client().get_integration(StarliteIntegration) is None: + client = sentry_sdk.get_client() + if client.get_integration(StarliteIntegration) is None: return await old_call(self, scope, receive, send) middleware_name = self.__class__.__name__ - with sentry_sdk.start_span( - op=OP.MIDDLEWARE_STARLITE, - name=middleware_name, - origin=StarliteIntegration.origin, + is_span_streaming_enabled = has_span_streaming_enabled(client.options) + + def _start_middleware_span(op: str, name: str) -> "Any": + if is_span_streaming_enabled: + return sentry_sdk.traces.start_span( + name=name, + attributes={ + "sentry.op": op, + "sentry.origin": StarliteIntegration.origin, + SPANDATA.MIDDLEWARE_NAME: middleware_name, + }, + ) + return sentry_sdk.start_span( + op=op, + name=name, + origin=StarliteIntegration.origin, + ) + + with _start_middleware_span( + op=OP.MIDDLEWARE_STARLITE, name=middleware_name ) as middleware_span: - middleware_span.set_tag("starlite.middleware_name", middleware_name) + if not is_span_streaming_enabled: + middleware_span.set_tag("starlite.middleware_name", middleware_name) # Creating spans for the "receive" callback async def _sentry_receive( @@ -158,12 +177,12 @@ async def _sentry_receive( ) -> "Union[HTTPReceiveMessage, WebSocketReceiveMessage]": if sentry_sdk.get_client().get_integration(StarliteIntegration) is None: return await receive(*args, **kwargs) - with sentry_sdk.start_span( + with _start_middleware_span( op=OP.MIDDLEWARE_STARLITE_RECEIVE, name=getattr(receive, "__qualname__", str(receive)), - origin=StarliteIntegration.origin, ) as span: - span.set_tag("starlite.middleware_name", middleware_name) + if not is_span_streaming_enabled: + span.set_tag("starlite.middleware_name", middleware_name) return await receive(*args, **kwargs) receive_name = getattr(receive, "__name__", str(receive)) @@ -174,12 +193,12 @@ async def _sentry_receive( async def _sentry_send(message: "Message") -> None: if sentry_sdk.get_client().get_integration(StarliteIntegration) is None: return await send(message) - with sentry_sdk.start_span( + with _start_middleware_span( op=OP.MIDDLEWARE_STARLITE_SEND, name=getattr(send, "__qualname__", str(send)), - origin=StarliteIntegration.origin, ) as span: - span.set_tag("starlite.middleware_name", middleware_name) + if not is_span_streaming_enabled: + span.set_tag("starlite.middleware_name", middleware_name) return await send(message) send_name = getattr(send, "__name__", str(send)) diff --git a/tests/integrations/starlite/test_starlite.py b/tests/integrations/starlite/test_starlite.py index 5c038c482d..0e7e67fe2a 100644 --- a/tests/integrations/starlite/test_starlite.py +++ b/tests/integrations/starlite/test_starlite.py @@ -9,6 +9,7 @@ from starlite.middleware.session.memory_backend import MemoryBackendConfig from starlite.testing import TestClient +import sentry_sdk from sentry_sdk import capture_message from sentry_sdk.integrations.starlite import StarliteIntegration @@ -111,10 +112,14 @@ def test_catch_exceptions( assert event["exception"]["values"][0]["mechanism"]["type"] == "starlite" -def test_middleware_spans(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_middleware_spans(sentry_init, capture_events, capture_items, span_streaming): sentry_init( traces_sample_rate=1.0, integrations=[StarliteIntegration()], + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, ) logging_config = LoggingMiddlewareConfig() @@ -128,32 +133,58 @@ def test_middleware_spans(sentry_init, capture_events): rate_limit_config.middleware, ] ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = TestClient( starlite_app, raise_server_exceptions=False, base_url="http://testserver.local" ) client.get("/message") - (_, transaction_event) = events - expected = {"SessionMiddleware", "LoggingMiddleware", "RateLimitMiddleware"} - found = set() - starlite_spans = ( - span - for span in transaction_event["spans"] - if span["op"] == "middleware.starlite" - ) + if span_streaming: + sentry_sdk.flush() + + middleware_spans = [ + item.payload + for item in items + if item.payload.get("attributes", {}).get("sentry.op") + == "middleware.starlite" + ] + assert len(middleware_spans) == 3 + + found = set() + for span in middleware_spans: + assert span["name"] in expected + assert span["name"] not in found + found.add(span["name"]) + assert span["name"] == span["attributes"]["middleware.name"] + else: + (_, transaction_event) = events + + found = set() + middleware_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == "middleware.starlite" + ] + assert len(middleware_spans) == 3 - for span in starlite_spans: - assert span["description"] in expected - assert span["description"] not in found - found.add(span["description"]) - assert span["description"] == span["tags"]["starlite.middleware_name"] + for span in middleware_spans: + assert span["description"] in expected + assert span["description"] not in found + found.add(span["description"]) + assert span["description"] == span["tags"]["starlite.middleware_name"] -def test_middleware_callback_spans(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_middleware_callback_spans( + sentry_init, capture_events, capture_items, span_streaming +): class SampleMiddleware(AbstractMiddleware): async def __call__(self, scope, receive, send) -> None: async def do_stuff(message): @@ -167,15 +198,20 @@ async def do_stuff(message): sentry_init( traces_sample_rate=1.0, integrations=[StarliteIntegration()], + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, ) starlite_app = starlite_app_factory(middleware=[SampleMiddleware]) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = TestClient(starlite_app, raise_server_exceptions=False) client.get("/message") - (_, transaction_events) = events - expected_starlite_spans = [ { "op": "middleware.starlite", @@ -194,25 +230,52 @@ async def do_stuff(message): }, ] - def is_matching_span(expected_span, actual_span): - return ( - expected_span["op"] == actual_span["op"] - and expected_span["description"] == actual_span["description"] - and expected_span["tags"] == actual_span["tags"] - ) + if span_streaming: + sentry_sdk.flush() - actual_starlite_spans = list( - span - for span in transaction_events["spans"] - if "middleware.starlite" in span["op"] - ) - assert len(actual_starlite_spans) == 3 - - for expected_span in expected_starlite_spans: - assert any( - is_matching_span(expected_span, actual_span) - for actual_span in actual_starlite_spans + actual_starlite_spans = [ + item.payload + for item in items + if "middleware.starlite" + in item.payload.get("attributes", {}).get("sentry.op", "") + ] + assert len(actual_starlite_spans) == 3 + + def is_matching_span_streaming(expected_span, actual_span): + return ( + expected_span["op"] == actual_span["attributes"]["sentry.op"] + and expected_span["description"] == actual_span["name"] + and expected_span["tags"]["starlite.middleware_name"] + == actual_span["attributes"]["middleware.name"] + ) + + for expected_span in expected_starlite_spans: + assert any( + is_matching_span_streaming(expected_span, actual_span) + for actual_span in actual_starlite_spans + ) + else: + (_, transaction_events) = events + + def is_matching_span(expected_span, actual_span): + return ( + expected_span["op"] == actual_span["op"] + and expected_span["description"] == actual_span["description"] + and expected_span["tags"] == actual_span["tags"] + ) + + actual_starlite_spans = list( + span + for span in transaction_events["spans"] + if "middleware.starlite" in span["op"] ) + assert len(actual_starlite_spans) == 3 + + for expected_span in expected_starlite_spans: + assert any( + is_matching_span(expected_span, actual_span) + for actual_span in actual_starlite_spans + ) def test_middleware_receive_send(sentry_init, capture_events): @@ -238,7 +301,10 @@ async def __call__(self, scope, receive, send): client.get("/message") -def test_middleware_partial_receive_send(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_middleware_partial_receive_send( + sentry_init, capture_events, capture_items, span_streaming +): class SamplePartialReceiveSendMiddleware(AbstractMiddleware): async def __call__(self, scope, receive, send): message = await receive() @@ -262,16 +328,21 @@ async def my_send(*args, **kwargs): sentry_init( traces_sample_rate=1.0, integrations=[StarliteIntegration()], + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, ) starlite_app = starlite_app_factory(middleware=[SamplePartialReceiveSendMiddleware]) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = TestClient(starlite_app, raise_server_exceptions=False) # See SamplePartialReceiveSendMiddleware.__call__ above for assertions of correct behavior client.get("/message") - (_, transaction_events) = events - expected_starlite_spans = [ { "op": "middleware.starlite", @@ -290,31 +361,62 @@ async def my_send(*args, **kwargs): }, ] - def is_matching_span(expected_span, actual_span): - return ( - expected_span["op"] == actual_span["op"] - and actual_span["description"].startswith(expected_span["description"]) - and expected_span["tags"] == actual_span["tags"] - ) - - actual_starlite_spans = list( - span - for span in transaction_events["spans"] - if "middleware.starlite" in span["op"] - ) - assert len(actual_starlite_spans) == 3 + if span_streaming: + sentry_sdk.flush() - for expected_span in expected_starlite_spans: - assert any( - is_matching_span(expected_span, actual_span) - for actual_span in actual_starlite_spans + actual_starlite_spans = [ + item.payload + for item in items + if "middleware.starlite" + in item.payload.get("attributes", {}).get("sentry.op", "") + ] + assert len(actual_starlite_spans) == 3 + + def is_matching_span_streaming(expected_span, actual_span): + return ( + expected_span["op"] == actual_span["attributes"]["sentry.op"] + and actual_span["name"].startswith(expected_span["description"]) + and expected_span["tags"]["starlite.middleware_name"] + == actual_span["attributes"]["middleware.name"] + ) + + for expected_span in expected_starlite_spans: + assert any( + is_matching_span_streaming(expected_span, actual_span) + for actual_span in actual_starlite_spans + ) + else: + (_, transaction_events) = events + + def is_matching_span(expected_span, actual_span): + return ( + expected_span["op"] == actual_span["op"] + and actual_span["description"].startswith(expected_span["description"]) + and expected_span["tags"] == actual_span["tags"] + ) + + actual_starlite_spans = list( + span + for span in transaction_events["spans"] + if "middleware.starlite" in span["op"] ) + assert len(actual_starlite_spans) == 3 + + for expected_span in expected_starlite_spans: + assert any( + is_matching_span(expected_span, actual_span) + for actual_span in actual_starlite_spans + ) -def test_span_origin(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_span_origin(sentry_init, capture_events, capture_items, span_streaming): sentry_init( integrations=[StarliteIntegration()], traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, ) logging_config = LoggingMiddlewareConfig() @@ -328,18 +430,36 @@ def test_span_origin(sentry_init, capture_events): rate_limit_config.middleware, ] ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = TestClient( starlite_app, raise_server_exceptions=False, base_url="http://testserver.local" ) client.get("/message") - (_, event) = events + if span_streaming: + sentry_sdk.flush() + + starlite_items = [ + item + for item in items + if "starlite" in item.payload.get("attributes", {}).get("sentry.op", "") + ] + assert len(starlite_items) > 0 + for item in starlite_items: + assert item.payload["attributes"]["sentry.origin"] == "auto.http.starlite" + else: + (_, event) = events - assert event["contexts"]["trace"]["origin"] == "auto.http.starlite" - for span in event["spans"]: - assert span["origin"] == "auto.http.starlite" + assert event["contexts"]["trace"]["origin"] == "auto.http.starlite" + starlite_spans = [span for span in event["spans"] if "starlite" in span["op"]] + assert len(starlite_spans) > 0 + for span in starlite_spans: + assert span["origin"] == "auto.http.starlite" @pytest.mark.parametrize( From 36e13d889f443e59795f11939de7114f858d3932 Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Tue, 19 May 2026 16:09:59 +0200 Subject: [PATCH 07/15] test: Respect context manager lifecycles in `fake_record_sql_queries` (#6295) In the static trace lifecycle, set `span.start_timestamp` and `span.timestamp` immediately after `__enter__` and `__exit__` are called, respectively. In the streaming trace lifecycle, set both `span._start_timestamp` and `span._end_timestamp` immediately after `__enter__` is called, since the query source is added inside the context manager. Then reset `span._end_timestamp` immediately before `__exit__`, as otherwise the span is not captured. --- .../integrations/django/test_db_query_data.py | 22 ++++---- .../sqlalchemy/test_sqlalchemy.py | 52 +++++++------------ 2 files changed, 29 insertions(+), 45 deletions(-) diff --git a/tests/integrations/django/test_db_query_data.py b/tests/integrations/django/test_db_query_data.py index 8978253027..272b2c1664 100644 --- a/tests/integrations/django/test_db_query_data.py +++ b/tests/integrations/django/test_db_query_data.py @@ -346,17 +346,16 @@ def test_no_query_source_if_duration_too_short(sentry_init, client, capture_even class fake_record_sql_queries: # noqa: N801 def __init__(self, *args, **kwargs): - with record_sql_queries(*args, **kwargs) as span: - self.span = span - - self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) - self.span.timestamp = datetime(2024, 1, 1, microsecond=99999) + self._ctx_mgr = record_sql_queries(*args, **kwargs) def __enter__(self): + self.span = self._ctx_mgr.__enter__() + self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) return self.span def __exit__(self, type, value, traceback): - pass + self._ctx_mgr.__exit__(type, value, traceback) + self.span.timestamp = datetime(2024, 1, 1, microsecond=99999) with mock.patch( "sentry_sdk.integrations.django.record_sql_queries", @@ -404,17 +403,16 @@ def test_query_source_if_duration_over_threshold(sentry_init, client, capture_ev class fake_record_sql_queries: # noqa: N801 def __init__(self, *args, **kwargs): - with record_sql_queries(*args, **kwargs) as span: - self.span = span - - self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) - self.span.timestamp = datetime(2024, 1, 1, microsecond=101000) + self._ctx_mgr = record_sql_queries(*args, **kwargs) def __enter__(self): + self.span = self._ctx_mgr.__enter__() + self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) return self.span def __exit__(self, type, value, traceback): - pass + self._ctx_mgr.__exit__(type, value, traceback) + self.span.timestamp = datetime(2024, 1, 1, microsecond=101000) with mock.patch( "sentry_sdk.integrations.django.record_sql_queries", diff --git a/tests/integrations/sqlalchemy/test_sqlalchemy.py b/tests/integrations/sqlalchemy/test_sqlalchemy.py index 1f53c47444..6da8ca1b65 100644 --- a/tests/integrations/sqlalchemy/test_sqlalchemy.py +++ b/tests/integrations/sqlalchemy/test_sqlalchemy.py @@ -934,25 +934,19 @@ class Person(Base): class fake_record_sql_queries: # noqa: N801 def __init__(self, *args, **kwargs): - with record_sql_queries_supporting_streaming( + self._ctx_mgr = record_sql_queries_supporting_streaming( *args, **kwargs - ) as span: - self.span = span - - if span_streaming: - self.span._start_timestamp = datetime(2024, 1, 1, microsecond=0) - self.span._end_timestamp = datetime( - 2024, 1, 1, microsecond=99999 - ) - else: - self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) - self.span.timestamp = datetime(2024, 1, 1, microsecond=99999) + ) def __enter__(self): + self.span = self._ctx_mgr.__enter__() + self.span._start_timestamp = datetime(2024, 1, 1, microsecond=0) + self.span._end_timestamp = datetime(2024, 1, 1, microsecond=99999) return self.span def __exit__(self, type, value, traceback): - pass + self.span._end_timestamp = None + self._ctx_mgr.__exit__(type, value, traceback) with mock.patch( "sentry_sdk.integrations.sqlalchemy.record_sql_queries_supporting_streaming", @@ -1000,25 +994,18 @@ class Person(Base): class fake_record_sql_queries: # noqa: N801 def __init__(self, *args, **kwargs): - with record_sql_queries_supporting_streaming( + self._ctx_mgr = record_sql_queries_supporting_streaming( *args, **kwargs - ) as span: - self.span = span - - if span_streaming: - self.span._start_timestamp = datetime(2024, 1, 1, microsecond=0) - self.span._end_timestamp = datetime( - 2024, 1, 1, microsecond=99999 - ) - else: - self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) - self.span.timestamp = datetime(2024, 1, 1, microsecond=99999) + ) def __enter__(self): + self.span = self._ctx_mgr.__enter__() + self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) return self.span def __exit__(self, type, value, traceback): - pass + self._ctx_mgr.__exit__(type, value, traceback) + self.span.timestamp = datetime(2024, 1, 1, microsecond=99999) with mock.patch( "sentry_sdk.integrations.sqlalchemy.record_sql_queries_supporting_streaming", @@ -1159,19 +1146,18 @@ class Person(Base): class fake_record_sql_queries: # noqa: N801 def __init__(self, *args, **kwargs): - with record_sql_queries_supporting_streaming( + self._ctx_mgr = record_sql_queries_supporting_streaming( *args, **kwargs - ) as span: - self.span = span - - self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) - self.span.timestamp = datetime(2024, 1, 1, microsecond=101000) + ) def __enter__(self): + self.span = self._ctx_mgr.__enter__() + self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) return self.span def __exit__(self, type, value, traceback): - pass + self._ctx_mgr.__exit__(type, value, traceback) + self.span.timestamp = datetime(2024, 1, 1, microsecond=101000) with mock.patch( "sentry_sdk.integrations.sqlalchemy.record_sql_queries_supporting_streaming", From c87a6928f7507a9e3745c9e2e717566f6329d71f Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 19 May 2026 10:14:37 -0400 Subject: [PATCH 08/15] test(flask): Add span streaming test coverage (#6264) Add test coverage to ensure that span streaming works for Flask. No additional changes are needed because we are no longer adding the request body to the streamed spans, and span streaming support has been added earlier to the uWSGI middleware which the Flask integration leverages. Fixes PY-2323 Fixes #6021 --- sentry_sdk/integrations/flask.py | 5 +- tests/integrations/flask/test_flask.py | 212 +++++++++++++++++++------ 2 files changed, 166 insertions(+), 51 deletions(-) diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index 8636dff067..c85bf5be6e 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -95,10 +95,9 @@ def setup_once() -> None: def sentry_patched_wsgi_app( self: "Any", environ: "Dict[str, str]", start_response: "Callable[..., Any]" ) -> "_ScopedResponse": - if sentry_sdk.get_client().get_integration(FlaskIntegration) is None: - return old_app(self, environ, start_response) - integration = sentry_sdk.get_client().get_integration(FlaskIntegration) + if integration is None: + return old_app(self, environ, start_response) middleware = SentryWsgiMiddleware( lambda *a, **kw: old_app(self, *a, **kw), diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index e5df8d790c..5d0cf96b08 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -15,6 +15,8 @@ from flask.views import View from flask_login import LoginManager, login_user +from sentry_sdk.traces import SpanStatus + try: from werkzeug.wrappers.request import UnsupportedMediaType except ImportError: @@ -82,6 +84,7 @@ def test_has_context(sentry_init, app, capture_events): assert event["request"]["url"] == "http://localhost/message" +@pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( "url,transaction_style,expected_transaction,expected_source", [ @@ -91,29 +94,45 @@ def test_has_context(sentry_init, app, capture_events): ("/message/123456", "url", "/message/", "route"), ], ) -def test_transaction_style( +def test_transaction_or_segment_style( sentry_init, app, capture_events, + capture_items, url, transaction_style, expected_transaction, expected_source, + span_streaming, ): sentry_init( integrations=[ flask_sentry.FlaskIntegration(transaction_style=transaction_style) - ] + ], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = app.test_client() response = client.get(url) assert response.status_code == 200 - (event,) = events - assert event["transaction"] == expected_transaction - assert event["transaction_info"] == {"source": expected_source} + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items if i.type == "span"] + assert len(spans) == 1 + (segment,) = spans + assert segment["name"] == expected_transaction + assert segment["attributes"]["sentry.span.source"] == expected_source + else: + (_, event) = events + assert event["transaction"] == expected_transaction + assert event["transaction_info"] == {"source": expected_source} @pytest.mark.parametrize("debug", (True, False)) @@ -763,8 +782,15 @@ def zerodivision(e): assert not events -def test_tracing_success(sentry_init, capture_events, app): - sentry_init(traces_sample_rate=1.0, integrations=[flask_sentry.FlaskIntegration()]) +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_tracing_success( + sentry_init, capture_events, capture_items, app, span_streaming +): + sentry_init( + traces_sample_rate=1.0, + integrations=[flask_sentry.FlaskIntegration()], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) @app.before_request def _(): @@ -776,30 +802,61 @@ def hi_tx(): capture_message("hi") return "ok" - events = capture_events() + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() with app.test_client() as client: response = client.get("/message_tx") assert response.status_code == 200 - message_event, transaction_event = events + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items if i.type == "span"] + message_events = [i.payload for i in items if i.type == "event"] - assert transaction_event["type"] == "transaction" - assert transaction_event["transaction"] == "hi_tx" - assert transaction_event["contexts"]["trace"]["status"] == "ok" - assert transaction_event["tags"]["view"] == "yes" - assert transaction_event["tags"]["before_request"] == "yes" + assert len(spans) == 1 + assert len(message_events) == 1 - assert message_event["message"] == "hi" - assert message_event["transaction"] == "hi_tx" - assert message_event["tags"]["view"] == "yes" - assert message_event["tags"]["before_request"] == "yes" + (segment,) = spans + (message_event,) = message_events + assert segment["name"] == "hi_tx" + assert segment["status"] == SpanStatus.OK + assert segment["attributes"]["sentry.origin"] == "auto.http.flask" -def test_tracing_error(sentry_init, capture_events, app): - sentry_init(traces_sample_rate=1.0, integrations=[flask_sentry.FlaskIntegration()]) + assert message_event["message"] == "hi" + assert message_event["transaction"] == "hi_tx" + assert message_event["tags"]["view"] == "yes" + assert message_event["tags"]["before_request"] == "yes" + else: + message_event, transaction_event = events - events = capture_events() + assert transaction_event["type"] == "transaction" + assert transaction_event["transaction"] == "hi_tx" + assert transaction_event["contexts"]["trace"]["status"] == "ok" + assert transaction_event["tags"]["view"] == "yes" + assert transaction_event["tags"]["before_request"] == "yes" + + assert message_event["message"] == "hi" + assert message_event["transaction"] == "hi_tx" + assert message_event["tags"]["view"] == "yes" + assert message_event["tags"]["before_request"] == "yes" + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_tracing_error(sentry_init, capture_events, capture_items, app, span_streaming): + sentry_init( + traces_sample_rate=1.0, + integrations=[flask_sentry.FlaskIntegration()], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() @app.route("/error") def error(): @@ -810,15 +867,33 @@ def error(): response = client.get("/error") assert response.status_code == 500 - error_event, transaction_event = events + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items if i.type == "span"] + error_events = [i.payload for i in items if i.type == "event"] - assert transaction_event["type"] == "transaction" - assert transaction_event["transaction"] == "error" - assert transaction_event["contexts"]["trace"]["status"] == "internal_error" + assert len(spans) == 1 + assert len(error_events) == 1 - assert error_event["transaction"] == "error" - (exception,) = error_event["exception"]["values"] - assert exception["type"] == "ZeroDivisionError" + (segment,) = spans + (error_event,) = error_events + + assert segment["name"] == "error" + assert segment["status"] == SpanStatus.ERROR + + assert error_event["transaction"] == "error" + (exception,) = error_event["exception"]["values"] + assert exception["type"] == "ZeroDivisionError" + else: + error_event, transaction_event = events + + assert transaction_event["type"] == "transaction" + assert transaction_event["transaction"] == "error" + assert transaction_event["contexts"]["trace"]["status"] == "internal_error" + + assert error_event["transaction"] == "error" + (exception,) = error_event["exception"]["values"] + assert exception["type"] == "ZeroDivisionError" def test_error_has_trace_context_if_tracing_disabled(sentry_init, capture_events, app): @@ -995,34 +1070,54 @@ def test_response_status_code_not_found_in_transaction_context( assert transaction["contexts"]["response"]["status_code"] == 404 -def test_span_origin(sentry_init, app, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_span_origin(sentry_init, app, capture_events, capture_items, span_streaming): sentry_init( integrations=[flask_sentry.FlaskIntegration()], traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = app.test_client() client.get("/message") - (_, event) = events - - assert event["contexts"]["trace"]["origin"] == "auto.http.flask" + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items if i.type == "span"] + assert len(spans) == 1 + (segment,) = spans + assert segment["attributes"]["sentry.origin"] == "auto.http.flask" + else: + (_, event) = events + assert event["contexts"]["trace"]["origin"] == "auto.http.flask" -def test_transaction_http_method_default( +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_transaction_or_segment_http_method_default( sentry_init, app, capture_events, + capture_items, + span_streaming, ): """ - By default OPTIONS and HEAD requests do not create a transaction. + By default OPTIONS and HEAD requests do not create a transaction or segment. """ sentry_init( traces_sample_rate=1.0, integrations=[flask_sentry.FlaskIntegration()], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = app.test_client() response = client.get("/nomessage") @@ -1034,16 +1129,25 @@ def test_transaction_http_method_default( response = client.head("/nomessage") assert response.status_code == 200 - (event,) = events - - assert len(events) == 1 - assert event["request"]["method"] == "GET" + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items if i.type == "span"] + assert len(spans) == 1 + (segment,) = spans + assert segment["attributes"]["http.request.method"] == "GET" + else: + (event,) = events + assert len(events) == 1 + assert event["request"]["method"] == "GET" -def test_transaction_http_method_custom( +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_transaction_or_segment_http_method_custom( sentry_init, app, capture_events, + capture_items, + span_streaming, ): """ Configure FlaskIntegration to ONLY capture OPTIONS and HEAD requests. @@ -1058,8 +1162,13 @@ def test_transaction_http_method_custom( ) # capitalization does not matter ) # case does not matter ], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = app.test_client() response = client.get("/nomessage") @@ -1071,8 +1180,15 @@ def test_transaction_http_method_custom( response = client.head("/nomessage") assert response.status_code == 200 - assert len(events) == 2 - - (event1, event2) = events - assert event1["request"]["method"] == "OPTIONS" - assert event2["request"]["method"] == "HEAD" + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items if i.type == "span"] + assert len(spans) == 2 + (options_segment, head_segment) = spans + assert options_segment["attributes"]["http.request.method"] == "OPTIONS" + assert head_segment["attributes"]["http.request.method"] == "HEAD" + else: + assert len(events) == 2 + (event1, event2) = events + assert event1["request"]["method"] == "OPTIONS" + assert event2["request"]["method"] == "HEAD" From 295a7846fa85f1a28bef7069750f67ce27c29ea2 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 19 May 2026 11:01:43 -0400 Subject: [PATCH 09/15] feat(aiohttp): Remove request body capture from span streaming (#6297) Removes request body capture from aiohttp span streaming to reduce complexity. The aiohttp integration previously captured the request body and set it as the `http.request.body.data` span attribute during span streaming. This change removes that functionality to simplify the integration, along with the associated tests that verified the behavior. Closes #6292 Fixes PY-2422 --- sentry_sdk/integrations/aiohttp.py | 83 +++++++---------- tests/integrations/aiohttp/test_aiohttp.py | 103 --------------------- 2 files changed, 32 insertions(+), 154 deletions(-) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 3d1ee98f08..3282918490 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -201,60 +201,41 @@ async def sentry_app_handle( with span_ctx as span: try: - try: - response = await old_handle(self, request) - except HTTPException as e: - if isinstance(span, StreamedSpan) and not isinstance( - span, NoOpStreamedSpan - ): - span.set_attribute( - "http.response.status_code", e.status_code - ) - - if e.status_code >= 400: - span.status = SpanStatus.ERROR.value - else: - span.status = SpanStatus.OK.value - else: - # Since a NoOpStreamedSpan can end up here, we have to guard against it - # so this only gets set in the legacy transaction approach. - if not isinstance(span, NoOpStreamedSpan): - span.set_http_status(e.status_code) - - if ( - e.status_code - in integration._failed_request_status_codes - ): - _capture_exception() - raise - except (asyncio.CancelledError, ConnectionResetError): - if isinstance(span, StreamedSpan): - span.status = SpanStatus.ERROR.value - else: - span.set_status(SPANSTATUS.CANCELLED) - raise - except Exception: - # This will probably map to a 500 but seems like we - # have no way to tell. Do not set span status. - reraise(*_capture_exception()) - finally: - # The handler has had a chance to read the body, so - # request._read_bytes may now be populated. Capture - # body data on the segment regardless of outcome. + response = await old_handle(self, request) + except HTTPException as e: if isinstance(span, StreamedSpan) and not isinstance( span, NoOpStreamedSpan ): - with capture_internal_exceptions(): - raw_data = get_aiohttp_request_data(request) - body_data = ( - raw_data.value - if isinstance(raw_data, AnnotatedValue) - else raw_data - ) - if body_data is not None: - span._segment.set_attribute( - "http.request.body.data", body_data - ) + span.set_attribute( + "http.response.status_code", e.status_code + ) + + if e.status_code >= 400: + span.status = SpanStatus.ERROR.value + else: + span.status = SpanStatus.OK.value + else: + # Since a NoOpStreamedSpan can end up here, we have to guard against it + # so this only gets set in the legacy transaction approach. + if not isinstance(span, NoOpStreamedSpan): + span.set_http_status(e.status_code) + + if ( + e.status_code + in integration._failed_request_status_codes + ): + _capture_exception() + raise + except (asyncio.CancelledError, ConnectionResetError): + if isinstance(span, StreamedSpan): + span.status = SpanStatus.ERROR.value + else: + span.set_status(SPANSTATUS.CANCELLED) + raise + except Exception: + # This will probably map to a 500 but seems like we + # have no way to tell. Do not set span status. + reraise(*_capture_exception()) try: # A valid response handler will return a valid response with a status. But, if the handler diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index de2d3a9998..0a0032c1cf 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -19,10 +19,8 @@ import sentry_sdk from sentry_sdk import capture_message, start_transaction -from sentry_sdk._types import OVER_SIZE_LIMIT_SUBSTITUTE from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.aiohttp import ( - BODY_NOT_READ_MESSAGE, AioHttpIntegration, create_trace_config, ) @@ -1283,107 +1281,6 @@ async def hello(request): assert server_span["attributes"]["user.ip_address"] == "127.0.0.1" -@pytest.mark.asyncio -async def test_request_body_captured_on_segment_span_streaming( - sentry_init, aiohttp_client, capture_items -): - sentry_init( - integrations=[AioHttpIntegration()], - traces_sample_rate=1.0, - _experiments={"trace_lifecycle": "stream"}, - ) - - body = {"some": "value"} - - async def hello(request): - # Reading the body populates request._read_bytes; the integration - # captures body data in a finally after the handler returns. - await request.json() - return web.Response(text="hello") - - app = web.Application() - app.router.add_post("/", hello) - - items = capture_items("span") - - client = await aiohttp_client(app) - resp = await client.post("/", json=body) - assert resp.status == 200 - - sentry_sdk.flush() - - server_segment, client_segment = [item.payload for item in items] - assert server_segment["is_segment"] is True - assert server_segment["attributes"]["http.request.body.data"] == json.dumps(body) - - -@pytest.mark.asyncio -async def test_request_body_not_read_span_streaming( - sentry_init, aiohttp_client, capture_items -): - sentry_init( - integrations=[AioHttpIntegration()], - traces_sample_rate=1.0, - _experiments={"trace_lifecycle": "stream"}, - ) - - async def hello(request): - # Handler does not read the body; request._read_bytes stays None. - return web.Response(text="hello") - - app = web.Application() - app.router.add_post("/", hello) - - items = capture_items("span") - - client = await aiohttp_client(app) - resp = await client.post("/", json={"some": "value"}) - assert resp.status == 200 - - sentry_sdk.flush() - - server_segment, client_segment = [item.payload for item in items] - assert server_segment["is_segment"] is True - assert ( - server_segment["attributes"]["http.request.body.data"] == BODY_NOT_READ_MESSAGE - ) - - -@pytest.mark.asyncio -async def test_request_body_over_size_limit_span_streaming( - sentry_init, aiohttp_client, capture_items -): - sentry_init( - integrations=[AioHttpIntegration()], - traces_sample_rate=1.0, - max_request_body_size="small", - _experiments={"trace_lifecycle": "stream"}, - ) - - async def hello(request): - await request.read() - return web.Response(text="hello") - - app = web.Application() - app.router.add_post("/", hello) - - items = capture_items("span") - - client = await aiohttp_client(app) - # "small" caps at 1 KB; send a body larger than that. - resp = await client.post("/", data=b"x" * 2000) - assert resp.status == 200 - - sentry_sdk.flush() - - server_segment, client_segment = [item.payload for item in items] - assert server_segment["is_segment"] is True - assert ( - server_segment["attributes"]["http.request.body.data"] - == OVER_SIZE_LIMIT_SUBSTITUTE - ) - - @pytest.mark.asyncio async def test_url_query_attribute_span_streaming( sentry_init, aiohttp_client, capture_items From 52529691a87197ab49db62caa240c9b1ab3e8d14 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Tue, 19 May 2026 17:02:43 +0200 Subject: [PATCH 10/15] feat(socket): Support span streaming (#6296) ## Issues * resolves: #6057 * resolves: PY-2359 --- sentry_sdk/integrations/socket.py | 92 ++++++++--- tests/integrations/socket/test_socket.py | 185 ++++++++++++++++------- 2 files changed, 196 insertions(+), 81 deletions(-) diff --git a/sentry_sdk/integrations/socket.py b/sentry_sdk/integrations/socket.py index 02d7075e00..775170fb9f 100644 --- a/sentry_sdk/integrations/socket.py +++ b/sentry_sdk/integrations/socket.py @@ -2,8 +2,9 @@ import sentry_sdk from sentry_sdk._types import MYPY -from sentry_sdk.consts import OP +from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration +from sentry_sdk.tracing_utils import has_span_streaming_enabled if MYPY: from socket import AddressFamily, SocketKind @@ -50,22 +51,39 @@ def create_connection( timeout: "Optional[float]" = socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore source_address: "Optional[Tuple[Union[bytearray, bytes, str], int]]" = None, ) -> "socket.socket": - integration = sentry_sdk.get_client().get_integration(SocketIntegration) + client = sentry_sdk.get_client() + integration = client.get_integration(SocketIntegration) if integration is None: return real_create_connection(address, timeout, source_address) - with sentry_sdk.start_span( - op=OP.SOCKET_CONNECTION, - name=_get_span_description(address[0], address[1]), - origin=SocketIntegration.origin, - ) as span: - span.set_data("address", address) - span.set_data("timeout", timeout) - span.set_data("source_address", source_address) - - return real_create_connection( - address=address, timeout=timeout, source_address=source_address - ) + if has_span_streaming_enabled(client.options): + with sentry_sdk.traces.start_span( + name=_get_span_description(address[0], address[1]), + attributes={ + "sentry.op": OP.SOCKET_CONNECTION, + "sentry.origin": SocketIntegration.origin, + }, + ) as span: + if address[0] is not None: + span.set_attribute(SPANDATA.SERVER_ADDRESS, address[0]) + span.set_attribute(SPANDATA.SERVER_PORT, address[1]) + + return real_create_connection( + address=address, timeout=timeout, source_address=source_address + ) + else: + with sentry_sdk.start_span( + op=OP.SOCKET_CONNECTION, + name=_get_span_description(address[0], address[1]), + origin=SocketIntegration.origin, + ) as span: + span.set_data("address", address) + span.set_data("timeout", timeout) + span.set_data("source_address", source_address) + + return real_create_connection( + address=address, timeout=timeout, source_address=source_address + ) socket.create_connection = create_connection # type: ignore @@ -81,18 +99,44 @@ def getaddrinfo( proto: int = 0, flags: int = 0, ) -> "List[Tuple[AddressFamily, SocketKind, int, str, Union[Tuple[str, int], Tuple[str, int, int, int], Tuple[int, bytes]]]]": - integration = sentry_sdk.get_client().get_integration(SocketIntegration) + client = sentry_sdk.get_client() + integration = client.get_integration(SocketIntegration) if integration is None: return real_getaddrinfo(host, port, family, type, proto, flags) - with sentry_sdk.start_span( - op=OP.SOCKET_DNS, - name=_get_span_description(host, port), - origin=SocketIntegration.origin, - ) as span: - span.set_data("host", host) - span.set_data("port", port) - - return real_getaddrinfo(host, port, family, type, proto, flags) + if has_span_streaming_enabled(client.options): + with sentry_sdk.traces.start_span( + name=_get_span_description(host, port), + attributes={ + "sentry.op": OP.SOCKET_DNS, + "sentry.origin": SocketIntegration.origin, + }, + ) as span: + if isinstance(host, str): + span.set_attribute(SPANDATA.SERVER_ADDRESS, host) + elif isinstance(host, bytes): + span.set_attribute( + SPANDATA.SERVER_ADDRESS, host.decode(errors="replace") + ) + + if isinstance(port, int): + span.set_attribute(SPANDATA.SERVER_PORT, port) + elif port is not None: + try: + span.set_attribute(SPANDATA.SERVER_PORT, int(port)) + except (ValueError, TypeError): + pass + + return real_getaddrinfo(host, port, family, type, proto, flags) + else: + with sentry_sdk.start_span( + op=OP.SOCKET_DNS, + name=_get_span_description(host, port), + origin=SocketIntegration.origin, + ) as span: + span.set_data("host", host) + span.set_data("port", port) + + return real_getaddrinfo(host, port, family, type, proto, flags) socket.getaddrinfo = getaddrinfo diff --git a/tests/integrations/socket/test_socket.py b/tests/integrations/socket/test_socket.py index cc109e0968..158f85a4b7 100644 --- a/tests/integrations/socket/test_socket.py +++ b/tests/integrations/socket/test_socket.py @@ -1,5 +1,8 @@ import socket +import pytest + +import sentry_sdk from sentry_sdk import start_transaction from sentry_sdk.integrations.socket import SocketIntegration from tests.conftest import ApproxDict, create_mock_http_server @@ -7,75 +10,143 @@ PORT = create_mock_http_server() -def test_getaddrinfo_trace(sentry_init, capture_events): - sentry_init(integrations=[SocketIntegration()], traces_sample_rate=1.0) - events = capture_events() - - with start_transaction(): - socket.getaddrinfo("localhost", PORT) - - (event,) = events - (span,) = event["spans"] - - assert span["op"] == "socket.dns" - assert span["description"] == f"localhost:{PORT}" # noqa: E231 - assert span["data"] == ApproxDict( - { - "host": "localhost", - "port": PORT, - } +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_getaddrinfo_trace(sentry_init, capture_events, capture_items, span_streaming): + sentry_init( + integrations=[SocketIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - -def test_create_connection_trace(sentry_init, capture_events): + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="root"): + socket.getaddrinfo("localhost", PORT) + sentry_sdk.flush() + + spans = [item.payload for item in items if item.type == "span"] + dns_span, _root = spans + + assert dns_span["attributes"]["sentry.op"] == "socket.dns" + assert dns_span["attributes"]["sentry.origin"] == "auto.socket.socket" + assert dns_span["name"] == f"localhost:{PORT}" # noqa: E231 + assert dns_span["attributes"]["server.address"] == "localhost" + assert dns_span["attributes"]["server.port"] == PORT + else: + events = capture_events() + + with start_transaction(): + socket.getaddrinfo("localhost", PORT) + + (event,) = events + (span,) = event["spans"] + + assert span["op"] == "socket.dns" + assert span["description"] == f"localhost:{PORT}" # noqa: E231 + assert span["data"] == ApproxDict( + { + "host": "localhost", + "port": PORT, + } + ) + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_create_connection_trace( + sentry_init, capture_events, capture_items, span_streaming +): timeout = 10 - sentry_init(integrations=[SocketIntegration()], traces_sample_rate=1.0) - events = capture_events() - - with start_transaction(): - socket.create_connection(("localhost", PORT), timeout, None) - - (event,) = events - (connect_span, dns_span) = event["spans"] - # as getaddrinfo gets called in create_connection it should also contain a dns span - - assert connect_span["op"] == "socket.connection" - assert connect_span["description"] == f"localhost:{PORT}" # noqa: E231 - assert connect_span["data"] == ApproxDict( - { - "address": ["localhost", PORT], - "timeout": timeout, - "source_address": None, - } - ) - - assert dns_span["op"] == "socket.dns" - assert dns_span["description"] == f"localhost:{PORT}" # noqa: E231 - assert dns_span["data"] == ApproxDict( - { - "host": "localhost", - "port": PORT, - } + sentry_init( + integrations=[SocketIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - -def test_span_origin(sentry_init, capture_events): + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="root"): + socket.create_connection(("localhost", PORT), timeout, None) + sentry_sdk.flush() + + spans = [item.payload for item in items if item.type == "span"] + # as getaddrinfo gets called in create_connection it should also contain a dns span + # spans finish in order: dns (inner) ends first, connect ends, then root + dns_span, connect_span, _root = spans + + assert connect_span["attributes"]["sentry.op"] == "socket.connection" + assert connect_span["name"] == f"localhost:{PORT}" # noqa: E231 + assert connect_span["attributes"]["server.address"] == "localhost" + assert connect_span["attributes"]["server.port"] == PORT + + assert dns_span["attributes"]["sentry.op"] == "socket.dns" + assert dns_span["name"] == f"localhost:{PORT}" # noqa: E231 + assert dns_span["attributes"]["server.address"] == "localhost" + assert dns_span["attributes"]["server.port"] == PORT + else: + events = capture_events() + + with start_transaction(): + socket.create_connection(("localhost", PORT), timeout, None) + + (event,) = events + (connect_span, dns_span) = event["spans"] + # as getaddrinfo gets called in create_connection it should also contain a dns span + + assert connect_span["op"] == "socket.connection" + assert connect_span["description"] == f"localhost:{PORT}" # noqa: E231 + assert connect_span["data"] == ApproxDict( + { + "address": ["localhost", PORT], + "timeout": timeout, + "source_address": None, + } + ) + + assert dns_span["op"] == "socket.dns" + assert dns_span["description"] == f"localhost:{PORT}" # noqa: E231 + assert dns_span["data"] == ApproxDict( + { + "host": "localhost", + "port": PORT, + } + ) + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_span_origin(sentry_init, capture_events, capture_items, span_streaming): sentry_init( integrations=[SocketIntegration()], traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() - with start_transaction(name="foo"): - socket.create_connection(("localhost", PORT), 1, None) + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="foo"): + socket.create_connection(("localhost", PORT), 1, None) + sentry_sdk.flush() + + spans = [item.payload for item in items if item.type == "span"] + dns_span, connect_span, _root = spans + + assert connect_span["attributes"]["sentry.op"] == "socket.connection" + assert connect_span["attributes"]["sentry.origin"] == "auto.socket.socket" + + assert dns_span["attributes"]["sentry.op"] == "socket.dns" + assert dns_span["attributes"]["sentry.origin"] == "auto.socket.socket" + else: + events = capture_events() + + with start_transaction(name="foo"): + socket.create_connection(("localhost", PORT), 1, None) - (event,) = events + (event,) = events - assert event["contexts"]["trace"]["origin"] == "manual" + assert event["contexts"]["trace"]["origin"] == "manual" - assert event["spans"][0]["op"] == "socket.connection" - assert event["spans"][0]["origin"] == "auto.socket.socket" + assert event["spans"][0]["op"] == "socket.connection" + assert event["spans"][0]["origin"] == "auto.socket.socket" - assert event["spans"][1]["op"] == "socket.dns" - assert event["spans"][1]["origin"] == "auto.socket.socket" + assert event["spans"][1]["op"] == "socket.dns" + assert event["spans"][1]["origin"] == "auto.socket.socket" From f4833fe10a9835b686463d03be365e2e34a72f94 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 20 May 2026 11:20:59 -0400 Subject: [PATCH 11/15] feat(strawberry): Support span streaming Add span streaming support to the Strawberry GraphQL integration, mirroring the pattern used in the starlite and aiohttp integrations. Fixes PY-2366 Fixes #6063 --- sentry_sdk/integrations/strawberry.py | 146 +++- .../strawberry/test_strawberry.py | 794 ++++++++++++------ 2 files changed, 669 insertions(+), 271 deletions(-) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 6884190fd6..1d9ef1dfbf 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -8,7 +8,8 @@ from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.tracing import TransactionSource +from sentry_sdk.tracing import Span, TransactionSource +from sentry_sdk.tracing_utils import StreamedSpan, has_span_streaming_enabled from sentry_sdk.utils import ( capture_internal_exceptions, ensure_integration_enabled, @@ -180,53 +181,116 @@ def on_operation(self) -> "Generator[None, None, None]": ) scope = sentry_sdk.get_isolation_scope() - event_processor = _make_request_event_processor(self.execution_context) + execution_context = self.execution_context + event_processor = _make_request_event_processor(execution_context) scope.add_event_processor(event_processor) - graphql_span = sentry_sdk.start_span( - op=op, - name=description, - origin=StrawberryIntegration.origin, - ) + client = sentry_sdk.get_client() + is_span_streaming_enabled = has_span_streaming_enabled(client.options) + if is_span_streaming_enabled: + additional_attributes: "dict[str, Any]" = {} + + if should_send_default_pii(): + additional_attributes["graphql.document"] = execution_context.query + + if operation_name: + additional_attributes["graphql.operation.name"] = operation_name + + graphql_span = sentry_sdk.traces.start_span( + name=description, + attributes={ + "sentry.origin": StrawberryIntegration.origin, + "sentry.op": op, + "graphql.operation.type": operation_type, + **additional_attributes, + }, + ) + else: + graphql_span = sentry_sdk.start_span( + op=op, + name=description, + origin=StrawberryIntegration.origin, + ) + graphql_span.__enter__() - graphql_span.set_data("graphql.operation.type", operation_type) - graphql_span.set_data("graphql.operation.name", operation_name) - if should_send_default_pii(): - graphql_span.set_data("graphql.document", self.execution_context.query) - graphql_span.set_data("graphql.resource_name", self._resource_name) + if type(graphql_span) is Span: + if should_send_default_pii(): + graphql_span.set_data("graphql.document", execution_context.query) + + graphql_span.set_data("graphql.operation.type", operation_type) + graphql_span.set_data("graphql.operation.name", operation_name) + # This attribute is being removed in streamed spans + graphql_span.set_data("graphql.resource_name", self._resource_name) yield - transaction = graphql_span.containing_transaction - if transaction and self.execution_context.operation_name: - transaction.name = self.execution_context.operation_name - transaction.source = TransactionSource.COMPONENT - transaction.op = op + if type(graphql_span) is StreamedSpan: + if execution_context.operation_name: + segment = graphql_span._segment + segment.set_attribute("sentry.source", TransactionSource.COMPONENT) + segment.set_attribute("sentry.op", op) + segment.name = execution_context.operation_name + elif type(graphql_span) is Span: + transaction = graphql_span.containing_transaction + if transaction and execution_context.operation_name: + transaction.name = execution_context.operation_name + transaction.source = TransactionSource.COMPONENT + transaction.op = op graphql_span.__exit__(None, None, None) def on_validate(self) -> "Generator[None, None, None]": - validation_span = sentry_sdk.start_span( - op=OP.GRAPHQL_VALIDATE, - name="validation", - origin=StrawberryIntegration.origin, - ) + client = sentry_sdk.get_client() + is_span_streaming_enabled = has_span_streaming_enabled(client.options) + + if is_span_streaming_enabled: + validation_span = sentry_sdk.traces.start_span( + name="validation", + attributes={ + "sentry.op": OP.GRAPHQL_VALIDATE, + "sentry.origin": StrawberryIntegration.origin, + }, + ) + else: + validation_span = sentry_sdk.start_span( + op=OP.GRAPHQL_VALIDATE, + name="validation", + origin=StrawberryIntegration.origin, + ) yield - validation_span.finish() + if is_span_streaming_enabled and type(validation_span) is StreamedSpan: + validation_span.end() + else: + validation_span.finish() def on_parse(self) -> "Generator[None, None, None]": - parsing_span = sentry_sdk.start_span( - op=OP.GRAPHQL_PARSE, - name="parsing", - origin=StrawberryIntegration.origin, - ) + client = sentry_sdk.get_client() + is_span_streaming_enabled = has_span_streaming_enabled(client.options) + + if is_span_streaming_enabled: + parsing_span = sentry_sdk.traces.start_span( + name="parsing", + attributes={ + "sentry.op": OP.GRAPHQL_PARSE, + "sentry.origin": StrawberryIntegration.origin, + }, + ) + else: + parsing_span = sentry_sdk.start_span( + op=OP.GRAPHQL_PARSE, + name="parsing", + origin=StrawberryIntegration.origin, + ) yield - parsing_span.finish() + if is_span_streaming_enabled and type(parsing_span) is StreamedSpan: + parsing_span.end() + else: + parsing_span.finish() def should_skip_tracing( self, @@ -263,6 +327,18 @@ async def resolve( field_path = "{}.{}".format(info.parent_type, info.field_name) + client = sentry_sdk.get_client() + is_span_streaming_enabled = has_span_streaming_enabled(client.options) + if is_span_streaming_enabled: + with sentry_sdk.traces.start_span( + name=f"resolving {field_path}", + attributes={ + "sentry.origin": StrawberryIntegration.origin, + "sentry.op": OP.GRAPHQL_RESOLVE, + }, + ): + return await self._resolve(_next, root, info, *args, **kwargs) + with sentry_sdk.start_span( op=OP.GRAPHQL_RESOLVE, name="resolving {}".format(field_path), @@ -290,6 +366,18 @@ def resolve( field_path = "{}.{}".format(info.parent_type, info.field_name) + client = sentry_sdk.get_client() + is_span_streaming_enabled = has_span_streaming_enabled(client.options) + if is_span_streaming_enabled: + with sentry_sdk.traces.start_span( + name=f"resolving {field_path}", + attributes={ + "sentry.origin": StrawberryIntegration.origin, + "sentry.op": OP.GRAPHQL_RESOLVE, + }, + ): + return _next(root, info, *args, **kwargs) + with sentry_sdk.start_span( op=OP.GRAPHQL_RESOLVE, name="resolving {}".format(field_path), diff --git a/tests/integrations/strawberry/test_strawberry.py b/tests/integrations/strawberry/test_strawberry.py index 3a509a835d..16e15142ff 100644 --- a/tests/integrations/strawberry/test_strawberry.py +++ b/tests/integrations/strawberry/test_strawberry.py @@ -2,6 +2,8 @@ import pytest +import sentry_sdk + strawberry = pytest.importorskip("strawberry") pytest.importorskip("fastapi") pytest.importorskip("flask") @@ -287,14 +289,17 @@ def test_breadcrumb_no_operation_name( "send_default_pii", [True, False], ) +@pytest.mark.parametrize("span_streaming", [True, False]) def test_capture_transaction_on_error( request, sentry_init, capture_events, + capture_items, client_factory, async_execution, framework_integrations, send_default_pii, + span_streaming, ): sentry_init( send_default_pii=send_default_pii, @@ -303,8 +308,13 @@ def test_capture_transaction_on_error( ] + framework_integrations, traces_sample_rate=1, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() schema = strawberry.Schema(Query) @@ -314,59 +324,109 @@ def test_capture_transaction_on_error( query = "query ErrorQuery { error }" client.post("/graphql", json={"query": query, "operationName": "ErrorQuery"}) - assert len(events) == 2 - (_, transaction_event) = events - - assert transaction_event["transaction"] == "ErrorQuery" - assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_QUERY - assert transaction_event["spans"] - - query_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_QUERY - ] - assert len(query_spans) == 1, "exactly one query span expected" - query_span = query_spans[0] - assert query_span["description"] == "query ErrorQuery" - assert query_span["data"]["graphql.operation.type"] == "query" - assert query_span["data"]["graphql.operation.name"] == "ErrorQuery" - assert query_span["data"]["graphql.resource_name"] - - if send_default_pii is True: - assert query_span["data"]["graphql.document"] == query + if span_streaming: + sentry_sdk.flush() + error_events = [i.payload for i in items if i.type == "event"] + spans = [i.payload for i in items if i.type == "span"] + + assert len(error_events) == 1 + + if async_execution: + # When FastAPI is run, there's an extra span from the httpx client + # so we need to account for that + assert len(spans) == 6 + parse_span, validate_span, resolve_span, query_span, segment, _ = spans + else: + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans + + assert segment["is_segment"] is True + assert segment["name"] == "ErrorQuery" + assert segment["attributes"]["sentry.op"] == OP.GRAPHQL_QUERY + + assert query_span["attributes"]["sentry.op"] == OP.GRAPHQL_QUERY + assert query_span["name"] == "query ErrorQuery" + assert query_span["attributes"]["graphql.operation.type"] == "query" + assert query_span["attributes"]["graphql.operation.name"] == "ErrorQuery" + + if send_default_pii is True: + assert query_span["attributes"]["graphql.document"] == query + else: + assert "graphql.document" not in query_span["attributes"] + + assert parse_span["attributes"]["sentry.op"] == OP.GRAPHQL_PARSE + assert parse_span["name"] == "parsing" + assert parse_span["parent_span_id"] == query_span["span_id"] + + assert validate_span["attributes"]["sentry.op"] == OP.GRAPHQL_VALIDATE + assert validate_span["name"] == "validation" + assert validate_span["parent_span_id"] == query_span["span_id"] + + assert resolve_span["attributes"]["sentry.op"] == OP.GRAPHQL_RESOLVE + assert resolve_span["name"] == "resolving Query.error" + assert resolve_span["parent_span_id"] == query_span["span_id"] else: - assert "graphql.document" not in query_span["data"] - - parse_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_PARSE - ] - assert len(parse_spans) == 1, "exactly one parse span expected" - parse_span = parse_spans[0] - assert parse_span["parent_span_id"] == query_span["span_id"] - assert parse_span["description"] == "parsing" - - validate_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_VALIDATE - ] - assert len(validate_spans) == 1, "exactly one validate span expected" - validate_span = validate_spans[0] - assert validate_span["parent_span_id"] == query_span["span_id"] - assert validate_span["description"] == "validation" - - resolve_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_RESOLVE - ] - assert len(resolve_spans) == 1, "exactly one resolve span expected" - resolve_span = resolve_spans[0] - assert resolve_span["parent_span_id"] == query_span["span_id"] - assert resolve_span["description"] == "resolving Query.error" - assert resolve_span["data"] == ApproxDict( - { - "graphql.field_name": "error", - "graphql.parent_type": "Query", - "graphql.field_path": "Query.error", - "graphql.path": "error", - } - ) + assert len(events) == 2 + (_, transaction_event) = events + + assert transaction_event["transaction"] == "ErrorQuery" + assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_QUERY + assert transaction_event["spans"] + + query_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_QUERY + ] + assert len(query_spans) == 1, "exactly one query span expected" + query_span = query_spans[0] + assert query_span["description"] == "query ErrorQuery" + assert query_span["data"]["graphql.operation.type"] == "query" + assert query_span["data"]["graphql.operation.name"] == "ErrorQuery" + assert query_span["data"]["graphql.resource_name"] + + if send_default_pii is True: + assert query_span["data"]["graphql.document"] == query + else: + assert "graphql.document" not in query_span["data"] + + parse_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_PARSE + ] + assert len(parse_spans) == 1, "exactly one parse span expected" + parse_span = parse_spans[0] + assert parse_span["parent_span_id"] == query_span["span_id"] + assert parse_span["description"] == "parsing" + + validate_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_VALIDATE + ] + assert len(validate_spans) == 1, "exactly one validate span expected" + validate_span = validate_spans[0] + assert validate_span["parent_span_id"] == query_span["span_id"] + assert validate_span["description"] == "validation" + + resolve_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_RESOLVE + ] + assert len(resolve_spans) == 1, "exactly one resolve span expected" + resolve_span = resolve_spans[0] + assert resolve_span["parent_span_id"] == query_span["span_id"] + assert resolve_span["description"] == "resolving Query.error" + assert resolve_span["data"] == ApproxDict( + { + "graphql.field_name": "error", + "graphql.parent_type": "Query", + "graphql.field_path": "Query.error", + "graphql.path": "error", + } + ) @parameterize_strawberry_test @@ -374,14 +434,17 @@ def test_capture_transaction_on_error( "send_default_pii", [True, False], ) +@pytest.mark.parametrize("span_streaming", [True, False]) def test_capture_transaction_on_success( request, sentry_init, capture_events, + capture_items, client_factory, async_execution, framework_integrations, send_default_pii, + span_streaming, ): sentry_init( integrations=[ @@ -390,8 +453,13 @@ def test_capture_transaction_on_success( + framework_integrations, traces_sample_rate=1, send_default_pii=send_default_pii, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() schema = strawberry.Schema(Query) @@ -401,59 +469,104 @@ def test_capture_transaction_on_success( query = "query GreetingQuery { hello }" client.post("/graphql", json={"query": query, "operationName": "GreetingQuery"}) - assert len(events) == 1 - (transaction_event,) = events - - assert transaction_event["transaction"] == "GreetingQuery" - assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_QUERY - assert transaction_event["spans"] - - query_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_QUERY - ] - assert len(query_spans) == 1, "exactly one query span expected" - query_span = query_spans[0] - assert query_span["description"] == "query GreetingQuery" - assert query_span["data"]["graphql.operation.type"] == "query" - assert query_span["data"]["graphql.operation.name"] == "GreetingQuery" - assert query_span["data"]["graphql.resource_name"] - - if send_default_pii is True: - assert query_span["data"]["graphql.document"] == query + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items] + + if async_execution: + assert len(spans) == 6 + parse_span, validate_span, resolve_span, query_span, segment, _ = spans + else: + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans + + assert segment["is_segment"] is True + assert segment["name"] == "GreetingQuery" + assert segment["attributes"]["sentry.op"] == OP.GRAPHQL_QUERY + + assert query_span["attributes"]["sentry.op"] == OP.GRAPHQL_QUERY + assert query_span["name"] == "query GreetingQuery" + assert query_span["attributes"]["graphql.operation.type"] == "query" + assert query_span["attributes"]["graphql.operation.name"] == "GreetingQuery" + + if send_default_pii is True: + assert query_span["attributes"]["graphql.document"] == query + else: + assert "graphql.document" not in query_span["attributes"] + + assert parse_span["attributes"]["sentry.op"] == OP.GRAPHQL_PARSE + assert parse_span["name"] == "parsing" + assert parse_span["parent_span_id"] == query_span["span_id"] + + assert validate_span["attributes"]["sentry.op"] == OP.GRAPHQL_VALIDATE + assert validate_span["name"] == "validation" + assert validate_span["parent_span_id"] == query_span["span_id"] + + assert resolve_span["attributes"]["sentry.op"] == OP.GRAPHQL_RESOLVE + assert resolve_span["name"] == "resolving Query.hello" + assert resolve_span["parent_span_id"] == query_span["span_id"] else: - assert "graphql.document" not in query_span["data"] - - parse_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_PARSE - ] - assert len(parse_spans) == 1, "exactly one parse span expected" - parse_span = parse_spans[0] - assert parse_span["parent_span_id"] == query_span["span_id"] - assert parse_span["description"] == "parsing" - - validate_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_VALIDATE - ] - assert len(validate_spans) == 1, "exactly one validate span expected" - validate_span = validate_spans[0] - assert validate_span["parent_span_id"] == query_span["span_id"] - assert validate_span["description"] == "validation" - - resolve_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_RESOLVE - ] - assert len(resolve_spans) == 1, "exactly one resolve span expected" - resolve_span = resolve_spans[0] - assert resolve_span["parent_span_id"] == query_span["span_id"] - assert resolve_span["description"] == "resolving Query.hello" - assert resolve_span["data"] == ApproxDict( - { - "graphql.field_name": "hello", - "graphql.parent_type": "Query", - "graphql.field_path": "Query.hello", - "graphql.path": "hello", - } - ) + assert len(events) == 1 + (transaction_event,) = events + + assert transaction_event["transaction"] == "GreetingQuery" + assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_QUERY + assert transaction_event["spans"] + + query_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_QUERY + ] + assert len(query_spans) == 1, "exactly one query span expected" + query_span = query_spans[0] + assert query_span["description"] == "query GreetingQuery" + assert query_span["data"]["graphql.operation.type"] == "query" + assert query_span["data"]["graphql.operation.name"] == "GreetingQuery" + assert query_span["data"]["graphql.resource_name"] + + if send_default_pii is True: + assert query_span["data"]["graphql.document"] == query + else: + assert "graphql.document" not in query_span["data"] + + parse_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_PARSE + ] + assert len(parse_spans) == 1, "exactly one parse span expected" + parse_span = parse_spans[0] + assert parse_span["parent_span_id"] == query_span["span_id"] + assert parse_span["description"] == "parsing" + + validate_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_VALIDATE + ] + assert len(validate_spans) == 1, "exactly one validate span expected" + validate_span = validate_spans[0] + assert validate_span["parent_span_id"] == query_span["span_id"] + assert validate_span["description"] == "validation" + + resolve_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_RESOLVE + ] + assert len(resolve_spans) == 1, "exactly one resolve span expected" + resolve_span = resolve_spans[0] + assert resolve_span["parent_span_id"] == query_span["span_id"] + assert resolve_span["description"] == "resolving Query.hello" + assert resolve_span["data"] == ApproxDict( + { + "graphql.field_name": "hello", + "graphql.parent_type": "Query", + "graphql.field_path": "Query.hello", + "graphql.path": "hello", + } + ) @parameterize_strawberry_test @@ -461,14 +574,17 @@ def test_capture_transaction_on_success( "send_default_pii", [True, False], ) +@pytest.mark.parametrize("span_streaming", [True, False]) def test_transaction_no_operation_name( request, sentry_init, capture_events, + capture_items, client_factory, async_execution, framework_integrations, send_default_pii, + span_streaming, ): sentry_init( integrations=[ @@ -477,8 +593,13 @@ def test_transaction_no_operation_name( + framework_integrations, traces_sample_rate=1, send_default_pii=send_default_pii, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() schema = strawberry.Schema(Query) @@ -488,62 +609,109 @@ def test_transaction_no_operation_name( query = "{ hello }" client.post("/graphql", json={"query": query}) - assert len(events) == 1 - (transaction_event,) = events - - if async_execution: - assert transaction_event["transaction"] == "/graphql" + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items] + + if async_execution: + assert len(spans) == 6 + parse_span, validate_span, resolve_span, query_span, segment, _ = spans + else: + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans + + assert segment["is_segment"] is True + if async_execution: + assert segment["name"] == "/graphql" + else: + assert segment["name"] == "graphql_view" + + assert query_span["attributes"]["sentry.op"] == OP.GRAPHQL_QUERY + assert query_span["name"] == "query" + assert query_span["attributes"]["graphql.operation.type"] == "query" + assert "graphql.operation.name" not in query_span["attributes"] + + if send_default_pii is True: + assert query_span["attributes"]["graphql.document"] == query + else: + assert "graphql.document" not in query_span["attributes"] + + assert parse_span["attributes"]["sentry.op"] == OP.GRAPHQL_PARSE + assert parse_span["name"] == "parsing" + assert parse_span["parent_span_id"] == query_span["span_id"] + + assert validate_span["attributes"]["sentry.op"] == OP.GRAPHQL_VALIDATE + assert validate_span["name"] == "validation" + assert validate_span["parent_span_id"] == query_span["span_id"] + + assert resolve_span["attributes"]["sentry.op"] == OP.GRAPHQL_RESOLVE + assert resolve_span["name"] == "resolving Query.hello" + assert resolve_span["parent_span_id"] == query_span["span_id"] else: - assert transaction_event["transaction"] == "graphql_view" - - assert transaction_event["spans"] - - query_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_QUERY - ] - assert len(query_spans) == 1, "exactly one query span expected" - query_span = query_spans[0] - assert query_span["description"] == "query" - assert query_span["data"]["graphql.operation.type"] == "query" - assert query_span["data"]["graphql.operation.name"] is None - assert query_span["data"]["graphql.resource_name"] - - if send_default_pii is True: - assert query_span["data"]["graphql.document"] == query - else: - assert "graphql.document" not in query_span["data"] - - parse_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_PARSE - ] - assert len(parse_spans) == 1, "exactly one parse span expected" - parse_span = parse_spans[0] - assert parse_span["parent_span_id"] == query_span["span_id"] - assert parse_span["description"] == "parsing" - - validate_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_VALIDATE - ] - assert len(validate_spans) == 1, "exactly one validate span expected" - validate_span = validate_spans[0] - assert validate_span["parent_span_id"] == query_span["span_id"] - assert validate_span["description"] == "validation" - - resolve_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_RESOLVE - ] - assert len(resolve_spans) == 1, "exactly one resolve span expected" - resolve_span = resolve_spans[0] - assert resolve_span["parent_span_id"] == query_span["span_id"] - assert resolve_span["description"] == "resolving Query.hello" - assert resolve_span["data"] == ApproxDict( - { - "graphql.field_name": "hello", - "graphql.parent_type": "Query", - "graphql.field_path": "Query.hello", - "graphql.path": "hello", - } - ) + assert len(events) == 1 + (transaction_event,) = events + + if async_execution: + assert transaction_event["transaction"] == "/graphql" + else: + assert transaction_event["transaction"] == "graphql_view" + + assert transaction_event["spans"] + + query_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_QUERY + ] + assert len(query_spans) == 1, "exactly one query span expected" + query_span = query_spans[0] + assert query_span["description"] == "query" + assert query_span["data"]["graphql.operation.type"] == "query" + assert query_span["data"]["graphql.operation.name"] is None + assert query_span["data"]["graphql.resource_name"] + + if send_default_pii is True: + assert query_span["data"]["graphql.document"] == query + else: + assert "graphql.document" not in query_span["data"] + + parse_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_PARSE + ] + assert len(parse_spans) == 1, "exactly one parse span expected" + parse_span = parse_spans[0] + assert parse_span["parent_span_id"] == query_span["span_id"] + assert parse_span["description"] == "parsing" + + validate_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_VALIDATE + ] + assert len(validate_spans) == 1, "exactly one validate span expected" + validate_span = validate_spans[0] + assert validate_span["parent_span_id"] == query_span["span_id"] + assert validate_span["description"] == "validation" + + resolve_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_RESOLVE + ] + assert len(resolve_spans) == 1, "exactly one resolve span expected" + resolve_span = resolve_spans[0] + assert resolve_span["parent_span_id"] == query_span["span_id"] + assert resolve_span["description"] == "resolving Query.hello" + assert resolve_span["data"] == ApproxDict( + { + "graphql.field_name": "hello", + "graphql.parent_type": "Query", + "graphql.field_path": "Query.hello", + "graphql.path": "hello", + } + ) @parameterize_strawberry_test @@ -551,14 +719,17 @@ def test_transaction_no_operation_name( "send_default_pii", [True, False], ) +@pytest.mark.parametrize("span_streaming", [True, False]) def test_transaction_mutation( request, sentry_init, capture_events, + capture_items, client_factory, async_execution, framework_integrations, send_default_pii, + span_streaming, ): sentry_init( integrations=[ @@ -567,8 +738,13 @@ def test_transaction_mutation( + framework_integrations, traces_sample_rate=1, send_default_pii=send_default_pii, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() schema = strawberry.Schema(Query, mutation=Mutation) @@ -578,59 +754,104 @@ def test_transaction_mutation( query = 'mutation Change { change(attribute: "something") }' client.post("/graphql", json={"query": query}) - assert len(events) == 1 - (transaction_event,) = events - - assert transaction_event["transaction"] == "Change" - assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_MUTATION - assert transaction_event["spans"] - - query_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_MUTATION - ] - assert len(query_spans) == 1, "exactly one mutation span expected" - query_span = query_spans[0] - assert query_span["description"] == "mutation" - assert query_span["data"]["graphql.operation.type"] == "mutation" - assert query_span["data"]["graphql.operation.name"] is None - assert query_span["data"]["graphql.resource_name"] - - if send_default_pii is True: - assert query_span["data"]["graphql.document"] == query + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items] + + if async_execution: + assert len(spans) == 6 + parse_span, validate_span, resolve_span, mutation_span, segment, _ = spans + else: + assert len(spans) == 5 + parse_span, validate_span, resolve_span, mutation_span, segment = spans + + assert segment["is_segment"] is True + assert segment["name"] == "Change" + assert segment["attributes"]["sentry.op"] == OP.GRAPHQL_MUTATION + + assert mutation_span["attributes"]["sentry.op"] == OP.GRAPHQL_MUTATION + assert mutation_span["name"] == "mutation" + assert mutation_span["attributes"]["graphql.operation.type"] == "mutation" + assert "graphql.operation.name" not in mutation_span["attributes"] + + if send_default_pii is True: + assert mutation_span["attributes"]["graphql.document"] == query + else: + assert "graphql.document" not in mutation_span["attributes"] + + assert parse_span["attributes"]["sentry.op"] == OP.GRAPHQL_PARSE + assert parse_span["name"] == "parsing" + assert parse_span["parent_span_id"] == mutation_span["span_id"] + + assert validate_span["attributes"]["sentry.op"] == OP.GRAPHQL_VALIDATE + assert validate_span["name"] == "validation" + assert validate_span["parent_span_id"] == mutation_span["span_id"] + + assert resolve_span["attributes"]["sentry.op"] == OP.GRAPHQL_RESOLVE + assert resolve_span["name"] == "resolving Mutation.change" + assert resolve_span["parent_span_id"] == mutation_span["span_id"] else: - assert "graphql.document" not in query_span["data"] - - parse_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_PARSE - ] - assert len(parse_spans) == 1, "exactly one parse span expected" - parse_span = parse_spans[0] - assert parse_span["parent_span_id"] == query_span["span_id"] - assert parse_span["description"] == "parsing" - - validate_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_VALIDATE - ] - assert len(validate_spans) == 1, "exactly one validate span expected" - validate_span = validate_spans[0] - assert validate_span["parent_span_id"] == query_span["span_id"] - assert validate_span["description"] == "validation" - - resolve_spans = [ - span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_RESOLVE - ] - assert len(resolve_spans) == 1, "exactly one resolve span expected" - resolve_span = resolve_spans[0] - assert resolve_span["parent_span_id"] == query_span["span_id"] - assert resolve_span["description"] == "resolving Mutation.change" - assert resolve_span["data"] == ApproxDict( - { - "graphql.field_name": "change", - "graphql.parent_type": "Mutation", - "graphql.field_path": "Mutation.change", - "graphql.path": "change", - } - ) + assert len(events) == 1 + (transaction_event,) = events + + assert transaction_event["transaction"] == "Change" + assert transaction_event["contexts"]["trace"]["op"] == OP.GRAPHQL_MUTATION + assert transaction_event["spans"] + + query_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_MUTATION + ] + assert len(query_spans) == 1, "exactly one mutation span expected" + query_span = query_spans[0] + assert query_span["description"] == "mutation" + assert query_span["data"]["graphql.operation.type"] == "mutation" + assert query_span["data"]["graphql.operation.name"] is None + assert query_span["data"]["graphql.resource_name"] + + if send_default_pii is True: + assert query_span["data"]["graphql.document"] == query + else: + assert "graphql.document" not in query_span["data"] + + parse_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_PARSE + ] + assert len(parse_spans) == 1, "exactly one parse span expected" + parse_span = parse_spans[0] + assert parse_span["parent_span_id"] == query_span["span_id"] + assert parse_span["description"] == "parsing" + + validate_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_VALIDATE + ] + assert len(validate_spans) == 1, "exactly one validate span expected" + validate_span = validate_spans[0] + assert validate_span["parent_span_id"] == query_span["span_id"] + assert validate_span["description"] == "validation" + + resolve_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_RESOLVE + ] + assert len(resolve_spans) == 1, "exactly one resolve span expected" + resolve_span = resolve_spans[0] + assert resolve_span["parent_span_id"] == query_span["span_id"] + assert resolve_span["description"] == "resolving Mutation.change" + assert resolve_span["data"] == ApproxDict( + { + "graphql.field_name": "change", + "graphql.parent_type": "Mutation", + "graphql.field_path": "Mutation.change", + "graphql.path": "change", + } + ) @parameterize_strawberry_test @@ -661,13 +882,16 @@ def test_handle_none_query_gracefully( @parameterize_strawberry_test +@pytest.mark.parametrize("span_streaming", [True, False]) def test_span_origin( request, sentry_init, capture_events, + capture_items, client_factory, async_execution, framework_integrations, + span_streaming, ): """ Tests for OP.GRAPHQL_MUTATION, OP.GRAPHQL_PARSE, OP.GRAPHQL_VALIDATE, OP.GRAPHQL_RESOLVE, @@ -678,8 +902,13 @@ def test_span_origin( ] + framework_integrations, traces_sample_rate=1, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() schema = strawberry.Schema(Query, mutation=Mutation) @@ -689,27 +918,51 @@ def test_span_origin( query = 'mutation Change { change(attribute: "something") }' client.post("/graphql", json={"query": query}) - (event,) = events - is_flask = "Flask" in str(framework_integrations[0]) - if is_flask: - assert event["contexts"]["trace"]["origin"] == "auto.http.flask" + + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items] + + if async_execution: + assert len(spans) == 6 + parse_span, validate_span, resolve_span, mutation_span, segment, _ = spans + else: + assert len(spans) == 5 + parse_span, validate_span, resolve_span, mutation_span, segment = spans + + assert segment["is_segment"] is True + if is_flask: + assert segment["attributes"]["sentry.origin"] == "auto.http.flask" + else: + assert segment["attributes"]["sentry.origin"] == "auto.http.starlette" + + for span in (parse_span, validate_span, resolve_span, mutation_span): + assert span["attributes"]["sentry.origin"] == "auto.graphql.strawberry" else: - assert event["contexts"]["trace"]["origin"] == "auto.http.starlette" + (event,) = events - for span in event["spans"]: - if span["op"].startswith("graphql."): - assert span["origin"] == "auto.graphql.strawberry" + if is_flask: + assert event["contexts"]["trace"]["origin"] == "auto.http.flask" + else: + assert event["contexts"]["trace"]["origin"] == "auto.http.starlette" + + for span in event["spans"]: + if span["op"].startswith("graphql."): + assert span["origin"] == "auto.graphql.strawberry" @parameterize_strawberry_test +@pytest.mark.parametrize("span_streaming", [True, False]) def test_span_origin2( request, sentry_init, capture_events, + capture_items, client_factory, async_execution, framework_integrations, + span_streaming, ): """ Tests for OP.GRAPHQL_QUERY @@ -720,8 +973,13 @@ def test_span_origin2( ] + framework_integrations, traces_sample_rate=1, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() schema = strawberry.Schema(Query, mutation=Mutation) @@ -731,27 +989,51 @@ def test_span_origin2( query = "query GreetingQuery { hello }" client.post("/graphql", json={"query": query, "operationName": "GreetingQuery"}) - (event,) = events - is_flask = "Flask" in str(framework_integrations[0]) - if is_flask: - assert event["contexts"]["trace"]["origin"] == "auto.http.flask" + + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items] + + if async_execution: + assert len(spans) == 6 + parse_span, validate_span, resolve_span, query_span, segment, _ = spans + else: + assert len(spans) == 5 + parse_span, validate_span, resolve_span, query_span, segment = spans + + assert segment["is_segment"] is True + if is_flask: + assert segment["attributes"]["sentry.origin"] == "auto.http.flask" + else: + assert segment["attributes"]["sentry.origin"] == "auto.http.starlette" + + for span in (parse_span, validate_span, resolve_span, query_span): + assert span["attributes"]["sentry.origin"] == "auto.graphql.strawberry" else: - assert event["contexts"]["trace"]["origin"] == "auto.http.starlette" + (event,) = events + + if is_flask: + assert event["contexts"]["trace"]["origin"] == "auto.http.flask" + else: + assert event["contexts"]["trace"]["origin"] == "auto.http.starlette" - for span in event["spans"]: - if span["op"].startswith("graphql."): - assert span["origin"] == "auto.graphql.strawberry" + for span in event["spans"]: + if span["op"].startswith("graphql."): + assert span["origin"] == "auto.graphql.strawberry" @parameterize_strawberry_test +@pytest.mark.parametrize("span_streaming", [True, False]) def test_span_origin3( request, sentry_init, capture_events, + capture_items, client_factory, async_execution, framework_integrations, + span_streaming, ): """ Tests for OP.GRAPHQL_SUBSCRIPTION @@ -762,8 +1044,13 @@ def test_span_origin3( ] + framework_integrations, traces_sample_rate=1, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() schema = strawberry.Schema(Query, subscription=Subscription) @@ -773,14 +1060,37 @@ def test_span_origin3( query = "subscription { messageAdded { content } }" client.post("/graphql", json={"query": query}) - (event,) = events - is_flask = "Flask" in str(framework_integrations[0]) - if is_flask: - assert event["contexts"]["trace"]["origin"] == "auto.http.flask" + + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items] + + if async_execution: + assert len(spans) == 6 + parse_span, validate_span, resolve_span, subscription_span, segment, _ = ( + spans + ) + else: + assert len(spans) == 5 + parse_span, validate_span, resolve_span, subscription_span, segment = spans + + assert segment["is_segment"] is True + if is_flask: + assert segment["attributes"]["sentry.origin"] == "auto.http.flask" + else: + assert segment["attributes"]["sentry.origin"] == "auto.http.starlette" + + for span in (parse_span, validate_span, resolve_span, subscription_span): + assert span["attributes"]["sentry.origin"] == "auto.graphql.strawberry" else: - assert event["contexts"]["trace"]["origin"] == "auto.http.starlette" + (event,) = events + + if is_flask: + assert event["contexts"]["trace"]["origin"] == "auto.http.flask" + else: + assert event["contexts"]["trace"]["origin"] == "auto.http.starlette" - for span in event["spans"]: - if span["op"].startswith("graphql."): - assert span["origin"] == "auto.graphql.strawberry" + for span in event["spans"]: + if span["op"].startswith("graphql."): + assert span["origin"] == "auto.graphql.strawberry" From bb06a8efbfe0323fe9894c672d5a004817fcee65 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 20 May 2026 12:55:23 -0400 Subject: [PATCH 12/15] cleanups --- sentry_sdk/integrations/strawberry.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 68eff9ceb3..5598db8a9c 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -228,7 +228,7 @@ def on_operation(self) -> "Generator[None, None, None]": if type(graphql_span) is StreamedSpan: if execution_context.operation_name: segment = graphql_span._segment - segment.set_attribute("sentry.source", TransactionSource.COMPONENT) + segment.set_attribute("sentry.span.source", TransactionSource.COMPONENT) segment.set_attribute("sentry.op", op) segment.name = execution_context.operation_name elif type(graphql_span) is Span: @@ -327,7 +327,6 @@ async def resolve( field_path = "{}.{}".format(info.parent_type, info.field_name) -<<<<<<< HEAD client = sentry_sdk.get_client() is_span_streaming_enabled = has_span_streaming_enabled(client.options) if is_span_streaming_enabled: @@ -340,8 +339,6 @@ async def resolve( ): return await self._resolve(_next, root, info, *args, **kwargs) -======= ->>>>>>> master with sentry_sdk.start_span( op=OP.GRAPHQL_RESOLVE, name="resolving {}".format(field_path), @@ -369,7 +366,6 @@ def resolve( field_path = "{}.{}".format(info.parent_type, info.field_name) -<<<<<<< HEAD client = sentry_sdk.get_client() is_span_streaming_enabled = has_span_streaming_enabled(client.options) if is_span_streaming_enabled: @@ -382,8 +378,6 @@ def resolve( ): return _next(root, info, *args, **kwargs) -======= ->>>>>>> master with sentry_sdk.start_span( op=OP.GRAPHQL_RESOLVE, name="resolving {}".format(field_path), From ec14f158ba71045684c00a4babfd4f165f547f6e Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 20 May 2026 13:32:45 -0400 Subject: [PATCH 13/15] Change the check for streamed spans as it was too restrictive - filtered out noop streamed spans --- sentry_sdk/integrations/strawberry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 5598db8a9c..ead1009fb2 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -225,13 +225,13 @@ def on_operation(self) -> "Generator[None, None, None]": yield - if type(graphql_span) is StreamedSpan: + if isinstance(graphql_span, StreamedSpan): if execution_context.operation_name: segment = graphql_span._segment segment.set_attribute("sentry.span.source", TransactionSource.COMPONENT) segment.set_attribute("sentry.op", op) segment.name = execution_context.operation_name - elif type(graphql_span) is Span: + elif isinstance(graphql_span, Span): transaction = graphql_span.containing_transaction if transaction and execution_context.operation_name: transaction.name = execution_context.operation_name @@ -261,7 +261,7 @@ def on_validate(self) -> "Generator[None, None, None]": yield - if is_span_streaming_enabled and type(validation_span) is StreamedSpan: + if is_span_streaming_enabled and isinstance(validation_span, StreamedSpan): validation_span.end() else: validation_span.finish() @@ -287,7 +287,7 @@ def on_parse(self) -> "Generator[None, None, None]": yield - if is_span_streaming_enabled and type(parsing_span) is StreamedSpan: + if is_span_streaming_enabled and isinstance(parsing_span, StreamedSpan): parsing_span.end() else: parsing_span.finish() From bcebbf2765dd639a57d5d4c8ff26701ee63ffa67 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 20 May 2026 13:38:24 -0400 Subject: [PATCH 14/15] Reintroduce the stricter check for a streamed span so that we do not accidentally try to access a segment on a noop streamed span --- sentry_sdk/integrations/strawberry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index ead1009fb2..4294b0cdb1 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -225,7 +225,7 @@ def on_operation(self) -> "Generator[None, None, None]": yield - if isinstance(graphql_span, StreamedSpan): + if type(graphql_span) is StreamedSpan: if execution_context.operation_name: segment = graphql_span._segment segment.set_attribute("sentry.span.source", TransactionSource.COMPONENT) From f05a40b399ebc4f8e76362cf591ebfecea819b27 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 21 May 2026 11:03:06 -0400 Subject: [PATCH 15/15] Address CR comments --- sentry_sdk/integrations/strawberry.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 4294b0cdb1..7fe4609cf3 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -8,6 +8,7 @@ from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.traces import SegmentSource from sentry_sdk.tracing import Span, TransactionSource from sentry_sdk.tracing_utils import StreamedSpan, has_span_streaming_enabled from sentry_sdk.utils import ( @@ -181,8 +182,7 @@ def on_operation(self) -> "Generator[None, None, None]": ) scope = sentry_sdk.get_isolation_scope() - execution_context = self.execution_context - event_processor = _make_request_event_processor(execution_context) + event_processor = _make_request_event_processor(self.execution_context) scope.add_event_processor(event_processor) client = sentry_sdk.get_client() @@ -191,7 +191,7 @@ def on_operation(self) -> "Generator[None, None, None]": additional_attributes: "dict[str, Any]" = {} if should_send_default_pii(): - additional_attributes["graphql.document"] = execution_context.query + additional_attributes["graphql.document"] = self.execution_context.query if operation_name: additional_attributes["graphql.operation.name"] = operation_name @@ -211,12 +211,11 @@ def on_operation(self) -> "Generator[None, None, None]": name=description, origin=StrawberryIntegration.origin, ) - - graphql_span.__enter__() + graphql_span.__enter__() if type(graphql_span) is Span: if should_send_default_pii(): - graphql_span.set_data("graphql.document", execution_context.query) + graphql_span.set_data("graphql.document", self.execution_context.query) graphql_span.set_data("graphql.operation.type", operation_type) graphql_span.set_data("graphql.operation.name", operation_name) @@ -226,15 +225,15 @@ def on_operation(self) -> "Generator[None, None, None]": yield if type(graphql_span) is StreamedSpan: - if execution_context.operation_name: + if self.execution_context.operation_name: segment = graphql_span._segment - segment.set_attribute("sentry.span.source", TransactionSource.COMPONENT) + segment.set_attribute("sentry.span.source", SegmentSource.COMPONENT) segment.set_attribute("sentry.op", op) - segment.name = execution_context.operation_name + segment.name = self.execution_context.operation_name elif isinstance(graphql_span, Span): transaction = graphql_span.containing_transaction - if transaction and execution_context.operation_name: - transaction.name = execution_context.operation_name + if transaction and self.execution_context.operation_name: + transaction.name = self.execution_context.operation_name transaction.source = TransactionSource.COMPONENT transaction.op = op @@ -261,7 +260,7 @@ def on_validate(self) -> "Generator[None, None, None]": yield - if is_span_streaming_enabled and isinstance(validation_span, StreamedSpan): + if isinstance(validation_span, StreamedSpan): validation_span.end() else: validation_span.finish() @@ -287,7 +286,7 @@ def on_parse(self) -> "Generator[None, None, None]": yield - if is_span_streaming_enabled and isinstance(parsing_span, StreamedSpan): + if isinstance(parsing_span, StreamedSpan): parsing_span.end() else: parsing_span.finish()