data_collection is typed as Optional[DataCollectionUserOptions] in the Experiments TypedDict, so None type checks. But the two functions that read it disagree about what None means:
has_data_collection_enabled() (sentry_sdk/utils.py) checks for key presence: "data_collection" in options.get("_experiments", {})
_resolve_data_collection() (sentry_sdk/data_collection.py) only treats a non-None value as user provided: if user_dc is not None
So with data_collection=None the client lands in a state where neither side of the option is active. data_collection resolves to the legacy send_default_pii mapping with provided_by_user=False, but every caller of has_data_collection_enabled() takes the data collection branch.
Two consequences I was able to reproduce:
1. The default event scrubber is never installed.
In _get_options(), has_data_collection_enabled(rv) is true, so the EventScrubber is skipped. An explicitly configured event_scrubber is also discarded, with a warning saying data collection configuration was provided, which is not what happened.
sentry_sdk.init(_experiments={"data_collection": None})
sentry_sdk.get_isolation_scope().set_extra("password", "hunter2")
sentry_sdk.capture_message("hi")
| config |
extra["password"] in the event |
default (no _experiments) |
"[Filtered]" |
data_collection={} |
"[Filtered]" |
data_collection=None |
"hunter2" |
Same for token and x_forwarded_for.
2. WSGI request attributes gain URL query params.
_get_request_attributes() takes the data collection branch and attaches http.query, url.path and url.full, using the denylist derived from send_default_pii=False. Without data_collection set at all, should_send_default_pii() is false and none of those are attached.
# QUERY_STRING = "email=a%40b.com&coupon=SAVE10", no data_collection set
attributes.get("http.query") # None
# same request, _experiments={"data_collection": None}
attributes.get("http.query") # 'email=a%40b.com&coupon=SAVE10'
Which behaviour is intended?
There is an existing test, test_has_data_collection_enabled_gates_on_presence, that asserts {"data_collection": None} returns True, so the presence check looks deliberate. But combined with _resolve_data_collection returning provided_by_user=False, the result is that a user who passes None loses default scrubbing and gains query param collection, which I doubt is intended either way.
Two ways to make the two functions agree:
has_data_collection_enabled() gates on a configured value (... .get("data_collection") is not None), so None means "not configured". This matches the Optional annotation and _resolve_data_collection. It requires updating the existing test.
_resolve_data_collection() treats a present-but-None value as {}, i.e. full opt in. That makes None mean "opt into the new defaults", which seems surprising for a value typed Optional.
I have a patch for option 1 with regression tests, happy to open a PR if that is the direction you want. Also happy to leave it if you would rather handle it as part of the wider send_default_pii migration.
Environment
sentry-sdk 2.68.1 (master at e0d105e), Python 3.13
data_collectionis typed asOptional[DataCollectionUserOptions]in theExperimentsTypedDict, soNonetype checks. But the two functions that read it disagree about whatNonemeans:has_data_collection_enabled()(sentry_sdk/utils.py) checks for key presence:"data_collection" in options.get("_experiments", {})_resolve_data_collection()(sentry_sdk/data_collection.py) only treats a non-None value as user provided:if user_dc is not NoneSo with
data_collection=Nonethe client lands in a state where neither side of the option is active.data_collectionresolves to the legacysend_default_piimapping withprovided_by_user=False, but every caller ofhas_data_collection_enabled()takes the data collection branch.Two consequences I was able to reproduce:
1. The default event scrubber is never installed.
In
_get_options(),has_data_collection_enabled(rv)is true, so theEventScrubberis skipped. An explicitly configuredevent_scrubberis also discarded, with a warning saying data collection configuration was provided, which is not what happened.extra["password"]in the event_experiments)"[Filtered]"data_collection={}"[Filtered]"data_collection=None"hunter2"Same for
tokenandx_forwarded_for.2. WSGI request attributes gain URL query params.
_get_request_attributes()takes the data collection branch and attacheshttp.query,url.pathandurl.full, using the denylist derived fromsend_default_pii=False. Withoutdata_collectionset at all,should_send_default_pii()is false and none of those are attached.Which behaviour is intended?
There is an existing test,
test_has_data_collection_enabled_gates_on_presence, that asserts{"data_collection": None}returnsTrue, so the presence check looks deliberate. But combined with_resolve_data_collectionreturningprovided_by_user=False, the result is that a user who passesNoneloses default scrubbing and gains query param collection, which I doubt is intended either way.Two ways to make the two functions agree:
has_data_collection_enabled()gates on a configured value (... .get("data_collection") is not None), soNonemeans "not configured". This matches theOptionalannotation and_resolve_data_collection. It requires updating the existing test._resolve_data_collection()treats a present-but-Nonevalue as{}, i.e. full opt in. That makesNonemean "opt into the new defaults", which seems surprising for a value typedOptional.I have a patch for option 1 with regression tests, happy to open a PR if that is the direction you want. Also happy to leave it if you would rather handle it as part of the wider
send_default_piimigration.Environment
sentry-sdk 2.68.1 (master at
e0d105e), Python 3.13