From 820bb4cb26853438a460e1252693adac822369b4 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 9 Jun 2026 14:51:59 +0200 Subject: [PATCH 01/10] feat(django): Add user attributes in span streaming --- sentry_sdk/consts.py | 18 +++++++++++ sentry_sdk/integrations/django/__init__.py | 35 ++++++++++++++++++++++ tests/integrations/django/test_basic.py | 33 ++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 3630dc8320..8eb37d37c4 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -978,12 +978,30 @@ class SPANDATA: Example: "MainThread" """ + USER_EMAIL = "user.email" + """ + User email address. + Example: "test@example.com" + """ + + USER_ID = "user.id" + """ + Unique identifier of the user. + Example: "S-1-5-21-202424912787-2692429404-2351956786-1000" + """ + USER_IP_ADDRESS = "user.ip_address" """ The IP address of the user that triggered the request. Example: "10.1.2.80" """ + USER_NAME = "user.name" + """ + Short name or login/username of the user. + Example: "j.smith" + """ + URL_FULL = "url.full" """ The URL of the resource that was fetched. diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 4c444009e0..9f710331a5 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -40,6 +40,7 @@ from django.conf import settings from django.conf import settings as django_settings from django.core import signals + from django.utils.functional import SimpleLazyObject try: from django.urls import resolve @@ -464,6 +465,40 @@ def _after_get_response(request: "WSGIRequest") -> None: scope = sentry_sdk.get_current_scope() _attempt_resolve_again(request, scope, integration.transaction_style) + span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) + if span_streaming: + user = getattr(request, "user", None) + + # Evaluating a SimpleLazyObject in an async view can raise django.core.exceptions.SynchronousOnlyOperation. + # Exit early if the user has not been materialized yet. + is_lazy = isinstance(user, SimpleLazyObject) + if is_lazy and hasattr(request, "_cached_user"): + user = request._cached_user + elif is_lazy: + return + + if user is None or not is_authenticated(user): + return + + segment_span = scope.streamed_span._segment + try: + user_id = str(user.pk) + except Exception: + pass + segment_span.set_attribute(SPANDATA.USER_ID, user_id) + + try: + user_email = user.email + except Exception: + pass + segment_span.set_attribute(SPANDATA.USER_EMAIL, user_email) + + try: + username = user.get_username() + except Exception: + pass + segment_span.set_attribute(SPANDATA.USER_NAME, username) + def _patch_get_response() -> None: """ diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index 4ffad98ad1..076dc2fd77 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -530,6 +530,39 @@ def test_user_captured( } +@pytest.mark.forked +@pytest_mark_django_db_decorator() +def test_materialized_user_captured_on_segment_span( + sentry_init, + client, + capture_events, + capture_items, +): + sentry_init( + integrations=[DjangoIntegration()], + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + content, status, headers = unpack_werkzeug_response(client.get(reverse("mylogin"))) + assert content == b"ok" + + items = capture_items("span") + + content, status, headers = unpack_werkzeug_response( + client.get(reverse("template_test")) + ) + + sentry_sdk.flush() + spans = [item.payload for item in items] + (span,) = (span for span in spans if span["name"] == "/template-test") + + assert span["attributes"][SPANDATA.USER_ID] == "1" + assert span["attributes"][SPANDATA.USER_EMAIL] == "lennon@thebeatles.com" + assert span["attributes"][SPANDATA.USER_NAME] == "john" + + @pytest.mark.forked @pytest_mark_django_db_decorator() @pytest.mark.parametrize("span_streaming", [True, False]) From c0c2b6e045285f52950577b74eaf61ccaf84466a Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 9 Jun 2026 14:54:32 +0200 Subject: [PATCH 02/10] mypy --- sentry_sdk/integrations/django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 9f710331a5..6207088652 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -477,7 +477,7 @@ def _after_get_response(request: "WSGIRequest") -> None: elif is_lazy: return - if user is None or not is_authenticated(user): + if user is None or not is_authenticated(user) or scope.streamed_span is None: return segment_span = scope.streamed_span._segment From 7ddfce2422f3cf25f11911eeded561d8e7f873b5 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 9 Jun 2026 15:04:01 +0200 Subject: [PATCH 03/10] type check before _segment access --- sentry_sdk/integrations/django/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 6207088652..510811434b 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -477,7 +477,11 @@ def _after_get_response(request: "WSGIRequest") -> None: elif is_lazy: return - if user is None or not is_authenticated(user) or scope.streamed_span is None: + if ( + user is None + or not is_authenticated(user) + or type(scope.streamed_span) is not StreamedSpan + ): return segment_span = scope.streamed_span._segment From 38daa24e113fe2d929ce3bc508ae74e8bba9ee61 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 9 Jun 2026 15:05:55 +0200 Subject: [PATCH 04/10] empty commit to trigger ci From 0f877fb42975303ff3fcea1d65eee2956745512e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 9 Jun 2026 15:15:33 +0200 Subject: [PATCH 05/10] . --- sentry_sdk/integrations/django/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 510811434b..48f17eecef 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -459,11 +459,13 @@ def _attempt_resolve_again( def _after_get_response(request: "WSGIRequest") -> None: integration = sentry_sdk.get_client().get_integration(DjangoIntegration) - if integration is None or integration.transaction_style != "url": + if integration is None: return scope = sentry_sdk.get_current_scope() - _attempt_resolve_again(request, scope, integration.transaction_style) + + if integration.transaction_style == "url": + _attempt_resolve_again(request, scope, integration.transaction_style) span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: From 90e1517a0836c7b2b5296e24154f25e7ad224d64 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 9 Jun 2026 15:18:34 +0200 Subject: [PATCH 06/10] gate behind pii and check None --- sentry_sdk/integrations/django/__init__.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 48f17eecef..fdbcae122a 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -468,7 +468,7 @@ def _after_get_response(request: "WSGIRequest") -> None: _attempt_resolve_again(request, scope, integration.transaction_style) span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) - if span_streaming: + if span_streaming and should_send_default_pii(): user = getattr(request, "user", None) # Evaluating a SimpleLazyObject in an async view can raise django.core.exceptions.SynchronousOnlyOperation. @@ -487,23 +487,30 @@ def _after_get_response(request: "WSGIRequest") -> None: return segment_span = scope.streamed_span._segment + + user_id = None try: user_id = str(user.pk) except Exception: pass - segment_span.set_attribute(SPANDATA.USER_ID, user_id) + if user_id is not None: + segment_span.set_attribute(SPANDATA.USER_ID, user_id) + user_email = None try: user_email = user.email except Exception: pass - segment_span.set_attribute(SPANDATA.USER_EMAIL, user_email) + if user_email is not None: + segment_span.set_attribute(SPANDATA.USER_EMAIL, user_email) + username = None try: username = user.get_username() except Exception: pass - segment_span.set_attribute(SPANDATA.USER_NAME, username) + if username is not None: + segment_span.set_attribute(SPANDATA.USER_NAME, username) def _patch_get_response() -> None: From 3db9491ffbaa3d07a31aa63196c828ba10ddf5f6 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 9 Jun 2026 15:35:23 +0200 Subject: [PATCH 07/10] set on scope instead of span --- sentry_sdk/integrations/django/__init__.py | 14 ++++---------- tests/integrations/django/test_basic.py | 2 +- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index fdbcae122a..1642f0ab07 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -479,22 +479,16 @@ def _after_get_response(request: "WSGIRequest") -> None: elif is_lazy: return - if ( - user is None - or not is_authenticated(user) - or type(scope.streamed_span) is not StreamedSpan - ): + if user is None or not is_authenticated(user): return - segment_span = scope.streamed_span._segment - user_id = None try: user_id = str(user.pk) except Exception: pass if user_id is not None: - segment_span.set_attribute(SPANDATA.USER_ID, user_id) + scope.set_attribute(SPANDATA.USER_ID, user_id) user_email = None try: @@ -502,7 +496,7 @@ def _after_get_response(request: "WSGIRequest") -> None: except Exception: pass if user_email is not None: - segment_span.set_attribute(SPANDATA.USER_EMAIL, user_email) + scope.set_attribute(SPANDATA.USER_EMAIL, user_email) username = None try: @@ -510,7 +504,7 @@ def _after_get_response(request: "WSGIRequest") -> None: except Exception: pass if username is not None: - segment_span.set_attribute(SPANDATA.USER_NAME, username) + scope.set_attribute(SPANDATA.USER_NAME, username) def _patch_get_response() -> None: diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index 076dc2fd77..f7be9e5da6 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -532,7 +532,7 @@ def test_user_captured( @pytest.mark.forked @pytest_mark_django_db_decorator() -def test_materialized_user_captured_on_segment_span( +def test_materialized_user_captured( sentry_init, client, capture_events, From ea4a3cf28c16d42a80125d23ab2d4e4a1107bc0d Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 9 Jun 2026 16:33:53 +0200 Subject: [PATCH 08/10] use Scope.set_user instead of individual attributes --- sentry_sdk/integrations/django/__init__.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 1642f0ab07..6ee6e6ffe6 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -482,29 +482,23 @@ def _after_get_response(request: "WSGIRequest") -> None: if user is None or not is_authenticated(user): return - user_id = None + user_info = {} try: - user_id = str(user.pk) + user_info["id"] = str(user.pk) except Exception: pass - if user_id is not None: - scope.set_attribute(SPANDATA.USER_ID, user_id) - user_email = None try: - user_email = user.email + user_info["email"] = user.email except Exception: pass - if user_email is not None: - scope.set_attribute(SPANDATA.USER_EMAIL, user_email) - username = None try: - username = user.get_username() + user_info["username"] = user.get_username() except Exception: pass - if username is not None: - scope.set_attribute(SPANDATA.USER_NAME, username) + + scope.set_user(user_info) def _patch_get_response() -> None: From 669481ff6d0bb03b94c84e532ae4bc8776a14229 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 10 Jun 2026 09:35:08 +0200 Subject: [PATCH 09/10] only call sentry_sdk.get_client() once --- sentry_sdk/integrations/django/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 6ee6e6ffe6..d22b959a1d 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -458,7 +458,8 @@ def _attempt_resolve_again( def _after_get_response(request: "WSGIRequest") -> None: - integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + client = sentry_sdk.get_client() + integration = client.get_integration(DjangoIntegration) if integration is None: return @@ -467,7 +468,7 @@ def _after_get_response(request: "WSGIRequest") -> None: if integration.transaction_style == "url": _attempt_resolve_again(request, scope, integration.transaction_style) - span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) + span_streaming = has_span_streaming_enabled(client.options) if span_streaming and should_send_default_pii(): user = getattr(request, "user", None) From a1e04a91b857e3a972a2b5631e564af23bcb1966 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 10 Jun 2026 09:38:10 +0200 Subject: [PATCH 10/10] use sentry_sdk.set_user() --- sentry_sdk/integrations/django/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index d22b959a1d..361b60079d 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -463,9 +463,8 @@ def _after_get_response(request: "WSGIRequest") -> None: if integration is None: return - scope = sentry_sdk.get_current_scope() - if integration.transaction_style == "url": + scope = sentry_sdk.get_current_scope() _attempt_resolve_again(request, scope, integration.transaction_style) span_streaming = has_span_streaming_enabled(client.options) @@ -499,7 +498,7 @@ def _after_get_response(request: "WSGIRequest") -> None: except Exception: pass - scope.set_user(user_info) + sentry_sdk.set_user(user_info) def _patch_get_response() -> None: