From 666e6fbccef4436deb7743b7a883b157d1996ef1 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 4 Jun 2026 08:37:59 -0400 Subject: [PATCH 01/14] fix(asgi): Gate query string and client IP behind send_default_pii Move http.query and client.address attribute collection inside the should_send_default_pii() check so sensitive values are not captured by default. Fixes PY-2514 Fixes #6499 --- sentry_sdk/integrations/_asgi_common.py | 9 +++++---- tests/integrations/asgi/test_asgi.py | 13 ++++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index 0aa88b100d..eafce0efaa 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -121,6 +121,7 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": for header, value in headers.items(): attributes[f"http.request.header.{header.lower()}"] = value + if should_send_default_pii(): query = _get_query(asgi_scope) if query: attributes["http.query"] = query @@ -129,9 +130,9 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": asgi_scope, "http" if ty == "http" else "ws", headers.get("host") ) - client = asgi_scope.get("client") - if client and should_send_default_pii(): - ip = _get_ip(asgi_scope) - attributes["client.address"] = ip + client = asgi_scope.get("client") + if client: + ip = _get_ip(asgi_scope) + attributes["client.address"] = ip return attributes diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index dd0bc8b59d..50d3606d98 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -164,6 +164,10 @@ def test_invalid_transaction_style(asgi3_app): @pytest.mark.asyncio +@pytest.mark.parametrize( + "should_send_pii", + [True, False], +) @pytest.mark.parametrize( "span_streaming", [True, False], @@ -174,9 +178,10 @@ async def test_capture_transaction( capture_events, capture_items, span_streaming, + should_send_pii, ): sentry_init( - send_default_pii=True, + send_default_pii=should_send_pii, traces_sample_rate=1.0, _experiments={ "trace_lifecycle": "stream" if span_streaming else "static", @@ -203,16 +208,18 @@ async def test_capture_transaction( assert span["attributes"]["sentry.span.source"] == "url" assert span["attributes"]["sentry.op"] == "http.server" - assert span["attributes"]["url.full"] == "http://localhost/some_url" assert span["attributes"]["network.protocol.name"] == "http" assert span["attributes"]["http.request.method"] == "GET" - assert span["attributes"]["http.query"] == "somevalue=123" assert span["attributes"]["http.request.header.host"] == "localhost" assert span["attributes"]["http.request.header.remote-addr"] == "127.0.0.1" assert ( span["attributes"]["http.request.header.user-agent"] == "ASGI-Test-Client" ) + if should_send_pii: + assert span["attributes"]["url.full"] == "http://localhost/some_url" + assert span["attributes"]["http.query"] == "somevalue=123" + else: (transaction_event,) = events From adc69e9a9ed4ab1d11db01f565f6a640ba92d5ac Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 4 Jun 2026 08:55:53 -0400 Subject: [PATCH 02/14] fix --- sentry_sdk/integrations/_asgi_common.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index eafce0efaa..1229e0cc24 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -121,18 +121,18 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": for header, value in headers.items(): attributes[f"http.request.header.{header.lower()}"] = value - if should_send_default_pii(): - query = _get_query(asgi_scope) - if query: - attributes["http.query"] = query + if should_send_default_pii(): + query = _get_query(asgi_scope) + if query: + attributes["http.query"] = query - attributes["url.full"] = _get_url( - asgi_scope, "http" if ty == "http" else "ws", headers.get("host") - ) + attributes["url.full"] = _get_url( + asgi_scope, "http" if ty == "http" else "ws", headers.get("host") + ) - client = asgi_scope.get("client") - if client: - ip = _get_ip(asgi_scope) - attributes["client.address"] = ip + client = asgi_scope.get("client") + if client and should_send_default_pii(): + ip = _get_ip(asgi_scope) + attributes["client.address"] = ip return attributes From 44efa73b9c8e48579acf3d89575b6a5ef128e4a2 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 4 Jun 2026 08:45:07 -0400 Subject: [PATCH 03/14] 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 04/14] 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 05/14] 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 eb14dd061bd6f5cc9813d8580e5c78aec8a55e27 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 10 Jun 2026 10:55:09 -0400 Subject: [PATCH 06/14] Update the value set on to include the query string --- sentry_sdk/integrations/_asgi_common.py | 4 +++- tests/integrations/asgi/test_asgi.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index 1229e0cc24..9cd9ca6ac9 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -126,9 +126,11 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": if query: attributes["http.query"] = query - attributes["url.full"] = _get_url( + url_without_query_string = _get_url( asgi_scope, "http" if ty == "http" else "ws", headers.get("host") ) + query_string = _get_query(asgi_scope) + attributes["url.full"] = f"{url_without_query_string}?{query_string}" client = asgi_scope.get("client") if client and should_send_default_pii(): diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index 50d3606d98..b6c4705ea3 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -217,7 +217,10 @@ async def test_capture_transaction( ) if should_send_pii: - assert span["attributes"]["url.full"] == "http://localhost/some_url" + assert ( + span["attributes"]["url.full"] + == "http://localhost/some_url?somevalue=123" + ) assert span["attributes"]["http.query"] == "somevalue=123" else: From a792df4a7217bb94f5f3e7e1edc57c8e7f1f3e9d Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 10 Jun 2026 11:04:45 -0400 Subject: [PATCH 07/14] . --- sentry_sdk/integrations/_asgi_common.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index 9cd9ca6ac9..bb44896a04 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -130,7 +130,11 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": asgi_scope, "http" if ty == "http" else "ws", headers.get("host") ) query_string = _get_query(asgi_scope) - attributes["url.full"] = f"{url_without_query_string}?{query_string}" + attributes["url.full"] = ( + f"{url_without_query_string}?{query_string}" + if query_string is not None + else url_without_query_string + ) client = asgi_scope.get("client") if client and should_send_default_pii(): From 5d745484760a20fecc431352ba49946e283173c6 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Wed, 10 Jun 2026 14:26:10 -0400 Subject: [PATCH 08/14] 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 09/14] 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 10/14] 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 11/14] 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 From 34d96883eee53a715c091233d90aecb32d1c3d12 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 11 Jun 2026 11:48:38 -0400 Subject: [PATCH 12/14] Set user properties on the scope using instead of --- sentry_sdk/integrations/quart.py | 15 +++++++++------ tests/integrations/quart/test_quart.py | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 36612c5090..168a8f23f4 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -210,12 +210,7 @@ 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 - ) - + user_properties = {} if len(request_websocket.access_route) >= 1: segment.set_attribute( "client.address", request_websocket.access_route[0] @@ -223,6 +218,14 @@ async def _request_websocket_started(app: "Quart", **kwargs: "Any") -> None: segment.set_attribute( "user.ip_address", request_websocket.access_route[0] ) + user_properties["ip_address"] = request_websocket.access_route[0] + + current_user_id = _get_current_user_id_from_quart() + if current_user_id: + user_properties["id"] = current_user_id + + if user_properties: + sentry_sdk.get_current_scope().set_user(user_properties) 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 7c7579501b..56f2f1c5be 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -932,6 +932,7 @@ async def login(): segment = spans[1] if send_default_pii and user_id is not None: assert segment["attributes"]["user.id"] == user_id + print("SEGMENT ATTRS", segment["attributes"]) else: assert "user.id" not in segment.get("attributes", {}) From 25f916c1ae103a1bc2852ff3fa82a9a254562511 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 11 Jun 2026 15:32:53 -0400 Subject: [PATCH 13/14] Remove debug statement --- tests/integrations/quart/test_quart.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py index 56f2f1c5be..7c7579501b 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -932,7 +932,6 @@ async def login(): segment = spans[1] if send_default_pii and user_id is not None: assert segment["attributes"]["user.id"] == user_id - print("SEGMENT ATTRS", segment["attributes"]) else: assert "user.id" not in segment.get("attributes", {}) From 42bbc3f2dbdd2c95201e550e07701cd7a3b7306e Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 11 Jun 2026 15:37:11 -0400 Subject: [PATCH 14/14] fix --- sentry_sdk/integrations/quart.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 168a8f23f4..b141103dcc 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -225,7 +225,11 @@ async def _request_websocket_started(app: "Quart", **kwargs: "Any") -> None: user_properties["id"] = current_user_id if user_properties: - sentry_sdk.get_current_scope().set_user(user_properties) + current_scope = sentry_sdk.get_current_scope() + existing_user_properties = current_scope._user or {} + current_scope.set_user( + {**existing_user_properties, **user_properties} + ) evt_processor = _make_request_event_processor(app, request_websocket, integration) scope.add_event_processor(evt_processor)