From 44efa73b9c8e48579acf3d89575b6a5ef128e4a2 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 4 Jun 2026 08:45:07 -0400 Subject: [PATCH 1/7] feat(quart): Add span streaming support to Quart integration Add span streaming support for the Quart integration when the trace_lifecycle stream experiment is enabled. Sets HTTP request attributes (method, headers, URL, query, client IP) on the segment span and uses the correct source constant from sentry_sdk.traces for span-first mode. Depends on https://github.com/getsentry/sentry-python/pull/6501 being merged first. Fixes PY-2352 Fixes #6050 --- sentry_sdk/integrations/quart.py | 47 ++++- tests/integrations/quart/test_quart.py | 241 +++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 5c1f4fd418..741ac97588 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -9,7 +9,10 @@ from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.tracing import SOURCE_FOR_STYLE +from sentry_sdk.traces import SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE +from sentry_sdk.traces import StreamedSpan, get_current_span +from sentry_sdk.tracing import SOURCE_FOR_STYLE as TRANSACTION_SOURCE_FOR_STYLE +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( capture_internal_exceptions, ensure_integration_enabled, @@ -144,9 +147,16 @@ def _set_transaction_name_and_source( "url": request.url_rule.rule, "endpoint": request.url_rule.endpoint, } + + source = ( + SEGMENT_SOURCE_FOR_STYLE[transaction_style] + if has_span_streaming_enabled(sentry_sdk.get_client().options) + else TRANSACTION_SOURCE_FOR_STYLE[transaction_style] + ) + scope.set_transaction_name( - name_for_style[transaction_style], - source=SOURCE_FOR_STYLE[transaction_style], + name=name_for_style[transaction_style], + source=source, ) except Exception: pass @@ -169,6 +179,37 @@ async def _request_websocket_started(app: "Quart", **kwargs: "Any") -> None: ) scope = sentry_sdk.get_isolation_scope() + + if has_span_streaming_enabled(sentry_sdk.get_client().options): + current_span = get_current_span() + if type(current_span) is StreamedSpan: + segment = current_span._segment + + segment.set_attribute("http.request.method", request_websocket.method) + header_attributes: "dict[str, Any]" = {} + + for header, header_value in _filter_headers( + dict(request_websocket.headers), use_annotated_value=False + ).items(): + header_attributes[f"http.request.header.{header.lower()}"] = ( + header_value + ) + + segment.set_attributes(header_attributes) + + if should_send_default_pii(): + segment.set_attribute("url.full", request_websocket.url) + segment.set_attribute( + "url.query", + request_websocket.query_string.decode("utf-8", errors="replace"), + ) + segment.set_attribute( + "client.address", request_websocket.access_route[0] + ) + segment.set_attribute( + "user.ip_address", request_websocket.access_route[0] + ) + evt_processor = _make_request_event_processor(app, request_websocket, integration) scope.add_event_processor(evt_processor) diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py index 55e2d025fa..159cca2ae5 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -14,6 +14,7 @@ set_tag, ) from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE def quart_app_factory(): @@ -647,3 +648,243 @@ async def test_span_origin(sentry_init, capture_events): (_, event) = events assert event["contexts"]["trace"]["origin"] == "auto.http.quart" + + +@pytest.mark.asyncio +async def test_span_streaming_basic(sentry_init, capture_items): + sentry_init( + integrations=[quart_sentry.QuartIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("span") + + app = quart_app_factory() + client = app.test_client() + response = await client.get("/message") + assert response.status_code == 200 + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 1 + + segment = spans[0] + assert segment["is_segment"] is True + assert "parent_span_id" not in segment + assert segment["status"] == "ok" + assert segment["attributes"]["sentry.op"] == "http.server" + assert segment["attributes"]["sentry.origin"] == "auto.http.quart" + assert segment["attributes"]["http.request.method"] == "GET" + assert segment["name"] == "hi" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url,transaction_style,expected_name,expected_source", + [ + ("/message", "endpoint", "hi", "component"), + ("/message", "url", "/message", "route"), + ("/message/123456", "endpoint", "hi_with_id", "component"), + ("/message/123456", "url", "/message/", "route"), + ], +) +async def test_span_streaming_transaction_style( + sentry_init, + capture_items, + url, + transaction_style, + expected_name, + expected_source, +): + sentry_init( + integrations=[ + quart_sentry.QuartIntegration(transaction_style=transaction_style) + ], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("span") + + app = quart_app_factory() + client = app.test_client() + response = await client.get(url) + assert response.status_code == 200 + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 1 + + segment = spans[0] + assert segment["is_segment"] is True + assert segment["name"] == expected_name + assert segment["attributes"]["sentry.span.source"] == expected_source + + +@pytest.mark.asyncio +async def test_span_streaming_with_error(sentry_init, capture_items): + sentry_init( + integrations=[quart_sentry.QuartIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("event", "span") + + app = quart_app_factory() + + @app.route("/error") + async def error(): + 1 / 0 + + client = app.test_client() + try: + await client.get("/error") + except ZeroDivisionError: + pass + + sentry_sdk.flush() + + events = [item.payload for item in items if item.type == "event"] + spans = [item.payload for item in items if item.type == "span"] + assert len(events) == 1 + assert len(spans) == 1 + + error_event = events[0] + segment = spans[0] + + assert segment["trace_id"] == error_event["contexts"]["trace"]["trace_id"] + assert segment["is_segment"] is True + assert "parent_span_id" not in segment + assert error_event["contexts"]["trace"]["span_id"] == segment["span_id"] + assert segment["status"] == "error" + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "quart" + assert error_event["exception"]["values"][0]["mechanism"]["handled"] is False + + +@pytest.mark.asyncio +async def test_span_streaming_request_attributes_no_pii(sentry_init, capture_items): + sentry_init( + integrations=[quart_sentry.QuartIntegration()], + traces_sample_rate=1.0, + send_default_pii=False, + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("span") + + app = quart_app_factory() + client = app.test_client() + response = await client.get("/message?foo=bar") + assert response.status_code == 200 + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 1 + + segment = spans[0] + assert segment["attributes"]["http.request.method"] == "GET" + assert "http.request.header.host" in segment["attributes"] + + assert "url.full" not in segment["attributes"] + assert "url.query" not in segment["attributes"] + assert "client.address" not in segment["attributes"] + assert "user.ip_address" not in segment["attributes"] + + +@pytest.mark.asyncio +async def test_span_streaming_request_attributes_with_pii(sentry_init, capture_items): + sentry_init( + integrations=[quart_sentry.QuartIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("span") + + app = quart_app_factory() + client = app.test_client() + response = await client.get("/message?foo=bar&baz=qux") + assert response.status_code == 200 + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 1 + + segment = spans[0] + assert segment["attributes"]["http.request.method"] == "GET" + assert "http.request.header.host" in segment["attributes"] + + assert ( + segment["attributes"]["url.full"] == "http://localhost/message?foo=bar&baz=qux" + ) + assert segment["attributes"]["url.query"] == "foo=bar&baz=qux" + assert "client.address" in segment["attributes"] + assert "user.ip_address" in segment["attributes"] + + +@pytest.mark.asyncio +async def test_span_streaming_sensitive_header_scrubbing(sentry_init, capture_items): + sentry_init( + integrations=[quart_sentry.QuartIntegration()], + traces_sample_rate=1.0, + send_default_pii=False, + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("span") + + app = quart_app_factory() + client = app.test_client() + response = await client.get( + "/message", + headers={ + "Authorization": "Bearer secret-token", + "X-Custom-Header": "passthrough", + }, + ) + assert response.status_code == 200 + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 1 + + segment = spans[0] + assert ( + segment["attributes"]["http.request.header.authorization"] + == SENSITIVE_DATA_SUBSTITUTE + ) + assert segment["attributes"]["http.request.header.x-custom-header"] == "passthrough" + + +@pytest.mark.asyncio +async def test_span_streaming_sensitive_header_passthrough_with_pii( + sentry_init, capture_items +): + sentry_init( + integrations=[quart_sentry.QuartIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("span") + + app = quart_app_factory() + client = app.test_client() + response = await client.get( + "/message", + headers={"Authorization": "Bearer secret-token"}, + ) + assert response.status_code == 200 + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 1 + + segment = spans[0] + assert ( + segment["attributes"]["http.request.header.authorization"] + == "Bearer secret-token" + ) From 40ce26ca8dc21fa82133f7f3c068bed13ceaf7aa Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 4 Jun 2026 10:26:31 -0400 Subject: [PATCH 2/7] fix latent access bug - the access_route array could be empty --- sentry_sdk/integrations/quart.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 741ac97588..df2c0a9372 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -203,12 +203,14 @@ async def _request_websocket_started(app: "Quart", **kwargs: "Any") -> None: "url.query", request_websocket.query_string.decode("utf-8", errors="replace"), ) - segment.set_attribute( - "client.address", request_websocket.access_route[0] - ) - segment.set_attribute( - "user.ip_address", request_websocket.access_route[0] - ) + + if len(request_websocket.access_route) >= 1: + segment.set_attribute( + "client.address", request_websocket.access_route[0] + ) + segment.set_attribute( + "user.ip_address", request_websocket.access_route[0] + ) evt_processor = _make_request_event_processor(app, request_websocket, integration) scope.add_event_processor(evt_processor) @@ -235,7 +237,8 @@ def inner(event: "Event", hint: "dict[str, Any]") -> "Event": request_info["headers"] = _filter_headers(dict(request.headers)) if should_send_default_pii(): - request_info["env"] = {"REMOTE_ADDR": request.access_route[0]} + if len(request.access_route) >= 1: + request_info["env"] = {"REMOTE_ADDR": request.access_route[0]} _add_user_to_event(event) return event From 4b88f1c4705c76361476fe3b5ec9e3f1479a223e Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 4 Jun 2026 10:32:49 -0400 Subject: [PATCH 3/7] small cleanups --- tests/integrations/quart/test_quart.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py index 159cca2ae5..a2f8bba7b7 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -747,6 +747,7 @@ async def error(): events = [item.payload for item in items if item.type == "event"] spans = [item.payload for item in items if item.type == "span"] + assert len(events) == 1 assert len(spans) == 1 @@ -755,9 +756,11 @@ async def error(): assert segment["trace_id"] == error_event["contexts"]["trace"]["trace_id"] assert segment["is_segment"] is True + assert segment["status"] == "error" + assert "parent_span_id" not in segment + assert error_event["contexts"]["trace"]["span_id"] == segment["span_id"] - assert segment["status"] == "error" assert error_event["exception"]["values"][0]["mechanism"]["type"] == "quart" assert error_event["exception"]["values"][0]["mechanism"]["handled"] is False From 5d745484760a20fecc431352ba49946e283173c6 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 10 Jun 2026 14:26:10 -0400 Subject: [PATCH 4/7] Address CR comments --- sentry_sdk/integrations/quart.py | 37 +++++++++---- tests/integrations/quart/test_quart.py | 75 ++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index df2c0a9372..91cde1d719 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -120,9 +120,15 @@ def decorator(old_func: "Any") -> "Any": @wraps(old_func) @ensure_integration_enabled(QuartIntegration, old_func) def _sentry_func(*args: "Any", **kwargs: "Any") -> "Any": - current_scope = sentry_sdk.get_current_scope() - if current_scope.transaction is not None: - current_scope.transaction.update_active_thread() + client = sentry_sdk.get_client() + if has_span_streaming_enabled(client.options): + span = get_current_span() + if span is not None and hasattr(span, "_segment"): + span._segment._update_active_thread() + else: + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() sentry_scope = sentry_sdk.get_isolation_scope() if sentry_scope.profile is not None: @@ -204,6 +210,12 @@ async def _request_websocket_started(app: "Quart", **kwargs: "Any") -> None: request_websocket.query_string.decode("utf-8", errors="replace"), ) + current_user_id = _get_current_user_id_from_quart() + if current_user_id: + sentry_sdk.get_current_scope().set_attribute( + "user.id", current_user_id + ) + if len(request_websocket.access_route) >= 1: segment.set_attribute( "client.address", request_websocket.access_route[0] @@ -239,7 +251,11 @@ def inner(event: "Event", hint: "dict[str, Any]") -> "Event": if should_send_default_pii(): if len(request.access_route) >= 1: request_info["env"] = {"REMOTE_ADDR": request.access_route[0]} - _add_user_to_event(event) + + current_user_id = _get_current_user_id_from_quart() + if current_user_id: + user_info = event.setdefault("user", {}) + user_info["id"] = current_user_id return event @@ -262,15 +278,14 @@ async def _capture_exception( sentry_sdk.capture_event(event, hint=hint) -def _add_user_to_event(event: "Event") -> None: +def _get_current_user_id_from_quart() -> str | None: if quart_auth is None: return - user = quart_auth.current_user - if user is None: + if quart_auth.current_user is None: return - with capture_internal_exceptions(): - user_info = event.setdefault("user", {}) - - user_info["id"] = quart_auth.current_user._auth_id + try: + return quart_auth.current_user._auth_id + except Exception: + return None diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py index a2f8bba7b7..7c7579501b 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -633,6 +633,38 @@ async def test_active_thread_id( assert str(data["active"]) == trace_context["data"]["thread.id"] +@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) +@pytest.mark.asyncio +async def test_active_thread_id_span_streaming( + sentry_init, capture_items, teardown_profiling, endpoint +): + with mock.patch( + "sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0 + ): + sentry_init( + traces_sample_rate=1.0, + profiles_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + app = quart_app_factory() + + items = capture_items("span") + + async with app.test_client() as client: + response = await client.get(endpoint) + assert response.status_code == 200 + + data = json.loads(await response.get_data(as_text=True)) + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 1 + + segment = spans[0] + assert str(data["active"]) == segment["attributes"]["thread.id"] + + @pytest.mark.asyncio async def test_span_origin(sentry_init, capture_events): sentry_init( @@ -861,6 +893,49 @@ async def test_span_streaming_sensitive_header_scrubbing(sentry_init, capture_it assert segment["attributes"]["http.request.header.x-custom-header"] == "passthrough" +@pytest.mark.asyncio +@pytest.mark.parametrize("send_default_pii", [True, False]) +@pytest.mark.parametrize("user_id", [None, "42"]) +async def test_span_streaming_quart_auth_user_id( + send_default_pii, + sentry_init, + user_id, + capture_items, +): + from quart_auth import AuthUser, login_user + + sentry_init( + integrations=[quart_sentry.QuartIntegration()], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + _experiments={"trace_lifecycle": "stream"}, + ) + items = capture_items("span") + + app = quart_app_factory() + + @app.route("/login") + async def login(): + if user_id is not None: + login_user(AuthUser(user_id)) + return "ok" + + client = app.test_client() + assert (await client.get("/login")).status_code == 200 + assert (await client.get("/message")).status_code == 200 + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 2 + + segment = spans[1] + if send_default_pii and user_id is not None: + assert segment["attributes"]["user.id"] == user_id + else: + assert "user.id" not in segment.get("attributes", {}) + + @pytest.mark.asyncio async def test_span_streaming_sensitive_header_passthrough_with_pii( sentry_init, capture_items From a50d5921521d7fd09446a24e7531087536a518db Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 10 Jun 2026 14:31:54 -0400 Subject: [PATCH 5/7] satisfy the linter --- sentry_sdk/integrations/quart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 91cde1d719..aa8c8a0939 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -280,10 +280,10 @@ async def _capture_exception( def _get_current_user_id_from_quart() -> str | None: if quart_auth is None: - return + return None if quart_auth.current_user is None: - return + return None try: return quart_auth.current_user._auth_id From e5eb0b33608840743fec3995d06e381805eb7dec Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 10 Jun 2026 14:58:32 -0400 Subject: [PATCH 6/7] Put the typing in string quotes --- sentry_sdk/integrations/quart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index aa8c8a0939..600a712fe5 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -2,7 +2,7 @@ import inspect import sys from functools import wraps -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import sentry_sdk from sentry_sdk.integrations import DidNotEnable, Integration @@ -278,7 +278,7 @@ async def _capture_exception( sentry_sdk.capture_event(event, hint=hint) -def _get_current_user_id_from_quart() -> str | None: +def _get_current_user_id_from_quart() -> "str | None": if quart_auth is None: return None From 5ece2528f0f334726d40c55bb560d54dc290905f Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 10 Jun 2026 14:59:58 -0400 Subject: [PATCH 7/7] remove unused import --- sentry_sdk/integrations/quart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 600a712fe5..36612c5090 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -2,7 +2,7 @@ import inspect import sys from functools import wraps -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING import sentry_sdk from sentry_sdk.integrations import DidNotEnable, Integration