diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index d1c97ffe08..e07866a000 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -7,6 +7,8 @@ DEFAULT_MAX_STACK_FRAMES = 100 DEFAULT_ADD_FULL_STACK = False +_SENTRY_HEADER_NAMES = frozenset(("baggage", "sentry-trace")) + # Also needs to be at the top to prevent circular import class EndpointType(Enum): diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 61dc2d376a..4f7b421357 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -4,7 +4,7 @@ import sentry_sdk from sentry_sdk.api import continue_trace -from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS +from sentry_sdk.consts import _SENTRY_HEADER_NAMES, OP, SPANDATA, SPANSTATUS from sentry_sdk.data_collection import ( _apply_data_collection_filtering_to_query_string, ) @@ -29,6 +29,7 @@ ) from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, + SENTRY_TRACE_HEADER_NAME, SOURCE_FOR_STYLE, TransactionSource, ) @@ -43,6 +44,8 @@ HAS_REAL_CONTEXTVARS, SENSITIVE_DATA_SUBSTITUTE, AnnotatedValue, + _get_aws_sigv4_signed_headers_from_authorization_header, + _get_aws_sigv4_signed_headers_from_url_query_string, _register_control_flow_exception, capture_internal_exceptions, ensure_integration_enabled, @@ -464,12 +467,50 @@ async def on_request_start( span = legacy_span if should_propagate_trace(client, str(params.url)): + # existing `sentry-trace`: skip so it is not duplicated. + headers_to_skip: "set[str]" = set() + if SENTRY_TRACE_HEADER_NAME in params.headers: + headers_to_skip.add(SENTRY_TRACE_HEADER_NAME) + + with capture_internal_exceptions(): + authorization = params.headers.get("Authorization") + if authorization: + if isinstance(authorization, bytes): + authorization = authorization.decode("latin-1") + # `SignedHeaders` lists fields covered by SigV4. + signed_headers = ( + _get_aws_sigv4_signed_headers_from_authorization_header( + authorization + ) + ) + # skip signed `sentry-trace` and `baggage`. + headers_to_skip.update( + _SENTRY_HEADER_NAMES.intersection(signed_headers) + ) + + # presigned URLs list signed names in the query string. + query_signed_headers = ( + _get_aws_sigv4_signed_headers_from_url_query_string(str(params.url)) + ) + headers_to_skip.update( + _SENTRY_HEADER_NAMES.intersection(query_signed_headers) + ) + for ( key, value, ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers( span=span ): + # skip signed headers and an existing `sentry-trace`. + if key.lower() in headers_to_skip: + logger.debug( + "[Tracing] Not adding `{key}` header to outgoing request " + "to {url}: it already exists or is covered by the AWS " + "SigV4 signature.".format(key=key, url=params.url) + ) + continue + logger.debug( "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format( key=key, value=value, url=params.url diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index fbc5838f61..02f8b245f7 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -6,11 +6,11 @@ from typing import TYPE_CHECKING import sentry_sdk -from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.consts import _SENTRY_HEADER_NAMES, OP, SPANDATA from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor from sentry_sdk.traces import StreamedSpan -from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, Span +from sentry_sdk.tracing import SENTRY_TRACE_HEADER_NAME, Span from sentry_sdk.tracing_utils import ( EnvironHeaders, add_http_breadcrumb, @@ -42,9 +42,6 @@ "version": "%s.%s.%s" % (sys.version_info[:3]), "build": sys.version, } - -_SENTRY_HEADER_NAMES = frozenset((BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME)) - try: from botocore.awsrequest import AWSHTTPConnection, AWSHTTPSConnection except ImportError: diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 918148b062..434cee80f2 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -875,7 +875,9 @@ async def handler(request): @pytest.mark.asyncio -async def test_outgoing_trace_headers(sentry_init, aiohttp_raw_server, aiohttp_client): +async def test_outgoing_trace_headers_adds_missing_unsigned_propagation_headers( + sentry_init, aiohttp_raw_server, aiohttp_client +): sentry_init( integrations=[AioHttpIntegration()], traces_sample_rate=1.0, @@ -903,10 +905,11 @@ async def handler(request): parent_span_id=request_span.span_id, sampled=1, ) + assert resp.request_info.headers["baggage"].count("sentry-trace_id=") == 1 @pytest.mark.asyncio -async def test_outgoing_trace_headers_append_to_baggage( +async def test_outgoing_trace_headers_appends_baggage_but_preserves_sentry_trace( sentry_init, aiohttp_raw_server, aiohttp_client ): sentry_init( @@ -927,12 +930,94 @@ async def handler(request): trace_id="0123456789012345678901234567890", ): client = await aiohttp_client(raw_server) - resp = await client.get("/", headers={"bagGage": "custom=value"}) + resp = await client.get( + "/", + headers={ + "bagGage": "custom=value", + "Sentry-Trace": "existing-trace", + }, + ) assert ( resp.request_info.headers["baggage"] == "custom=value,sentry-trace_id=0123456789012345678901234567890,sentry-sample_rand=0.500000,sentry-environment=production,sentry-release=d08ebdb9309e1b004c6f52202de58a09c2268e42,sentry-transaction=/interactions/other-dogs/new-dog,sentry-sample_rate=1.0,sentry-sampled=true" ) + # existing `sentry-trace`: leave as-is. + assert resp.request_info.headers["sentry-trace"] == "existing-trace" + + +@pytest.mark.asyncio +async def test_outgoing_trace_headers_preserves_signed_propagation_headers( + sentry_init, aiohttp_raw_server, aiohttp_client +): + sentry_init( + integrations=[AioHttpIntegration()], + traces_sample_rate=1.0, + ) + + async def handler(request): + return web.Response(text="OK") + + raw_server = await aiohttp_raw_server(handler) + # both propagation headers are named in `SignedHeaders`. + authorization = ( + "AWS4-HMAC-SHA256 " + "Credential=test/20260804/eu-west-1/secretsmanager/aws4_request, " + "SignedHeaders=baggage;host;sentry-trace, " + "Signature=sixtyseven" + ) + + with start_transaction(name="test", sampled=True): + client = await aiohttp_client(raw_server) + resp = await client.get( + "/", + headers={ + "baggage": "vendor=value", + "sentry-trace": "existing-trace", + "Authorization": authorization, + }, + ) + + headers = resp.request_info.headers + # signed `baggage`: leave as-is. + assert headers["baggage"] == "vendor=value" + # signed `sentry-trace`: leave as-is. + assert headers["sentry-trace"] == "existing-trace" + + +@pytest.mark.asyncio +async def test_outgoing_trace_headers_preserves_query_signed_baggage( + sentry_init, aiohttp_raw_server, aiohttp_client +): + sentry_init( + integrations=[AioHttpIntegration()], + traces_sample_rate=1.0, + ) + + async def handler(request): + return web.Response(text="OK") + + raw_server = await aiohttp_raw_server(handler) + path = ( + "/" + "?X-Amz-Algorithm=AWS4-HMAC-SHA256" + "&X-Amz-Credential=" + "test%2F20260804%2Feu-west-1%2Fs3%2Faws4_request" + "&X-Amz-Date=20260804T120000Z" + "&X-Amz-Expires=60" + "&X-Amz-SignedHeaders=baggage%3Bhost" + "&X-Amz-Signature=sixtyseven" + ) + + with start_transaction(name="test", sampled=True): + client = await aiohttp_client(raw_server) + resp = await client.get(path, headers={"baggage": "vendor=value"}) + + headers = resp.request_info.headers + # query-signed `baggage`: leave as-is. + assert headers["baggage"] == "vendor=value" + # unsigned `sentry-trace`: add it. + assert "sentry-trace" in headers @pytest.mark.asyncio