diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index fbd2578048..e91fed097c 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -183,7 +183,7 @@ class DataCollectionUserOptions(TypedDict, total=False): cookies: "KeyValueCollectionBehaviour" http_headers: "HttpHeadersCollectionUserOptions" http_bodies: "List[str]" - query_params: "KeyValueCollectionBehaviour" + url_query_params: "KeyValueCollectionBehaviour" graphql: "GraphQLCollectionUserOptions" gen_ai: "GenAICollectionUserOptions" database_query_data: bool @@ -197,7 +197,7 @@ class DataCollection(TypedDict): cookies: "KeyValueCollectionBehaviour" http_headers: "HttpHeadersCollectionBehaviour" http_bodies: "List[str]" - query_params: "KeyValueCollectionBehaviour" + url_query_params: "KeyValueCollectionBehaviour" graphql: "GraphQLCollectionBehaviour" gen_ai: "GenAICollectionBehaviour" database_query_data: bool diff --git a/sentry_sdk/data_collection.py b/sentry_sdk/data_collection.py index 4908b82e9a..af1821de8f 100644 --- a/sentry_sdk/data_collection.py +++ b/sentry_sdk/data_collection.py @@ -7,7 +7,7 @@ ``data_collection`` supersedes the single ``send_default_pii`` boolean with a structured configuration that lets users enable or restrict automatically -collected data by category (user identity, cookies, HTTP headers, query params, +collected data by category (user identity, cookies, HTTP headers, URL query params, HTTP bodies, generative AI inputs/outputs, stack frame variables, source context). @@ -23,10 +23,13 @@ """ import warnings -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, List, Mapping, Optional, Union, cast +from urllib.parse import parse_qs + +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE if TYPE_CHECKING: - from typing import Any, Dict, Literal + from typing import Any, Dict from sentry_sdk._types import ( DataCollection, @@ -48,7 +51,7 @@ # Default number of source lines captured above and below a stack frame. _DEFAULT_FRAME_CONTEXT_LINES = 5 -# Collection modes for key-value data (cookies, headers, query params). +# Collection modes for key-value data (cookies, headers, URL query params). # snake_case (Python-only deviation from the spec's camelCase); never # serialized to Sentry. _VALID_KEY_VALUE_COLLECTION_BEHAVIOUR_MODES = ("off", "denylist", "allowlist") @@ -77,6 +80,82 @@ ] +def _is_sensitive_key(key: str, extra_terms: "Optional[List[str]]" = None) -> bool: + """ + Return whether ``key`` matches the sensitive denylist using a partial, + case-insensitive substring match. + + :param extra_terms: additional deny terms (e.g. user-provided) to consider + alongside the built-in `_SENSITIVE_DENYLIST`. + """ + lowered = key.lower() + for term in _SENSITIVE_DENYLIST: + if term in lowered: + return True + if extra_terms: + for term in extra_terms: + if term and term.lower() in lowered: + return True + return False + + +def _apply_data_collection_filtering_to_query_string( + query_string: str, + behaviour: "KeyValueCollectionBehaviour", +) -> "Union[str, None]": + parsed_qs = parse_qs(query_string, keep_blank_values=True) + filtered_qs = _apply_key_value_collection_filtering( + items=parsed_qs, behaviour=behaviour + ) + + if filtered_qs: + parts = [] + for key, value in filtered_qs.items(): + values = value if isinstance(value, list) else [value] + for item in values: + parts.append(f"{key}={item}") + return "&".join(parts) + + return None + + +def _apply_key_value_collection_filtering( + items: "Mapping[str, Any]", + behaviour: "KeyValueCollectionBehaviour", + substitute: "Any" = SENSITIVE_DATA_SUBSTITUTE, +) -> "Dict[str, Any]": + + if behaviour["mode"] == "off": + return {} + + result: "Dict[str, Any]" = {} + + if behaviour["mode"] == "allowlist": + for key, value in items.items(): + is_allowed = False + if isinstance(key, str): + lowered = key.lower() + is_allowed = any( + term and term.lower() in lowered + for term in behaviour.get("terms", []) + ) + if is_allowed and not _is_sensitive_key(key): + result[key] = value + else: + result[key] = substitute + return result + + # denylist behaviour + for key, value in items.items(): + if isinstance(key, str) and _is_sensitive_key( + key=key, extra_terms=behaviour.get("terms", []) + ): + result[key] = substitute + else: + result[key] = value + return result + + def _map_from_send_default_pii( *, send_default_pii: bool, @@ -88,13 +167,12 @@ def _map_from_send_default_pii( ``send_default_pii`` collects today. Used when ``data_collection`` is not provided explicitly. """ - kv_mode: "Literal['denylist', 'off']" = "denylist" if send_default_pii else "off" terms = [] if send_default_pii else ["forwarded", "-ip", "remote-", "via", "-user"] return { "provided_by_user": False, "user_info": send_default_pii, - "cookies": {"mode": kv_mode, "terms": terms}, + "cookies": {"mode": "denylist", "terms": terms}, # Headers are collected in both PII modes today (sensitive ones filtered # when PII is off), so this never maps to "off". "http_headers": { @@ -103,7 +181,7 @@ def _map_from_send_default_pii( # Bodies are collected regardless of PII today, bounded by # ``max_request_body_size``. "http_bodies": list(_ALL_HTTP_BODY_TYPES), - "query_params": {"mode": kv_mode, "terms": terms}, + "url_query_params": {"mode": "denylist", "terms": terms}, "graphql": {"document": send_default_pii, "variables": send_default_pii}, "gen_ai": {"inputs": send_default_pii, "outputs": send_default_pii}, "database_query_data": send_default_pii, @@ -153,7 +231,7 @@ def _resolve_explicit( "cookies": _kvcb_from_value(d.get("cookies") or {}), "http_headers": _http_headers_from_value(d.get("http_headers") or {}), "http_bodies": http_bodies, - "query_params": _kvcb_from_value(d.get("query_params") or {}), + "url_query_params": _kvcb_from_value(d.get("url_query_params") or {}), "graphql": _graphql_from_value(d.get("graphql") or {}), "gen_ai": _gen_ai_from_value(d.get("gen_ai") or {}), "database_query_data": d.get("database_query_data", True), diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index 85f8f11d6d..f2091bca8e 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -2,8 +2,11 @@ from enum import Enum from typing import TYPE_CHECKING +import sentry_sdk +from sentry_sdk.data_collection import _apply_data_collection_filtering_to_query_string from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import has_data_collection_enabled if TYPE_CHECKING: from typing import Any, Dict, Optional, Union @@ -75,7 +78,7 @@ def _get_url( return path -def _get_query(asgi_scope: "Any") -> "Any": +def _get_query(asgi_scope: "Any") -> "Optional[str]": """ Extract querystring from the ASGI scope, in the format that the Sentry protocol expects. """ @@ -112,13 +115,30 @@ def _get_request_data( """ request_data: "Dict[str, Any]" = {} ty = asgi_scope["type"] + client_options = sentry_sdk.get_client().options if ty in ("http", "websocket"): request_data["method"] = asgi_scope.get("method") - request_data["headers"] = headers = _filter_headers( - _get_headers(asgi_scope), + headers = _get_headers(asgi_scope) + + request_data["headers"] = _filter_headers( + headers, + use_annotated_value=False, ) - request_data["query_string"] = _get_query(asgi_scope) + + if has_data_collection_enabled(client_options): + qs = _get_query(asgi_scope) + if qs: + filtered_query_string = ( + _apply_data_collection_filtering_to_query_string( + query_string=qs, + behaviour=client_options["data_collection"]["url_query_params"], + ) + ) + if filtered_query_string: + request_data["query_string"] = filtered_query_string + else: + request_data["query_string"] = _get_query(asgi_scope) request_data["url"] = _get_url( asgi_scope, @@ -128,8 +148,12 @@ def _get_request_data( ) client = asgi_scope.get("client") - if client and should_send_default_pii(): - request_data["env"] = {"REMOTE_ADDR": _get_ip(asgi_scope)} + if client: + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["user_info"]: + request_data["env"] = {"REMOTE_ADDR": _get_ip(asgi_scope)} + elif should_send_default_pii(): + request_data["env"] = {"REMOTE_ADDR": _get_ip(asgi_scope)} return request_data @@ -148,11 +172,44 @@ def _get_request_attributes( if asgi_scope.get("method"): attributes["http.request.method"] = asgi_scope["method"].upper() - headers = _filter_headers(_get_headers(asgi_scope), use_annotated_value=False) - for header, value in headers.items(): + headers = _get_headers(asgi_scope) + + filtered_headers = _filter_headers(headers, use_annotated_value=False) + for header, value in filtered_headers.items(): attributes[f"http.request.header.{header.lower()}"] = value - if should_send_default_pii(): + client_options = sentry_sdk.get_client().options + if has_data_collection_enabled(client_options): + filtered_query_string = None + query = _get_query(asgi_scope) + + if query: + filtered_query_string = ( + _apply_data_collection_filtering_to_query_string( + query_string=query, + behaviour=client_options["data_collection"]["url_query_params"], + ) + ) + if filtered_query_string: + attributes["http.query"] = filtered_query_string + + path = _get_path(asgi_scope=asgi_scope, root_path_in_path=root_path_in_path) + attributes["url.path"] = path + + url_without_query_string = _get_url( + asgi_scope, + "http" if ty == "http" else "ws", + headers.get("host"), + path=path, + ) + + attributes["url.full"] = ( + f"{url_without_query_string}?{filtered_query_string}" + if filtered_query_string is not None + else url_without_query_string + ) + + elif should_send_default_pii(): query = _get_query(asgi_scope) if query: attributes["http.query"] = query @@ -173,9 +230,14 @@ def _get_request_attributes( else url_without_query_string ) - client = asgi_scope.get("client") - if client and should_send_default_pii(): - ip = _get_ip(asgi_scope) - attributes["client.address"] = ip + asgi_scope_client = asgi_scope.get("client") + if asgi_scope_client: + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["user_info"]: + ip = _get_ip(asgi_scope) + attributes["client.address"] = ip + elif should_send_default_pii(): + ip = _get_ip(asgi_scope) + attributes["client.address"] = ip return attributes diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 16a7639189..50db021a62 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -3,8 +3,9 @@ import sentry_sdk from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE +from sentry_sdk.data_collection import _apply_key_value_collection_filtering from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.utils import AnnotatedValue, logger +from sentry_sdk.utils import AnnotatedValue, has_data_collection_enabled, logger try: from django.http.request import RawPostDataException @@ -88,7 +89,14 @@ def extract_into_event(self, event: "Event") -> None: content_length = self.content_length() request_info = event.get("request", {}) - if should_send_default_pii(): + if has_data_collection_enabled(client.options): + cookies = _apply_key_value_collection_filtering( + items=dict(self.cookies()), + behaviour=client.options["data_collection"]["cookies"], + ) + if cookies: + request_info["cookies"] = cookies + elif should_send_default_pii(): request_info["cookies"] = dict(self.cookies()) if not request_body_within_bounds(client, content_length): @@ -206,19 +214,39 @@ def _filter_headers( headers: "Mapping[str, str]", use_annotated_value: bool = True, ) -> "Mapping[str, Union[AnnotatedValue, str]]": - if should_send_default_pii(): - return headers + client_options = sentry_sdk.get_client().options - substitute: "Union[AnnotatedValue, str]" = ( - SENSITIVE_DATA_SUBSTITUTE - if not use_annotated_value - else AnnotatedValue.removed_because_over_size_limit() - ) + if has_data_collection_enabled(client_options): + data_collection_configuration = client_options["data_collection"] + + filtered = _apply_key_value_collection_filtering( + items=headers, + behaviour=data_collection_configuration["http_headers"]["request"], + ) - return { - k: (v if k.upper().replace("-", "_") not in SENSITIVE_HEADERS else substitute) - for k, v in headers.items() - } + for key in filtered: + if isinstance(key, str) and key.lower() in ("cookie", "set-cookie"): + filtered[key] = SENSITIVE_DATA_SUBSTITUTE + + return filtered + else: + if should_send_default_pii(): + return headers + + substitute: "Union[AnnotatedValue, str]" = ( + SENSITIVE_DATA_SUBSTITUTE + if not use_annotated_value + else AnnotatedValue.removed_because_over_size_limit() + ) + + return { + k: ( + v + if k.upper().replace("-", "_") not in SENSITIVE_HEADERS + else substitute + ) + for k, v in headers.items() + } def _in_http_status_code_range( diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 7cddc466ac..552b5edabe 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -5,6 +5,9 @@ import sentry_sdk from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS +from sentry_sdk.data_collection import ( + _apply_data_collection_filtering_to_query_string, +) from sentry_sdk.integrations import ( _DEFAULT_FAILED_REQUEST_STATUS_CODES, DidNotEnable, @@ -46,6 +49,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, logger, parse_url, parse_version, @@ -150,7 +154,8 @@ async def sentry_app_handle( header_attributes: "dict[str, Any]" = {} for header, header_value in _filter_headers( - headers, use_annotated_value=False + headers, + use_annotated_value=False, ).items(): header_attributes[ f"http.request.header.{header.lower()}" @@ -160,7 +165,26 @@ async def sentry_app_handle( ) url_attributes = {} - if should_send_default_pii(): + if has_data_collection_enabled(client.options): + url_attributes["url.full"] = "%s://%s%s" % ( + request.scheme, + request.host, + request.path, + ) + url_attributes["url.path"] = request.path + + if request.query_string: + filtered_query_string = ( + _apply_data_collection_filtering_to_query_string( + query_string=request.query_string, + behaviour=client.options["data_collection"][ + "url_query_params" + ], + ) + ) + if filtered_query_string: + url_attributes["url.query"] = filtered_query_string + elif should_send_default_pii(): url_attributes["url.full"] = "%s://%s%s" % ( request.scheme, request.host, @@ -361,14 +385,33 @@ async def on_request_start( "sentry.origin": AioHttpIntegration.origin, "http.request.method": method, } - if parsed_url is not None and should_send_default_pii(): - attributes["url.full"] = parsed_url.url - attributes["url.path"] = params.url.path - - if parsed_url.query: - attributes["url.query"] = parsed_url.query - if parsed_url.fragment: - attributes["url.fragment"] = parsed_url.fragment + if parsed_url is not None: + if has_data_collection_enabled(client.options): + attributes["url.full"] = parsed_url.url + attributes["url.path"] = params.url.path + + if parsed_url.fragment: + attributes["url.fragment"] = parsed_url.fragment + + if parsed_url.query: + filtered_query = ( + _apply_data_collection_filtering_to_query_string( + query_string=parsed_url.query, + behaviour=client.options["data_collection"][ + "url_query_params" + ], + ) + ) + if filtered_query: + attributes["url.query"] = filtered_query + elif should_send_default_pii(): + attributes["url.full"] = parsed_url.url + attributes["url.path"] = params.url.path + + if parsed_url.query: + attributes["url.query"] = parsed_url.query + if parsed_url.fragment: + attributes["url.fragment"] = parsed_url.fragment span = sentry_sdk.traces.start_span( name=span_name, attributes=attributes diff --git a/sentry_sdk/integrations/aws_lambda.py b/sentry_sdk/integrations/aws_lambda.py index c7fe77714a..7fc01702cc 100644 --- a/sentry_sdk/integrations/aws_lambda.py +++ b/sentry_sdk/integrations/aws_lambda.py @@ -11,6 +11,7 @@ import sentry_sdk from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP +from sentry_sdk.data_collection import _apply_key_value_collection_filtering from sentry_sdk.integrations import Integration from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.integrations.cloud_resource_context import ( @@ -27,6 +28,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, logger, reraise, ) @@ -164,10 +166,20 @@ def sentry_handler( "httpMethod" ] - if should_send_default_pii() and "queryStringParameters" in request_data: + if "queryStringParameters" in request_data: qs = request_data["queryStringParameters"] if qs: - additional_attributes["url.query"] = urlencode(qs) + if has_data_collection_enabled(client.options): + filtered_qs = _apply_key_value_collection_filtering( + items=qs, + behaviour=client.options["data_collection"][ + "url_query_params" + ], + ) + if filtered_qs: + additional_attributes["url.query"] = urlencode(filtered_qs) + elif should_send_default_pii(): + additional_attributes["url.query"] = urlencode(qs) sampling_context = { "aws_event": aws_event, @@ -409,7 +421,18 @@ def event_processor( request["url"] = _get_url(aws_event, aws_context) if "queryStringParameters" in aws_event: - request["query_string"] = aws_event["queryStringParameters"] + query_string = aws_event["queryStringParameters"] + client_options = sentry_sdk.get_client().options + if has_data_collection_enabled(client_options): + if query_string: + filtered_qs = _apply_key_value_collection_filtering( + items=query_string, + behaviour=client_options["data_collection"]["url_query_params"], + ) + if filtered_qs: + request["query_string"] = filtered_qs + else: + request["query_string"] = query_string if "headers" in aws_event: request["headers"] = _filter_headers(aws_event["headers"]) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index c7b97c88b1..0b048e11ee 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -6,7 +6,6 @@ import sentry_sdk from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import DidNotEnable -from sentry_sdk.scope import should_send_default_pii from sentry_sdk.traces import StreamedSpan, get_current_span from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource from sentry_sdk.tracing_utils import has_span_streaming_enabled @@ -118,7 +117,7 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": # Extract information from request request_info = event.get("request", {}) if info: - if "cookies" in info and should_send_default_pii(): + if "cookies" in info: request_info["cookies"] = info["cookies"] if "data" in info: request_info["data"] = info["data"] diff --git a/sentry_sdk/integrations/gcp.py b/sentry_sdk/integrations/gcp.py index 91a62b3a81..8d63899751 100644 --- a/sentry_sdk/integrations/gcp.py +++ b/sentry_sdk/integrations/gcp.py @@ -8,6 +8,7 @@ import sentry_sdk from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP +from sentry_sdk.data_collection import _apply_data_collection_filtering_to_query_string from sentry_sdk.integrations import Integration from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.integrations.cloud_resource_context import CLOUD_PROVIDER @@ -20,6 +21,7 @@ TimeoutThread, capture_internal_exceptions, event_from_exception, + has_data_collection_enabled, logger, reraise, ) @@ -100,10 +102,20 @@ def sentry_func( if hasattr(gcp_event, "method"): additional_attributes["http.request.method"] = gcp_event.method - if should_send_default_pii() and hasattr(gcp_event, "query_string"): - additional_attributes["url.query"] = gcp_event.query_string.decode( - "utf-8", errors="replace" - ) + if hasattr(gcp_event, "query_string"): + query_string = gcp_event.query_string.decode("utf-8", errors="replace") + if query_string: + if has_data_collection_enabled(client.options): + filtered_qs = _apply_data_collection_filtering_to_query_string( + query_string=query_string, + behaviour=client.options["data_collection"][ + "url_query_params" + ], + ) + if filtered_qs: + additional_attributes["url.query"] = filtered_qs + elif should_send_default_pii(): + additional_attributes["url.query"] = query_string sampling_context = { "gcp_env": { @@ -235,9 +247,18 @@ def event_processor(event: "Event", hint: "Hint") -> "Optional[Event]": request["method"] = gcp_event.method if hasattr(gcp_event, "query_string"): - request["query_string"] = gcp_event.query_string.decode( - "utf-8", errors="replace" - ) + query_string = gcp_event.query_string.decode("utf-8", errors="replace") + client_options = sentry_sdk.get_client().options + if has_data_collection_enabled(client_options): + if query_string: + filtered_qs = _apply_data_collection_filtering_to_query_string( + query_string=query_string, + behaviour=client_options["data_collection"]["url_query_params"], + ) + if filtered_qs: + request["query_string"] = filtered_qs + else: + request["query_string"] = query_string if hasattr(gcp_event, "headers"): request["headers"] = _filter_headers(gcp_event.headers) diff --git a/sentry_sdk/integrations/litestar.py b/sentry_sdk/integrations/litestar.py index cb7aa87ae9..7acfed478e 100644 --- a/sentry_sdk/integrations/litestar.py +++ b/sentry_sdk/integrations/litestar.py @@ -3,6 +3,7 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.data_collection import _apply_key_value_collection_filtering from sentry_sdk.integrations import ( _DEFAULT_FAILED_REQUEST_STATUS_CODES, DidNotEnable, @@ -16,6 +17,7 @@ from sentry_sdk.utils import ( ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, transaction_from_function, ) @@ -279,7 +281,8 @@ def patch_http_route_handle() -> None: async def handle_wrapper( self: "HTTPRoute", scope: "HTTPScope", receive: "Receive", send: "Send" ) -> None: - if sentry_sdk.get_client().get_integration(LitestarIntegration) is None: + client = sentry_sdk.get_client() + if client.get_integration(LitestarIntegration) is None: return await old_handle(self, scope, receive, send) sentry_scope = sentry_sdk.get_isolation_scope() @@ -318,7 +321,14 @@ async def handle_wrapper( def event_processor(event: "Event", _: "Hint") -> "Event": request_info = event.get("request", {}) request_info["content_length"] = len(scope.get("_body", b"")) - if should_send_default_pii(): + if has_data_collection_enabled(client.options): + cookies = _apply_key_value_collection_filtering( + items=extracted_request_data["cookies"], + behaviour=client.options["data_collection"]["cookies"], + ) + if cookies: + request_info["cookies"] = cookies + elif should_send_default_pii(): request_info["cookies"] = extracted_request_data["cookies"] if request_data is not None: request_info["data"] = request_data diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 6a5603d825..8c281f5874 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING import sentry_sdk +from sentry_sdk.data_collection import _apply_data_collection_filtering_to_query_string from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.integrations.asgi import SentryAsgiMiddleware @@ -17,6 +18,8 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, + parse_url, ) if TYPE_CHECKING: @@ -203,7 +206,39 @@ async def _request_websocket_started(app: "Quart", **kwargs: "Any") -> None: segment.set_attributes(header_attributes) - if should_send_default_pii(): + client_options = sentry_sdk.get_client().options + filtered_query_string = None + if has_data_collection_enabled(client_options): + query_string = request_websocket.query_string.decode( + "utf-8", errors="replace" + ) + if query_string: + filtered_query_string = ( + _apply_data_collection_filtering_to_query_string( + query_string=query_string, + behaviour=client_options["data_collection"][ + "url_query_params" + ], + ) + ) + if filtered_query_string: + segment.set_attribute( + "url.query", + filtered_query_string, + ) + + parsed_url = parse_url(request_websocket.url) + segment.set_attribute( + "url.full", + f"{parsed_url.url}?{filtered_query_string}" + if filtered_query_string + else parsed_url.url, + ) + + # TODO: Add the user properties that are seen in the branch below here once + # code is added to respect the `user_info` settings within the data collection + # configuration + elif should_send_default_pii(): segment.set_attribute("url.full", request_websocket.url) segment.set_attribute( "url.query", diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index 908fceb0cf..4c11d6b1e6 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -8,6 +8,9 @@ import sentry_sdk from sentry_sdk import continue_trace from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.data_collection import ( + _apply_data_collection_filtering_to_query_string, +) from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers from sentry_sdk.integrations.logging import ignore_logger @@ -21,6 +24,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, parse_version, reraise, ) @@ -379,8 +383,25 @@ def _get_request_attributes(request: "Request") -> "Dict[str, Any]": attributes[f"{SPANDATA.HTTP_REQUEST_HEADER}.{header.lower()}"] = value urlparts = urlsplit(request.url) + client_options = sentry_sdk.get_client().options + + if has_data_collection_enabled(client_options): + attributes["url.path"] = urlparts.path - if should_send_default_pii(): + filtered_query = None + if urlparts.query: + filtered_query = _apply_data_collection_filtering_to_query_string( + query_string=urlparts.query, + behaviour=client_options["data_collection"]["url_query_params"], + ) + if filtered_query: + attributes[SPANDATA.HTTP_QUERY] = filtered_query + + attributes[SPANDATA.URL_FULL] = urlparts._replace( + query=filtered_query or "" + ).geturl() + + elif should_send_default_pii(): attributes[SPANDATA.URL_FULL] = request.url attributes["url.path"] = urlparts.path @@ -421,7 +442,18 @@ def sanic_processor(event: "Event", hint: "Optional[Hint]") -> "Optional[Event]" urlparts.path, ) - request_info["query_string"] = urlparts.query + client_options = sentry_sdk.get_client().options + if has_data_collection_enabled(client_options): + if urlparts.query: + filtered_query = _apply_data_collection_filtering_to_query_string( + query_string=urlparts.query, + behaviour=client_options["data_collection"]["url_query_params"], + ) + if filtered_query: + request_info["query_string"] = filtered_query + else: + request_info["query_string"] = urlparts.query + request_info["method"] = request.method request_info["env"] = {"REMOTE_ADDR": request.remote_addr} request_info["headers"] = _filter_headers(dict(request.headers)) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 74effb1516..21cdfeecc5 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -10,6 +10,7 @@ import sentry_sdk from sentry_sdk._types import OVER_SIZE_LIMIT_SUBSTITUTE from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.data_collection import _apply_key_value_collection_filtering from sentry_sdk.integrations import ( _DEFAULT_FAILED_REQUEST_STATUS_CODES, DidNotEnable, @@ -35,6 +36,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, nullcontext, parse_version, transaction_from_function, @@ -719,8 +721,15 @@ def __init__(self: "StarletteRequestExtractor", request: "Request") -> None: def extract_cookies_from_request( self: "StarletteRequestExtractor", ) -> "Optional[Dict[str, Any]]": + client_options = sentry_sdk.get_client().options cookies: "Optional[Dict[str, Any]]" = None - if should_send_default_pii(): + + if has_data_collection_enabled(client_options): + cookies = _apply_key_value_collection_filtering( + items=self.cookies(), + behaviour=client_options["data_collection"]["cookies"], + ) + elif should_send_default_pii(): cookies = self.cookies() return cookies @@ -734,7 +743,14 @@ async def extract_request_info( with capture_internal_exceptions(): # Add cookies - if should_send_default_pii(): + if has_data_collection_enabled(client.options): + cookies = _apply_key_value_collection_filtering( + items=self.cookies(), + behaviour=client.options["data_collection"]["cookies"], + ) + if cookies: + request_info["cookies"] = cookies + elif should_send_default_pii(): request_info["cookies"] = self.cookies() # If there is no body, just return the cookies diff --git a/sentry_sdk/integrations/starlite.py b/sentry_sdk/integrations/starlite.py index 3b759a62b7..6182d0f85c 100644 --- a/sentry_sdk/integrations/starlite.py +++ b/sentry_sdk/integrations/starlite.py @@ -2,6 +2,7 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.data_collection import _apply_key_value_collection_filtering from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.scope import should_send_default_pii @@ -10,6 +11,7 @@ from sentry_sdk.utils import ( ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, nullcontext, transaction_from_function, ) @@ -227,7 +229,8 @@ def patch_http_route_handle() -> None: async def handle_wrapper( self: "HTTPRoute", scope: "HTTPScope", receive: "Receive", send: "Send" ) -> None: - if sentry_sdk.get_client().get_integration(StarliteIntegration) is None: + client = sentry_sdk.get_client() + if client.get_integration(StarliteIntegration) is None: return await old_handle(self, scope, receive, send) sentry_scope = sentry_sdk.get_isolation_scope() @@ -265,7 +268,14 @@ async def handle_wrapper( def event_processor(event: "Event", _: "Hint") -> "Event": request_info = event.get("request", {}) request_info["content_length"] = len(scope.get("_body", b"")) - if should_send_default_pii(): + if has_data_collection_enabled(client.options): + cookies = _apply_key_value_collection_filtering( + items=extracted_request_data["cookies"], + behaviour=client.options["data_collection"]["cookies"], + ) + if cookies: + request_info["cookies"] = cookies + elif should_send_default_pii(): request_info["cookies"] = extracted_request_data["cookies"] if request_data is not None: request_info["data"] = request_data diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py index 859b0d0870..ad8e2075e6 100644 --- a/sentry_sdk/integrations/tornado.py +++ b/sentry_sdk/integrations/tornado.py @@ -5,6 +5,7 @@ import sentry_sdk from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.data_collection import _apply_data_collection_filtering_to_query_string from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.integrations._wsgi_common import ( RequestExtractor, @@ -24,6 +25,8 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, + parse_url, transaction_from_function, ) @@ -189,6 +192,7 @@ def _handle_request_impl(self: "RequestHandler") -> "Generator[None, None, None] def _get_request_attributes(request: "Any") -> "Dict[str, Any]": attributes = {} # type: Dict[str, Any] + client_options = sentry_sdk.get_client().options if request.method: attributes[SPANDATA.HTTP_REQUEST_METHOD] = request.method.upper() @@ -197,7 +201,24 @@ def _get_request_attributes(request: "Any") -> "Dict[str, Any]": for header, value in headers.items(): attributes[f"{SPANDATA.HTTP_REQUEST_HEADER}.{header.lower()}"] = value - if should_send_default_pii(): + if has_data_collection_enabled(client_options): + attributes["url.path"] = request.path + + filtered_query = None + if request.query: + filtered_query = _apply_data_collection_filtering_to_query_string( + query_string=request.query, + behaviour=client_options["data_collection"]["url_query_params"], + ) + if filtered_query: + attributes[SPANDATA.URL_QUERY] = filtered_query + + parsed_url = parse_url(request.full_url()) + attributes[SPANDATA.URL_FULL] = ( + f"{parsed_url.url}?{filtered_query}" if filtered_query else parsed_url.url + ) + + elif should_send_default_pii(): attributes[SPANDATA.URL_FULL] = request.full_url() attributes["url.path"] = request.path @@ -273,7 +294,18 @@ def tornado_processor(event: "Event", hint: "dict[str, Any]") -> "Event": request.path, ) - request_info["query_string"] = request.query + 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( + query_string=request.query, + behaviour=client_options["data_collection"]["url_query_params"], + ) + if filtered_query: + request_info["query_string"] = filtered_query + else: + request_info["query_string"] = request.query + request_info["method"] = request.method request_info["env"] = {"REMOTE_ADDR": request.remote_ip} request_info["headers"] = _filter_headers(dict(request.headers)) diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 168b889b60..5ee60bcc41 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -6,6 +6,7 @@ from sentry_sdk._werkzeug import _get_headers, get_host from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.data_collection import _apply_data_collection_filtering_to_query_string from sentry_sdk.integrations._wsgi_common import ( DEFAULT_HTTP_METHODS_TO_CAPTURE, _filter_headers, @@ -19,6 +20,7 @@ ContextVar, capture_internal_exceptions, event_from_exception, + has_data_collection_enabled, nullcontext, reraise, ) @@ -355,6 +357,7 @@ def _make_wsgi_event_processor( method = environ.get("REQUEST_METHOD") env = dict(_get_environ(environ)) headers = _filter_headers(dict(_get_headers(environ))) + client_options = sentry_sdk.get_client().options def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": with capture_internal_exceptions(): @@ -367,11 +370,22 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": user_info.setdefault("ip_address", client_ip) request_info["url"] = request_url - request_info["query_string"] = query_string request_info["method"] = method request_info["env"] = env request_info["headers"] = headers + if has_data_collection_enabled(client_options): + if query_string: + filtered_qs = _apply_data_collection_filtering_to_query_string( + query_string=query_string, + behaviour=client_options["data_collection"]["url_query_params"], + ) + if filtered_qs: + request_info["query_string"] = filtered_qs + else: + # This was not originally gated so if data collection is not enabled, leave as-is. + request_info["query_string"] = query_string + return event return event_processor @@ -409,7 +423,26 @@ def _get_request_attributes( except ValueError: pass - if should_send_default_pii(): + client_options = sentry_sdk.get_client().options + + if has_data_collection_enabled(client_options): + query_string = environ.get("QUERY_STRING") + if query_string: + filtered_qs = _apply_data_collection_filtering_to_query_string( + query_string=query_string, + behaviour=client_options["data_collection"]["url_query_params"], + ) + + if filtered_qs: + attributes["http.query"] = filtered_qs + + path = environ.get("PATH_INFO", "") + if path: + attributes["url.path"] = path + + attributes["url.full"] = get_request_url(environ, use_x_forwarded_for) + + elif should_send_default_pii(): client_ip = get_client_ip(environ) if client_ip: attributes["client.address"] = client_ip diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 1790e13fbf..de651e2d56 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1200,16 +1200,22 @@ def _set_initial_sampling_decision( # we would have bailed already if neither `traces_sampler` nor # `traces_sample_rate` were defined, so one of these should work; prefer # the hook if so - sample_rate = ( - client.options["traces_sampler"](sampling_context) - if callable(client.options.get("traces_sampler")) - # default inheritance behavior - else ( + if callable(client.options.get("traces_sampler")): + try: + sample_rate = client.options["traces_sampler"](sampling_context) + except Exception: + logger.warning("[Tracing] traces_sampler raised; falling back to parent sample rate or traces_sample_rate", exc_info=True) + sample_rate = ( + sampling_context["parent_sampled"] + if sampling_context["parent_sampled"] is not None + else client.options["traces_sample_rate"] + ) + else: + sample_rate = ( sampling_context["parent_sampled"] if sampling_context["parent_sampled"] is not None else client.options["traces_sample_rate"] ) - ) # Since this is coming from the user (or from a function provided by the # user), who knows what we might get. (The only valid values are diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 8cb1cbe3ae..1ff4cdf882 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -42,6 +42,7 @@ from typing import Any, Dict, Generator, Iterator, Optional, Tuple, Union from sentry_sdk._types import Attributes + from sentry_sdk.client import BaseClient SENTRY_TRACE_REGEX = re.compile( @@ -1543,6 +1544,16 @@ def add_sentry_baggage_to_headers( stripped_existing_baggage + separator + sentry_baggage ) +def _get_fallback_sample_rate(client: "BaseClient", propagation_context: "PropagationContext") -> "Union[float, bool]": + if propagation_context.parent_sampled is not None: + propagation_context_sample_rate = propagation_context._sample_rate() + + if propagation_context_sample_rate is not None: + return propagation_context_sample_rate + else: + return propagation_context.parent_sampled + else: + return client.options["traces_sample_rate"] def _make_sampling_decision( name: str, @@ -1587,15 +1598,13 @@ def _make_sampling_decision( if propagation_context.custom_sampling_context: sampling_context.update(propagation_context.custom_sampling_context) - sample_rate = client.options["traces_sampler"](sampling_context) + try: + sample_rate = client.options["traces_sampler"](sampling_context) + except Exception: + logger.warning("[Tracing] traces_sampler raised; falling back to parent sample rate or traces_sample_rate", exc_info=True) + sample_rate = _get_fallback_sample_rate(client=client, propagation_context=propagation_context) else: - if propagation_context.parent_sampled is not None: - if propagation_context._sample_rate() is not None: - sample_rate = propagation_context._sample_rate() - else: - sample_rate = propagation_context.parent_sampled - else: - sample_rate = client.options["traces_sample_rate"] + sample_rate = _get_fallback_sample_rate(client=client, propagation_context=propagation_context) # Validate whether the sample_rate we got is actually valid. Since # traces_sampler is user-provided, it could return anything. diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 6661af807e..1c1d1b8bf5 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -1254,8 +1254,185 @@ async def hello(request): ) +@pytest.mark.parametrize( + "options,expected", + [ + pytest.param( + { + "send_default_pii": True, + "data_collection": None, + }, + { + "authorization": "[Filtered]", + "custom": "foobar", + "cookie": "[Filtered]", + }, + id="enabled_send_default_pii_redacts_auth_header_due_to_data_collection_default_settings", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": None, + }, + { + "authorization": "[Filtered]", + "custom": "foobar", + "cookie": "[Filtered]", + }, + id="disabled_send_default_pii_redacts_auth_header_due_to_data_collection_default_settings", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": {"http_headers": {"request": {"mode": "off"}}}, + }, + None, + id="data_collection_off_does_not_add_headers", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": {"http_headers": {"request": {"mode": "allowlist"}}}, + }, + { + "authorization": "[Filtered]", + "custom": "[Filtered]", + "cookie": "[Filtered]", + }, + id="data_collection_allow_list_redacts_terms_that_do_not_appear", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": { + "http_headers": { + "request": {"mode": "allowlist", "terms": ["Authorization"]} + } + }, + }, + { + "authorization": "[Filtered]", + "custom": "[Filtered]", + "cookie": "[Filtered]", + }, + id="data_collection_allow_list_redacts_sensitive_terms_even_when_provided_by_user", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": { + "http_headers": { + "request": {"mode": "allowlist", "terms": ["custom"]} + } + }, + }, + { + "authorization": "[Filtered]", + "custom": "foobar", + "cookie": "[Filtered]", + }, + id="data_collection_allow_list_does_not_redact_provided_term", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": { + "http_headers": { + "request": {"mode": "denylist", "terms": ["custom"]} + } + }, + }, + { + "authorization": "[Filtered]", + "custom": "[Filtered]", + "cookie": "[Filtered]", + }, + id="data_collection_deny_list_redacts_sensitive_terms_when_provided_by_user", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": { + "http_headers": { + "request": {"mode": "allowlist", "terms": ["cookie"]} + } + }, + }, + { + "authorization": "[Filtered]", + "custom": "[Filtered]", + "cookie": "[Filtered]", + }, + id="data_collection_cookie_is_always_redacted_even_when_allow_listed", + ), + ], +) @pytest.mark.asyncio async def test_sensitive_header_passthrough_with_pii_span_streaming( + sentry_init, aiohttp_client, capture_items, options, expected, request +): + sentry_init( + integrations=[AioHttpIntegration()], + traces_sample_rate=1.0, + send_default_pii=options["send_default_pii"], + _experiments={ + "trace_lifecycle": "stream", + "data_collection": options["data_collection"], + }, + ) + + 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) + await client.get( + "/", + headers={ + "Authorization": "Bearer secret-token", + "x-custom-header": "foobar", + "Cookie": "sessionid=secret", + }, + ) + + sentry_sdk.flush() + + (server_span,) = [item.payload for item in items] + + if request.node.callspec.id.endswith("data_collection_off_does_not_add_headers"): + assert "http.request.header.authorization" not in server_span["attributes"] + assert "http.request.header.cookie" not in server_span["attributes"] + else: + assert ( + server_span["attributes"]["http.request.header.authorization"] + == expected["authorization"] + ) + assert ( + server_span["attributes"]["http.request.header.x-custom-header"] + == expected["custom"] + ) + assert ( + server_span["attributes"]["http.request.header.cookie"] + == 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( sentry_init, aiohttp_client, capture_items ): sentry_init( @@ -1615,3 +1792,165 @@ async def hello(request): else: assert "user.ip_address" not in server_span["attributes"] assert "user.ip_address" not in child_span["attributes"] + + +NO_QUERY_STRING = object() +_QUERY_PARAM_DATA_COLLECTION_CASES = [ + pytest.param( + {"send_default_pii": True}, + "toy=tennisball&color=red&auth=secret", + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_QUERY_STRING, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_QUERY_STRING, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "denylist", "terms": ["toy"]} + } + } + }, + "toy=[Filtered]&color=red&auth=[Filtered]", + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["auth"]} + } + } + }, + "toy=[Filtered]&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + {"_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}}, + NO_QUERY_STRING, + id="data_collection_off", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}, + }, + NO_QUERY_STRING, + id="data_collection_wins_over_send_default_pii", + ), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "init_kwargs, expected_query", _QUERY_PARAM_DATA_COLLECTION_CASES +) +async def test_server_url_query_data_collection_span_streaming( + sentry_init, aiohttp_client, capture_items, init_kwargs, expected_query +): + init_kwargs = dict(init_kwargs) + sentry_init( + integrations=[AioHttpIntegration()], + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + **init_kwargs.pop("_experiments", {}), + }, + **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("/?toy=tennisball&color=red&auth=secret") + assert resp.status == 200 + + sentry_sdk.flush() + + (server_span,) = [item.payload for item in items] + + if expected_query is NO_QUERY_STRING: + assert "url.query" not in server_span["attributes"] + else: + assert server_span["attributes"]["url.query"] == expected_query + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "init_kwargs, expected_query", _QUERY_PARAM_DATA_COLLECTION_CASES +) +async def test_client_url_query_data_collection_span_streaming( + sentry_init, + aiohttp_raw_server, + aiohttp_client, + capture_items, + init_kwargs, + expected_query, +): + init_kwargs = dict(init_kwargs) + sentry_init( + integrations=[AioHttpIntegration()], + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + **init_kwargs.pop("_experiments", {}), + }, + **init_kwargs, + ) + + async def handler(request): + return web.Response(text="OK") + + raw_server = await aiohttp_raw_server(handler) + + async def hello(request): + span_client = await aiohttp_client(raw_server) + await span_client.get("/?toy=tennisball&color=red&auth=secret") + return web.Response(text="hello") + + app = web.Application() + app.router.add_get(r"/", hello) + + items = capture_items("span") + + client = await aiohttp_client(app) + await client.get("/") + + sentry_sdk.flush() + + inner_client_span = items[0].payload + + if expected_query is NO_QUERY_STRING: + assert "url.query" not in inner_client_span["attributes"] + else: + assert inner_client_span["attributes"]["url.query"] == expected_query diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index 3bce0d1e10..35f6c26091 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -5,7 +5,10 @@ import sentry_sdk from sentry_sdk import capture_message -from sentry_sdk.integrations._asgi_common import _get_headers, _get_ip +from sentry_sdk.integrations._asgi_common import ( + _get_headers, + _get_ip, +) from sentry_sdk.integrations.asgi import SentryAsgiMiddleware, _looks_like_asgi3 from sentry_sdk.tracing import TransactionSource @@ -831,6 +834,439 @@ def test_get_headers(): } +@pytest.mark.asyncio +async def test_get_request_data_url_with_filtered_host( + sentry_init, capture_events, asgi3_app +): + # allowlist mode in data collection that does not allow "host" scrubs the host + # header value, but the reported URL must still resolve rather than embedding the + # substituted "[Filtered]" value. + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "data_collection": { + "http_headers": {"request": {"mode": "allowlist", "terms": []}} + } + }, + ) + app = SentryAsgiMiddleware(asgi3_app) + + events = capture_events() + scope = {"server": ("example.com", 80), "scheme": "http"} + async with TestClient(app, scope=scope) as client: + await client.get("/foo", headers={"host": "example.com"}) + + sentry_sdk.flush() + + (transaction_event,) = events + + assert transaction_event["request"]["headers"]["host"] == "[Filtered]" + assert transaction_event["request"]["url"] == "http://example.com/foo" + + +@pytest.mark.asyncio +async def test_get_request_attributes_url_with_filtered_host( + sentry_init, capture_items, asgi3_app +): + # As with the request data, an allowlist mode that does not allow "host" scrubs + # the host header value, but "url.full" must still resolve rather than embedding + # the substituted value. + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "data_collection": { + "http_headers": {"request": {"mode": "allowlist", "terms": []}} + }, + }, + ) + app = SentryAsgiMiddleware(asgi3_app) + + items = capture_items("span") + scope = {"server": ("example.com", 80), "scheme": "http"} + async with TestClient(app, scope=scope) as client: + await client.get("/foo?somevalue=123", headers={"host": "example.com"}) + + sentry_sdk.flush() + + assert len(items) == 1 + attributes = items[0].payload["attributes"] + + assert attributes["http.request.header.host"] == "[Filtered]" + assert attributes["url.full"] == "http://example.com/foo?somevalue=123" + + +@pytest.mark.asyncio +async def test_get_request_attributes_url_with_headers_off( + sentry_init, capture_items, asgi3_app +): + # "off" mode in data collection captures no headers at all, but "url.full" must + # still resolve via the (uncaptured) host header rather than being dropped. + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "data_collection": {"http_headers": {"request": {"mode": "off"}}}, + }, + ) + app = SentryAsgiMiddleware(asgi3_app) + + items = capture_items("span") + scope = {"server": ("example.com", 80), "scheme": "http"} + async with TestClient(app, scope=scope) as client: + await client.get("/foo?somevalue=123", headers={"host": "example.com"}) + + sentry_sdk.flush() + + assert len(items) == 1 + attributes = items[0].payload["attributes"] + + assert not any(key.startswith("http.request.header.") for key in attributes) + assert attributes["url.full"] == "http://example.com/foo?somevalue=123" + + +NO_QUERY_STRING = object() +QUERY_STRING = "token=abc&theme=dark&lang=en&session=xyz" + + +def _http_scope(): + return {"server": ("example.com", 80), "scheme": "http"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "init_kwargs, expected_query_string", + [ + pytest.param( + {"send_default_pii": True}, + QUERY_STRING, + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + QUERY_STRING, + id="send_default_pii_false", + ), + pytest.param( + {}, + QUERY_STRING, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "token=[Filtered]&theme=dark&lang=en&session=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "denylist", "terms": ["theme"]} + } + } + }, + "token=[Filtered]&theme=[Filtered]&lang=en&session=[Filtered]", + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["theme"]} + } + } + }, + "token=[Filtered]&theme=dark&lang=[Filtered]&session=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["token"]} + } + } + }, + "token=[Filtered]&theme=[Filtered]&lang=[Filtered]&session=[Filtered]", + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + } + }, + NO_QUERY_STRING, + id="data_collection_off", + ), + # data_collection wins over send_default_pii: filtering still applies. + pytest.param( + { + "send_default_pii": True, + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + }, + }, + NO_QUERY_STRING, + id="data_collection_wins_over_send_default_pii", + ), + ], +) +async def test_get_request_data_query_string_data_collection( + sentry_init, capture_events, asgi3_app, init_kwargs, expected_query_string +): + sentry_init(traces_sample_rate=1.0, **init_kwargs) + app = SentryAsgiMiddleware(asgi3_app) + + events = capture_events() + async with TestClient(app, scope=_http_scope()) as client: + await client.get(f"/foo?{QUERY_STRING}", headers={"host": "example.com"}) + + sentry_sdk.flush() + + (transaction_event,) = events + request_data = transaction_event["request"] + + if expected_query_string is NO_QUERY_STRING: + assert "query_string" not in request_data + else: + assert request_data["query_string"] == expected_query_string + + +@pytest.mark.asyncio +async def test_get_request_data_query_string_empty_legacy_is_none( + sentry_init, capture_events, asgi3_app +): + # Legacy path: the query string is always set even when empty (``None``). + sentry_init(send_default_pii=True, traces_sample_rate=1.0) + app = SentryAsgiMiddleware(asgi3_app) + + events = capture_events() + async with TestClient(app, scope=_http_scope()) as client: + await client.get("/foo", headers={"host": "example.com"}) + + sentry_sdk.flush() + + (transaction_event,) = events + assert transaction_event["request"]["query_string"] is None + + +@pytest.mark.asyncio +async def test_get_request_data_empty_query_string_dropped_with_data_collection( + sentry_init, capture_events, asgi3_app +): + sentry_init(traces_sample_rate=1.0, _experiments={"data_collection": {}}) + app = SentryAsgiMiddleware(asgi3_app) + + events = capture_events() + async with TestClient(app, scope=_http_scope()) as client: + await client.get("/foo", headers={"host": "example.com"}) + + sentry_sdk.flush() + + (transaction_event,) = events + assert "query_string" not in transaction_event["request"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "init_kwargs, expected_query, expected_url_full", + [ + pytest.param( + {"send_default_pii": True}, + QUERY_STRING, + "http://example.com/foo?" + QUERY_STRING, + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_QUERY_STRING, + None, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_QUERY_STRING, + None, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "token=[Filtered]&theme=dark&lang=en&session=[Filtered]", + "http://example.com/foo?token=[Filtered]&theme=dark&lang=en&session=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["theme"]} + } + } + }, + "token=[Filtered]&theme=dark&lang=[Filtered]&session=[Filtered]", + "http://example.com/foo?token=[Filtered]&theme=dark&lang=[Filtered]&session=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + } + }, + NO_QUERY_STRING, + "http://example.com/foo", + id="data_collection_off", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + }, + }, + NO_QUERY_STRING, + "http://example.com/foo", + id="data_collection_wins_over_send_default_pii", + ), + ], +) +async def test_get_request_attributes_query_data_collection( + sentry_init, + capture_items, + asgi3_app, + init_kwargs, + expected_query, + expected_url_full, +): + kwargs = {k: v for k, v in init_kwargs.items() if k != "_experiments"} + experiments = {"trace_lifecycle": "stream", **init_kwargs.get("_experiments", {})} + sentry_init(traces_sample_rate=1.0, _experiments=experiments, **kwargs) + app = SentryAsgiMiddleware(asgi3_app) + + items = capture_items("span") + async with TestClient(app, scope=_http_scope()) as client: + await client.get(f"/foo?{QUERY_STRING}", headers={"host": "example.com"}) + + sentry_sdk.flush() + + assert len(items) == 1 + attributes = items[0].payload["attributes"] + + if expected_query is NO_QUERY_STRING: + assert "http.query" not in attributes + else: + assert attributes["http.query"] == expected_query + + if expected_url_full is None: + assert "url.full" not in attributes + assert "url.path" not in attributes + else: + assert attributes["url.full"] == expected_url_full + assert attributes["url.path"] == "/foo" + + +USER_INFO_CASES = [ + pytest.param( + {"_experiments": {"data_collection": {"user_info": False}}}, + True, + False, + id="dc_user_info_false", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + True, + True, + id="dc_default_user_info", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": {"data_collection": {"user_info": False}}, + }, + True, + False, + id="dc_wins_over_pii", + ), + pytest.param( + {"send_default_pii": True}, + True, + True, + id="legacy_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + True, + False, + id="legacy_pii_false", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + False, + False, + id="no_client", + ), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("init_kwargs, has_client, expect_ip", USER_INFO_CASES) +async def test_get_request_data_env_user_info( + sentry_init, capture_events, asgi3_app, init_kwargs, has_client, expect_ip +): + sentry_init(traces_sample_rate=1.0, **init_kwargs) + app = SentryAsgiMiddleware(asgi3_app) + + scope = _http_scope() + if has_client: + scope["client"] = ("127.0.0.1", 60457) + + events = capture_events() + async with TestClient(app, scope=scope) as client: + await client.get("/foo", headers={"host": "example.com"}) + + sentry_sdk.flush() + + (transaction_event,) = events + request_data = transaction_event["request"] + + if expect_ip: + assert request_data["env"] == {"REMOTE_ADDR": "127.0.0.1"} + else: + assert "env" not in request_data + + +@pytest.mark.asyncio +@pytest.mark.parametrize("init_kwargs, has_client, expect_ip", USER_INFO_CASES) +async def test_get_request_attributes_client_address_user_info( + sentry_init, capture_items, asgi3_app, init_kwargs, has_client, expect_ip +): + kwargs = {k: v for k, v in init_kwargs.items() if k != "_experiments"} + experiments = {"trace_lifecycle": "stream", **init_kwargs.get("_experiments", {})} + sentry_init(traces_sample_rate=1.0, _experiments=experiments, **kwargs) + app = SentryAsgiMiddleware(asgi3_app) + + scope = _http_scope() + if has_client: + scope["client"] = ("127.0.0.1", 60457) + + items = capture_items("span") + async with TestClient(app, scope=scope) as client: + await client.get("/foo", headers={"host": "example.com"}) + + sentry_sdk.flush() + + assert len(items) == 1 + attributes = items[0].payload["attributes"] + + if expect_ip: + assert attributes["client.address"] == "127.0.0.1" + else: + assert "client.address" not in attributes + + @pytest.mark.asyncio @pytest.mark.parametrize( "request_url,transaction_style,expected_transaction_name,expected_transaction_source", diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionAllowlist/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionAllowlist/.gitignore new file mode 100644 index 0000000000..1c56884372 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionAllowlist/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionAllowlist/.lock b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionAllowlist/.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionAllowlist/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionAllowlist/index.py new file mode 100644 index 0000000000..a79e1a03a7 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionAllowlist/index.py @@ -0,0 +1,27 @@ +import os + +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + integrations=[AwsLambdaIntegration()], + _experiments={ + "data_collection": { + "http_headers": { + "request": { + "mode": "allowlist", + # "authorization" is allowlisted on purpose to show that an + # allowlist entry cannot override the built-in sensitive + # denylist. + "terms": ["user-agent", "x-allow-me", "authorization"], + } + } + } + }, +) + + +def handler(event, context): + return {"event": event} diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionDenylist/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionDenylist/.gitignore new file mode 100644 index 0000000000..1c56884372 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionDenylist/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionDenylist/.lock b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionDenylist/.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionDenylist/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionDenylist/index.py new file mode 100644 index 0000000000..4c59de9cb4 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionDenylist/index.py @@ -0,0 +1,26 @@ +import os + +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + integrations=[AwsLambdaIntegration()], + _experiments={ + "data_collection": { + "http_headers": { + "request": { + "mode": "denylist", + # Custom terms deny otherwise non-sensitive headers on top + # of the built-in sensitive denylist. + "terms": ["x-forwarded", "user-agent"], + } + } + } + }, +) + + +def handler(event, context): + return {"event": event} diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionOff/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionOff/.gitignore new file mode 100644 index 0000000000..1c56884372 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionOff/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionOff/.lock b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionOff/.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionOff/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionOff/index.py new file mode 100644 index 0000000000..6f3a7a8126 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionOff/index.py @@ -0,0 +1,21 @@ +import os + +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + integrations=[AwsLambdaIntegration()], + _experiments={ + "data_collection": { + "http_headers": { + "request": {"mode": "off"}, + } + } + }, +) + + +def handler(event, context): + return {"event": event} diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryAllowlist/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryAllowlist/.gitignore new file mode 100644 index 0000000000..1c56884372 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryAllowlist/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryAllowlist/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryAllowlist/index.py new file mode 100644 index 0000000000..67ed8be6e3 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryAllowlist/index.py @@ -0,0 +1,24 @@ +import os + +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + integrations=[AwsLambdaIntegration()], + _experiments={ + "data_collection": { + "url_query_params": { + "mode": "allowlist", + # "token" is allowlisted on purpose to show that an allowlist + # entry cannot override the built-in sensitive denylist. + "terms": ["page", "token"], + } + } + }, +) + + +def handler(event, context): + return {"event": event} diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryDenylist/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryDenylist/.gitignore new file mode 100644 index 0000000000..1c56884372 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryDenylist/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryDenylist/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryDenylist/index.py new file mode 100644 index 0000000000..389d7890ea --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryDenylist/index.py @@ -0,0 +1,24 @@ +import os + +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + integrations=[AwsLambdaIntegration()], + _experiments={ + "data_collection": { + "url_query_params": { + "mode": "denylist", + # Custom terms deny otherwise non-sensitive query params on top + # of the built-in sensitive denylist. + "terms": ["tracking"], + } + } + }, +) + + +def handler(event, context): + return {"event": event} diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryOff/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryOff/.gitignore new file mode 100644 index 0000000000..1c56884372 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryOff/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryOff/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryOff/index.py new file mode 100644 index 0000000000..02446562d9 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkDataCollectionUrlQueryOff/index.py @@ -0,0 +1,19 @@ +import os + +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + integrations=[AwsLambdaIntegration()], + _experiments={ + "data_collection": { + "url_query_params": {"mode": "off"}, + } + }, +) + + +def handler(event, context): + return {"event": event} diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSendDefaultPii/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSendDefaultPii/.gitignore new file mode 100644 index 0000000000..1c56884372 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSendDefaultPii/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSendDefaultPii/.lock b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSendDefaultPii/.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSendDefaultPii/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSendDefaultPii/index.py new file mode 100644 index 0000000000..502a6ae3ee --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSendDefaultPii/index.py @@ -0,0 +1,15 @@ +import os + +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + send_default_pii=True, + integrations=[AwsLambdaIntegration()], +) + + +def handler(event, context): + return {"event": event} diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSpanStreamingDataCollection/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSpanStreamingDataCollection/.gitignore new file mode 100644 index 0000000000..1c56884372 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSpanStreamingDataCollection/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSpanStreamingDataCollection/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSpanStreamingDataCollection/index.py new file mode 100644 index 0000000000..cb6c4ac9ea --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSpanStreamingDataCollection/index.py @@ -0,0 +1,23 @@ +import os + +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + integrations=[AwsLambdaIntegration()], + _experiments={ + "trace_lifecycle": "stream", + "data_collection": { + "url_query_params": { + "mode": "denylist", + "terms": ["tracking"], + } + }, + }, +) + + +def handler(event, context): + return {"event": event} diff --git a/tests/integrations/aws_lambda/test_aws_lambda.py b/tests/integrations/aws_lambda/test_aws_lambda.py index ec01284e58..a89979c46a 100644 --- a/tests/integrations/aws_lambda/test_aws_lambda.py +++ b/tests/integrations/aws_lambda/test_aws_lambda.py @@ -354,7 +354,7 @@ def test_non_dict_event( assert transaction_event["tags"]["batch_request"] is True -def test_request_data(lambda_client, test_environment): +def test_request_data_with_send_default_pii_false(lambda_client, test_environment): payload = b""" { "resource": "/asd", @@ -363,7 +363,9 @@ def test_request_data(lambda_client, test_environment): "headers": { "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", "User-Agent": "custom", - "X-Forwarded-Proto": "https" + "X-Forwarded-Proto": "https", + "Authorization": "Bearer secret-token", + "Cookie": "sessionid=secret" }, "queryStringParameters": { "bonkers": "true" @@ -393,7 +395,13 @@ def test_request_data(lambda_client, test_environment): "headers": { "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", "User-Agent": "custom", + # X-Forwarded-Proto is not sensitive and passes through. "X-Forwarded-Proto": "https", + # With send_default_pii=False, _filter_headers substitutes the + # SENSITIVE_HEADERS (Authorization, Cookie); the EventScrubber + # also scrubs them. Both end up as "[Filtered]". + "Authorization": "[Filtered]", + "Cookie": "[Filtered]", }, "method": "GET", "query_string": {"bonkers": "true"}, @@ -401,6 +409,359 @@ def test_request_data(lambda_client, test_environment): } +def test_request_data_with_send_default_pii_true(lambda_client, test_environment): + payload = b""" + { + "resource": "/asd", + "path": "/asd", + "httpMethod": "GET", + "headers": { + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "User-Agent": "custom", + "X-Forwarded-Proto": "https", + "Authorization": "Bearer secret-token", + "Cookie": "sessionid=secret" + }, + "queryStringParameters": { + "bonkers": "true" + }, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "identity": { + "sourceIp": "213.47.147.207", + "userArn": "42" + } + }, + "body": null, + "isBase64Encoded": false + } + """ + + lambda_client.invoke( + FunctionName="BasicOkSendDefaultPii", + Payload=payload, + ) + envelopes = test_environment["server"].envelopes + + (transaction_event,) = envelopes + + assert transaction_event["request"] == { + "headers": { + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "User-Agent": "custom", + "X-Forwarded-Proto": "https", + # With send_default_pii=True (and no data_collection config), + # _filter_headers passes headers through untouched. Authorization + # and Cookie are still scrubbed to "[Filtered]" by the always-on + # EventScrubber (DEFAULT_DENYLIST), independent of PII settings. + "Authorization": "[Filtered]", + "Cookie": "[Filtered]", + }, + "method": "GET", + "query_string": {"bonkers": "true"}, + "url": "https://iwsz2c7uwi.execute-api.us-east-1.amazonaws.com/asd", + "data": None, + } + + +def test_request_data_with_data_collection_allowlist(lambda_client, test_environment): + payload = b""" + { + "resource": "/asd", + "path": "/asd", + "httpMethod": "GET", + "headers": { + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "User-Agent": "custom", + "X-Forwarded-Proto": "https", + "Authorization": "Bearer secret-token", + "Cookie": "sessionid=secret", + "X-Allow-Me": "yes" + }, + "queryStringParameters": { + "bonkers": "true" + }, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "identity": { + "sourceIp": "213.47.147.207", + "userArn": "42" + } + }, + "body": null, + "isBase64Encoded": false + } + """ + + lambda_client.invoke( + FunctionName="BasicOkDataCollectionAllowlist", + Payload=payload, + ) + envelopes = test_environment["server"].envelopes + + (transaction_event,) = envelopes + + assert transaction_event["request"] == { + "headers": { + # Allowlisted, non-sensitive headers pass through. + "User-Agent": "custom", + "X-Allow-Me": "yes", + # Not allowlisted -> substituted. + "Host": "[Filtered]", + "X-Forwarded-Proto": "[Filtered]", + # Allowlisted but sensitive -> still filtered; an allowlist entry + # cannot override the built-in sensitive denylist. + "Authorization": "[Filtered]", + # Not allowlisted, and cookies are always substituted. + "Cookie": "[Filtered]", + }, + "method": "GET", + "query_string": {"bonkers": "true"}, + "url": "https://iwsz2c7uwi.execute-api.us-east-1.amazonaws.com/asd", + } + + +def test_request_data_with_data_collection_denylist(lambda_client, test_environment): + payload = b""" + { + "resource": "/asd", + "path": "/asd", + "httpMethod": "GET", + "headers": { + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "User-Agent": "custom", + "X-Forwarded-Proto": "https", + "Authorization": "Bearer secret-token", + "Cookie": "sessionid=secret", + "X-Custom": "keep-me" + }, + "queryStringParameters": { + "bonkers": "true" + }, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "identity": { + "sourceIp": "213.47.147.207", + "userArn": "42" + } + }, + "body": null, + "isBase64Encoded": false + } + """ + + lambda_client.invoke( + FunctionName="BasicOkDataCollectionDenylist", + Payload=payload, + ) + envelopes = test_environment["server"].envelopes + + (transaction_event,) = envelopes + + assert transaction_event["request"] == { + "headers": { + # Not denied by any term -> pass through. + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "X-Custom": "keep-me", + # Denied by custom terms. + "User-Agent": "[Filtered]", + "X-Forwarded-Proto": "[Filtered]", + # Denied by the built-in sensitive denylist. + "Authorization": "[Filtered]", + # Cookies are always substituted. + "Cookie": "[Filtered]", + }, + "method": "GET", + "query_string": {"bonkers": "true"}, + "url": "https://iwsz2c7uwi.execute-api.us-east-1.amazonaws.com/asd", + } + + +def test_request_data_with_data_collection_off(lambda_client, test_environment): + payload = b""" + { + "resource": "/asd", + "path": "/asd", + "httpMethod": "GET", + "headers": { + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "User-Agent": "custom", + "X-Forwarded-Proto": "https", + "Authorization": "Bearer secret-token", + "Cookie": "sessionid=secret" + }, + "queryStringParameters": { + "bonkers": "true" + }, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "identity": { + "sourceIp": "213.47.147.207", + "userArn": "42" + } + }, + "body": null, + "isBase64Encoded": false + } + """ + + lambda_client.invoke( + FunctionName="BasicOkDataCollectionOff", + Payload=payload, + ) + envelopes = test_environment["server"].envelopes + + (transaction_event,) = envelopes + + assert transaction_event["request"] == { + # With request headers collection turned off, no headers are collected. + "headers": {}, + "method": "GET", + "query_string": {"bonkers": "true"}, + "url": "https://iwsz2c7uwi.execute-api.us-east-1.amazonaws.com/asd", + } + + +def test_url_query_params_with_data_collection_denylist( + lambda_client, test_environment +): + payload = b""" + { + "resource": "/asd", + "path": "/asd", + "httpMethod": "GET", + "headers": { + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "X-Forwarded-Proto": "https" + }, + "queryStringParameters": { + "page": "2", + "tracking": "campaign", + "token": "secret-token" + }, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "identity": { + "sourceIp": "213.47.147.207", + "userArn": "42" + } + }, + "body": null, + "isBase64Encoded": false + } + """ + + lambda_client.invoke( + FunctionName="BasicOkDataCollectionUrlQueryDenylist", + Payload=payload, + ) + envelopes = test_environment["server"].envelopes + + (transaction_event,) = envelopes + + assert transaction_event["request"]["query_string"] == { + # Not denied by any term -> pass through. + "page": "2", + # Denied by custom terms. + "tracking": "[Filtered]", + # Denied by the built-in sensitive denylist. + "token": "[Filtered]", + } + + +def test_url_query_params_with_data_collection_allowlist( + lambda_client, test_environment +): + payload = b""" + { + "resource": "/asd", + "path": "/asd", + "httpMethod": "GET", + "headers": { + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "X-Forwarded-Proto": "https" + }, + "queryStringParameters": { + "page": "2", + "tracking": "campaign", + "token": "secret-token" + }, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "identity": { + "sourceIp": "213.47.147.207", + "userArn": "42" + } + }, + "body": null, + "isBase64Encoded": false + } + """ + + lambda_client.invoke( + FunctionName="BasicOkDataCollectionUrlQueryAllowlist", + Payload=payload, + ) + envelopes = test_environment["server"].envelopes + + (transaction_event,) = envelopes + + assert transaction_event["request"]["query_string"] == { + # Allowlisted, non-sensitive -> pass through. + "page": "2", + # Not allowlisted -> substituted. + "tracking": "[Filtered]", + # Allowlisted but sensitive -> still filtered; an allowlist entry + # cannot override the built-in sensitive denylist. + "token": "[Filtered]", + } + + +def test_url_query_params_with_data_collection_off(lambda_client, test_environment): + payload = b""" + { + "resource": "/asd", + "path": "/asd", + "httpMethod": "GET", + "headers": { + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "X-Forwarded-Proto": "https" + }, + "queryStringParameters": { + "page": "2", + "tracking": "campaign" + }, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "identity": { + "sourceIp": "213.47.147.207", + "userArn": "42" + } + }, + "body": null, + "isBase64Encoded": false + } + """ + + lambda_client.invoke( + FunctionName="BasicOkDataCollectionUrlQueryOff", + Payload=payload, + ) + envelopes = test_environment["server"].envelopes + + (transaction_event,) = envelopes + + # With url_query_params collection turned off, no query string is collected. + assert "query_string" not in transaction_event["request"] + + def test_trace_continuation(lambda_client, test_environment): trace_id = "471a43a4192642f0b136d5159a501701" parent_span_id = "6e8f22c393e68f19" @@ -708,6 +1069,38 @@ def test_span_streaming_request_attributes(lambda_client, test_environment): assert _get_span_attr(attrs, "aws.log.stream.names") == ["$LATEST"] +def test_span_streaming_url_query_params_with_data_collection( + lambda_client, test_environment +): + payload = { + "httpMethod": "GET", + "queryStringParameters": { + "page": "2", + "tracking": "campaign", + "token": "secret-token", + }, + "path": "/test", + } + + lambda_client.invoke( + FunctionName="BasicOkSpanStreamingDataCollection", + Payload=json.dumps(payload), + ) + span_items = test_environment["server"].span_items + + segment_spans = [s for s in span_items if s["is_segment"]] + assert len(segment_spans) == 1 + segment_span = segment_spans[0] + attrs = segment_span["attributes"] + + # "page" passes through; "tracking" is denied by a custom term and "token" + # by the built-in sensitive denylist. + assert ( + _get_span_attr(attrs, "url.query") + == "page=2&tracking=%5BFiltered%5D&token=%5BFiltered%5D" + ) + + @pytest.mark.parametrize( "lambda_function_name", ["RaiseErrorPerformanceEnabled", "RaiseErrorPerformanceDisabled"], diff --git a/tests/integrations/django/test_data_scrubbing.py b/tests/integrations/django/test_data_scrubbing.py index 643a68d1b3..0c9497c18d 100644 --- a/tests/integrations/django/test_data_scrubbing.py +++ b/tests/integrations/django/test_data_scrubbing.py @@ -1,8 +1,10 @@ import pytest from werkzeug.test import Client +import sentry_sdk +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.django import DjangoIntegration -from tests.conftest import werkzeug_set_cookie +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 @@ -12,6 +14,12 @@ from django.core.urlresolvers import reverse +NO_COOKIES = object() + +# Sentinel: the query string (event) / ``http.query`` attribute (span) is absent. +NO_QUERY_STRING = object() + + @pytest.fixture def client(): return Client(application) @@ -99,3 +107,292 @@ def test_scrub_django_custom_session_cookies_filtered( "csrf_secret": "[Filtered]", "foo": "bar", } + + +@pytest.mark.forked +@pytest_mark_django_db_decorator() +@pytest.mark.parametrize( + "cookies_to_set, data_collection, expected_cookies", + [ + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar"}, + {"cookies": {"mode": "off"}}, + NO_COOKIES, + id="off", + ), + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar"}, + {"cookies": {"mode": "denylist"}}, + { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "bar", + }, + id="denylist-default", + ), + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar"}, + {"cookies": {"mode": "denylist", "terms": ["foo"]}}, + { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "[Filtered]", + }, + id="denylist-extra-terms", + ), + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar", "bar": "baz"}, + {"cookies": {"mode": "allowlist", "terms": ["foo"]}}, + { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "bar", + "bar": "[Filtered]", + }, + id="allowlist", + ), + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar", "bar": "baz"}, + {"cookies": {"mode": "allowlist", "terms": ["sessionid", "foo"]}}, + { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "bar", + "bar": "[Filtered]", + }, + id="allowlist-cannot-override-sensitive", + ), + pytest.param( + {"sessionid": "123", "csrftoken": "456", "foo": "bar"}, + {}, + { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "bar", + }, + id="cookies-omitted-defaults-to-denylist", + ), + ], +) +def test_data_collection_cookies( + sentry_init, + client, + capture_items, + cookies_to_set, + data_collection, + expected_cookies, +): + sentry_init( + integrations=[DjangoIntegration()], + _experiments={"data_collection": data_collection}, + ) + items = capture_items("event") + for name, value in cookies_to_set.items(): + werkzeug_set_cookie(client, "localhost", name, value) + client.get(reverse("view_exc")) + + (event,) = (item.payload for item in items if item.type == "event") + if expected_cookies is NO_COOKIES: + assert "cookies" not in event["request"] + else: + assert event["request"]["cookies"] == expected_cookies + + +@pytest.mark.forked +@pytest_mark_django_db_decorator() +def test_data_collection_cookies_precedence_over_send_default_pii( + sentry_init, client, capture_items +): + # ``data_collection`` is the single source of truth: even with + # ``send_default_pii=False``, the configured cookie behaviour still applies. + sentry_init( + integrations=[DjangoIntegration()], + send_default_pii=False, + _experiments={"data_collection": {"cookies": {"mode": "denylist"}}}, + ) + items = capture_items("event") + werkzeug_set_cookie(client, "localhost", "sessionid", "123") + werkzeug_set_cookie(client, "localhost", "csrftoken", "456") + werkzeug_set_cookie(client, "localhost", "foo", "bar") + client.get(reverse("view_exc")) + + (event,) = (item.payload for item in items if item.type == "event") + assert event["request"]["cookies"] == { + "sessionid": "[Filtered]", + "csrftoken": "[Filtered]", + "foo": "bar", + } + + +# 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. +QUERY_STRING = "toy=tennisball&color=red&auth=secret" + + +@pytest.mark.forked +@pytest_mark_django_db_decorator() +@pytest.mark.parametrize( + "init_kwargs, expected_query_string", + [ + pytest.param( + {"send_default_pii": True}, + "toy=tennisball&color=red&auth=secret", + id="legacy_send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + "toy=tennisball&color=red&auth=secret", + id="legacy_send_default_pii_false", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + } + }, + NO_QUERY_STRING, + id="data_collection_off", + ), + ], +) +def test_query_string_data_collection( + sentry_init, + client, + capture_events, + init_kwargs, + expected_query_string, +): + sentry_init(integrations=[DjangoIntegration()], **init_kwargs) + events = capture_events() + + client.get(reverse("view_exc") + "?" + QUERY_STRING) + + (event,) = events + + if expected_query_string is NO_QUERY_STRING: + assert "query_string" not in event["request"] + else: + assert event["request"]["query_string"] == expected_query_string + + +@pytest.mark.forked +@pytest_mark_django_db_decorator() +@pytest.mark.parametrize( + "init_kwargs, expected_query", + [ + pytest.param( + {"send_default_pii": True}, + "toy=tennisball&color=red&auth=secret", + id="legacy_send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_QUERY_STRING, + id="legacy_send_default_pii_false", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + } + }, + NO_QUERY_STRING, + id="data_collection_off", + ), + ], +) +def test_span_http_query_data_collection( + sentry_init, + client, + capture_items, + init_kwargs, + expected_query, +): + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + **init_kwargs.pop("_experiments", {}), + }, + **init_kwargs, + ) + + items = capture_items("span") + + unpack_werkzeug_response(client.get(reverse("message") + "?" + QUERY_STRING)) + + sentry_sdk.flush() + + spans = [item.payload for item in items] + (root_span,) = (span for span in spans if span["name"] == "/message") + + if expected_query is NO_QUERY_STRING: + assert SPANDATA.HTTP_QUERY not in root_span["attributes"] + else: + assert root_span["attributes"][SPANDATA.HTTP_QUERY] == expected_query + + +@pytest.mark.forked +@pytest_mark_django_db_decorator() +def test_query_string_empty_legacy_emits_empty_string( + sentry_init, client, capture_events +): + sentry_init(integrations=[DjangoIntegration()], send_default_pii=True) + events = capture_events() + + client.get(reverse("view_exc")) + + (event,) = events + assert event["request"]["query_string"] == "" + + +@pytest.mark.forked +@pytest_mark_django_db_decorator() +def test_empty_query_string_is_dropped_with_data_collection( + sentry_init, client, capture_events +): + # ``data_collection`` path: an empty query string is dropped entirely to + # reduce envelope size, so the ``query_string`` key is absent. + sentry_init( + integrations=[DjangoIntegration()], + _experiments={"data_collection": {}}, + ) + events = capture_events() + + client.get(reverse("view_exc")) + + (event,) = events + assert "query_string" not in event["request"] diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index 87e0b959cc..6ff170de2d 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -29,9 +29,16 @@ capture_message, set_tag, ) +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.serializer import MAX_DATABAG_BREADTH +NO_QUERY_STRING = object() + +# 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. +QUERY_STRING = "toy=tennisball&color=red&auth=secret" + login_manager = LoginManager() @@ -1220,3 +1227,174 @@ def test_transaction_or_segment_http_method_custom( (event1, event2) = events assert event1["request"]["method"] == "OPTIONS" assert event2["request"]["method"] == "HEAD" + + +@pytest.mark.parametrize( + "init_kwargs, expected_query_string", + [ + pytest.param( + {"send_default_pii": True}, + "toy=tennisball&color=red&auth=secret", + id="legacy_send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + "toy=tennisball&color=red&auth=secret", + id="legacy_send_default_pii_false", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + } + }, + NO_QUERY_STRING, + id="data_collection_off", + ), + ], +) +def test_query_string_data_collection( + sentry_init, + app, + capture_events, + monkeypatch, + init_kwargs, + expected_query_string, +): + sentry_init(integrations=[flask_sentry.FlaskIntegration()], **init_kwargs) + # This test is about query-string filtering, not user data. Disable + # flask_login so the module-level login manager (which has no user_loader) + # does not raise when send_default_pii is on. + monkeypatch.setattr(flask_sentry, "flask_login", None) + events = capture_events() + + client = app.test_client() + client.get("/message?" + QUERY_STRING) + + (event,) = events + + if expected_query_string is NO_QUERY_STRING: + assert "query_string" not in event["request"] + else: + assert event["request"]["query_string"] == expected_query_string + + +@pytest.mark.parametrize( + "init_kwargs, expected_query", + [ + pytest.param( + {"send_default_pii": True}, + "toy=tennisball&color=red&auth=secret", + id="legacy_send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_QUERY_STRING, + id="legacy_send_default_pii_false", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + } + }, + NO_QUERY_STRING, + id="data_collection_off", + ), + ], +) +def test_span_http_query_data_collection( + sentry_init, + app, + capture_items, + monkeypatch, + init_kwargs, + expected_query, +): + sentry_init( + integrations=[flask_sentry.FlaskIntegration()], + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + **init_kwargs.pop("_experiments", {}), + }, + **init_kwargs, + ) + monkeypatch.setattr(flask_sentry, "flask_login", None) + + items = capture_items("span") + + client = app.test_client() + client.get("/message?" + QUERY_STRING) + + sentry_sdk.flush() + + spans = [item.payload for item in items if item.type == "span"] + (segment,) = spans + + if expected_query is NO_QUERY_STRING: + assert SPANDATA.HTTP_QUERY not in segment["attributes"] + else: + assert segment["attributes"][SPANDATA.HTTP_QUERY] == expected_query + + +def test_query_string_empty_legacy_emits_empty_string( + sentry_init, app, capture_events, monkeypatch +): + sentry_init(integrations=[flask_sentry.FlaskIntegration()], send_default_pii=True) + monkeypatch.setattr(flask_sentry, "flask_login", None) + events = capture_events() + + client = app.test_client() + client.get("/message") + + (event,) = events + assert event["request"]["query_string"] == "" + + +def test_empty_query_string_is_dropped_with_data_collection( + sentry_init, app, capture_events +): + sentry_init( + integrations=[flask_sentry.FlaskIntegration()], + _experiments={"data_collection": {}}, + ) + events = capture_events() + + client = app.test_client() + client.get("/message") + + (event,) = events + assert "query_string" not in event["request"] diff --git a/tests/integrations/gcp/test_gcp.py b/tests/integrations/gcp/test_gcp.py index 86638b1644..7dd9b475a8 100644 --- a/tests/integrations/gcp/test_gcp.py +++ b/tests/integrations/gcp/test_gcp.py @@ -786,3 +786,174 @@ def cloud_function(functionhandler, event): assert attrs["gcp.project.id"] == "serverless_project" assert attrs["faas.identity"] == "func_ID" assert attrs["faas.entry_point"] == "cloud_function" + + +# Sentinel: the query string (event) / ``url.query`` attribute (span) is absent. +NO_QUERY_STRING = "__NO_QUERY_STRING__" + + +# Each case is (send_default_pii, data_collection, expected_event, expected_span). +# ``send_default_pii`` / ``data_collection`` of None means the option is omitted. +# The two paths only diverge for the legacy (no ``data_collection``) rows: the +# event processor always records the raw query string, while the span-streaming +# path gates the ``url.query`` attribute on ``send_default_pii``. +_QUERY_STRING_DATA_COLLECTION_CASES = [ + pytest.param( + True, + None, + "toy=tennisball&color=red&auth=secret", + "toy=tennisball&color=red&auth=secret", + id="send_default_pii_true", + ), + pytest.param( + False, + None, + "toy=tennisball&color=red&auth=secret", + NO_QUERY_STRING, + id="send_default_pii_false", + ), + pytest.param( + None, + None, + "toy=tennisball&color=red&auth=secret", + NO_QUERY_STRING, + id="defaults", + ), + # data_collection configured: query string is routed through filtering. + # Spec defaults -> denylist: only the sensitive ``auth`` is redacted. + pytest.param( + None, + {}, + "toy=tennisball&color=red&auth=[Filtered]", + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + None, + {"url_query_params": {"mode": "denylist", "terms": ["toy"]}}, + "toy=[Filtered]&color=red&auth=[Filtered]", + "toy=[Filtered]&color=red&auth=[Filtered]", + id="data_collection_denylist_custom_terms", + ), + # allowlist with only ``toy`` allowed: ``color`` is redacted even though it + # is not sensitive, proving the redaction comes from the allowlist. + pytest.param( + None, + {"url_query_params": {"mode": "allowlist", "terms": ["toy"]}}, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + None, + {"url_query_params": {"mode": "off"}}, + NO_QUERY_STRING, + NO_QUERY_STRING, + id="data_collection_off", + ), +] + + +def _build_init_kwargs(send_default_pii, data_collection): + """Render the keyword-argument string passed to ``init_sdk`` in the + subprocess.""" + kwargs = [] + if send_default_pii is not None: + kwargs.append("send_default_pii=%r" % send_default_pii) + if data_collection is not None: + kwargs.append("_experiments=%r" % {"data_collection": data_collection}) + + return ", ".join(kwargs) + + +@pytest.mark.parametrize( + "send_default_pii, data_collection, expected_event, expected_span", + _QUERY_STRING_DATA_COLLECTION_CASES, +) +def test_query_string_data_collection_event_processor( + run_cloud_function, + send_default_pii, + data_collection, + expected_event, + expected_span, +): + init_kwargs = _build_init_kwargs(send_default_pii, data_collection) + envelope_items, _, _ = run_cloud_function( + dedent( + """ + functionhandler = None + + from collections import namedtuple + GCPEvent = namedtuple("GCPEvent", ["headers", "method", "query_string"]) + event = GCPEvent( + headers={}, + method="GET", + query_string=b"toy=tennisball&color=red&auth=secret", + ) + + def cloud_function(functionhandler, event): + raise Exception("something went wrong") + """ + ) + + FUNCTIONS_PRELUDE + + dedent( + """ + init_sdk(%s) + gcp_functions.worker_v1.FunctionHandler.invoke_user_function(functionhandler, event) + """ + % init_kwargs + ) + ) + + request = envelope_items[0]["request"] + if expected_event == NO_QUERY_STRING: + assert "query_string" not in request + else: + assert request["query_string"] == expected_event + + +@pytest.mark.parametrize( + "send_default_pii, data_collection, expected_event, expected_span", + _QUERY_STRING_DATA_COLLECTION_CASES, +) +def test_query_string_data_collection_span_streaming( + run_cloud_function, + send_default_pii, + data_collection, + expected_event, + expected_span, +): + init_kwargs = _build_init_kwargs(send_default_pii, data_collection) + _, _, span_items = run_cloud_function( + dedent( + """ + functionhandler = None + + from collections import namedtuple + GCPEvent = namedtuple("GCPEvent", ["headers", "method", "query_string"]) + event = GCPEvent( + headers={}, + method="GET", + query_string=b"toy=tennisball&color=red&auth=secret", + ) + + def cloud_function(functionhandler, event): + return "ok" + """ + ) + + FUNCTIONS_PRELUDE + + dedent( + """ + init_sdk(traces_sample_rate=1.0, trace_lifecycle="stream", %s) + gcp_functions.worker_v1.FunctionHandler.invoke_user_function(functionhandler, event) + """ + % init_kwargs + ) + ) + + assert len(span_items) == 1 + attrs = span_items[0]["attributes"] + if expected_span == NO_QUERY_STRING: + assert "url.query" not in attrs + else: + assert attrs["url.query"] == expected_span diff --git a/tests/integrations/litestar/test_litestar.py b/tests/integrations/litestar/test_litestar.py index 4abb037e36..b7af8e98c8 100644 --- a/tests/integrations/litestar/test_litestar.py +++ b/tests/integrations/litestar/test_litestar.py @@ -15,6 +15,7 @@ import sentry_sdk from sentry_sdk import capture_message +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.integrations.litestar import LitestarIntegration from tests.conftest import ApproxDict from tests.integrations.conftest import parametrize_test_configurable_status_codes @@ -683,6 +684,139 @@ async def __call__(self, scope, receive, send): assert "user" not in event +COOKIE_HEADER = "jwt=tokenval; theme=dark; lang=en; identity=alice" + +# Sentinel meaning "the request payload should have no ``cookies`` key at all", +# as opposed to an empty ``{}`` dict. +NO_COOKIES = object() + + +@pytest.mark.parametrize( + "init_kwargs, expected_cookies", + [ + pytest.param( + {"send_default_pii": True}, + { + "jwt": "tokenval", + "theme": "dark", + "lang": "en", + "identity": "alice", + }, + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_COOKIES, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_COOKIES, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, + NO_COOKIES, + id="data_collection_off", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}}, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "denylist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["identity"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}, + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_wins_over_send_default_pii", + ), + ], +) +def test_cookie_data_collection( + sentry_init, capture_events, init_kwargs, expected_cookies +): + sentry_init( + traces_sample_rate=1.0, + integrations=[LitestarIntegration()], + **init_kwargs, + ) + + litestar_app = litestar_app_factory() + events = capture_events() + + client = TestClient(litestar_app) + client.get("/message", headers={"cookie": COOKIE_HEADER}) + + (event, transaction_event) = events + + if expected_cookies is NO_COOKIES: + assert "cookies" not in event["request"] + assert "cookies" not in transaction_event["request"] + else: + assert event["request"]["cookies"] == expected_cookies + assert transaction_event["request"]["cookies"] == expected_cookies + + @parametrize_test_configurable_status_codes @pytest.mark.parametrize("span_streaming", [True, False]) def test_configurable_status_codes_handler( diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py index 730b24ef33..0d80c1cad2 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -13,8 +13,8 @@ capture_message, set_tag, ) +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE def quart_app_factory(): @@ -861,8 +861,171 @@ async def test_span_streaming_request_attributes_with_pii(sentry_init, capture_i assert "user.ip_address" in segment["attributes"] +@pytest.mark.parametrize( + "options,expected", + [ + pytest.param( + { + "send_default_pii": True, + "data_collection": None, + }, + { + "authorization": "[Filtered]", + "custom": "passthrough", + "cookie": "[Filtered]", + }, + id="enabled_send_default_pii_redacts_auth_header_due_to_data_collection_default_settings", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": None, + }, + { + "authorization": "[Filtered]", + "custom": "passthrough", + "cookie": "[Filtered]", + }, + id="disabled_send_default_pii_redacts_auth_header_due_to_data_collection_default_settings", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": {"http_headers": {"request": {"mode": "off"}}}, + }, + None, + id="data_collection_off_does_not_add_headers", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": {"http_headers": {"request": {"mode": "allowlist"}}}, + }, + { + "authorization": "[Filtered]", + "custom": "[Filtered]", + "cookie": "[Filtered]", + }, + id="data_collection_allow_list_redacts_terms_that_do_not_appear", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": { + "http_headers": { + "request": {"mode": "allowlist", "terms": ["Authorization"]} + } + }, + }, + { + "authorization": "[Filtered]", + "custom": "[Filtered]", + "cookie": "[Filtered]", + }, + id="data_collection_allow_list_redacts_sensitive_terms_even_when_provided_by_user", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": { + "http_headers": { + "request": {"mode": "allowlist", "terms": ["custom"]} + } + }, + }, + { + "authorization": "[Filtered]", + "custom": "passthrough", + "cookie": "[Filtered]", + }, + id="data_collection_allow_list_does_not_redact_provided_term", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": { + "http_headers": { + "request": {"mode": "denylist", "terms": ["custom"]} + } + }, + }, + { + "authorization": "[Filtered]", + "custom": "[Filtered]", + "cookie": "[Filtered]", + }, + id="data_collection_deny_list_redacts_sensitive_terms_when_provided_by_user", + ), + pytest.param( + { + "send_default_pii": False, + "data_collection": { + "http_headers": { + "request": {"mode": "allowlist", "terms": ["cookie"]} + } + }, + }, + { + "authorization": "[Filtered]", + "custom": "[Filtered]", + "cookie": "[Filtered]", + }, + id="data_collection_cookie_is_always_redacted_even_when_allow_listed", + ), + ], +) +@pytest.mark.asyncio +async def test_span_streaming_sensitive_header_scrubbing( + sentry_init, capture_items, options, expected, request +): + sentry_init( + integrations=[quart_sentry.QuartIntegration()], + traces_sample_rate=1.0, + send_default_pii=options["send_default_pii"], + _experiments={ + "trace_lifecycle": "stream", + "data_collection": options["data_collection"], + }, + ) + 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", + "Cookie": "sessionid=secret", + }, + ) + assert response.status_code == 200 + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 1 + + segment = spans[0] + if request.node.callspec.id == "data_collection_off_does_not_add_headers": + assert "http.request.header.authorization" not in segment["attributes"] + assert "http.request.header.cookie" not in segment["attributes"] + else: + assert ( + segment["attributes"]["http.request.header.authorization"] + == expected["authorization"] + ) + assert ( + segment["attributes"]["http.request.header.x-custom-header"] + == expected["custom"] + ) + assert segment["attributes"]["http.request.header.cookie"] == expected["cookie"] + + @pytest.mark.asyncio -async def test_span_streaming_sensitive_header_scrubbing(sentry_init, capture_items): +async def test_span_streaming_sensitive_header_without_data_collection( + sentry_init, capture_items +): sentry_init( integrations=[quart_sentry.QuartIntegration()], traces_sample_rate=1.0, @@ -939,7 +1102,7 @@ async def login(): @pytest.mark.asyncio -async def test_span_streaming_sensitive_header_passthrough_with_pii( +async def test_span_streaming_sensitive_header_passthrough_with_pii_and_no_data_collection( sentry_init, capture_items ): sentry_init( @@ -968,3 +1131,155 @@ async def test_span_streaming_sensitive_header_passthrough_with_pii( segment["attributes"]["http.request.header.authorization"] == "Bearer secret-token" ) + + +NO_QUERY_STRING = object() +_QUERY_PARAM_DATA_COLLECTION_CASES = [ + pytest.param( + {"send_default_pii": True}, + "toy=tennisball&color=red&auth=secret", + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_QUERY_STRING, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_QUERY_STRING, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "denylist", "terms": ["toy"]} + } + } + }, + "toy=[Filtered]&color=red&auth=[Filtered]", + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["auth"]} + } + } + }, + "toy=[Filtered]&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + {"_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}}, + NO_QUERY_STRING, + id="data_collection_off", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}, + }, + NO_QUERY_STRING, + id="data_collection_wins_over_send_default_pii", + ), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "init_kwargs, expected_query", _QUERY_PARAM_DATA_COLLECTION_CASES +) +async def test_span_streaming_url_query_data_collection( + sentry_init, capture_items, init_kwargs, expected_query +): + init_kwargs = dict(init_kwargs) + experiments = {"trace_lifecycle": "stream"} + experiments.update(init_kwargs.pop("_experiments", {})) + sentry_init( + integrations=[quart_sentry.QuartIntegration()], + traces_sample_rate=1.0, + _experiments=experiments, + **init_kwargs, + ) + items = capture_items("span") + + app = quart_app_factory() + client = app.test_client() + response = await client.get("/message?toy=tennisball&color=red&auth=secret") + assert response.status_code == 200 + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 1 + + segment = spans[0] + + data_collection_enabled = "data_collection" in experiments + url_attrs_expected = data_collection_enabled or init_kwargs.get( + "send_default_pii", False + ) + + if expected_query is NO_QUERY_STRING: + assert "url.query" not in segment["attributes"] + if url_attrs_expected: + # When the filtered query string is empty, url.full carries the + # base URL only (no query). + assert segment["attributes"]["url.full"] == "http://localhost/message" + else: + assert "url.full" not in segment["attributes"] + else: + assert segment["attributes"]["url.query"] == expected_query + assert segment["attributes"]["url.full"] == ( + f"http://localhost/message?{expected_query}" + ) + + +@pytest.mark.asyncio +async def test_span_streaming_url_query_multi_and_blank_values( + sentry_init, capture_items +): + sentry_init( + integrations=[quart_sentry.QuartIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream", "data_collection": {}}, + ) + items = capture_items("span") + + app = quart_app_factory() + client = app.test_client() + response = await client.get("/message?foo=1&foo=2&empty=") + assert response.status_code == 200 + + sentry_sdk.flush() + + spans = [item.payload for item in items] + assert len(spans) == 1 + + segment = spans[0] + query = segment["attributes"]["url.query"] + # Repeated keys are preserved and blank values are kept. + assert "foo=1" in query + assert "foo=2" in query + assert "empty=" in query + # url.full carries the same filtered query string. + assert segment["attributes"]["url.full"] == f"http://localhost/message?{query}" diff --git a/tests/integrations/sanic/test_sanic.py b/tests/integrations/sanic/test_sanic.py index 703da52bc1..85ce9f4174 100644 --- a/tests/integrations/sanic/test_sanic.py +++ b/tests/integrations/sanic/test_sanic.py @@ -598,3 +598,160 @@ def child_span_handler(request): else: assert "user.ip_address" not in server_span["attributes"] assert "user.ip_address" not in child_span["attributes"] + + +NO_QUERY_STRING = object() + +_QUERY_PARAM_DATA_COLLECTION_CASES = [ + pytest.param( + {"send_default_pii": True}, + "toy=tennisball&color=red&auth=secret", + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_QUERY_STRING, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_QUERY_STRING, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "denylist", "terms": ["toy"]} + } + } + }, + "toy=[Filtered]&color=red&auth=[Filtered]", + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["auth"]} + } + } + }, + "toy=[Filtered]&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + {"_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}}, + NO_QUERY_STRING, + id="data_collection_off", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}, + }, + NO_QUERY_STRING, + id="data_collection_wins_over_send_default_pii", + ), +] + + +@pytest.mark.skipif( + not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version" +) +@pytest.mark.parametrize( + "init_kwargs, expected_query", _QUERY_PARAM_DATA_COLLECTION_CASES +) +def test_url_query_data_collection_span_streaming( + sentry_init, app, capture_items, init_kwargs, expected_query +): + init_kwargs = dict(init_kwargs) + experiments = dict(init_kwargs.pop("_experiments", {})) + experiments["trace_lifecycle"] = "stream" + sentry_init( + integrations=[SanicIntegration()], + traces_sample_rate=1.0, + _experiments=experiments, + **init_kwargs, + ) + + items = capture_items("span") + + c = get_client(app) + with c as client: + _, response = client.get("/message?toy=tennisball&color=red&auth=secret") + assert response.status == 200 + + sentry_sdk.flush() + + (server_span,) = [ + i.payload + for i in items + if i.payload["attributes"].get("sentry.origin") == "auto.http.sanic" + and i.payload["is_segment"] + ] + + data_collection_enabled = "data_collection" in experiments + url_attrs_expected = data_collection_enabled or init_kwargs.get( + "send_default_pii", False + ) + + if expected_query is NO_QUERY_STRING: + assert "http.query" not in server_span["attributes"] + if url_attrs_expected: + assert server_span["attributes"]["url.full"].endswith("/message") + assert server_span["attributes"]["url.path"].endswith("/message") + else: + assert "url.full" not in server_span["attributes"] + assert "url.path" not in server_span["attributes"] + else: + assert server_span["attributes"]["http.query"] == expected_query + assert server_span["attributes"]["url.full"].endswith( + f"/message?{expected_query}" + ) + assert server_span["attributes"]["url.path"].endswith("/message") + + +@pytest.mark.parametrize( + "init_kwargs, expected_query", _QUERY_PARAM_DATA_COLLECTION_CASES +) +def test_url_query_data_collection_event_processor( + sentry_init, app, capture_events, init_kwargs, expected_query +): + sentry_init(integrations=[SanicIntegration()], **init_kwargs) + + events = capture_events() + + c = get_client(app) + with c as client: + _, response = client.get("/message?toy=tennisball&color=red&auth=secret") + assert response.status == 200 + + (event,) = events + + assert event["request"]["url"].endswith("/message") + assert event["request"]["method"] == "GET" + if "data_collection" not in init_kwargs.get("_experiments", {}): + assert ( + event["request"]["query_string"] == "toy=tennisball&color=red&auth=secret" + ) + elif expected_query is NO_QUERY_STRING: + assert "query_string" not in event["request"] + else: + assert event["request"]["query_string"] == expected_query diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index 4ea57153c2..58477ba65e 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -25,6 +25,7 @@ import sentry_sdk from sentry_sdk import capture_message, get_baggage, get_traceparent +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.integrations.starlette import ( @@ -39,6 +40,15 @@ BODY_JSON = {"some": "json", "for": "testing", "nested": {"numbers": 123}} +COOKIE_HEADER = "jwt=tokenval; theme=dark; lang=en; identity=alice" + +# Sentinel meaning "the request payload should have no ``cookies`` key at all", +# as opposed to an empty ``{}`` dict. +NO_COOKIES = object() + +QUERY_STRING = "toy=tennisball&color=red&auth=secret" +NO_QUERY_STRING = object() + BODY_FORM = """--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="username"\r\n\r\nJane\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="password"\r\n\r\nhello123\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="photo"; filename="photo.jpg"\r\nContent-Type: image/jpg\r\nContent-Transfer-Encoding: base64\r\n\r\n{{image_data}}\r\n--fd721ef49ea403a6--\r\n""".replace( "{{image_data}}", str(base64.b64encode(open(PICTURE, "rb").read())) ) @@ -537,6 +547,359 @@ async def test_request_info_no_pii(sentry_init, capture_events): assert transaction_event["request"]["data"] == BODY_JSON +@pytest.mark.asyncio +@pytest.mark.parametrize( + "init_kwargs, expected_cookies", + [ + pytest.param( + {"send_default_pii": True}, + { + "jwt": "tokenval", + "theme": "dark", + "lang": "en", + "identity": "alice", + }, + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_COOKIES, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_COOKIES, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, + NO_COOKIES, + id="data_collection_off", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}}, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "denylist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["identity"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}, + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_wins_over_send_default_pii", + ), + ], +) +async def test_cookie_data_collection( + sentry_init, capture_events, init_kwargs, expected_cookies +): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarletteIntegration()], + **init_kwargs, + ) + + starlette_app = starlette_app_factory() + events = capture_events() + + client = TestClient(starlette_app) + client.get("/message", headers={"cookie": COOKIE_HEADER}) + + (event, transaction_event) = events + + if expected_cookies is NO_COOKIES: + assert "cookies" not in event["request"] + assert "cookies" not in transaction_event["request"] + else: + assert event["request"]["cookies"] == expected_cookies + assert transaction_event["request"]["cookies"] == expected_cookies + + +@pytest.mark.parametrize( + "init_kwargs, expected_query_string", + [ + pytest.param( + {"send_default_pii": True}, + QUERY_STRING, + id="legacy_send_default_pii_true", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + } + }, + NO_QUERY_STRING, + id="data_collection_off", + ), + ], +) +def test_query_string_data_collection( + sentry_init, capture_events, init_kwargs, expected_query_string +): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarletteIntegration()], + **init_kwargs, + ) + + starlette_app = starlette_app_factory() + events = capture_events() + + client = TestClient(starlette_app) + client.get("/message?" + QUERY_STRING) + + (event, transaction_event) = events + + if expected_query_string is NO_QUERY_STRING: + assert "query_string" not in event["request"] + assert "query_string" not in transaction_event["request"] + else: + assert event["request"]["query_string"] == expected_query_string + assert transaction_event["request"]["query_string"] == expected_query_string + + +@pytest.mark.parametrize( + "init_kwargs, expected_query, expected_url_full", + [ + pytest.param( + {"send_default_pii": True}, + QUERY_STRING, + "http://testserver/message?" + QUERY_STRING, + id="legacy_send_default_pii_true", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + "http://testserver/message?toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + } + }, + NO_QUERY_STRING, + "http://testserver/message", + id="data_collection_off", + ), + ], +) +def test_span_http_query_data_collection( + sentry_init, capture_items, init_kwargs, expected_query, expected_url_full +): + sentry_init( + auto_enabling_integrations=False, + integrations=[StarletteIntegration()], + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + **init_kwargs.pop("_experiments", {}), + }, + **init_kwargs, + ) + + starlette_app = starlette_app_factory() + items = capture_items("span") + + client = TestClient(starlette_app) + client.get("/message?" + QUERY_STRING) + + sentry_sdk.flush() + + segments = [ + item.payload + for item in items + if item.payload.get("is_segment") + and item.payload["attributes"].get("sentry.op") == "http.server" + ] + (segment,) = segments + attributes = segment["attributes"] + + if expected_query is NO_QUERY_STRING: + assert SPANDATA.HTTP_QUERY not in attributes + else: + assert attributes[SPANDATA.HTTP_QUERY] == expected_query + + assert attributes["url.full"] == expected_url_full + assert attributes["url.path"] == "/message" + + +# TestClient provides ("testclient", 50000) as the scope's client. +TESTCLIENT_IP = "testclient" +NO_USER_INFO = object() + +USER_INFO_CASES = [ + pytest.param( + {"send_default_pii": True}, + TESTCLIENT_IP, + id="legacy_send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_USER_INFO, + id="legacy_send_default_pii_false", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + TESTCLIENT_IP, + id="data_collection_default_user_info_true", + ), + pytest.param( + {"_experiments": {"data_collection": {"user_info": True}}}, + TESTCLIENT_IP, + id="data_collection_user_info_true", + ), + pytest.param( + {"_experiments": {"data_collection": {"user_info": False}}}, + NO_USER_INFO, + id="data_collection_user_info_false", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": {"data_collection": {"user_info": False}}, + }, + NO_USER_INFO, + id="data_collection_wins_over_send_default_pii", + ), +] + + +@pytest.mark.parametrize("init_kwargs, expected_ip", USER_INFO_CASES) +def test_user_info_data_collection( + sentry_init, capture_events, init_kwargs, expected_ip +): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarletteIntegration()], + **init_kwargs, + ) + + starlette_app = starlette_app_factory() + events = capture_events() + + client = TestClient(starlette_app) + client.get("/message") + + (event, transaction_event) = events + + if expected_ip is NO_USER_INFO: + assert "env" not in event["request"] + assert "env" not in transaction_event["request"] + else: + assert event["request"]["env"] == {"REMOTE_ADDR": expected_ip} + assert transaction_event["request"]["env"] == {"REMOTE_ADDR": expected_ip} + + +@pytest.mark.parametrize("init_kwargs, expected_ip", USER_INFO_CASES) +def test_user_info_data_collection_with_streamed_spans( + sentry_init, capture_items, init_kwargs, expected_ip +): + sentry_init( + auto_enabling_integrations=False, + integrations=[StarletteIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream", + _experiments={ + **init_kwargs.pop("_experiments", {}), + }, + **init_kwargs, + ) + + starlette_app = starlette_app_factory() + items = capture_items("span") + + client = TestClient(starlette_app) + client.get("/message") + + sentry_sdk.flush() + + assert len(items) == 1 + attributes = items[0].payload["attributes"] + + if expected_ip is NO_USER_INFO: + assert SPANDATA.CLIENT_ADDRESS not in attributes + else: + assert attributes[SPANDATA.CLIENT_ADDRESS] == expected_ip + + @pytest.mark.parametrize( "url,transaction_style,expected_transaction,expected_source", [ diff --git a/tests/integrations/starlite/test_starlite.py b/tests/integrations/starlite/test_starlite.py index 3b6dc44131..6ef5d6c53b 100644 --- a/tests/integrations/starlite/test_starlite.py +++ b/tests/integrations/starlite/test_starlite.py @@ -11,6 +11,7 @@ import sentry_sdk from sentry_sdk import capture_message +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.integrations.starlite import StarliteIntegration @@ -574,3 +575,136 @@ async def __call__(self, scope, receive, send): } else: assert "user" not in event + + +COOKIE_HEADER = "jwt=tokenval; theme=dark; lang=en; identity=alice" + +# Sentinel meaning "the request payload should have no ``cookies`` key at all", +# as opposed to an empty ``{}`` dict. +NO_COOKIES = object() + + +@pytest.mark.parametrize( + "init_kwargs, expected_cookies", + [ + pytest.param( + {"send_default_pii": True}, + { + "jwt": "tokenval", + "theme": "dark", + "lang": "en", + "identity": "alice", + }, + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_COOKIES, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_COOKIES, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, + NO_COOKIES, + id="data_collection_off", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}}, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "denylist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["identity"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}, + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_wins_over_send_default_pii", + ), + ], +) +def test_cookie_data_collection( + sentry_init, capture_events, init_kwargs, expected_cookies +): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarliteIntegration()], + **init_kwargs, + ) + + starlite_app = starlite_app_factory() + events = capture_events() + + client = TestClient(starlite_app) + client.get("/message", headers={"cookie": COOKIE_HEADER}) + + (event, transaction_event) = events + + if expected_cookies is NO_COOKIES: + assert "cookies" not in event["request"] + assert "cookies" not in transaction_event["request"] + else: + assert event["request"]["cookies"] == expected_cookies + assert transaction_event["request"]["cookies"] == expected_cookies diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py index c5b788b289..1b0a838303 100644 --- a/tests/integrations/tornado/test_tornado.py +++ b/tests/integrations/tornado/test_tornado.py @@ -6,6 +6,7 @@ import sentry_sdk from sentry_sdk import capture_message, start_transaction +from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE from sentry_sdk.integrations.tornado import TornadoIntegration @@ -110,6 +111,385 @@ def test_basic(tornado_testcase, sentry_init, capture_events): assert not sentry_sdk.get_isolation_scope()._tags +# Sent by every data-collection cookie test below. Mixes benign cookies +# (``theme``, ``lang``) with ones whose names match the data-collection +# sensitive denylist (``jwt``, ``identity``). Those two names are deliberately +# NOT in the ``EventScrubber`` denylist (which matches keys exactly), so any +# filtering we observe on them comes from the extractor's data-collection +# logic, not the always-on scrubber. +COOKIE_HEADER = "jwt=tokenval; theme=dark; lang=en; identity=alice" +NO_COOKIES = object() + + +@pytest.mark.parametrize( + "init_kwargs, expected_cookies", + [ + pytest.param( + {"send_default_pii": True}, + { + "jwt": "tokenval", + "theme": "dark", + "lang": "en", + "identity": "alice", + }, + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_COOKIES, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_COOKIES, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "off"}}}}, + NO_COOKIES, + id="data_collection_off", + ), + pytest.param( + {"_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}}, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "denylist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["theme"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "cookies": {"mode": "allowlist", "terms": ["identity"]} + } + } + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": SENSITIVE_DATA_SUBSTITUTE, + "lang": SENSITIVE_DATA_SUBSTITUTE, + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"cookies": {"mode": "denylist"}}}, + }, + { + "jwt": SENSITIVE_DATA_SUBSTITUTE, + "theme": "dark", + "lang": "en", + "identity": SENSITIVE_DATA_SUBSTITUTE, + }, + id="data_collection_wins_over_send_default_pii", + ), + ], +) +def test_cookie_data_collection( + tornado_testcase, sentry_init, capture_events, init_kwargs, expected_cookies +): + sentry_init(integrations=[TornadoIntegration()], **init_kwargs) + events = capture_events() + client = tornado_testcase(Application([(r"/hi", CrashingHandler)])) + + response = client.fetch("/hi", headers={"Cookie": COOKIE_HEADER}) + assert response.code == 500 + + (event,) = events + if expected_cookies is NO_COOKIES: + assert "cookies" not in event["request"] + else: + assert event["request"]["cookies"] == expected_cookies + + +class QueryHandler(RequestHandler): + async def get(self): + self.write("ok") + + +NO_QUERY_STRING = object() +_QUERY_PARAM_DATA_COLLECTION_CASES = [ + pytest.param( + {"send_default_pii": True}, + "toy=tennisball&color=red&auth=secret", + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_QUERY_STRING, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_QUERY_STRING, + id="defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "denylist", "terms": ["toy"]} + } + } + }, + "toy=[Filtered]&color=red&auth=[Filtered]", + id="data_collection_denylist_custom_terms", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["auth"]} + } + } + }, + "toy=[Filtered]&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist_sensitive_term", + ), + pytest.param( + {"_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}}, + NO_QUERY_STRING, + id="data_collection_off", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": {"data_collection": {"url_query_params": {"mode": "off"}}}, + }, + NO_QUERY_STRING, + id="data_collection_wins_over_send_default_pii", + ), +] + + +@pytest.mark.parametrize( + "init_kwargs, expected_query", _QUERY_PARAM_DATA_COLLECTION_CASES +) +def test_url_query_data_collection_span_streaming( + tornado_testcase, sentry_init, capture_items, init_kwargs, expected_query +): + init_kwargs = dict(init_kwargs) + sentry_init( + integrations=[TornadoIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream", + **init_kwargs, + ) + + items = capture_items("span") + + client = tornado_testcase(Application([(r"/hi", QueryHandler)])) + response = client.fetch("/hi?toy=tennisball&color=red&auth=secret") + assert response.code == 200 + + sentry_sdk.flush() + + (server_span,) = [item.payload for item in items] + + data_collection_enabled = "data_collection" in init_kwargs.get("_experiments", {}) + url_attrs_expected = data_collection_enabled or init_kwargs.get( + "send_default_pii", False + ) + + if expected_query is NO_QUERY_STRING: + assert "url.query" not in server_span["attributes"] + if url_attrs_expected: + assert server_span["attributes"]["url.full"].endswith("/hi") + assert server_span["attributes"]["url.path"] == "/hi" + else: + assert "url.full" not in server_span["attributes"] + assert "url.path" not in server_span["attributes"] + else: + assert server_span["attributes"]["url.query"] == expected_query + assert server_span["attributes"]["url.full"].endswith(f"/hi?{expected_query}") + assert server_span["attributes"]["url.full"].startswith("http://") + assert server_span["attributes"]["url.path"] == "/hi" + + +@pytest.mark.parametrize( + "init_kwargs, expected_query", _QUERY_PARAM_DATA_COLLECTION_CASES +) +def test_url_query_data_collection_event_processor( + tornado_testcase, sentry_init, capture_events, init_kwargs, expected_query +): + sentry_init( + integrations=[TornadoIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="static", + **init_kwargs, + ) + + events = capture_events() + + client = tornado_testcase(Application([(r"/hi", QueryHandler)])) + response = client.fetch("/hi?toy=tennisball&color=red&auth=secret") + assert response.code == 200 + + sentry_sdk.flush() + + (event,) = events + + assert event["request"]["url"].endswith("/hi") + assert event["request"]["method"] == "GET" + if "data_collection" not in init_kwargs.get("_experiments", {}): + assert ( + event["request"]["query_string"] == "toy=tennisball&color=red&auth=secret" + ) + elif expected_query is NO_QUERY_STRING: + assert "query_string" not in event["request"] + else: + assert event["request"]["query_string"] == expected_query + + +def test_url_query_data_collection_no_query_string( + tornado_testcase, sentry_init, capture_items +): + sentry_init( + integrations=[TornadoIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream", + _experiments={"data_collection": {}}, + ) + + items = capture_items("span") + + client = tornado_testcase(Application([(r"/hi", QueryHandler)])) + response = client.fetch("/hi") + assert response.code == 200 + + sentry_sdk.flush() + + (server_span,) = [item.payload for item in items] + + assert "url.query" not in server_span["attributes"] + assert server_span["attributes"]["url.full"].endswith("/hi") + assert server_span["attributes"]["url.path"] == "/hi" + + +def test_url_query_data_collection_repeated_and_blank_params( + tornado_testcase, sentry_init, capture_items +): + sentry_init( + integrations=[TornadoIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream", + _experiments={"data_collection": {}}, + ) + + items = capture_items("span") + + client = tornado_testcase(Application([(r"/hi", QueryHandler)])) + response = client.fetch("/hi?a=1&a=2&b=") + assert response.code == 200 + + sentry_sdk.flush() + + (server_span,) = [item.payload for item in items] + + assert server_span["attributes"]["url.query"] == "a=1&a=2&b=" + + +def test_url_query_data_collection__event_processor_no_query_string( + tornado_testcase, sentry_init, capture_events +): + sentry_init( + integrations=[TornadoIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="static", + _experiments={"data_collection": {}}, + ) + + events = capture_events() + + client = tornado_testcase(Application([(r"/hi", QueryHandler)])) + response = client.fetch("/hi") + assert response.code == 200 + + sentry_sdk.flush() + + (event,) = events + + assert "query_string" not in event["request"] + assert event["request"]["url"].endswith("/hi") + assert event["request"]["method"] == "GET" + + +def test_url_query_data_collection_event_processor_repeated_and_blank_params( + tornado_testcase, sentry_init, capture_events +): + sentry_init( + integrations=[TornadoIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="static", + _experiments={"data_collection": {}}, + ) + + events = capture_events() + + client = tornado_testcase(Application([(r"/hi", QueryHandler)])) + response = client.fetch("/hi?a=1&a=2&b=") + assert response.code == 200 + + sentry_sdk.flush() + + (event,) = events + + assert event["request"]["query_string"] == "a=1&a=2&b=" + + @pytest.mark.parametrize("send_pii", [True, False]) @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize( @@ -133,7 +513,7 @@ def test_transactions( integrations=[TornadoIntegration()], traces_sample_rate=1.0, send_default_pii=send_pii, - _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + trace_lifecycle="stream" if span_streaming else "static", ) if span_streaming: diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index 3b684adb0d..ebef296e56 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -858,6 +858,434 @@ def test_get_request_url_x_forwarded_proto(environ, use_x_forwarded_for, expecte assert get_request_url(environ, use_x_forwarded_for) == expected_url +@pytest.mark.parametrize("send_default_pii", [True, False]) +def test_request_headers_data_collection_default_redacts_sensitive( + sentry_init, crashing_app, capture_events, send_default_pii +): + """ + When ``data_collection`` is configured (even as ``None``, i.e. spec + defaults), the WSGI event processor routes request headers through the + data-collection filtering path. Sensitive headers are redacted regardless + of ``send_default_pii`` -- the value of that legacy option must not change + the outcome. + """ + sentry_init( + send_default_pii=send_default_pii, + _experiments={"data_collection": None}, + ) + app = SentryWsgiMiddleware(crashing_app) + client = Client(app) + events = capture_events() + + with pytest.raises(ZeroDivisionError): + client.get( + "/", + headers={ + "Authorization": "Bearer secret-token", + "X-Custom-Header": "passthrough", + }, + ) + + (event,) = events + headers = event["request"]["headers"] + + assert headers["Authorization"] == "[Filtered]" + assert headers["X-Custom-Header"] == "passthrough" + + +def test_request_headers_legacy_no_pii_redacts_sensitive( + sentry_init, crashing_app, capture_events +): + """ + With no ``data_collection`` configured, ``_filter_headers`` falls back to + the legacy ``send_default_pii`` behaviour. When PII is disabled, headers in + ``SENSITIVE_HEADERS`` are replaced with an ``AnnotatedValue`` (the default + ``use_annotated_value=True`` on the event-processor call site), which + serializes to an emptied value plus a ``_meta`` annotation. Non-sensitive + headers pass through untouched. + + ``X-Forwarded-For`` is used because it is in ``SENSITIVE_HEADERS`` but is + not scrubbed by the default ``EventScrubber``, so the substitution we are + asserting on can only come from ``_filter_headers``. + """ + sentry_init(send_default_pii=False) + app = SentryWsgiMiddleware(crashing_app) + client = Client(app) + events = capture_events() + + with pytest.raises(ZeroDivisionError): + client.get( + "/", + headers={ + "X-Forwarded-For": "1.2.3.4", + "X-Custom-Header": "passthrough", + }, + ) + + (event,) = events + + assert event["request"]["headers"]["X-Forwarded-For"] == "" + assert event["request"]["headers"]["X-Custom-Header"] == "passthrough" + + # The emptied value is accompanied by a `_meta` annotation marking it as + # removed, confirming the substitution came from the AnnotatedValue path. + assert event["_meta"]["request"]["headers"]["X-Forwarded-For"] == { + "": {"rem": [["!config", "x"]]} + } + + +def test_request_headers_data_collection_off_collects_no_headers( + sentry_init, crashing_app, capture_events +): + """ + With ``http_headers.request`` mode set to ``off``, no request headers are + collected at all -- the filtering returns an empty mapping. + """ + sentry_init( + _experiments={ + "data_collection": {"http_headers": {"request": {"mode": "off"}}} + }, + ) + app = SentryWsgiMiddleware(crashing_app) + client = Client(app) + events = capture_events() + + with pytest.raises(ZeroDivisionError): + client.get( + "/", + headers={ + "X-Forwarded-For": "1.2.3.4", + "X-Custom-Header": "passthrough", + }, + ) + + (event,) = events + + assert event["request"]["headers"] == {} + + +def test_request_headers_data_collection_allowlist_redacts_all_but_allowed_terms( + sentry_init, crashing_app, capture_events +): + """ + An ``allowlist`` allows through only headers matching a configured term + (partial, case-insensitive); every other header key is kept but its value + is redacted. + """ + sentry_init( + _experiments={ + "data_collection": { + "http_headers": {"request": {"mode": "allowlist", "terms": ["custom"]}} + } + }, + ) + app = SentryWsgiMiddleware(crashing_app) + client = Client(app) + events = capture_events() + + with pytest.raises(ZeroDivisionError): + client.get( + "/", + headers={ + "X-Forwarded-For": "1.2.3.4", + "X-Custom-Header": "passthrough", + }, + ) + + (event,) = events + headers = event["request"]["headers"] + + assert headers["X-Custom-Header"] == "passthrough" + assert headers["X-Forwarded-For"] == "[Filtered]" + assert headers["Host"] == "[Filtered]" + + +def test_request_headers_data_collection_denylist_redacts_only_matched_terms( + sentry_init, crashing_app, capture_events +): + """ + A ``denylist`` passes headers through by default, redacting only those + matching a configured term (partial, case-insensitive). + """ + sentry_init( + _experiments={ + "data_collection": { + "http_headers": {"request": {"mode": "denylist", "terms": ["custom"]}} + } + }, + ) + app = SentryWsgiMiddleware(crashing_app) + client = Client(app) + events = capture_events() + + with pytest.raises(ZeroDivisionError): + client.get( + "/", + headers={ + "X-Forwarded-For": "1.2.3.4", + "X-Custom-Header": "passthrough", + }, + ) + + (event,) = events + headers = event["request"]["headers"] + + assert headers["X-Custom-Header"] == "[Filtered]" + assert headers["X-Forwarded-For"] == "1.2.3.4" + assert headers["Host"] == "localhost" + + +def test_request_headers_data_collection_cookie_always_redacted( + sentry_init, crashing_app, capture_events +): + """ + The ``cookie``/``set-cookie`` headers are always redacted in the + data-collection path, even when explicitly allowlisted. A sibling header + (``custom``) that is also allowlisted passes through, isolating the + cookie override. + + The middleware is driven with an explicit environ because werkzeug's test + ``Client`` manages its own cookie jar and strips the ``Cookie`` header. + """ + sentry_init( + _experiments={ + "data_collection": { + "http_headers": { + "request": {"mode": "allowlist", "terms": ["cookie", "custom"]} + } + } + }, + ) + app = SentryWsgiMiddleware(crashing_app) + events = capture_events() + + environ = { + "REQUEST_METHOD": "GET", + "PATH_INFO": "/", + "SERVER_NAME": "localhost", + "SERVER_PORT": "80", + "wsgi.url_scheme": "http", + "HTTP_COOKIE": "sessionid=secret", + "HTTP_X_CUSTOM_HEADER": "passthrough", + } + + with pytest.raises(ZeroDivisionError): + list(app(environ, lambda status, headers: None)) + + (event,) = events + headers = event["request"]["headers"] + + assert headers["Cookie"] == "[Filtered]" + assert headers["X-Custom-Header"] == "passthrough" + + +def test_request_headers_legacy_pii_passes_headers_through( + sentry_init, crashing_app, capture_events +): + """ + With no ``data_collection`` configured and ``send_default_pii`` enabled, + the legacy path returns all headers unchanged -- including those in + ``SENSITIVE_HEADERS``. + """ + sentry_init(send_default_pii=True) + app = SentryWsgiMiddleware(crashing_app) + client = Client(app) + events = capture_events() + + with pytest.raises(ZeroDivisionError): + client.get( + "/", + headers={ + "X-Forwarded-For": "1.2.3.4", + "X-Custom-Header": "passthrough", + }, + ) + + (event,) = events + headers = event["request"]["headers"] + + assert headers["X-Forwarded-For"] == "1.2.3.4" + assert headers["X-Custom-Header"] == "passthrough" + + +# Sentinel: the query string (event) / ``http.query`` attribute (span) is absent. +NO_QUERY_STRING = object() + + +@pytest.mark.parametrize( + "init_kwargs, expected_query_string", + [ + # No data_collection: the legacy path always sets the query string + # unchanged, regardless of send_default_pii. + pytest.param( + {"send_default_pii": True}, + "toy=tennisball&color=red&auth=secret", + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + "toy=tennisball&color=red&auth=secret", + id="send_default_pii_false", + ), + pytest.param( + {}, + "toy=tennisball&color=red&auth=secret", + id="defaults", + ), + # data_collection configured: query string is routed through filtering. + # Spec defaults -> denylist: only the sensitive ``auth`` is redacted. + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "denylist", "terms": ["toy"]} + } + } + }, + "toy=[Filtered]&color=red&auth=[Filtered]", + id="data_collection_denylist_custom_terms", + ), + # allowlist with only ``toy`` allowed: ``color`` is redacted even though + # it is not sensitive, proving the redaction comes from the allowlist. + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + } + }, + NO_QUERY_STRING, + id="data_collection_off", + ), + ], +) +def test_query_string_data_collection( + sentry_init, crashing_app, capture_events, init_kwargs, expected_query_string +): + sentry_init(**init_kwargs) + app = SentryWsgiMiddleware(crashing_app) + client = Client(app) + events = capture_events() + + with pytest.raises(ZeroDivisionError): + client.get("/?toy=tennisball&color=red&auth=secret") + + (event,) = events + + if expected_query_string is NO_QUERY_STRING: + assert "query_string" not in event["request"] + else: + assert event["request"]["query_string"] == expected_query_string + + +@pytest.mark.parametrize( + "init_kwargs, expected_query", + [ + # No data_collection: the ``http.query`` attribute follows the legacy + # send_default_pii gate. + pytest.param( + {"send_default_pii": True}, + "toy=tennisball&color=red&auth=secret", + id="send_default_pii_true", + ), + pytest.param( + {"send_default_pii": False}, + NO_QUERY_STRING, + id="send_default_pii_false", + ), + pytest.param( + {}, + NO_QUERY_STRING, + id="defaults", + ), + # data_collection configured: attribute is routed through filtering. + pytest.param( + {"_experiments": {"data_collection": {}}}, + "toy=tennisball&color=red&auth=[Filtered]", + id="data_collection_denylist_default", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "denylist", "terms": ["toy"]} + } + } + }, + "toy=[Filtered]&color=red&auth=[Filtered]", + id="data_collection_denylist_custom_terms", + ), + # allowlist with only ``toy`` allowed: ``color`` is redacted even though + # it is not sensitive, proving the redaction comes from the allowlist. + pytest.param( + { + "_experiments": { + "data_collection": { + "url_query_params": {"mode": "allowlist", "terms": ["toy"]} + } + } + }, + "toy=tennisball&color=[Filtered]&auth=[Filtered]", + id="data_collection_allowlist", + ), + pytest.param( + { + "_experiments": { + "data_collection": {"url_query_params": {"mode": "off"}} + } + }, + NO_QUERY_STRING, + id="data_collection_off", + ), + ], +) +def test_span_http_query_data_collection( + sentry_init, capture_items, init_kwargs, expected_query +): + def dogpark(environ, start_response): + start_response("200 OK", []) + return ["Go get the ball! Good dog!"] + + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + **init_kwargs.pop("_experiments", {}), + }, + **init_kwargs, + ) + app = SentryWsgiMiddleware(dogpark) + client = Client(app) + + items = capture_items("span") + + client.get("/dogs/are/great?toy=tennisball&color=red&auth=secret") + + sentry_sdk.flush() + + (span,) = [item.payload for item in items] + + if expected_query is NO_QUERY_STRING: + assert "http.query" not in span["attributes"] + else: + assert span["attributes"]["http.query"] == expected_query + + @pytest.mark.parametrize("send_default_pii", [True, False]) def test_user_ip_address_on_all_spans(sentry_init, capture_items, send_default_pii): def dogpark(environ, start_response): diff --git a/tests/test_data_collection.py b/tests/test_data_collection.py index fc5a8ea754..284303319e 100644 --- a/tests/test_data_collection.py +++ b/tests/test_data_collection.py @@ -88,8 +88,8 @@ def _get(dc, path): "user_info": False, "gen_ai.inputs": False, "gen_ai.outputs": False, - "cookies.mode": "off", - "query_params.mode": "off", + "cookies.mode": "denylist", + "url_query_params.mode": "denylist", "http_headers.request.mode": "denylist", "http_bodies": _ALL_HTTP_BODY_TYPES, "queues": False, @@ -104,14 +104,14 @@ def _get(dc, path): "gen_ai.inputs": True, "gen_ai.outputs": True, "cookies.mode": "denylist", - "query_params.mode": "denylist", + "url_query_params.mode": "denylist", "queues": True, }, id="send_default_pii_true_collects_pii", ), pytest.param( {"send_default_pii": False}, - {"user_info": False, "cookies.mode": "off", "queues": False}, + {"user_info": False, "cookies.mode": "denylist", "queues": False}, id="send_default_pii_false_collects_no_pii", ), pytest.param( @@ -121,7 +121,7 @@ def _get(dc, path): "gen_ai.inputs": True, "gen_ai.outputs": True, "cookies.mode": "denylist", - "query_params.mode": "denylist", + "url_query_params.mode": "denylist", "http_bodies": _ALL_HTTP_BODY_TYPES, "queues": True, }, @@ -155,7 +155,7 @@ def _get(dc, path): "_experiments": { "data_collection": { "cookies": {"mode": "off"}, - "query_params": {"mode": "allowlist", "terms": ["page"]}, + "url_query_params": {"mode": "allowlist", "terms": ["page"]}, "http_headers": {"request": {"mode": "off"}}, "gen_ai": {"inputs": False, "outputs": True}, } @@ -163,8 +163,8 @@ def _get(dc, path): }, { "cookies.mode": "off", - "query_params.mode": "allowlist", - "query_params.terms": ["page"], + "url_query_params.mode": "allowlist", + "url_query_params.terms": ["page"], "http_headers.request.mode": "off", "gen_ai.inputs": False, "gen_ai.outputs": True, @@ -177,7 +177,7 @@ def _get(dc, path): "data_collection": { "cookies": None, "http_headers": None, - "query_params": None, + "url_query_params": None, "graphql": None, "gen_ai": None, } @@ -186,7 +186,7 @@ def _get(dc, path): { "cookies.mode": "denylist", "http_headers.request.mode": "denylist", - "query_params.mode": "denylist", + "url_query_params.mode": "denylist", "graphql.document": True, "graphql.variables": True, "gen_ai.inputs": True, diff --git a/tests/tracing/test_sampling.py b/tests/tracing/test_sampling.py index 7d3ee3a0e6..f6b5b92121 100644 --- a/tests/tracing/test_sampling.py +++ b/tests/tracing/test_sampling.py @@ -204,6 +204,96 @@ def test_tolerates_traces_sampler_returning_a_boolean_span_streaming( assert span.sampled is traces_sampler_return_value +@pytest.mark.parametrize( + "traces_sample_rate,expected_decision", + [(0.0, False), (0.25, False), (0.75, True), (1.00, True)], +) +def test_traces_sampler_raising_falls_back_to_traces_sample_rate( + sentry_init, + traces_sample_rate, + expected_decision, +): + sentry_init( + traces_sampler=mock.Mock(side_effect=ValueError("boom")), + traces_sample_rate=traces_sample_rate, + ) + + baggage = Baggage(sentry_items={"sample_rand": "0.500000"}) + transaction = start_transaction(name="dogpark", baggage=baggage) + assert transaction.sampled is expected_decision + + +@pytest.mark.parametrize("parent_sampling_decision", [True, False]) +def test_traces_sampler_raising_falls_back_to_parent_sampling_decision( + sentry_init, parent_sampling_decision +): + # set traces_sample_rate to produce the opposite of the parent decision, + # to prove the parent's decision takes precedence in the fallback + sentry_init( + traces_sampler=mock.Mock(side_effect=ValueError("boom")), + traces_sample_rate=0.0 if parent_sampling_decision else 1.0, + ) + + baggage = Baggage(sentry_items={"sample_rand": "0.500000"}) + transaction = start_transaction( + name="dogpark", baggage=baggage, parent_sampled=parent_sampling_decision + ) + assert transaction.sampled is parent_sampling_decision + + +@pytest.mark.parametrize( + "traces_sample_rate,expected_decision", + [(0.0, False), (0.25, False), (0.75, True), (1.00, True)], +) +def test_traces_sampler_raising_falls_back_to_traces_sample_rate_span_streaming( + sentry_init, + traces_sample_rate, + expected_decision, +): + sentry_init( + traces_sampler=mock.Mock(side_effect=ValueError("boom")), + traces_sample_rate=traces_sample_rate, + _experiments={ + "trace_lifecycle": "stream", + }, + ) + + sentry_sdk.traces.continue_trace( + { + "sentry-trace": "0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331", + "baggage": "sentry-sample_rand=0.500000", + } + ) + + with sentry_sdk.traces.start_span(name="dogpark") as span: + assert span.sampled is expected_decision + + +def test_traces_sampler_raising_falls_back_to_propagated_sample_rate_span_streaming( + sentry_init, +): + # parent said "don't sample", but its propagated sample rate of 0.75 + # combined with sample_rand 0.5 should win over both the flag and + # traces_sample_rate=0.0 + sentry_init( + traces_sampler=mock.Mock(side_effect=ValueError("boom")), + traces_sample_rate=0.0, + _experiments={ + "trace_lifecycle": "stream", + }, + ) + + sentry_sdk.traces.continue_trace( + { + "sentry-trace": "0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-0", + "baggage": "sentry-sample_rate=0.75,sentry-sample_rand=0.500000", + } + ) + + with sentry_sdk.traces.start_span(name="dogpark") as span: + assert span.sampled is True + + @pytest.mark.parametrize("sampling_decision", [True, False]) def test_only_captures_transaction_when_sampled_is_true( sentry_init, sampling_decision, capture_events