diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py index 24532be2bc..65b28b9d96 100644 --- a/sentry_sdk/integrations/boto3.py +++ b/sentry_sdk/integrations/boto3.py @@ -6,8 +6,13 @@ from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.scope import should_send_default_pii from sentry_sdk.traces import StreamedSpan -from sentry_sdk.tracing import Span -from sentry_sdk.tracing_utils import add_http_breadcrumb, has_span_streaming_enabled +from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, Span +from sentry_sdk.tracing_utils import ( + add_http_breadcrumb, + add_sentry_baggage_to_headers, + has_span_streaming_enabled, + should_propagate_trace, +) from sentry_sdk.utils import ( capture_internal_exceptions, parse_url, @@ -49,6 +54,8 @@ def sentry_patched_init( "request-created", partial(_sentry_request_created, service_id=service_id), ) + # run after other `before-sign` handlers, allowing it to see and preserve existing baggage. + meta.events.register_last("before-sign", _sentry_before_sign) meta.events.register("after-call", _sentry_after_call) meta.events.register("after-call-error", _sentry_after_call_error) @@ -143,6 +150,60 @@ def _sentry_request_created( request.context["_sentrysdk_span"] = span +def _sentry_before_sign( + request: "AWSRequest", signature_version: "Any", **kwargs: "Any" +) -> None: + client = sentry_sdk.get_client() + if client.get_integration(Boto3Integration) is None: + return + + with capture_internal_exceptions(): + # presigned requests are executed later by another caller. Adding propagation + # headers here would make those headers part of the signature, requiring the caller to reproduce the same values. + if isinstance(signature_version, str) and signature_version.endswith( + ("-query", "-presign-post") + ): + return + + if request.url is None or not should_propagate_trace(client, request.url): + return + + def _replace_header(request: "AWSRequest", key: str, value: str) -> None: + """ + Botocore's `HTTPHeaders` inherits from `email.message.Message`, where: + headers["foo"] = "old" + headers["foo"] = "new" + produces two fields: {"foo": "old", "foo": "new"}. So delete existing + fields before assigning replacement. + """ + if key in request.headers: + del request.headers[key] + request.headers[key] = value + + # use span associated with this botocore request + span = request.context.get("_sentrysdk_span") + + headers = sentry_sdk.get_current_scope().iter_trace_propagation_headers( + span=span + ) + for header_name, header_value in headers: + if header_name != BAGGAGE_HEADER_NAME: + # normal headers (e.g. `sentry-trace`) are non-shared, so replace stale values + _replace_header(request, header_name, header_value) + continue + + # merge existing `baggage` values under single header + existing_values = request.headers.get_all(BAGGAGE_HEADER_NAME, []) + combined_baggage = { + BAGGAGE_HEADER_NAME: ",".join(str(value) for value in existing_values) + } + # preserve third-party baggage, replace stale `sentry-*` values + add_sentry_baggage_to_headers(combined_baggage, header_value) + _replace_header( + request, BAGGAGE_HEADER_NAME, combined_baggage[BAGGAGE_HEADER_NAME] + ) + + def _sentry_after_call( context: "Dict[str, Any]", parsed: "Dict[str, Any]", **kwargs: "Any" ) -> None: diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index cb31d90785..534e940504 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -10,7 +10,7 @@ from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor, should_send_default_pii from sentry_sdk.traces import StreamedSpan -from sentry_sdk.tracing import Span +from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, Span from sentry_sdk.tracing_utils import ( EnvironHeaders, add_http_breadcrumb, @@ -20,6 +20,8 @@ ) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, + _get_aws_sigv4_signed_headers_from_authorization_header, + _get_aws_sigv4_signed_headers_from_url_query_string, capture_internal_exceptions, ensure_integration_enabled, is_sentry_url, @@ -29,7 +31,7 @@ ) if TYPE_CHECKING: - from typing import Any, Callable, Dict, List, Optional, Union + from typing import Any, Callable, Dict, List, Optional, Tuple, Union from sentry_sdk._types import Event, Hint @@ -40,6 +42,14 @@ "build": sys.version, } +_SENTRY_HEADER_NAMES = frozenset((BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME)) + +try: + from botocore.awsrequest import AWSHTTPConnection, AWSHTTPSConnection +except ImportError: + AWSHTTPConnection = None # type: ignore[misc,assignment] + AWSHTTPSConnection = None # type: ignore[misc,assignment] + class StdlibIntegration(Integration): identifier = "stdlib" @@ -73,7 +83,178 @@ def _complete_span(span: "Union[Span, StreamedSpan]") -> None: add_http_request_source(span) +def _get_wrapped_putheader( + original_putheader: "Callable[..., Any]", +) -> "Callable[..., Any]": + """ + Responsible for tracking which sentry headers are present and whether + they are listed in AWS SigV4 `SignedHeaders`. + """ + + def putheader(self: "HTTPConnection", header: "Any", *values: "Any") -> "Any": + rv = original_putheader(self, header, *values) + + request_headers: "Optional[Dict[str, Tuple[bool, bool]]]" = getattr( + self, "_sentrysdk_request_headers", None + ) + if request_headers is None: + return rv + + if isinstance(header, bytes): + normalized_header = header.decode("ascii", "ignore").lower() + elif isinstance(header, str): + normalized_header = header.lower() + else: + return rv + + if normalized_header in _SENTRY_HEADER_NAMES: + _, is_signed = request_headers.get(normalized_header, (False, False)) + request_headers[normalized_header] = (True, is_signed) + + if normalized_header == "authorization" and values: + with capture_internal_exceptions(): + authorization = values[0] + if isinstance(authorization, bytes): + authorization = authorization.decode("latin-1") + for ( + signed_header + ) in _get_aws_sigv4_signed_headers_from_authorization_header( + authorization + ): + if signed_header in _SENTRY_HEADER_NAMES: + is_present, _ = request_headers.get( + signed_header, (False, False) + ) + request_headers[signed_header] = (is_present, True) + + return rv + + return putheader + + +def _get_wrapped_endheaders( + original_endheaders: "Callable[..., Any]", +) -> "Callable[..., Any]": + """ + Responsible for injecting trace propagation headers, ensuring that the request is not invalidated + by honoring signed headers. + """ + + def endheaders(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any": + real_url = getattr(self, "_sentrysdk_trace_url", None) + span = getattr(self, "_sentrysdk_span", None) + + try: + if real_url is not None: + with capture_internal_exceptions(): + request_headers: "Optional[Dict[str, Tuple[bool, bool]]]" = getattr( + self, "_sentrysdk_request_headers", {} + ) + if request_headers is not None: + for ( + signed_header + ) in _get_aws_sigv4_signed_headers_from_url_query_string( + real_url + ): + if signed_header in _SENTRY_HEADER_NAMES: + is_present, _ = request_headers.get( + signed_header, (False, False) + ) + request_headers[signed_header] = (is_present, True) + + for ( + header_name, + header_value, + ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers( + span=span + ): + normalized_header = header_name.lower() + # preserve signed headers and avoid duplicate `sentry-trace`. + is_present, is_signed = request_headers.get( + normalized_header, (False, False) + ) + if is_signed or ( + is_present and normalized_header != BAGGAGE_HEADER_NAME + ): + continue + + logger.debug( + "[Tracing] Adding `{key}` header {value} to outgoing request to {real_url}.".format( + key=header_name, + value=header_value, + real_url=real_url, + ) + ) + self.putheader(header_name, header_value) + return original_endheaders(self, *args, **kwargs) + finally: + self._sentrysdk_trace_url = None # type: ignore[attr-defined] + + return endheaders + + +def _get_wrapped_putrequest( + original_putrequest: "Callable[..., Any]", +) -> "Callable[..., Any]": + """ + Responsible for initializing request and signed header tracking on the instance. + """ + + def putrequest( + self: "HTTPConnection", method: str, url: str, *args: "Any", **kwargs: "Any" + ) -> "Any": + # track which propagation headers are present and signed e.g. {"sentry-trace": (is_present, is_signed)} + request_headers: "Optional[Dict[str, Tuple[bool, bool]]]" = {} + self._sentrysdk_request_headers = request_headers # type: ignore[attr-defined] + + try: + rv = original_putrequest(self, method, url, *args, **kwargs) + except BaseException: + self._sentrysdk_request_headers = None # type: ignore[attr-defined] + raise + + return rv + + return putrequest + + +def _patch_aws_connection() -> None: + """ + Patch AWS connection classes. These classes provide functions to sign HTTP headers, and subsequently + injecting trace propagation headers would invalidate the request. + + Detect whether propagation headers are present and signed by patching + `putheader()`. Store that state in `_sentrysdk_request_headers`, initialized + by the `putrequest()` patch. + + Do not edit signed headers when adding trace propagation headers in the `endheaders()` patch. + """ + if AWSHTTPConnection is not None: + AWSHTTPConnection.putheader = _get_wrapped_putheader( # type: ignore[method-assign] + AWSHTTPConnection.putheader + ) + AWSHTTPConnection.endheaders = _get_wrapped_endheaders( # type: ignore[method-assign] + AWSHTTPConnection.endheaders + ) + AWSHTTPConnection.putrequest = _get_wrapped_putrequest( # type: ignore[method-assign] + AWSHTTPConnection.putrequest + ) + + if AWSHTTPSConnection is not None: + AWSHTTPSConnection.putheader = _get_wrapped_putheader( # type: ignore[method-assign] + AWSHTTPSConnection.putheader + ) + AWSHTTPSConnection.endheaders = _get_wrapped_endheaders( # type: ignore[method-assign] + AWSHTTPSConnection.endheaders + ) + AWSHTTPSConnection.putrequest = _get_wrapped_putrequest( # type: ignore[method-assign] + AWSHTTPSConnection.putrequest + ) + + def _install_httplib() -> None: + _patch_aws_connection() + real_putrequest = HTTPConnection.putrequest real_getresponse = HTTPConnection.getresponse real_read = HTTPResponse.read @@ -189,7 +370,11 @@ def putrequest( rv = real_putrequest(self, method, url, *args, **kwargs) - if should_propagate_trace(client, real_url): + # If _sentrysdk_request_headers is present, trace propagation headers should + # be injected in an `endheaders()` patch. + if should_propagate_trace(client, real_url) and not hasattr( + self, "_sentrysdk_request_headers" + ): for ( key, value, @@ -202,6 +387,8 @@ def putrequest( ) ) self.putheader(key, value) + elif should_propagate_trace(client, real_url): + self._sentrysdk_trace_url = real_url # type: ignore[attr-defined] self._sentrysdk_span = span # type: ignore[attr-defined] self._sentrysdk_breadcrumb = breadcrumb # type: ignore[attr-defined] diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 45e5376d1c..9ba485b85f 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -87,6 +87,9 @@ FALSY_ENV_VALUES = frozenset(("false", "f", "n", "no", "off", "0")) TRUTHY_ENV_VALUES = frozenset(("true", "t", "y", "yes", "on", "1")) +_AWS_SIGV4_SIGNING_ALGORITHMS = frozenset( + ("AWS4-HMAC-SHA256", "AWS4-ECDSA-P256-SHA256") +) MAX_STACK_FRAMES = 2000 """Maximum number of stack frames to send to Sentry. @@ -1697,6 +1700,40 @@ def parse_url(url: str, sanitize: bool = True) -> "ParsedUrl": ) +def _get_aws_sigv4_signed_headers_from_authorization_header( + authorization: str, +) -> "Set[str]": + # only AWS SigV4 authorization has the SignedHeaders parameter. + value = authorization.lstrip() + algorithm, _, parameters = value.partition(" ") + if algorithm not in _AWS_SIGV4_SIGNING_ALGORITHMS: + return set() + + for part in parameters.split(","): + part = part.strip() + if part.startswith("SignedHeaders="): + _, _, header_names = part.partition("=") + return {header.lower() for header in header_names.split(";") if header} + + return set() + + +def _get_aws_sigv4_signed_headers_from_url_query_string(url: str) -> "Set[str]": + query = { + key.lower(): values for key, values in parse_qs(urlsplit(url).query).items() + } + algorithm = query.get("x-amz-algorithm", [""])[0] + if algorithm not in _AWS_SIGV4_SIGNING_ALGORITHMS: + return set() + + # presigned requests have SignedHeaders in the URL query. + return { + header.lower() + for header in query.get("x-amz-signedheaders", [""])[0].split(";") + if header + } + + def is_valid_sample_rate(rate: "Any", source: str) -> bool: """ Checks the given sample rate to make sure it is valid type and value (a diff --git a/tests/integrations/boto3/test_aws_http_connection.py b/tests/integrations/boto3/test_aws_http_connection.py new file mode 100644 index 0000000000..2c3a30103f --- /dev/null +++ b/tests/integrations/boto3/test_aws_http_connection.py @@ -0,0 +1,188 @@ +from http.client import HTTPMessage +from http.server import BaseHTTPRequestHandler, HTTPServer +from threading import Thread +from unittest import mock + +import pytest +from botocore.awsrequest import AWSHTTPConnection + +import sentry_sdk +from sentry_sdk.integrations.stdlib import StdlibIntegration +from sentry_sdk.utils import ( + _get_aws_sigv4_signed_headers_from_authorization_header, + _get_aws_sigv4_signed_headers_from_url_query_string, +) + + +@pytest.fixture +def local_http_server(): + requests = [] + + class TraceHeaderHandler(BaseHTTPRequestHandler): + def do_POST(self): + requests.append(self.headers) + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + + server = HTTPServer(("127.0.0.1", 0), TraceHeaderHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + yield server, requests + finally: + server.shutdown() + server.server_close() + thread.join() + + +def _request(server, headers, path="/"): + connection = AWSHTTPConnection("127.0.0.1", server.server_port) + connection.request("POST", path, headers=dict(headers)) + + response = connection.getresponse() + response.read() + connection.close() + + +@pytest.mark.parametrize("span_streaming", [False, True]) +def test_aws_http_connection_appends_to_unsigned_baggage( + sentry_init, local_http_server, span_streaming +): + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + default_integrations=False, + integrations=[StdlibIntegration()], + ) + server, requests = local_http_server + + with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=67): + if span_streaming: + with sentry_sdk.traces.start_span(name="test"): # type: ignore[attr-defined] + _request(server, [("baggage", "vendor=value")]) + else: + with sentry_sdk.start_transaction(name="test", sampled=True): + _request(server, [("baggage", "vendor=value")]) + + headers: HTTPMessage = requests[0] + + # preserve existing unsigned baggage + baggage_headers = headers.get_all("baggage") + assert baggage_headers is not None + assert len(baggage_headers) == 2 + assert baggage_headers[0] == "vendor=value" + assert baggage_headers[1].count("sentry-trace_id=") == 1 + assert "sentry-sample_rand=0.000067" in baggage_headers[1] + sentry_trace_headers = headers.get_all("sentry-trace") + assert sentry_trace_headers is not None + assert len(sentry_trace_headers) == 1 + + +@pytest.mark.parametrize("span_streaming", [False, True]) +def test_aws_http_connection_skips_signed_baggage( + sentry_init, local_http_server, span_streaming +): + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + default_integrations=False, + integrations=[StdlibIntegration()], + ) + server, requests = local_http_server + + # simulate AWS SigV4 request that is already signed. + authorization = ( + "AWS4-HMAC-SHA256 " + "Credential=test/20260804/eu-west-1/secretsmanager/aws4_request, " + "SignedHeaders=baggage;host;sentry-trace, " + "Signature=sixtyseven" + ) + + if span_streaming: + with sentry_sdk.traces.start_span(name="test"): # type: ignore[attr-defined] + _request( + server, + [ + ("baggage", "vendor=value"), + ("sentry-trace", "existing-trace"), + ("Authorization", authorization), + ], + ) + else: + with sentry_sdk.start_transaction(name="test", sampled=True): + _request( + server, + [ + ("baggage", "vendor=value"), + ("sentry-trace", "existing-trace"), + ("Authorization", authorization), + ], + ) + + headers: HTTPMessage = requests[0] + + # do not append baggage after SigV4 signs it. + assert headers.get_all("baggage") == ["vendor=value"] + # preserves existing `sentry-trace` header. + assert headers.get_all("sentry-trace") == ["existing-trace"] + assert _get_aws_sigv4_signed_headers_from_authorization_header( + headers.get("Authorization", "") + ) >= { + "baggage", + "host", + "sentry-trace", + } + + +@pytest.mark.parametrize("span_streaming", [False, True]) +def test_aws_http_connection_skips_query_signed_baggage( + sentry_init, local_http_server, span_streaming +): + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + default_integrations=False, + integrations=[StdlibIntegration()], + ) + server, requests = local_http_server + 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" + ) + + if span_streaming: + with sentry_sdk.traces.start_span(name="test"): # type: ignore[attr-defined] + _request( + server, + [("baggage", "vendor=value")], + path=path, + ) + else: + with sentry_sdk.start_transaction(name="test", sampled=True): + _request( + server, + [("baggage", "vendor=value")], + path=path, + ) + + headers: HTTPMessage = requests[0] + # `baggage` is part of X-Amz-SignedHeaders, so may not be modified. + baggage_headers = headers.get_all("baggage") + assert baggage_headers is not None + assert len(baggage_headers) == 1 + assert headers["baggage"] == "vendor=value" + # `sentry-trace` was not signed, so it can be propagated. + sentry_trace_headers = headers.get_all("sentry-trace") + assert sentry_trace_headers is not None + assert len(sentry_trace_headers) == 1 + assert _get_aws_sigv4_signed_headers_from_url_query_string( + f"http://127.0.0.1:{server.server_port}{path}" + ) >= {"baggage", "host"} diff --git a/tests/integrations/boto3/test_trace_propagation.py b/tests/integrations/boto3/test_trace_propagation.py new file mode 100644 index 0000000000..b7ed90e8b9 --- /dev/null +++ b/tests/integrations/boto3/test_trace_propagation.py @@ -0,0 +1,214 @@ +from http.server import BaseHTTPRequestHandler, HTTPServer +from threading import Thread +from urllib.parse import parse_qs, urlparse + +import boto3 +import pytest +from botocore.config import Config + +import sentry_sdk +from sentry_sdk.integrations.boto3 import Boto3Integration +from sentry_sdk.integrations.stdlib import StdlibIntegration +from sentry_sdk.utils import ( + _get_aws_sigv4_signed_headers_from_authorization_header, + _get_aws_sigv4_signed_headers_from_url_query_string, +) + + +class _AwsRequestHandler(BaseHTTPRequestHandler): + requests = [] + + def do_HEAD(self): + self.__class__.requests.append(self.headers) + self.send_response(200) + self.end_headers() + + def log_message(self, format, *args): + pass + + +def _start_server(): + _AwsRequestHandler.requests = [] + server = HTTPServer(("127.0.0.1", 0), _AwsRequestHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread + + +@pytest.mark.parametrize("span_streaming", [False, True]) +def test_botocore_merges_propagation_before_sigv4_signing(sentry_init, span_streaming): + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + default_integrations=False, + integrations=[Boto3Integration(), StdlibIntegration()], + ) + + server, thread = _start_server() + + try: + client = boto3.client( # type: ignore[attr-defined] + "s3", + # connect to mock AWS server. + endpoint_url=f"http://127.0.0.1:{server.server_port}", + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + config=Config(signature_version="v4"), + ) + + def _inject_third_party_baggage(request, **kwargs): + request.headers.add_header( + "baggage", + "dd-origin=synthetics,sentry-trace_id=stale,sentry-sample_rand=0.100000", + ) + request.headers.add_header("baggage", "vendor=value") + + signed_request_headers = {} + + def capture_headers_after_instrumentation(request, **kwargs): + for header_name in ("baggage", "sentry-trace"): + signed_request_headers[header_name] = request.headers.get_all( + header_name + ) + + # register `before-sign` handler that adds third-party baggage. + client.meta.events.register("before-sign", _inject_third_party_baggage) + client.meta.events.register_last( + "before-sign", capture_headers_after_instrumentation + ) + + if span_streaming: + with sentry_sdk.traces.start_span( # type: ignore[attr-defined] + name="incoming" + ): + response = client.head_object( + Bucket="example-bucket", + Key="example-key", + ) + else: + with sentry_sdk.start_transaction(name="incoming", sampled=True): + response = client.head_object( + Bucket="example-bucket", + Key="example-key", + ) + + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + headers = _AwsRequestHandler.requests[-1] + + baggage_headers = headers.get_all("baggage") + assert baggage_headers is not None + assert len(baggage_headers) == 1 + assert baggage_headers == signed_request_headers["baggage"] + + baggage = baggage_headers[0] + # preserves third-party baggage. + assert "dd-origin=synthetics" in baggage + assert "vendor=value" in baggage + # add own `sentry-*` baggage. + assert "sentry-trace_id=" in baggage + assert "sentry-trace_id=stale" not in baggage + # replace stale values instead of duplicating them. + assert baggage.count("sentry-trace_id=") == 1 + assert baggage.count("sentry-sample_rand=") == 1 + + # adds single `sentry-trace` header. + sentry_trace_headers = headers.get_all("sentry-trace") + assert sentry_trace_headers is not None + assert len(sentry_trace_headers) == 1 + assert sentry_trace_headers == signed_request_headers["sentry-trace"] + # both `baggage` and `sentry-trace` are signed. + signed_headers = _get_aws_sigv4_signed_headers_from_authorization_header( + headers.get("Authorization", "") + ) + assert signed_headers >= {"baggage", "sentry-trace"} + finally: + server.shutdown() + server.server_close() + thread.join() + + +@pytest.mark.parametrize("span_streaming", [False, True]) +def test_botocore_without_boto3_integration_preserves_signed_baggage( + sentry_init, span_streaming +): + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + default_integrations=False, + integrations=[StdlibIntegration()], + ) + + server, thread = _start_server() + try: + client = boto3.client( # type: ignore[attr-defined] + "s3", + endpoint_url=f"http://127.0.0.1:{server.server_port}", + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + config=Config(signature_version="v4"), + ) + + def _inject_signed_baggage(request, **kwargs): + request.headers.add_header("baggage", "vendor=value") + + # register `before-sign` handler that third-party signed baggage. + client.meta.events.register("before-sign", _inject_signed_baggage) + + if span_streaming: + with sentry_sdk.traces.start_span( # type: ignore[attr-defined] + name="incoming" + ): + response = client.head_object( + Bucket="example-bucket", + Key="example-key", + ) + else: + with sentry_sdk.start_transaction(name="incoming", sampled=True): + response = client.head_object( + Bucket="example-bucket", + Key="example-key", + ) + + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + headers = _AwsRequestHandler.requests[-1] + # preserves third-party signed baggage. + assert headers.get_all("baggage") == ["vendor=value"] + # `httplib` still adds single `sentry-trace` header. + assert len(headers.get_all("sentry-trace")) == 1 + signed_headers = _get_aws_sigv4_signed_headers_from_authorization_header( + headers.get("Authorization", "") + ) + assert "baggage" in signed_headers + assert "sentry-trace" not in signed_headers + finally: + server.shutdown() + server.server_close() + thread.join() + + +def test_presigned_urls_do_not_require_sentry_headers(sentry_init): + sentry_init( + traces_sample_rate=1.0, + default_integrations=False, + integrations=[Boto3Integration(), StdlibIntegration()], + ) + client = boto3.client( # type: ignore[attr-defined] + "s3", + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + config=Config(signature_version="s3v4"), + ) + + url = client.generate_presigned_url( + "get_object", + Params={"Bucket": "example-bucket", "Key": "example-key"}, + ExpiresIn=60, + ) + query = parse_qs(urlparse(url).query) + + # only `host` header is signed. + assert query["X-Amz-SignedHeaders"] == ["host"] + assert _get_aws_sigv4_signed_headers_from_url_query_string(url) == {"host"} + # no `sentry-*` or baggage are added. + assert "sentry-trace" not in url + assert "baggage" not in url