From 5a2c70575353fe469c64965dc50d1f20fce5ad63 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Jul 2026 09:41:45 -0400 Subject: [PATCH 01/10] feat(django): Gate user identity behind data_collection config Apply the data_collection.user_info setting to Django user identity (id, email, username) collection, giving it precedence over the legacy send_default_pii boolean when explicitly configured. This mirrors the existing behavior for client IP/user info already shipped for Flask, WSGI, and ASGI. The user extraction logic in _after_get_response is pulled into a shared helper used by both the WSGI and ASGI event processors, and the parametrized send_default_pii/data_collection test matrix is extracted into tests/integrations/django/utils.py for reuse across the WSGI and ASGI test suites. Refs PY-2583 --- sentry_sdk/integrations/django/__init__.py | 78 +++++++++------ sentry_sdk/integrations/django/asgi.py | 8 +- tests/integrations/django/asgi/test_asgi.py | 33 +++++++ tests/integrations/django/myapp/urls.py | 5 + tests/integrations/django/myapp/views.py | 10 ++ .../django/test_data_scrubbing.py | 99 ++++++++++++------- tests/integrations/django/utils.py | 37 +++++++ 7 files changed, 202 insertions(+), 68 deletions(-) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 2c561164e6..0f8dfcd24a 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -30,6 +30,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, logger, transaction_from_function, walk_exception_chain, @@ -457,6 +458,39 @@ def _attempt_resolve_again( _set_transaction_name_and_source(scope, transaction_style, request) +def _get_user_from_request_and_set_on_scope(request: "WSGIRequest") -> None: + 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 + + user_info = {} + try: + user_info["id"] = str(user.pk) + except Exception: + pass + + try: + user_info["email"] = user.email + except Exception: + pass + + try: + user_info["username"] = user.get_username() + except Exception: + pass + + sentry_sdk.set_user(user_info) + + def _after_get_response(request: "WSGIRequest") -> None: client = sentry_sdk.get_client() integration = client.get_integration(DjangoIntegration) @@ -468,37 +502,12 @@ def _after_get_response(request: "WSGIRequest") -> None: _attempt_resolve_again(request, scope, integration.transaction_style) span_streaming = has_span_streaming_enabled(client.options) - 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. - # 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 - - user_info = {} - try: - user_info["id"] = str(user.pk) - except Exception: - pass - - try: - user_info["email"] = user.email - except Exception: - pass - - try: - user_info["username"] = user.get_username() - except Exception: - pass - - sentry_sdk.set_user(user_info) + if span_streaming: + if has_data_collection_enabled(client.options): + if client.options["data_collection"]["user_info"]: + _get_user_from_request_and_set_on_scope(request) + elif should_send_default_pii(): + _get_user_from_request_and_set_on_scope(request) def _patch_get_response() -> None: @@ -544,7 +553,12 @@ def wsgi_request_event_processor(event: "Event", hint: "dict[str, Any]") -> "Eve with capture_internal_exceptions(): DjangoRequestExtractor(request).extract_into_event(event) - if should_send_default_pii(): + client_options = sentry_sdk.get_client().options + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["user_info"]: + with capture_internal_exceptions(): + _set_user_info(request, event) + elif should_send_default_pii(): with capture_internal_exceptions(): _set_user_info(request, event) diff --git a/sentry_sdk/integrations/django/asgi.py b/sentry_sdk/integrations/django/asgi.py index 785fa2af34..df287e6436 100644 --- a/sentry_sdk/integrations/django/asgi.py +++ b/sentry_sdk/integrations/django/asgi.py @@ -22,6 +22,7 @@ from sentry_sdk.utils import ( capture_internal_exceptions, ensure_integration_enabled, + has_data_collection_enabled, ) if TYPE_CHECKING: @@ -70,7 +71,12 @@ def asgi_request_event_processor(event: "Event", hint: "dict[str, Any]") -> "Eve with capture_internal_exceptions(): DjangoRequestExtractor(request).extract_into_event(event) - if should_send_default_pii(): + client_options = sentry_sdk.get_client().options + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["user_info"]: + with capture_internal_exceptions(): + _set_user_info(request, event) + elif should_send_default_pii(): with capture_internal_exceptions(): _set_user_info(request, event) diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index 700704ad4f..a8617cfb4a 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -16,6 +16,10 @@ from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django.asgi import _asgi_middleware_mixin_factory from tests.integrations.django.myapp.asgi import channels_application +from tests.integrations.django.utils import ( + USER_INFO_INIT_KWARGS, + pytest_mark_django_db_decorator, +) try: from django.urls import reverse @@ -1096,3 +1100,32 @@ async def test_async_middleware_process_exception_is_awaited( assert response["status"] == 200 assert response["body"] == b"handled by async process_exception" + + +@pytest.mark.parametrize("application", APPS) +@pytest.mark.asyncio +@pytest.mark.skipif( + django.VERSION < (3, 0), reason="Django ASGI support shipped in 3.0" +) +@pytest.mark.parametrize("init_kwargs, expect_user", USER_INFO_INIT_KWARGS) +@pytest_mark_django_db_decorator() +async def test_user_identity_error_event_data_collection( + sentry_init, capture_events, application, init_kwargs, expect_user +): + sentry_init(integrations=[DjangoIntegration()], **init_kwargs) + events = capture_events() + + comm = HttpCommunicator(application, "GET", "/mylogin-with-exception") + await comm.get_response() + await comm.wait() + + event = events[-1] + + if expect_user: + assert event["user"]["id"] == "1" + assert event["user"]["email"] == "lennon@thebeatles.com" + assert event["user"]["username"] == "john" + else: + assert "id" not in event.get("user", {}) + assert "email" not in event.get("user", {}) + assert "username" not in event.get("user", {}) diff --git a/tests/integrations/django/myapp/urls.py b/tests/integrations/django/myapp/urls.py index 87e9750889..2c1cad4298 100644 --- a/tests/integrations/django/myapp/urls.py +++ b/tests/integrations/django/myapp/urls.py @@ -47,6 +47,11 @@ def path(path, *args, **kwargs): path("nomessage", views.nomessage, name="nomessage"), path("view-with-signal", views.view_with_signal, name="view_with_signal"), path("mylogin", views.mylogin, name="mylogin"), + path( + "mylogin-with-exception", + views.mylogin_with_exception, + name="mylogin_with_exception", + ), path("classbased", views.ClassBasedView.as_view(), name="classbased"), path("sentryclass", views.SentryClassBasedView(), name="sentryclass"), path( diff --git a/tests/integrations/django/myapp/views.py b/tests/integrations/django/myapp/views.py index 80587acaa3..ebaa3b37eb 100644 --- a/tests/integrations/django/myapp/views.py +++ b/tests/integrations/django/myapp/views.py @@ -135,6 +135,16 @@ def mylogin(request): return HttpResponse("ok") +@csrf_exempt +def mylogin_with_exception(request): + user, _ = User.objects.get_or_create( + username="john", defaults={"email": "lennon@thebeatles.com"} + ) + user.backend = "django.contrib.auth.backends.ModelBackend" + login(request, user) + 1 / 0 + + @csrf_exempt def handler500(request): return HttpResponseServerError("Sentry error.") diff --git a/tests/integrations/django/test_data_scrubbing.py b/tests/integrations/django/test_data_scrubbing.py index 5e0eb03508..4f20153a89 100644 --- a/tests/integrations/django/test_data_scrubbing.py +++ b/tests/integrations/django/test_data_scrubbing.py @@ -6,7 +6,10 @@ from sentry_sdk.integrations.django import DjangoIntegration from tests.conftest import unpack_werkzeug_response, werkzeug_set_cookie from tests.integrations.django.myapp.wsgi import application -from tests.integrations.django.utils import pytest_mark_django_db_decorator +from tests.integrations.django.utils import ( + USER_INFO_INIT_KWARGS, + pytest_mark_django_db_decorator, +) try: from django.urls import reverse @@ -392,40 +395,6 @@ def test_empty_query_string_is_dropped_with_data_collection( assert "query_string" not in event["request"] -USER_INFO_INIT_KWARGS = [ - pytest.param({"send_default_pii": True}, True, id="legacy_send_default_pii_true"), - pytest.param( - {"send_default_pii": False}, False, id="legacy_send_default_pii_false" - ), - pytest.param( - {"_experiments": {"data_collection": {"user_info": True}}}, - True, - id="data_collection_user_info_true", - ), - pytest.param( - {"_experiments": {"data_collection": {"user_info": False}}}, - False, - id="data_collection_user_info_false", - ), - pytest.param( - { - "send_default_pii": True, - "_experiments": {"data_collection": {"user_info": False}}, - }, - False, - id="data_collection_wins_over_send_default_pii_true", - ), - pytest.param( - { - "send_default_pii": False, - "_experiments": {"data_collection": {"user_info": True}}, - }, - True, - id="data_collection_wins_over_send_default_pii_false", - ), -] - - @pytest.mark.forked @pytest_mark_django_db_decorator() @pytest.mark.parametrize("init_kwargs, expect_ip", USER_INFO_INIT_KWARGS) @@ -462,6 +431,42 @@ def test_user_info_span_attributes_data_collection( assert "client.address" not in root_span["attributes"] +@pytest.mark.forked +@pytest_mark_django_db_decorator() +@pytest.mark.parametrize("init_kwargs, expect_user", USER_INFO_INIT_KWARGS) +def test_user_identity_span_attributes_data_collection( + sentry_init, client, capture_items, init_kwargs, expect_user +): + init_kwargs = dict(init_kwargs) # shallow copy so we can mutate + experiments = init_kwargs.pop("_experiments", {}) + + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream", + _experiments=experiments, + **init_kwargs, + ) + + unpack_werkzeug_response(client.get(reverse("mylogin"))) + + items = capture_items("span") + unpack_werkzeug_response(client.get(reverse("template_test"))) + sentry_sdk.flush() + + spans = [item.payload for item in items] + (span,) = (s for s in spans if s["name"] == "/template-test") + + if expect_user: + assert span["attributes"][SPANDATA.USER_ID] == "1" + assert span["attributes"][SPANDATA.USER_EMAIL] == "lennon@thebeatles.com" + assert span["attributes"][SPANDATA.USER_NAME] == "john" + else: + assert SPANDATA.USER_ID not in span["attributes"] + assert SPANDATA.USER_EMAIL not in span["attributes"] + assert SPANDATA.USER_NAME not in span["attributes"] + + @pytest.mark.forked @pytest_mark_django_db_decorator() @pytest.mark.parametrize("init_kwargs, expect_ip", USER_INFO_INIT_KWARGS) @@ -483,6 +488,30 @@ def test_user_info_error_event_data_collection( assert "REMOTE_ADDR" not in event["request"]["env"] +@pytest.mark.forked +@pytest_mark_django_db_decorator() +@pytest.mark.parametrize("init_kwargs, expect_user", USER_INFO_INIT_KWARGS) +def test_user_identity_error_event_data_collection( + sentry_init, client, capture_events, init_kwargs, expect_user +): + sentry_init(integrations=[DjangoIntegration()], **init_kwargs) + events = capture_events() + + client.get(reverse("mylogin")) + client.get(reverse("view_exc")) + + event = events[-1] + + if expect_user: + assert event["user"]["id"] == "1" + assert event["user"]["email"] == "lennon@thebeatles.com" + assert event["user"]["username"] == "john" + else: + assert "id" not in event.get("user", {}) + assert "email" not in event.get("user", {}) + assert "username" not in event.get("user", {}) + + @pytest.mark.forked @pytest_mark_django_db_decorator() def test_error_event_no_user_ip_address_without_remote_addr( diff --git a/tests/integrations/django/utils.py b/tests/integrations/django/utils.py index 8270b997ea..94495def4a 100644 --- a/tests/integrations/django/utils.py +++ b/tests/integrations/django/utils.py @@ -19,3 +19,40 @@ ) except AttributeError: pass + + +# Shared parametrization test matrix exercising the precedence between the legacy +# ``send_default_pii`` boolean and the ``data_collection.user_info`` setting. +# The second value indicates whether user info is expected to be collected. +USER_INFO_INIT_KWARGS = [ + pytest.param({"send_default_pii": True}, True, id="legacy_send_default_pii_true"), + pytest.param( + {"send_default_pii": False}, False, id="legacy_send_default_pii_false" + ), + pytest.param( + {"_experiments": {"data_collection": {"user_info": True}}}, + True, + id="data_collection_user_info_true", + ), + pytest.param( + {"_experiments": {"data_collection": {"user_info": False}}}, + False, + id="data_collection_user_info_false", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": {"data_collection": {"user_info": False}}, + }, + False, + id="data_collection_wins_over_send_default_pii_true", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"user_info": True}}, + }, + True, + id="data_collection_wins_over_send_default_pii_false", + ), +] From 4ee42d032cfafb5c39d30a0f289f03de74f37cfc Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Jul 2026 10:18:17 -0400 Subject: [PATCH 02/10] test(django): Run user identity ASGI test in a forked process The new test was the only DB-touching test without @pytest.mark.forked. Running in the parent process leaked the created user into the shared in-memory sqlite DB (UNIQUE constraint failures in test_basic) and caused the parent to hold the session-scoped django_db_setup fixture, so forked children skipped postgres test DB creation but still destroyed it on teardown (database does not exist errors). --- tests/integrations/django/asgi/test_asgi.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index a8617cfb4a..fb3212aff8 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -1102,6 +1102,7 @@ async def test_async_middleware_process_exception_is_awaited( assert response["body"] == b"handled by async process_exception" +@pytest.mark.forked @pytest.mark.parametrize("application", APPS) @pytest.mark.asyncio @pytest.mark.skipif( From 86566bdfae4ee9520c8cd530195ad1d5e8398c70 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Jul 2026 10:33:23 -0400 Subject: [PATCH 03/10] ref(tests): Share data_collection user info test matrix across suites Move the identical USER_INFO_INIT_KWARGS parametrization from the flask, wsgi, and django test suites into tests/integrations/utils.py as DATA_COLLECTION_USER_INFO_CASES, documenting that the second element of each case is whether user info is expected to be collected. --- tests/integrations/django/asgi/test_asgi.py | 8 ++-- .../django/test_data_scrubbing.py | 14 +++--- tests/integrations/django/utils.py | 37 --------------- tests/integrations/flask/test_flask.py | 47 ++----------------- tests/integrations/utils.py | 38 +++++++++++++++ tests/integrations/wsgi/test_wsgi.py | 43 ++--------------- 6 files changed, 55 insertions(+), 132 deletions(-) create mode 100644 tests/integrations/utils.py diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index fb3212aff8..752dba702a 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -16,10 +16,8 @@ from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django.asgi import _asgi_middleware_mixin_factory from tests.integrations.django.myapp.asgi import channels_application -from tests.integrations.django.utils import ( - USER_INFO_INIT_KWARGS, - pytest_mark_django_db_decorator, -) +from tests.integrations.django.utils import pytest_mark_django_db_decorator +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES try: from django.urls import reverse @@ -1108,7 +1106,7 @@ async def test_async_middleware_process_exception_is_awaited( @pytest.mark.skipif( django.VERSION < (3, 0), reason="Django ASGI support shipped in 3.0" ) -@pytest.mark.parametrize("init_kwargs, expect_user", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_user", DATA_COLLECTION_USER_INFO_CASES) @pytest_mark_django_db_decorator() async def test_user_identity_error_event_data_collection( sentry_init, capture_events, application, init_kwargs, expect_user diff --git a/tests/integrations/django/test_data_scrubbing.py b/tests/integrations/django/test_data_scrubbing.py index 4f20153a89..559b9b0466 100644 --- a/tests/integrations/django/test_data_scrubbing.py +++ b/tests/integrations/django/test_data_scrubbing.py @@ -6,10 +6,8 @@ from sentry_sdk.integrations.django import DjangoIntegration from tests.conftest import unpack_werkzeug_response, werkzeug_set_cookie from tests.integrations.django.myapp.wsgi import application -from tests.integrations.django.utils import ( - USER_INFO_INIT_KWARGS, - pytest_mark_django_db_decorator, -) +from tests.integrations.django.utils import pytest_mark_django_db_decorator +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES try: from django.urls import reverse @@ -397,7 +395,7 @@ def test_empty_query_string_is_dropped_with_data_collection( @pytest.mark.forked @pytest_mark_django_db_decorator() -@pytest.mark.parametrize("init_kwargs, expect_ip", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) def test_user_info_span_attributes_data_collection( sentry_init, client, capture_items, init_kwargs, expect_ip ): @@ -433,7 +431,7 @@ def test_user_info_span_attributes_data_collection( @pytest.mark.forked @pytest_mark_django_db_decorator() -@pytest.mark.parametrize("init_kwargs, expect_user", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_user", DATA_COLLECTION_USER_INFO_CASES) def test_user_identity_span_attributes_data_collection( sentry_init, client, capture_items, init_kwargs, expect_user ): @@ -469,7 +467,7 @@ def test_user_identity_span_attributes_data_collection( @pytest.mark.forked @pytest_mark_django_db_decorator() -@pytest.mark.parametrize("init_kwargs, expect_ip", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) def test_user_info_error_event_data_collection( sentry_init, client, capture_events, init_kwargs, expect_ip ): @@ -490,7 +488,7 @@ def test_user_info_error_event_data_collection( @pytest.mark.forked @pytest_mark_django_db_decorator() -@pytest.mark.parametrize("init_kwargs, expect_user", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_user", DATA_COLLECTION_USER_INFO_CASES) def test_user_identity_error_event_data_collection( sentry_init, client, capture_events, init_kwargs, expect_user ): diff --git a/tests/integrations/django/utils.py b/tests/integrations/django/utils.py index 94495def4a..8270b997ea 100644 --- a/tests/integrations/django/utils.py +++ b/tests/integrations/django/utils.py @@ -19,40 +19,3 @@ ) except AttributeError: pass - - -# Shared parametrization test matrix exercising the precedence between the legacy -# ``send_default_pii`` boolean and the ``data_collection.user_info`` setting. -# The second value indicates whether user info is expected to be collected. -USER_INFO_INIT_KWARGS = [ - pytest.param({"send_default_pii": True}, True, id="legacy_send_default_pii_true"), - pytest.param( - {"send_default_pii": False}, False, id="legacy_send_default_pii_false" - ), - pytest.param( - {"_experiments": {"data_collection": {"user_info": True}}}, - True, - id="data_collection_user_info_true", - ), - pytest.param( - {"_experiments": {"data_collection": {"user_info": False}}}, - False, - id="data_collection_user_info_false", - ), - pytest.param( - { - "send_default_pii": True, - "_experiments": {"data_collection": {"user_info": False}}, - }, - False, - id="data_collection_wins_over_send_default_pii_true", - ), - pytest.param( - { - "send_default_pii": False, - "_experiments": {"data_collection": {"user_info": True}}, - }, - True, - id="data_collection_wins_over_send_default_pii_false", - ), -] diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index c091e23f61..71c56d3fef 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -32,6 +32,7 @@ from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.serializer import MAX_DATABAG_BREADTH +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES # Query string used across the query-param filtering tests below. ``auth`` is a # built-in sensitive term, so it is redacted by the default denylist. @@ -1398,45 +1399,7 @@ def test_empty_query_string_is_dropped_with_data_collection( assert "query_string" not in event["request"] -# Parametrization shared by the user_info tests below. ``expect_ip`` is -# whether the client IP may be collected under the given init kwargs. -USER_INFO_INIT_KWARGS = [ - pytest.param({"send_default_pii": True}, True, id="legacy_send_default_pii_true"), - pytest.param( - {"send_default_pii": False}, False, id="legacy_send_default_pii_false" - ), - pytest.param( - {"_experiments": {"data_collection": {"user_info": True}}}, - True, - id="data_collection_user_info_true", - ), - pytest.param( - {"_experiments": {"data_collection": {"user_info": False}}}, - False, - id="data_collection_user_info_false", - ), - # ``data_collection`` is the single source of truth: it must win over - # ``send_default_pii`` when both are configured. - pytest.param( - { - "send_default_pii": True, - "_experiments": {"data_collection": {"user_info": False}}, - }, - False, - id="data_collection_wins_over_send_default_pii_true", - ), - pytest.param( - { - "send_default_pii": False, - "_experiments": {"data_collection": {"user_info": True}}, - }, - True, - id="data_collection_wins_over_send_default_pii_false", - ), -] - - -@pytest.mark.parametrize("init_kwargs, expect_ip", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) def test_user_info_span_attributes_data_collection( sentry_init, app, capture_items, monkeypatch, init_kwargs, expect_ip ): @@ -1472,7 +1435,7 @@ def test_user_info_span_attributes_data_collection( assert "client.address" not in segment["attributes"] -@pytest.mark.parametrize("init_kwargs, expect_ip", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) def test_user_info_error_event_data_collection( sentry_init, app, capture_events, monkeypatch, init_kwargs, expect_ip ): @@ -1523,7 +1486,7 @@ def crash(): assert "ip_address" not in event.get("user", {}) -@pytest.mark.parametrize("init_kwargs, expect_user", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_user", DATA_COLLECTION_USER_INFO_CASES) def test_flask_login_user_identity_error_event_data_collection( sentry_init, app, capture_events, init_kwargs, expect_user ): @@ -1571,7 +1534,7 @@ def crash(): assert "username" not in user -@pytest.mark.parametrize("init_kwargs, expect_user", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_user", DATA_COLLECTION_USER_INFO_CASES) def test_flask_login_user_identity_span_attributes_data_collection( sentry_init, app, capture_items, init_kwargs, expect_user ): diff --git a/tests/integrations/utils.py b/tests/integrations/utils.py new file mode 100644 index 0000000000..557ed3b62c --- /dev/null +++ b/tests/integrations/utils.py @@ -0,0 +1,38 @@ +import pytest + +# Shared parametrization test matrix exercising the precedence between the legacy +# ``send_default_pii`` boolean and the ``data_collection.user_info`` setting. +# Each case is ``(init_kwargs, expect_user_info)`` where the second element indicates +# whether user info (IP address, user identity, etc.) is expected to be collected. +DATA_COLLECTION_USER_INFO_CASES = [ + pytest.param({"send_default_pii": True}, True, id="legacy_send_default_pii_true"), + pytest.param( + {"send_default_pii": False}, False, id="legacy_send_default_pii_false" + ), + pytest.param( + {"_experiments": {"data_collection": {"user_info": True}}}, + True, + id="data_collection_user_info_true", + ), + pytest.param( + {"_experiments": {"data_collection": {"user_info": False}}}, + False, + id="data_collection_user_info_false", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": {"data_collection": {"user_info": False}}, + }, + False, + id="data_collection_wins_over_send_default_pii_true", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"user_info": True}}, + }, + True, + id="data_collection_wins_over_send_default_pii_false", + ), +] diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index 084abe0745..eb73be1499 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -11,6 +11,7 @@ _ScopedResponse, get_request_url, ) +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES @pytest.fixture @@ -1303,45 +1304,7 @@ def dogpark(environ, start_response): assert "user.ip_address" not in child_span["attributes"] -# Parametrization shared by the user_info tests below. ``expect_ip`` is -# whether the client IP may be collected under the given init kwargs. -USER_INFO_INIT_KWARGS = [ - pytest.param({"send_default_pii": True}, True, id="legacy_send_default_pii_true"), - pytest.param( - {"send_default_pii": False}, False, id="legacy_send_default_pii_false" - ), - pytest.param( - {"_experiments": {"data_collection": {"user_info": True}}}, - True, - id="data_collection_user_info_true", - ), - pytest.param( - {"_experiments": {"data_collection": {"user_info": False}}}, - False, - id="data_collection_user_info_false", - ), - # ``data_collection`` is the single source of truth: it must win over - # ``send_default_pii`` when both are configured. - pytest.param( - { - "send_default_pii": True, - "_experiments": {"data_collection": {"user_info": False}}, - }, - False, - id="data_collection_wins_over_send_default_pii_true", - ), - pytest.param( - { - "send_default_pii": False, - "_experiments": {"data_collection": {"user_info": True}}, - }, - True, - id="data_collection_wins_over_send_default_pii_false", - ), -] - - -@pytest.mark.parametrize("init_kwargs, expect_ip", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) def test_user_info_span_attributes_data_collection( sentry_init, capture_items, init_kwargs, expect_ip ): @@ -1381,7 +1344,7 @@ def dogpark(environ, start_response): assert "client.address" not in server_span["attributes"] -@pytest.mark.parametrize("init_kwargs, expect_ip", USER_INFO_INIT_KWARGS) +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) def test_user_info_error_event_data_collection( sentry_init, crashing_app, capture_events, init_kwargs, expect_ip ): From 057caa49746219e77de37f1255661d1dbc61583d Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Jul 2026 10:36:20 -0400 Subject: [PATCH 04/10] feat(sanic): Gate user info behind data_collection config Apply the data_collection.user_info setting to the Sanic integration's user IP collection, giving it precedence over the legacy send_default_pii boolean when explicitly configured. This covers both the user.ip_address scope attribute applied to all spans and the client.address span attribute, mirroring the behavior already shipped for Flask, WSGI, ASGI, and Django. The user IP span test is reparametrized with the shared DATA_COLLECTION_USER_INFO_CASES matrix from tests/integrations/utils.py, and a new test covers the previously untested client.address span attribute. Refs PY-2583 --- sentry_sdk/integrations/sanic.py | 18 +++++++--- tests/integrations/sanic/test_sanic.py | 49 +++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index 3f584b37ed..2d839d1c61 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -196,8 +196,12 @@ async def _context_enter(request: "Request") -> None: sentry_sdk.traces.continue_trace(dict(request.headers)) scope.set_custom_sampling_context({"sanic_request": request}) - if should_send_default_pii() and request.remote_addr: - scope.set_attribute(SPANDATA.USER_IP_ADDRESS, request.remote_addr) + if request.remote_addr: + if has_data_collection_enabled(client.options): + if client.options["data_collection"]["user_info"]: + scope.set_attribute(SPANDATA.USER_IP_ADDRESS, request.remote_addr) + elif should_send_default_pii(): + scope.set_attribute(SPANDATA.USER_IP_ADDRESS, request.remote_addr) span = sentry_sdk.traces.start_span( # Unless the request results in a 404 error, the name and source @@ -401,6 +405,10 @@ def _get_request_attributes(request: "Request") -> "Dict[str, Any]": query=filtered_query or "" ).geturl() + if request.remote_addr: + if client_options["data_collection"]["user_info"]: + attributes[SPANDATA.CLIENT_ADDRESS] = request.remote_addr + elif should_send_default_pii(): attributes[SPANDATA.URL_FULL] = request.url attributes["url.path"] = urlparts.path @@ -408,12 +416,12 @@ def _get_request_attributes(request: "Request") -> "Dict[str, Any]": if urlparts.query: attributes[SPANDATA.HTTP_QUERY] = urlparts.query + if request.remote_addr: + attributes[SPANDATA.CLIENT_ADDRESS] = request.remote_addr + if urlparts.scheme: attributes[SPANDATA.NETWORK_PROTOCOL_NAME] = urlparts.scheme - if should_send_default_pii() and request.remote_addr: - attributes[SPANDATA.CLIENT_ADDRESS] = request.remote_addr - return attributes diff --git a/tests/integrations/sanic/test_sanic.py b/tests/integrations/sanic/test_sanic.py index 1f030c425f..0bc6133744 100644 --- a/tests/integrations/sanic/test_sanic.py +++ b/tests/integrations/sanic/test_sanic.py @@ -16,6 +16,7 @@ from sentry_sdk.integrations.sanic import SanicIntegration from sentry_sdk.tracing import TransactionSource from tests.conftest import get_free_port +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES try: from sanic_testing import TestManager @@ -559,9 +560,9 @@ def test_span_origin(sentry_init, app, capture_events, capture_items, span_strea @pytest.mark.skipif( not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version" ) -@pytest.mark.parametrize("send_default_pii", [True, False]) +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) def test_user_ip_address_on_all_spans( - sentry_init, app, capture_items, send_default_pii + sentry_init, app, capture_items, init_kwargs, expect_ip ): app.config.FORWARDED_SECRET = "test" @@ -575,8 +576,8 @@ def child_span_handler(request): integrations=[SanicIntegration()], default_integrations=False, traces_sample_rate=1.0, - send_default_pii=send_default_pii, trace_lifecycle="stream", + **init_kwargs, ) items = capture_items("span") @@ -592,7 +593,7 @@ def child_span_handler(request): child_span, server_span = [item.payload for item in items] - if send_default_pii: + if expect_ip: assert server_span["attributes"]["user.ip_address"] == "127.0.0.1" assert child_span["attributes"]["user.ip_address"] == "127.0.0.1" else: @@ -600,6 +601,46 @@ def child_span_handler(request): assert "user.ip_address" not in child_span["attributes"] +@pytest.mark.skipif( + not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version" +) +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) +def test_client_address_span_attribute_data_collection( + sentry_init, app, capture_items, init_kwargs, expect_ip +): + app.config.FORWARDED_SECRET = "test" + + sentry_init( + integrations=[SanicIntegration()], + default_integrations=False, + traces_sample_rate=1.0, + trace_lifecycle="stream", + **init_kwargs, + ) + + items = capture_items("span") + + c = get_client(app) + with c as client: + client.get( + "/message", + headers={"Forwarded": "for=127.0.0.1;secret=test"}, + ) + + sentry_sdk.flush() + + (server_span,) = [ + item.payload + for item in items + if item.payload["attributes"].get("sentry.origin") == "auto.http.sanic" + ] + + if expect_ip: + assert server_span["attributes"]["client.address"] == "127.0.0.1" + else: + assert "client.address" not in server_span["attributes"] + + _QUERY_PARAM_DATA_COLLECTION_CASES = [ pytest.param( {"send_default_pii": True}, From c7cb8f89c2cd426a4afe08a5fbb8fce3f22b629c Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Jul 2026 11:04:35 -0400 Subject: [PATCH 05/10] feat(tornado): Gate user info behind data_collection config Apply the data_collection.user_info setting to the Tornado integration's user info collection, giving it precedence over the legacy send_default_pii boolean when explicitly configured. This covers the user.ip_address scope attribute applied to all spans, the client.address span attribute, and the is_authenticated user flag on error events, mirroring the behavior already shipped for Flask, WSGI, ASGI, Django, and Sanic. The user IP span test is reparametrized with the shared DATA_COLLECTION_USER_INFO_CASES matrix from tests/integrations/utils.py, and new tests cover the previously untested client.address span attribute and the data_collection gating of the is_authenticated flag. Refs PY-2583 --- sentry_sdk/integrations/tornado.py | 29 ++++++++-- tests/integrations/tornado/test_tornado.py | 65 ++++++++++++++++++++-- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py index 836f3a4b25..2c8923d1bd 100644 --- a/sentry_sdk/integrations/tornado.py +++ b/sentry_sdk/integrations/tornado.py @@ -133,8 +133,12 @@ def _handle_request_impl(self: "RequestHandler") -> "Generator[None, None, None] sentry_sdk.traces.continue_trace(dict(headers)) scope.set_custom_sampling_context({"tornado_request": self.request}) - if should_send_default_pii() and self.request.remote_ip: - scope.set_attribute(SPANDATA.USER_IP_ADDRESS, self.request.remote_ip) + if self.request.remote_ip: + if has_data_collection_enabled(client.options): + if client.options["data_collection"]["user_info"]: + scope.set_attribute(SPANDATA.USER_IP_ADDRESS, self.request.remote_ip) + elif should_send_default_pii(): + scope.set_attribute(SPANDATA.USER_IP_ADDRESS, self.request.remote_ip) span_ctx = sentry_sdk.traces.start_span( name=_DEFAULT_ROOT_SPAN_NAME, @@ -218,6 +222,10 @@ def _get_request_attributes(request: "Any") -> "Dict[str, Any]": f"{parsed_url.url}?{filtered_query}" if filtered_query else parsed_url.url ) + if request.remote_ip: + if client_options["data_collection"]["user_info"]: + attributes[SPANDATA.CLIENT_ADDRESS] = request.remote_ip + elif should_send_default_pii(): attributes[SPANDATA.URL_FULL] = request.full_url() attributes["url.path"] = request.path @@ -225,12 +233,12 @@ def _get_request_attributes(request: "Any") -> "Dict[str, Any]": if request.query: attributes[SPANDATA.URL_QUERY] = request.query + if request.remote_ip: + attributes[SPANDATA.CLIENT_ADDRESS] = request.remote_ip + if request.protocol: attributes[SPANDATA.NETWORK_PROTOCOL_NAME] = request.protocol - if should_send_default_pii() and request.remote_ip: - attributes[SPANDATA.CLIENT_ADDRESS] = request.remote_ip - with capture_internal_exceptions(): raw_data = _get_tornado_request_data(request) body_data = raw_data.value if isinstance(raw_data, AnnotatedValue) else raw_data @@ -310,7 +318,16 @@ def tornado_processor(event: "Event", hint: "dict[str, Any]") -> "Event": request_info["env"] = {"REMOTE_ADDR": request.remote_ip} request_info["headers"] = _filter_headers(dict(request.headers)) - if should_send_default_pii(): + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["user_info"]: + try: + current_user = handler.current_user + except Exception: + current_user = None + + if current_user: + event.setdefault("user", {}).setdefault("is_authenticated", True) + elif should_send_default_pii(): try: current_user = handler.current_user except Exception: diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py index 23fbc316d6..2268a791ee 100644 --- a/tests/integrations/tornado/test_tornado.py +++ b/tests/integrations/tornado/test_tornado.py @@ -8,6 +8,7 @@ from sentry_sdk import capture_message, start_transaction from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.integrations.tornado import TornadoIntegration +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES @pytest.fixture @@ -701,6 +702,32 @@ def get(self): assert "user" not in event +@pytest.mark.parametrize("init_kwargs, expect_user", DATA_COLLECTION_USER_INFO_CASES) +def test_user_auth_data_collection( + tornado_testcase, sentry_init, capture_events, init_kwargs, expect_user +): + sentry_init(integrations=[TornadoIntegration()], **init_kwargs) + events = capture_events() + + class UserHandler(RequestHandler): + def get(self): + 1 / 0 + + def get_current_user(self): + return 42 + + client = tornado_testcase(Application([(r"/auth", UserHandler)])) + + response = client.fetch("/auth") + assert response.code == 500 + + (event,) = events + if expect_user: + assert event["user"] == {"is_authenticated": True} + else: + assert "is_authenticated" not in event.get("user", {}) + + def test_formdata(tornado_testcase, sentry_init, capture_events): sentry_init(integrations=[TornadoIntegration()], send_default_pii=True) events = capture_events() @@ -927,15 +954,15 @@ def test_span_origin( assert event["contexts"]["trace"]["origin"] == "auto.http.tornado" -@pytest.mark.parametrize("send_default_pii", [True, False]) +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) def test_user_ip_address_on_all_spans( - tornado_testcase, sentry_init, capture_items, send_default_pii + tornado_testcase, sentry_init, capture_items, init_kwargs, expect_ip ): sentry_init( integrations=[TornadoIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, trace_lifecycle="stream", + **init_kwargs, ) items = capture_items("span") @@ -947,9 +974,39 @@ def test_user_ip_address_on_all_spans( child_span, server_span = [item.payload for item in items] - if send_default_pii: + if expect_ip: assert server_span["attributes"]["user.ip_address"] == "127.0.0.1" assert child_span["attributes"]["user.ip_address"] == "127.0.0.1" else: assert "user.ip_address" not in server_span["attributes"] assert "user.ip_address" not in child_span["attributes"] + + +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) +def test_client_address_span_attribute_data_collection( + tornado_testcase, sentry_init, capture_items, init_kwargs, expect_ip +): + sentry_init( + integrations=[TornadoIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream", + **init_kwargs, + ) + + items = capture_items("span") + + client = tornado_testcase(Application([(r"/hi", ChildSpanHandler)])) + client.fetch("/hi") + + sentry_sdk.flush() + + (server_span,) = [ + item.payload + for item in items + if item.payload["attributes"].get("sentry.origin") == "auto.http.tornado" + ] + + if expect_ip: + assert server_span["attributes"]["client.address"] == "127.0.0.1" + else: + assert "client.address" not in server_span["attributes"] From 69185971ddc4220948f53cad74eb35be32995459 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Jul 2026 11:27:33 -0400 Subject: [PATCH 06/10] fix unbound var --- sentry_sdk/integrations/tornado.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py index 2c8923d1bd..bfb4e4391e 100644 --- a/sentry_sdk/integrations/tornado.py +++ b/sentry_sdk/integrations/tornado.py @@ -290,6 +290,7 @@ def tornado_processor(event: "Event", hint: "dict[str, Any]") -> "Event": event["transaction"] = transaction_from_function(method) or "" event["transaction_info"] = {"source": TransactionSource.COMPONENT} + client_options = sentry_sdk.get_client().options with capture_internal_exceptions(): extractor = TornadoRequestExtractor(request) extractor.extract_into_event(event) @@ -302,7 +303,6 @@ def tornado_processor(event: "Event", hint: "dict[str, Any]") -> "Event": request.path, ) - client_options = sentry_sdk.get_client().options if has_data_collection_enabled(client_options): if request.query: filtered_query = _apply_data_collection_filtering_to_query_string( From 9038fe5b4aefd65c788a01a975db8a0230de4c73 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Jul 2026 11:28:25 -0400 Subject: [PATCH 07/10] lint --- sentry_sdk/integrations/tornado.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py index bfb4e4391e..e71bf94d12 100644 --- a/sentry_sdk/integrations/tornado.py +++ b/sentry_sdk/integrations/tornado.py @@ -136,9 +136,13 @@ def _handle_request_impl(self: "RequestHandler") -> "Generator[None, None, None] if self.request.remote_ip: if has_data_collection_enabled(client.options): if client.options["data_collection"]["user_info"]: - scope.set_attribute(SPANDATA.USER_IP_ADDRESS, self.request.remote_ip) + scope.set_attribute( + SPANDATA.USER_IP_ADDRESS, self.request.remote_ip + ) elif should_send_default_pii(): - scope.set_attribute(SPANDATA.USER_IP_ADDRESS, self.request.remote_ip) + scope.set_attribute( + SPANDATA.USER_IP_ADDRESS, self.request.remote_ip + ) span_ctx = sentry_sdk.traces.start_span( name=_DEFAULT_ROOT_SPAN_NAME, From 7377073fe2f9c175f6ed00805857a702ca8a7eb8 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Jul 2026 11:39:30 -0400 Subject: [PATCH 08/10] feat(pyramid): Gate user info behind data_collection config Apply the data_collection.user_info setting to the Pyramid integration's user id collection, giving it precedence over the legacy send_default_pii boolean when explicitly configured. This covers both the user.id segment attribute under span streaming and the user id attached to error events, mirroring the behavior already shipped for Flask, WSGI, ASGI, Django, Sanic, and Tornado. The user id segment test is reparametrized with the shared DATA_COLLECTION_USER_INFO_CASES matrix from tests/integrations/utils.py, and a new test adds the first coverage of the user id on error events. Refs PY-2583 --- sentry_sdk/integrations/pyramid.py | 23 ++++++++--- tests/integrations/pyramid/test_pyramid.py | 46 ++++++++++++++++++++-- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/pyramid.py b/sentry_sdk/integrations/pyramid.py index 6837d8345c..5ab6ef90e9 100644 --- a/sentry_sdk/integrations/pyramid.py +++ b/sentry_sdk/integrations/pyramid.py @@ -15,6 +15,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, reraise, ) @@ -86,10 +87,16 @@ def sentry_patched_call_view( scope = sentry_sdk.get_isolation_scope() - if should_send_default_pii() and has_span_streaming_enabled(client.options): - user_id = authenticated_userid(request) - if user_id: - scope.set_user({"id": user_id}) + if has_span_streaming_enabled(client.options): + if has_data_collection_enabled(client.options): + if client.options["data_collection"]["user_info"]: + user_id = authenticated_userid(request) + if user_id: + scope.set_user({"id": user_id}) + elif should_send_default_pii(): + user_id = authenticated_userid(request) + if user_id: + scope.set_user({"id": user_id}) scope.add_event_processor( _make_event_processor(weakref.ref(request), integration) @@ -229,7 +236,13 @@ def pyramid_event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": with capture_internal_exceptions(): PyramidRequestExtractor(request).extract_into_event(event) - if should_send_default_pii(): + client_options = sentry_sdk.get_client().options + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["user_info"]: + with capture_internal_exceptions(): + user_info = event.setdefault("user", {}) + user_info.setdefault("id", authenticated_userid(request)) + elif should_send_default_pii(): with capture_internal_exceptions(): user_info = event.setdefault("user", {}) user_info.setdefault("id", authenticated_userid(request)) diff --git a/tests/integrations/pyramid/test_pyramid.py b/tests/integrations/pyramid/test_pyramid.py index 06c38eccce..bdd5cb5049 100644 --- a/tests/integrations/pyramid/test_pyramid.py +++ b/tests/integrations/pyramid/test_pyramid.py @@ -15,6 +15,7 @@ from sentry_sdk.serializer import MAX_DATABAG_BREADTH from sentry_sdk.traces import SpanStatus from tests.conftest import unpack_werkzeug_response +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES try: from importlib.metadata import version @@ -559,19 +560,20 @@ def test_span_origin( assert event["contexts"]["trace"]["origin"] == "auto.http.pyramid" -@pytest.mark.parametrize("send_default_pii", [True, False]) +@pytest.mark.parametrize("init_kwargs, expect_user", DATA_COLLECTION_USER_INFO_CASES) def test_span_sets_user_id_on_segment( sentry_init, pyramid_config, capture_items, get_client, - send_default_pii, + init_kwargs, + expect_user, ): sentry_init( integrations=[PyramidIntegration()], traces_sample_rate=1.0, - send_default_pii=send_default_pii, trace_lifecycle="stream", + **init_kwargs, ) class AuthenticationPolicy: @@ -592,7 +594,43 @@ def authenticated_userid(self, request): assert len(spans) == 1 (segment,) = spans - if send_default_pii: + if expect_user: assert segment["attributes"]["user.id"] == "123-abc" else: assert "user.id" not in segment["attributes"] + + +@pytest.mark.parametrize("init_kwargs, expect_user", DATA_COLLECTION_USER_INFO_CASES) +def test_user_id_error_event_data_collection( + sentry_init, + pyramid_config, + capture_events, + route, + get_client, + init_kwargs, + expect_user, +): + sentry_init(integrations=[PyramidIntegration()], **init_kwargs) + events = capture_events() + + class AuthenticationPolicy: + def authenticated_userid(self, request): + return "123-abc" + + pyramid_config.set_authorization_policy(ACLAuthorizationPolicy()) + pyramid_config.set_authentication_policy(AuthenticationPolicy()) + + @route("/crash") + def crash(request): + 1 / 0 + + client = get_client() + with pytest.raises(ZeroDivisionError): + client.get("/crash") + + (event,) = events + + if expect_user: + assert event["user"]["id"] == "123-abc" + else: + assert "id" not in event.get("user", {}) From 1634927146572bd24658dd77c68992d39cb022a0 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Jul 2026 15:08:56 -0400 Subject: [PATCH 09/10] feat(aiohttp): Gate user info behind data_collection config Apply the data_collection.user_info setting to the aiohttp integration's user IP collection under span streaming, giving it precedence over the legacy send_default_pii boolean when explicitly configured. This covers both the client.address span attribute and the user.ip_address scope attribute, mirroring the behavior already shipped for Flask, WSGI, ASGI, Django, Sanic, Tornado, and Pyramid. Add test_user_address_with_data_collection_and_span_streaming, parametrized with the shared DATA_COLLECTION_USER_INFO_CASES matrix from tests/integrations/utils.py, and remove the now-redundant IP assertion block from test_sensitive_header_passthrough_with_pii_span_streaming that its TODO reserved for this change. Refs PY-2583 --- sentry_sdk/integrations/aiohttp.py | 24 ++++++++--- tests/integrations/aiohttp/test_aiohttp.py | 48 +++++++++++++++++----- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 9c21952119..858bf273f2 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -165,6 +165,8 @@ async def sentry_app_handle( ) url_attributes = {} + client_address_attributes = {} + if has_data_collection_enabled(client.options): url_attributes["url.full"] = "%s://%s%s" % ( request.scheme, @@ -188,6 +190,15 @@ async def sentry_app_handle( "?" + filtered_query_string ) + if request.remote: + if client.options["data_collection"]["user_info"]: + client_address_attributes["client.address"] = ( + request.remote + ) + scope.set_attribute( + SPANDATA.USER_IP_ADDRESS, request.remote + ) + elif should_send_default_pii(): url_full = "%s://%s%s" % ( request.scheme, @@ -201,12 +212,13 @@ async def sentry_app_handle( url_attributes["url.full"] = url_full url_attributes["url.path"] = request.path - client_address_attributes = {} - if should_send_default_pii() and request.remote: - client_address_attributes["client.address"] = request.remote - scope.set_attribute( - SPANDATA.USER_IP_ADDRESS, request.remote - ) + if request.remote: + client_address_attributes["client.address"] = ( + request.remote + ) + scope.set_attribute( + SPANDATA.USER_IP_ADDRESS, request.remote + ) span_ctx = sentry_sdk.traces.start_span( # If this name makes it to the UI, AIOHTTP's URL diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 34fcf6892c..438b009c07 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -26,6 +26,7 @@ ) from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE from tests.conftest import ApproxDict +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES @pytest.mark.asyncio @@ -1208,6 +1209,43 @@ async def hello(request): assert "http.request.header.host" in server_span["attributes"] +@pytest.mark.asyncio +@pytest.mark.parametrize("init_kwargs, expect_ip", DATA_COLLECTION_USER_INFO_CASES) +async def test_user_address_with_data_collection_and_span_streaming( + sentry_init, aiohttp_client, capture_items, init_kwargs, expect_ip +): + sentry_init( + integrations=[AioHttpIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream", + **init_kwargs, + ) + + async def hello(request): + return web.Response(text="hello") + + app = web.Application() + app.router.add_get("/", hello) + + items = capture_items("span") + + client = await aiohttp_client(app) + resp = await client.get("/") + assert resp.status == 200 + + sentry_sdk.flush() + + (server_span,) = [item.payload for item in items] + assert server_span["attributes"]["sentry.origin"] == "auto.http.aiohttp" + + if expect_ip: + assert server_span["attributes"]["client.address"] == "127.0.0.1" + assert server_span["attributes"]["user.ip_address"] == "127.0.0.1" + else: + assert "client.address" not in server_span["attributes"] + assert "user.ip_address" not in server_span["attributes"] + + @pytest.mark.asyncio async def test_sensitive_header_scrubbing_span_streaming( sentry_init, aiohttp_client, capture_items @@ -1420,16 +1458,6 @@ async def hello(request): == expected["cookie"] ) - # client.address and user.ip_address is captured under send_default_pii=True. - # TODO: This block will eventually need to be removed from this test into a separate - # test once data collection gating is introduced on these values - if options["send_default_pii"]: - assert server_span["attributes"]["client.address"] == "127.0.0.1" - assert server_span["attributes"]["user.ip_address"] == "127.0.0.1" - else: - assert "user.ip_address" not in server_span["attributes"] - assert "client.address" not in server_span["attributes"] - @pytest.mark.asyncio async def test_sensitive_header_passthrough_with_pii_span_streaming_without_data_collection( From 093a47e40aa9fe494f80da9feb30d413af5338c8 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 23 Jul 2026 15:26:34 -0400 Subject: [PATCH 10/10] feat(starlite,litestar): Gate user info behind data_collection config Apply the data_collection.user_info setting to the Starlite and Litestar integrations' user info collection on exception events, giving it precedence over the legacy send_default_pii boolean when explicitly configured. This gates the retrieve_user_from_scope call in each integration's exception handler, mirroring the behavior already shipped for Flask, WSGI, ASGI, Django, Sanic, Tornado, Pyramid, and aiohttp. The scope user exception event tests in both suites are reparametrized with the shared DATA_COLLECTION_USER_INFO_CASES matrix from tests/integrations/utils.py. Refs PY-2583 --- sentry_sdk/integrations/litestar.py | 8 +++++++- sentry_sdk/integrations/starlite.py | 8 +++++++- tests/integrations/litestar/test_litestar.py | 20 ++++++------------- tests/integrations/starlite/test_starlite.py | 21 +++++--------------- 4 files changed, 25 insertions(+), 32 deletions(-) diff --git a/sentry_sdk/integrations/litestar.py b/sentry_sdk/integrations/litestar.py index 7acfed478e..8a3f09ffa0 100644 --- a/sentry_sdk/integrations/litestar.py +++ b/sentry_sdk/integrations/litestar.py @@ -357,8 +357,14 @@ def retrieve_user_from_scope(scope: "LitestarScope") -> "Optional[dict[str, Any] @ensure_integration_enabled(LitestarIntegration) def exception_handler(exc: Exception, scope: "LitestarScope") -> None: user_info: "Optional[dict[str, Any]]" = None - if should_send_default_pii(): + client_options = sentry_sdk.get_client().options + + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["user_info"]: + user_info = retrieve_user_from_scope(scope) + elif should_send_default_pii(): user_info = retrieve_user_from_scope(scope) + if user_info and isinstance(user_info, dict): sentry_scope = sentry_sdk.get_isolation_scope() sentry_scope.set_user(user_info) diff --git a/sentry_sdk/integrations/starlite.py b/sentry_sdk/integrations/starlite.py index 6182d0f85c..8963fc9e53 100644 --- a/sentry_sdk/integrations/starlite.py +++ b/sentry_sdk/integrations/starlite.py @@ -312,8 +312,14 @@ def retrieve_user_from_scope(scope: "StarliteScope") -> "Optional[dict[str, Any] @ensure_integration_enabled(StarliteIntegration) def exception_handler(exc: Exception, scope: "StarliteScope", _: "State") -> None: user_info: "Optional[dict[str, Any]]" = None - if should_send_default_pii(): + client_options = sentry_sdk.get_client().options + + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["user_info"]: + user_info = retrieve_user_from_scope(scope) + elif should_send_default_pii(): user_info = retrieve_user_from_scope(scope) + if user_info and isinstance(user_info, dict): sentry_scope = sentry_sdk.get_isolation_scope() sentry_scope.set_user(user_info) diff --git a/tests/integrations/litestar/test_litestar.py b/tests/integrations/litestar/test_litestar.py index 3567e5c740..6f3615dabc 100644 --- a/tests/integrations/litestar/test_litestar.py +++ b/tests/integrations/litestar/test_litestar.py @@ -19,6 +19,7 @@ from sentry_sdk.integrations.litestar import LitestarIntegration from tests.conftest import ApproxDict from tests.integrations.conftest import parametrize_test_configurable_status_codes +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES def litestar_app_factory(middleware=None, debug=True, exception_handlers=None): @@ -611,24 +612,15 @@ def test_span_origin( assert span["origin"] == "auto.http.litestar" -@pytest.mark.parametrize( - "is_send_default_pii", - [ - True, - False, - ], - ids=[ - "send_default_pii=True", - "send_default_pii=False", - ], -) +@pytest.mark.parametrize("init_kwargs, expect_user", DATA_COLLECTION_USER_INFO_CASES) @pytest.mark.parametrize("span_streaming", [True, False]) def test_litestar_scope_user_on_exception_event( sentry_init, capture_exceptions, capture_events, capture_items, - is_send_default_pii, + init_kwargs, + expect_user, span_streaming, ): class TestUserMiddleware(AbstractMiddleware): @@ -642,8 +634,8 @@ async def __call__(self, scope, receive, send): sentry_init( integrations=[LitestarIntegration()], - send_default_pii=is_send_default_pii, trace_lifecycle="stream" if span_streaming else "static", + **init_kwargs, ) litestar_app = litestar_app_factory(middleware=[TestUserMiddleware]) @@ -673,7 +665,7 @@ async def __call__(self, scope, receive, send): assert len(events) == 1 (event,) = events - if is_send_default_pii: + if expect_user: assert "user" in event assert event["user"] == { "email": "lennon@thebeatles.com", diff --git a/tests/integrations/starlite/test_starlite.py b/tests/integrations/starlite/test_starlite.py index e41754b819..d596a5921f 100644 --- a/tests/integrations/starlite/test_starlite.py +++ b/tests/integrations/starlite/test_starlite.py @@ -13,6 +13,7 @@ from sentry_sdk import capture_message from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.integrations.starlite import StarliteIntegration +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES def starlite_app_factory(middleware=None, debug=True, exception_handlers=None): @@ -525,19 +526,9 @@ def test_span_origin(sentry_init, capture_events, capture_items, span_streaming) assert span["origin"] == "auto.http.starlite" -@pytest.mark.parametrize( - "is_send_default_pii", - [ - True, - False, - ], - ids=[ - "send_default_pii=True", - "send_default_pii=False", - ], -) +@pytest.mark.parametrize("init_kwargs, expect_user", DATA_COLLECTION_USER_INFO_CASES) def test_starlite_scope_user_on_exception_event( - sentry_init, capture_exceptions, capture_events, is_send_default_pii + sentry_init, capture_exceptions, capture_events, init_kwargs, expect_user ): class TestUserMiddleware(AbstractMiddleware): async def __call__(self, scope, receive, send): @@ -548,9 +539,7 @@ async def __call__(self, scope, receive, send): } await self.app(scope, receive, send) - sentry_init( - integrations=[StarliteIntegration()], send_default_pii=is_send_default_pii - ) + sentry_init(integrations=[StarliteIntegration()], **init_kwargs) starlite_app = starlite_app_factory(middleware=[TestUserMiddleware]) exceptions = capture_exceptions() events = capture_events() @@ -566,7 +555,7 @@ async def __call__(self, scope, receive, send): assert len(events) == 1 (event,) = events - if is_send_default_pii: + if expect_user: assert "user" in event assert event["user"] == { "email": "lennon@thebeatles.com",