From d32a43f2125a6d95a0024a6a9a6fe8ca4ea7e9c0 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 15:02:07 +0200 Subject: [PATCH 01/23] ref(boto3): simplify client span lifecycle and header handling --- sentry_sdk/integrations/boto3/_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index 8aeb4c2855..63e6479c48 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -115,6 +115,9 @@ def sentry_patched_make_api_call( raise streaming_body_instrumented = False + with capture_internal_exceptions(): + streaming_body_instrumented = _instrument_streaming_body(span, parsed) + streaming_body_instrumented = False with capture_internal_exceptions(): streaming_body_instrumented = _instrument_streaming_body(span, parsed) From 822438b0d9ae054e4cda1e936735164c5962408e Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Mon, 14 Sep 2026 14:31:40 +0200 Subject: [PATCH 02/23] feat(boto3): add attributes to `consts.py` --- sentry_sdk/consts.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 651d709061..90e89afc95 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -390,6 +390,18 @@ class SPANDATA: Example: ["Token limit exceeded"] """ + AWS_EXTENDED_REQUEST_ID = "aws.extended_request_id" + """ + The AWS extended request ID as returned in the response headers. + Example: "wzHcyEWfmOGDIE5QOhTAqFDoDWP3y8IUvpNINCwL9N4TEHbUw0/gZJ+VZTmCNCWR7fezEN3eCiQ=" + """ + + AWS_REQUEST_ID = "aws.request_id" + """ + The AWS request ID as returned in the response headers. + Example: "79b9da39-b7ae-508a-a6bc-864b2829c622" + """ + CACHE_HIT = "cache.hit" """ A boolean indicating whether the requested data was found in the cache. @@ -547,6 +559,12 @@ class SPANDATA: Example: my_user """ + ERROR_TYPE = "error.type" + """ + Describes a class of error the operation ended with. + Example: "timeout" + """ + GEN_AI_AGENT_NAME = "gen_ai.agent.name" """ The name of the agent being used. @@ -886,6 +904,12 @@ class SPANDATA: Example: GET """ + HTTP_REQUEST_RESEND_COUNT = "http.request.resend_count" + """ + The ordinal number of request resending attempt (for any reason, including redirects). + Example: 2 + """ + HTTP_ROUTE = "http.route" """ The matched route, that is, the path template used to match the request. From 46d24b2b3dc463277d4cf87f1cca7ff7dcf9ca1f Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 15 Sep 2026 14:56:11 +0200 Subject: [PATCH 03/23] merge changes --- sentry_sdk/integrations/boto3/_client.py | 17 +++- .../integrations/boto3/_instrumentation.py | 98 +++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index 63e6479c48..ba390c9450 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -6,9 +6,12 @@ from sentry_sdk.integrations.boto3._context import AwsCallContext from sentry_sdk.integrations.boto3._instrumentation import ( _finish_span, + _get_error_attributes, + _get_response_attributes, _instrument_streaming_body, _sentry_before_sign, _sentry_request_created, + _set_span_attributes, _start_client_span, ) from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan @@ -109,7 +112,19 @@ def sentry_patched_make_api_call( try: with span_ctx: - parsed = orig_make_api_call(self, operation_name, api_params) + try: + parsed = orig_make_api_call(self, operation_name, api_params) + except BaseException as error: + if span is not None: + with capture_internal_exceptions(): + _set_span_attributes(span, _get_error_attributes(error)) + raise + else: + if span is not None: + with capture_internal_exceptions(): + _set_span_attributes( + span, _get_response_attributes(parsed) + ) except BaseException as error: _finish_span(span, error) raise diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 315fecb803..b0e2056676 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -32,6 +32,7 @@ try: from botocore.awsrequest import AWSRequest + from botocore.exceptions import ClientError from botocore.response import StreamingBody except ImportError: raise DidNotEnable("botocore not installed") @@ -40,6 +41,7 @@ def _set_span_attributes( span: "Union[Span, StreamedSpan]", attributes: "Attributes" ) -> None: + """Will be removed in the major.""" if isinstance(span, StreamedSpan): span.set_attributes(attributes) return @@ -86,6 +88,95 @@ def _get_client_attributes( return attributes +def _get_response_attributes(response: "Any") -> "Attributes": + if not isinstance(response, dict): + return {} + + metadata = response.get("ResponseMetadata") + if not isinstance(metadata, dict): + return {} + attributes: "Attributes" = {} + + # botocore injects HTTP status into `ResponseMetadata` after parsing. + # https://github.com/boto/botocore/blob/develop/botocore/parsers.py#L273-L284 + status_code = metadata.get("HTTPStatusCode") + if isinstance(status_code, int) and 100 <= status_code <= 599: + attributes[SPANDATA.HTTP_STATUS_CODE] = status_code + + retry_attempts = metadata.get("RetryAttempts") + # botocore represents retries as `attempts - 1`; OTel suggests "if and only if", so skip zero. + # https://github.com/boto/botocore/blob/develop/botocore/endpoint.py#L221-L229 + # https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span + if ( + isinstance(retry_attempts, int) + # avoid emitting `resend_count=True`. + and not isinstance(retry_attempts, bool) + and retry_attempts > 0 + ): + attributes[SPANDATA.HTTP_REQUEST_RESEND_COUNT] = retry_attempts + + headers = metadata.get("HTTPHeaders") + if not isinstance(headers, dict): + headers = {} + + request_id = metadata.get("RequestId") + if not isinstance(request_id, str) or not request_id: + request_id = next( + ( + value + for value in ( + headers.get("x-amzn-requestid"), + headers.get("x-amzn-request-id"), + headers.get("x-amz-request-id"), + ) + if isinstance(value, str) and value + ), + None, + ) + if isinstance(request_id, str) and request_id: + attributes[SPANDATA.AWS_REQUEST_ID] = request_id + + # S3's `HostId` is the extended request ID returned in `x-amz-id-2`. + # https://docs.aws.amazon.com/AmazonS3/latest/developerguide/get-request-ids.html + extended_request_id = metadata.get("HostId") + if not isinstance(extended_request_id, str) or not extended_request_id: + extended_request_id = headers.get("x-amz-id-2") + if isinstance(extended_request_id, str) and extended_request_id: + attributes[SPANDATA.AWS_EXTENDED_REQUEST_ID] = extended_request_id + + return attributes + + +def _get_error_type(exception: "BaseException") -> str: + if isinstance(exception, ClientError): + # `ClientError` wraps AWS service errors; `Error.Code` identifies the + # actual service error, e.g. `AccessDeniedException`. + # https://docs.aws.amazon.com/boto3/latest/guide/error-handling.html + error = exception.response.get("Error") + if isinstance(error, dict): + error_code = error.get("Code") + if isinstance(error_code, str) and error_code: + return error_code + + # failures before a service response have no AWS error code. + # https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/ + exception_type = type(exception) + exception_name = exception_type.__qualname__ + exception_module = exception_type.__module__ + if exception_module not in ("builtins", "__builtins__"): + return "%s.%s" % (exception_module, exception_name) + return exception_name + + +def _get_error_attributes(exception: "BaseException") -> "Attributes": + attributes: "Attributes" = {} + if isinstance(exception, ClientError): + attributes.update(_get_response_attributes(exception.response)) + + attributes[SPANDATA.ERROR_TYPE] = _get_error_type(exception) + return attributes + + def _start_client_span( ctx: "AwsCallContext", ) -> "Optional[Union[Span, StreamedSpan]]": @@ -200,6 +291,13 @@ def finish_span(error: "Optional[BaseException]" = None) -> None: finished = True # finish stream span before boto span, and only once across read/close. + if error is not None: + with capture_internal_exceptions(): + attributes = _get_error_attributes(error) + _set_span_attributes(streaming_span, attributes) + if isinstance(span, StreamedSpan): + _set_span_attributes(span, attributes) + _finish_span(streaming_span, error) _finish_span(span, error) From 30d1e3f1faced9bed1bfe1466ec140807c51abb8 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Thu, 17 Sep 2026 17:54:09 +0200 Subject: [PATCH 04/23] add tests --- tests/integrations/boto3/test_client.py | 342 +++++++++++++++++++++++- 1 file changed, 340 insertions(+), 2 deletions(-) diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index 344fb6de5d..f71c34be6b 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -12,7 +12,11 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3 import Boto3Integration -from sentry_sdk.integrations.boto3._instrumentation import _instrument_streaming_body +from sentry_sdk.integrations.boto3._instrumentation import ( + _get_error_attributes, + _get_response_attributes, + _instrument_streaming_body, +) from sentry_sdk.integrations.boto3.consts import AWS_RPC_SYSTEM_NAME, ORIGIN from sentry_sdk.integrations.stdlib import StdlibIntegration from sentry_sdk.traces import StreamedSpan @@ -355,6 +359,7 @@ def _assert_span_finished(span, span_streaming): def _assert_one_failed_span(spans, span_streaming): assert len(spans) == 1 assert spans[0]["status"] in ("error", "internal_error") + assert _span_attributes(spans[0], span_streaming)[SPANDATA.ERROR_TYPE] _assert_span_finished(spans[0], span_streaming) @@ -364,9 +369,12 @@ def _capture_stubbed_client_span( api_params, capture_items, span_streaming, + response=None, ): with Stubber(client) as stubber: - stubber.add_response(method_name, {}, api_params) + stubber.add_response( + method_name, response if response is not None else {}, api_params + ) spans_by_op = _capture_boto3_spans_by_op( lambda: getattr(client, method_name)(**api_params), capture_items, @@ -382,6 +390,124 @@ def _span_attributes(span, span_streaming): return span["attributes"] if span_streaming else span["data"] +@pytest.mark.parametrize( + ("response", "expected"), + [ + (None, {}), + ({}, {}), + ({"ResponseMetadata": None}, {}), + ( + { + "ResponseMetadata": { + "RequestId": "request-id", + "HostId": "extended-request-id", + "HTTPStatusCode": 200, + "RetryAttempts": 0, + } + }, + { + SPANDATA.AWS_REQUEST_ID: "request-id", + SPANDATA.AWS_EXTENDED_REQUEST_ID: "extended-request-id", + SPANDATA.HTTP_STATUS_CODE: 200, + }, + ), + ( + { + "ResponseMetadata": { + "RequestId": "request-id", + "HTTPStatusCode": 200, + "RetryAttempts": 2, + } + }, + { + SPANDATA.AWS_REQUEST_ID: "request-id", + SPANDATA.HTTP_STATUS_CODE: 200, + SPANDATA.HTTP_REQUEST_RESEND_COUNT: 2, + }, + ), + ], +) +def test_get_response_attributes(response, expected): + assert _get_response_attributes(response) == expected + + +@pytest.mark.parametrize( + "header_name", + ["x-amzn-requestid", "x-amzn-request-id", "x-amz-request-id"], +) +def test_get_response_attributes_reads_request_id_header(header_name): + response = { + "ResponseMetadata": { + "HTTPHeaders": {header_name: "request-id"}, + } + } + + assert _get_response_attributes(response) == {SPANDATA.AWS_REQUEST_ID: "request-id"} + + +def test_get_response_attributes_reads_extended_request_id_header(): + response = { + "ResponseMetadata": { + "HTTPHeaders": {"x-amz-id-2": "extended-request-id"}, + } + } + + assert _get_response_attributes(response) == { + SPANDATA.AWS_EXTENDED_REQUEST_ID: "extended-request-id" + } + + +@pytest.mark.parametrize( + ("field", "value", "attribute"), + [ + ("RequestId", 123, SPANDATA.AWS_REQUEST_ID), + ("RequestId", "", SPANDATA.AWS_REQUEST_ID), + ("HTTPStatusCode", "200", SPANDATA.HTTP_STATUS_CODE), + ("HTTPStatusCode", True, SPANDATA.HTTP_STATUS_CODE), + ("HTTPStatusCode", 999, SPANDATA.HTTP_STATUS_CODE), + ("RetryAttempts", "2", SPANDATA.HTTP_REQUEST_RESEND_COUNT), + ("RetryAttempts", False, SPANDATA.HTTP_REQUEST_RESEND_COUNT), + ("RetryAttempts", -1, SPANDATA.HTTP_REQUEST_RESEND_COUNT), + ], +) +def test_get_response_attributes_ignores_malformed_field(field, value, attribute): + metadata = { + "RequestId": "request-id", + "HTTPStatusCode": 200, + "RetryAttempts": 2, + } + metadata[field] = value + + attributes = _get_response_attributes({"ResponseMetadata": metadata}) + expected = { + SPANDATA.AWS_REQUEST_ID: "request-id", + SPANDATA.HTTP_STATUS_CODE: 200, + SPANDATA.HTTP_REQUEST_RESEND_COUNT: 2, + } + expected.pop(attribute) + assert attributes == expected + + +@pytest.mark.parametrize( + "error_response", + [None, {"Code": ""}, {"Code": 123}], +) +def test_get_error_attributes_ignores_malformed_client_error_code(error_response): + error = ClientError( + { + "Error": {"Code": "placeholder"}, + "ResponseMetadata": {"HTTPStatusCode": 400}, + }, + "HeadObject", + ) + error.response["Error"] = error_response + + assert _get_error_attributes(error) == { + SPANDATA.HTTP_STATUS_CODE: 400, + SPANDATA.ERROR_TYPE: "botocore.exceptions.ClientError", + } + + @pytest.mark.parametrize( ( "service_name", @@ -517,6 +643,37 @@ def test_client_call_omits_missing_region( assert SPANDATA.CLOUD_REGION not in span["attributes"] +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_client_call_has_response_attributes( + capture_items, + client_factory, + span_streaming, +): + client = client_factory() + span = _capture_stubbed_client_span( + client, + "head_object", + {"Bucket": "bucket", "Key": "foo"}, + capture_items, + span_streaming, + response={ + "ResponseMetadata": { + "HTTPStatusCode": 200, + "RequestId": "request-id", + "HostId": "extended-request-id", + "RetryAttempts": 0, + } + }, + ) + attributes = _span_attributes(span, span_streaming) + + assert attributes[SPANDATA.HTTP_STATUS_CODE] == 200 + assert attributes[SPANDATA.AWS_REQUEST_ID] == "request-id" + assert attributes[SPANDATA.AWS_EXTENDED_REQUEST_ID] == "extended-request-id" + assert SPANDATA.HTTP_REQUEST_RESEND_COUNT not in attributes + assert SPANDATA.ERROR_TYPE not in attributes + + @pytest.mark.parametrize("span_streaming", [True, False]) def test_retry_attempts_share_one_client_span( capture_items, @@ -538,6 +695,8 @@ def test_retry_attempts_share_one_client_span( # all `AWSRequest` instances created during retries reference the same client span. assert len(set(request_span_ids)) == 1 assert len(client_spans) == 1 + attributes = _span_attributes(client_spans[0], span_streaming) + assert attributes[SPANDATA.HTTP_REQUEST_RESEND_COUNT] == attempt_count - 1 @pytest.mark.parametrize("span_streaming", [True, False]) @@ -561,6 +720,57 @@ def attempt_failed_head_object_call(): assert len(request_span_ids) == 2 assert len(set(request_span_ids)) == 1 _assert_one_failed_span(client_spans, span_streaming) + attributes = _span_attributes(client_spans[0], span_streaming) + assert attributes[SPANDATA.HTTP_STATUS_CODE] == 500 + assert attributes[SPANDATA.HTTP_REQUEST_RESEND_COUNT] == 1 + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_client_error_has_response_attributes_and_is_unchanged( + capture_items, + client_factory, + span_streaming, +): + client = client_factory() + original_exception = ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "must not become a span attribute", + }, + "ResponseMetadata": { + "RequestId": "request-id", + "HTTPStatusCode": 403, + "RetryAttempts": 1, + }, + }, + "HeadObject", + ) + + def raise_client_error(**kwargs): + raise original_exception + + client.meta.events.register("before-parameter-build", raise_client_error) + + def invoke_failing_client_method(): + with pytest.raises(ClientError) as exc_info: + client.head_object(Bucket="bucket", Key="foo") + assert exc_info.value is original_exception + + spans_by_op = _capture_boto3_spans_by_op( + invoke_failing_client_method, capture_items, span_streaming + ) + client_spans = spans_by_op.get(OP.HTTP_CLIENT, []) + _assert_one_failed_span(client_spans, span_streaming) + attributes = _span_attributes(client_spans[0], span_streaming) + + assert attributes[SPANDATA.AWS_REQUEST_ID] == "request-id" + assert attributes[SPANDATA.HTTP_STATUS_CODE] == 403 + assert attributes[SPANDATA.HTTP_REQUEST_RESEND_COUNT] == 1 + assert attributes[SPANDATA.ERROR_TYPE] == "AccessDeniedException" + assert "Error.Message" not in attributes + assert "exception.message" not in attributes + assert "error.message" not in attributes @pytest.mark.parametrize( @@ -601,6 +811,132 @@ def invoke_failing_client_method(): client_spans = spans_by_op.get(OP.HTTP_CLIENT, []) _assert_one_failed_span(client_spans, span_streaming) + attributes = _span_attributes(client_spans[0], span_streaming) + expected_error_type = ( + "botocore.exceptions.EndpointConnectionError" + if event_name == "before-send" + else "ValueError" + ) + assert attributes[SPANDATA.ERROR_TYPE] == expected_error_type + + +@pytest.mark.tests_internal_exceptions +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_response_attribute_extraction_failure_does_not_change_response( + capture_items, + client_factory, + monkeypatch, + span_streaming, +): + client = client_factory() + api_params = {"Bucket": "bucket", "Key": "foo"} + original_response = {"ResponseMetadata": {"HTTPStatusCode": 200}} + returned_responses = [] + + def fail_attribute_extraction(response): + raise RuntimeError("attribute extraction failed") + + monkeypatch.setattr( + "sentry_sdk.integrations.boto3._instrumentation._get_response_attributes", + fail_attribute_extraction, + ) + + def invoke_client_method(): + returned_responses.append(client.head_object(**api_params)) + + with Stubber(client) as stubber: + stubber.add_response("head_object", original_response, api_params) + spans_by_op = _capture_boto3_spans_by_op( + invoke_client_method, capture_items, span_streaming + ) + + client_spans = spans_by_op.get(OP.HTTP_CLIENT, []) + assert returned_responses == [original_response] + assert returned_responses[0] is original_response + assert len(client_spans) == 1 + _assert_span_finished(client_spans[0], span_streaming) + + +@pytest.mark.tests_internal_exceptions +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_error_attribute_extraction_failure_does_not_replace_original_exception( + capture_items, + client_factory, + monkeypatch, + span_streaming, +): + client = client_factory() + original_exception = ValueError("parameter processing failed") + + def raise_original_exception(**kwargs): + raise original_exception + + def fail_attribute_extraction(exception): + raise RuntimeError("attribute extraction failed") + + client.meta.events.register("before-parameter-build", raise_original_exception) + monkeypatch.setattr( + "sentry_sdk.integrations.boto3._instrumentation._get_error_attributes", + fail_attribute_extraction, + ) + + def invoke_failing_client_method(): + with pytest.raises(ValueError) as exc_info: + client.head_object(Bucket="bucket", Key="foo") + assert exc_info.value is original_exception + + spans_by_op = _capture_boto3_spans_by_op( + invoke_failing_client_method, capture_items, span_streaming + ) + client_spans = spans_by_op.get(OP.HTTP_CLIENT, []) + + assert len(client_spans) == 1 + assert client_spans[0]["status"] in ("error", "internal_error") + _assert_span_finished(client_spans[0], span_streaming) + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_streaming_response_attributes_belong_to_client_span( + capture_items, + client_factory, + span_streaming, +): + client = client_factory() + + def respond(request, **kwargs): + return AWSResponse( + request.url, + 200, + { + "content-length": "5", + "x-amz-request-id": "request-id", + }, + Body(b"hello"), + ) + + client.meta.events.register("before-send", respond) + + def invoke_client_method_and_read_body(): + body = client.get_object(Bucket="bucket", Key="foo")["Body"] + assert body.read() == b"hello" + assert body.read() == b"" + + spans_by_op = _capture_boto3_spans_by_op( + invoke_client_method_and_read_body, capture_items, span_streaming + ) + client_spans = spans_by_op.get(OP.HTTP_CLIENT, []) + stream_spans = spans_by_op.get(OP.HTTP_CLIENT_STREAM, []) + + assert len(client_spans) == 1 + assert len(stream_spans) == 1 + client_attributes = _span_attributes(client_spans[0], span_streaming) + stream_attributes = _span_attributes(stream_spans[0], span_streaming) + assert client_attributes[SPANDATA.AWS_REQUEST_ID] == "request-id" + assert client_attributes[SPANDATA.HTTP_STATUS_CODE] == 200 + assert SPANDATA.HTTP_REQUEST_RESEND_COUNT not in client_attributes + assert SPANDATA.AWS_REQUEST_ID not in stream_attributes + assert SPANDATA.HTTP_STATUS_CODE not in stream_attributes + @pytest.mark.parametrize("span_streaming", [True, False]) def test_streaming_body_read_failure_finishes_stream_span( @@ -646,3 +982,5 @@ def invoke_client_method_and_read_body(): assert len(client_spans) == 1 _assert_one_failed_span(client_spans, span_streaming) _assert_one_failed_span(stream_spans, span_streaming) + attributes = _span_attributes(stream_spans[0], span_streaming) + assert attributes[SPANDATA.ERROR_TYPE] == "OSError" From 250699d055c83947bc4b9831c36a318931a69ffd Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 15 Sep 2026 17:21:18 +0200 Subject: [PATCH 05/23] patch correct methods --- tests/integrations/boto3/test_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index f71c34be6b..9e0d678449 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -837,7 +837,7 @@ def fail_attribute_extraction(response): raise RuntimeError("attribute extraction failed") monkeypatch.setattr( - "sentry_sdk.integrations.boto3._instrumentation._get_response_attributes", + "sentry_sdk.integrations.boto3._client._get_response_attributes", fail_attribute_extraction, ) @@ -876,7 +876,7 @@ def fail_attribute_extraction(exception): client.meta.events.register("before-parameter-build", raise_original_exception) monkeypatch.setattr( - "sentry_sdk.integrations.boto3._instrumentation._get_error_attributes", + "sentry_sdk.integrations.boto3._client._get_error_attributes", fail_attribute_extraction, ) From d9edef50eb13dde787d7cd8d5ca5cb5a214d21cf Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 09:54:42 +0200 Subject: [PATCH 06/23] lint --- sentry_sdk/integrations/boto3/_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index ba390c9450..b7e0e1e526 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -122,9 +122,7 @@ def sentry_patched_make_api_call( else: if span is not None: with capture_internal_exceptions(): - _set_span_attributes( - span, _get_response_attributes(parsed) - ) + _set_span_attributes(span, _get_response_attributes(parsed)) except BaseException as error: _finish_span(span, error) raise From 90383dcbf80b601c117bad2ee946fd877420a059 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 13:55:57 +0200 Subject: [PATCH 07/23] use ordered search for request id and hist id --- .../integrations/boto3/_instrumentation.py | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index b0e2056676..3245c98f40 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -119,29 +119,33 @@ def _get_response_attributes(response: "Any") -> "Attributes": if not isinstance(headers, dict): headers = {} - request_id = metadata.get("RequestId") - if not isinstance(request_id, str) or not request_id: - request_id = next( - ( - value - for value in ( - headers.get("x-amzn-requestid"), - headers.get("x-amzn-request-id"), - headers.get("x-amz-request-id"), - ) - if isinstance(value, str) and value - ), - None, - ) - if isinstance(request_id, str) and request_id: + request_id = next( + ( + value + for value in ( + metadata.get("RequestId"), + headers.get("x-amzn-requestid"), + headers.get("x-amzn-request-id"), + headers.get("x-amz-request-id"), + ) + if isinstance(value, str) and value + ), + None, + ) + if request_id is not None: attributes[SPANDATA.AWS_REQUEST_ID] = request_id # S3's `HostId` is the extended request ID returned in `x-amz-id-2`. # https://docs.aws.amazon.com/AmazonS3/latest/developerguide/get-request-ids.html - extended_request_id = metadata.get("HostId") - if not isinstance(extended_request_id, str) or not extended_request_id: - extended_request_id = headers.get("x-amz-id-2") - if isinstance(extended_request_id, str) and extended_request_id: + extended_request_id = next( + ( + value + for value in (metadata.get("HostId"), headers.get("x-amz-id-2")) + if isinstance(value, str) and value + ), + None, + ) + if extended_request_id is not None: attributes[SPANDATA.AWS_EXTENDED_REQUEST_ID] = extended_request_id return attributes From d482500c9f59138d106a58d10e01be4214b279ec Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 14:14:14 +0200 Subject: [PATCH 08/23] ref(boto3): remove redundant span checks during enrichment --- sentry_sdk/integrations/boto3/_client.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index b7e0e1e526..2bbcb986c6 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -115,14 +115,12 @@ def sentry_patched_make_api_call( try: parsed = orig_make_api_call(self, operation_name, api_params) except BaseException as error: - if span is not None: - with capture_internal_exceptions(): - _set_span_attributes(span, _get_error_attributes(error)) + with capture_internal_exceptions(): + _set_span_attributes(span, _get_error_attributes(error)) raise else: - if span is not None: - with capture_internal_exceptions(): - _set_span_attributes(span, _get_response_attributes(parsed)) + with capture_internal_exceptions(): + _set_span_attributes(span, _get_response_attributes(parsed)) except BaseException as error: _finish_span(span, error) raise From 26fdcb1dc81f5326500605db7864f7db14fc0089 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 15:07:33 +0200 Subject: [PATCH 09/23] fix duplicates when merging --- sentry_sdk/integrations/boto3/_client.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index 2bbcb986c6..5d4ecc5e16 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -126,9 +126,6 @@ def sentry_patched_make_api_call( raise streaming_body_instrumented = False - with capture_internal_exceptions(): - streaming_body_instrumented = _instrument_streaming_body(span, parsed) - streaming_body_instrumented = False with capture_internal_exceptions(): streaming_body_instrumented = _instrument_streaming_body(span, parsed) From eeac30a040869d03a740b6101ce8cf6e18807020 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Mon, 21 Sep 2026 17:57:55 +0200 Subject: [PATCH 10/23] use consts and string lookup for `client.getintegration` --- sentry_sdk/integrations/boto3/_client.py | 5 ++--- sentry_sdk/integrations/boto3/_instrumentation.py | 4 ++-- tests/integrations/boto3/test_client.py | 2 +- tests/integrations/boto3/test_s3.py | 9 +++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index 5d4ecc5e16..3c125787bc 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -4,6 +4,7 @@ import sentry_sdk from sentry_sdk.integrations import DidNotEnable from sentry_sdk.integrations.boto3._context import AwsCallContext +from sentry_sdk.integrations.boto3.consts import IDENTIFIER from sentry_sdk.integrations.boto3._instrumentation import ( _finish_span, _get_error_attributes, @@ -68,8 +69,6 @@ def _activate_client_span( def _patch_botocore_client() -> None: - from sentry_sdk.integrations.boto3 import Boto3Integration - orig_init = BaseClient.__init__ orig_make_api_call = BaseClient._make_api_call # type: ignore @@ -91,7 +90,7 @@ def sentry_patched_make_api_call( https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span """ client = sentry_sdk.get_client() - if client.get_integration(Boto3Integration) is None: + if client.get_integration(IDENTIFIER) is None: return orig_make_api_call(self, operation_name, api_params) ctx = AwsCallContext(operation_name, api_params) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 3245c98f40..031dd91297 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -438,7 +438,7 @@ def _sentry_request_created( """ client = sentry_sdk.get_client() - if client.get_integration("boto3") is None: + if client.get_integration(IDENTIFIER) is None: return with capture_internal_exceptions(): @@ -466,7 +466,7 @@ def _sentry_before_sign( request: "AWSRequest", signature_version: "Any", **kwargs: "Any" ) -> None: client = sentry_sdk.get_client() - if client.get_integration("boto3") is None: + if client.get_integration(IDENTIFIER) is None: return with capture_internal_exceptions(): diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index 9e0d678449..3e0d5265ee 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -613,7 +613,7 @@ def test_client_call_attributes_are_available_at_span_creation( item.payload for item in items if item.payload["attributes"].get(SPANDATA.SENTRY_ORIGIN) - == Boto3Integration.origin + == ORIGIN ] assert client_spans == [] diff --git a/tests/integrations/boto3/test_s3.py b/tests/integrations/boto3/test_s3.py index 8c8b24ba13..4af2e55ee2 100644 --- a/tests/integrations/boto3/test_s3.py +++ b/tests/integrations/boto3/test_s3.py @@ -7,6 +7,7 @@ from sentry_sdk import capture_message from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.boto3 import Boto3Integration +from sentry_sdk.integrations.boto3.consts import ORIGIN from tests.conftest import ApproxDict from tests.integrations.boto3 import read_fixture from tests.integrations.boto3.aws_mock import MockResponse @@ -130,7 +131,7 @@ def test_streaming( "rpc.service": "S3", "sentry.environment": "production", "sentry.op": "http.client", - "sentry.origin": "auto.http.boto3", + "sentry.origin": ORIGIN, "sentry.release": mock.ANY, "sentry.sdk.name": "sentry.python", "sentry.sdk.version": mock.ANY, @@ -292,7 +293,7 @@ def test_omit_url_data_if_parsing_fails( "rpc.service": "S3", "sentry.environment": "production", "sentry.op": "http.client", - "sentry.origin": "auto.http.boto3", + "sentry.origin": ORIGIN, "sentry.release": mock.ANY, "sentry.sdk.name": "sentry.python", "sentry.sdk.version": mock.ANY, @@ -364,7 +365,7 @@ def test_span_origin( spans = [item.payload for item in items] assert spans[1]["attributes"]["sentry.origin"] == "manual" - assert spans[0]["attributes"]["sentry.origin"] == "auto.http.boto3" + assert spans[0]["attributes"]["sentry.origin"] == ORIGIN else: events = capture_events() @@ -376,7 +377,7 @@ def test_span_origin( (event,) = events assert event["contexts"]["trace"]["origin"] == "manual" - assert event["spans"][0]["origin"] == "auto.http.boto3" + assert event["spans"][0]["origin"] == ORIGIN def test_breadcrumb(sentry_init, capture_events): From 8b61aac1bda784c8e9923b513385f73ca7d54ab8 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 22 Sep 2026 11:15:46 +0200 Subject: [PATCH 11/23] lint --- sentry_sdk/integrations/boto3/_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index 3c125787bc..8b7cfeed0a 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -4,7 +4,6 @@ import sentry_sdk from sentry_sdk.integrations import DidNotEnable from sentry_sdk.integrations.boto3._context import AwsCallContext -from sentry_sdk.integrations.boto3.consts import IDENTIFIER from sentry_sdk.integrations.boto3._instrumentation import ( _finish_span, _get_error_attributes, @@ -15,6 +14,7 @@ _set_span_attributes, _start_client_span, ) +from sentry_sdk.integrations.boto3.consts import IDENTIFIER from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan from sentry_sdk.utils import capture_internal_exceptions From 308eb5f39ef336c1fd7481935ad823a4685beb0c Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 22 Sep 2026 12:05:23 +0200 Subject: [PATCH 12/23] ruff --- tests/integrations/boto3/test_client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index 3e0d5265ee..d679c576a9 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -612,8 +612,7 @@ def test_client_call_attributes_are_available_at_span_creation( client_spans = [ item.payload for item in items - if item.payload["attributes"].get(SPANDATA.SENTRY_ORIGIN) - == ORIGIN + if item.payload["attributes"].get(SPANDATA.SENTRY_ORIGIN) == ORIGIN ] assert client_spans == [] From 8e4ce5abfc3ca489bc3101eda1a9266320ca78f3 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 10:47:55 +0200 Subject: [PATCH 13/23] ref(boto3): add permalinks --- sentry_sdk/integrations/boto3/_instrumentation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 031dd91297..457f28e150 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -98,14 +98,14 @@ def _get_response_attributes(response: "Any") -> "Attributes": attributes: "Attributes" = {} # botocore injects HTTP status into `ResponseMetadata` after parsing. - # https://github.com/boto/botocore/blob/develop/botocore/parsers.py#L273-L284 + # https://github.com/boto/botocore/blob/358f8eec8c76201bb1a7a35644abcbc9036de7ed/botocore/parsers.py#L273-L284 status_code = metadata.get("HTTPStatusCode") if isinstance(status_code, int) and 100 <= status_code <= 599: attributes[SPANDATA.HTTP_STATUS_CODE] = status_code retry_attempts = metadata.get("RetryAttempts") # botocore represents retries as `attempts - 1`; OTel suggests "if and only if", so skip zero. - # https://github.com/boto/botocore/blob/develop/botocore/endpoint.py#L221-L229 + # https://github.com/boto/botocore/blob/358f8eec8c76201bb1a7a35644abcbc9036de7ed/botocore/endpoint.py#L221-L229 # https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span if ( isinstance(retry_attempts, int) From 832d90d123a621f8ad38b33c217b482ca45b3a42 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 10:54:31 +0200 Subject: [PATCH 14/23] fix(boto3): add `error.type` to both span kinds --- sentry_sdk/integrations/boto3/_instrumentation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 457f28e150..884000ff4f 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -299,8 +299,7 @@ def finish_span(error: "Optional[BaseException]" = None) -> None: with capture_internal_exceptions(): attributes = _get_error_attributes(error) _set_span_attributes(streaming_span, attributes) - if isinstance(span, StreamedSpan): - _set_span_attributes(span, attributes) + _set_span_attributes(span, attributes) _finish_span(streaming_span, error) _finish_span(span, error) From 0ee5f141ee82bc3fd6ebf5f90a77ebc5a0f434ef Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 11:02:58 +0200 Subject: [PATCH 15/23] ref(boto3): specify docstring for `_set_span_attributes()` further --- sentry_sdk/integrations/boto3/_instrumentation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 884000ff4f..59236d1290 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -41,7 +41,11 @@ def _set_span_attributes( span: "Union[Span, StreamedSpan]", attributes: "Attributes" ) -> None: - """Will be removed in the major.""" + """ + Will be removed in the next major version (3.0). This helper makes + it easier to migrate to `StreamedSpan` without having to remove + multiple conditional blocks intertwined with other logic. + """ if isinstance(span, StreamedSpan): span.set_attributes(attributes) return From a8f521e55c4de51266186f383e951fb2420a6c7f Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 11:05:13 +0200 Subject: [PATCH 16/23] ref(boto3): use f-string instead --- sentry_sdk/integrations/boto3/_instrumentation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 59236d1290..3ec10ef620 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -172,7 +172,7 @@ def _get_error_type(exception: "BaseException") -> str: exception_name = exception_type.__qualname__ exception_module = exception_type.__module__ if exception_module not in ("builtins", "__builtins__"): - return "%s.%s" % (exception_module, exception_name) + return f"{exception_module}.{exception_name}" return exception_name From 2d92b4ee590a3bf226b581964e78813fd9ac0b20 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 11:19:07 +0200 Subject: [PATCH 17/23] ref(boto3): remove comment regarding `HTTP_REQUEST_RESEND_COUNT` --- sentry_sdk/integrations/boto3/_instrumentation.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 3ec10ef620..1907468835 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -108,9 +108,6 @@ def _get_response_attributes(response: "Any") -> "Attributes": attributes[SPANDATA.HTTP_STATUS_CODE] = status_code retry_attempts = metadata.get("RetryAttempts") - # botocore represents retries as `attempts - 1`; OTel suggests "if and only if", so skip zero. - # https://github.com/boto/botocore/blob/358f8eec8c76201bb1a7a35644abcbc9036de7ed/botocore/endpoint.py#L221-L229 - # https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span if ( isinstance(retry_attempts, int) # avoid emitting `resend_count=True`. From 12dd5e63f0082f66dfdb5aad15c1ffbf7094c474 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 11:23:04 +0200 Subject: [PATCH 18/23] ref(boto3): remove `else` block --- sentry_sdk/integrations/boto3/_client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index 8b7cfeed0a..d30770ebe7 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -117,9 +117,8 @@ def sentry_patched_make_api_call( with capture_internal_exceptions(): _set_span_attributes(span, _get_error_attributes(error)) raise - else: - with capture_internal_exceptions(): - _set_span_attributes(span, _get_response_attributes(parsed)) + with capture_internal_exceptions(): + _set_span_attributes(span, _get_response_attributes(parsed)) except BaseException as error: _finish_span(span, error) raise From 21794bf3287f32fc25452bcd9de641fc2e1ab198 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 12:55:32 +0200 Subject: [PATCH 19/23] tests(boto3): remove uncessary testcases --- tests/integrations/boto3/test_client.py | 26 ------------------------- 1 file changed, 26 deletions(-) diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index d679c576a9..2706488109 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -13,7 +13,6 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3 import Boto3Integration from sentry_sdk.integrations.boto3._instrumentation import ( - _get_error_attributes, _get_response_attributes, _instrument_streaming_body, ) @@ -393,9 +392,7 @@ def _span_attributes(span, span_streaming): @pytest.mark.parametrize( ("response", "expected"), [ - (None, {}), ({}, {}), - ({"ResponseMetadata": None}, {}), ( { "ResponseMetadata": { @@ -465,9 +462,6 @@ def test_get_response_attributes_reads_extended_request_id_header(): ("HTTPStatusCode", "200", SPANDATA.HTTP_STATUS_CODE), ("HTTPStatusCode", True, SPANDATA.HTTP_STATUS_CODE), ("HTTPStatusCode", 999, SPANDATA.HTTP_STATUS_CODE), - ("RetryAttempts", "2", SPANDATA.HTTP_REQUEST_RESEND_COUNT), - ("RetryAttempts", False, SPANDATA.HTTP_REQUEST_RESEND_COUNT), - ("RetryAttempts", -1, SPANDATA.HTTP_REQUEST_RESEND_COUNT), ], ) def test_get_response_attributes_ignores_malformed_field(field, value, attribute): @@ -488,26 +482,6 @@ def test_get_response_attributes_ignores_malformed_field(field, value, attribute assert attributes == expected -@pytest.mark.parametrize( - "error_response", - [None, {"Code": ""}, {"Code": 123}], -) -def test_get_error_attributes_ignores_malformed_client_error_code(error_response): - error = ClientError( - { - "Error": {"Code": "placeholder"}, - "ResponseMetadata": {"HTTPStatusCode": 400}, - }, - "HeadObject", - ) - error.response["Error"] = error_response - - assert _get_error_attributes(error) == { - SPANDATA.HTTP_STATUS_CODE: 400, - SPANDATA.ERROR_TYPE: "botocore.exceptions.ClientError", - } - - @pytest.mark.parametrize( ( "service_name", From 7859e97871d80b027c049d49f42150759807fa7a Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 12:57:20 +0200 Subject: [PATCH 20/23] ref(boto3): remove uncessary defensive conditionals --- .../integrations/boto3/_instrumentation.py | 32 ++++++------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 1907468835..74f8cce948 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -25,7 +25,7 @@ ) if TYPE_CHECKING: - from typing import Any, Dict, Optional, Union + from typing import Any, Dict, Mapping, Optional, Union from sentry_sdk._types import Attributes from sentry_sdk.integrations.boto3._context import AwsCallContext @@ -92,13 +92,8 @@ def _get_client_attributes( return attributes -def _get_response_attributes(response: "Any") -> "Attributes": - if not isinstance(response, dict): - return {} - - metadata = response.get("ResponseMetadata") - if not isinstance(metadata, dict): - return {} +def _get_response_attributes(response: "Mapping[str, Any]") -> "Attributes": + metadata = response.get("ResponseMetadata", {}) attributes: "Attributes" = {} # botocore injects HTTP status into `ResponseMetadata` after parsing. @@ -107,18 +102,11 @@ def _get_response_attributes(response: "Any") -> "Attributes": if isinstance(status_code, int) and 100 <= status_code <= 599: attributes[SPANDATA.HTTP_STATUS_CODE] = status_code - retry_attempts = metadata.get("RetryAttempts") - if ( - isinstance(retry_attempts, int) - # avoid emitting `resend_count=True`. - and not isinstance(retry_attempts, bool) - and retry_attempts > 0 - ): + retry_attempts = metadata.get("RetryAttempts", 0) + if retry_attempts > 0: attributes[SPANDATA.HTTP_REQUEST_RESEND_COUNT] = retry_attempts - headers = metadata.get("HTTPHeaders") - if not isinstance(headers, dict): - headers = {} + headers = metadata.get("HTTPHeaders", {}) request_id = next( ( @@ -157,11 +145,9 @@ def _get_error_type(exception: "BaseException") -> str: # `ClientError` wraps AWS service errors; `Error.Code` identifies the # actual service error, e.g. `AccessDeniedException`. # https://docs.aws.amazon.com/boto3/latest/guide/error-handling.html - error = exception.response.get("Error") - if isinstance(error, dict): - error_code = error.get("Code") - if isinstance(error_code, str) and error_code: - return error_code + error_code: "Optional[str]" = exception.response.get("Error", {}).get("Code") + if error_code: + return error_code # failures before a service response have no AWS error code. # https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/ From 1f4e936dd2f83f4c4686f47e372123e13941d405 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 16:04:32 +0200 Subject: [PATCH 21/23] fix flaky test --- tests/integrations/boto3/test_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index 2706488109..9b2d502115 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -108,6 +108,7 @@ def record_client_span(request, **kwargs): assert isinstance(request_client_span, Span) assert not isinstance(request_client_span, StreamedSpan) assert request_client_span.timestamp is None + assert sentry_sdk.get_current_span() is parent if consume == "read": assert body.read() == b"x" @@ -134,6 +135,7 @@ def record_client_span(request, **kwargs): assert sentry_sdk.traces.get_current_span() is parent # type: ignore[attr-defined] else: assert request_client_span.timestamp is not None + assert sentry_sdk.get_current_span() is parent sentry_sdk.flush() if span_streaming: @@ -182,10 +184,8 @@ def record_client_span(request, **kwargs): assert stream_span["parent_span_id"] == client_span["span_id"] assert client_span["span_id"] == request_client_span.span_id end_timestamp = "end_timestamp" if span_streaming else "timestamp" - assert client_span["start_timestamp"] <= http_span["start_timestamp"] - assert http_span["start_timestamp"] <= stream_span["start_timestamp"] - assert http_span[end_timestamp] <= stream_span[end_timestamp] - assert stream_span[end_timestamp] <= client_span[end_timestamp] + for span in (client_span, http_span, stream_span): + assert span[end_timestamp] is not None @pytest.mark.parametrize("span_streaming", [True, False]) From d5d1faf2c88dd144e26da771d930bfd45d4fe3ea Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 17:11:09 +0200 Subject: [PATCH 22/23] trigger tests --- sentry_sdk/integrations/boto3/_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index d30770ebe7..9457bfb35e 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -133,4 +133,4 @@ def sentry_patched_make_api_call( return parsed BaseClient.__init__ = sentry_patched_init # type: ignore - BaseClient._make_api_call = sentry_patched_make_api_call # type: ignore + BaseClient._make_api_call = sentry_patched_make_api_call # type: ignore From 064eab6126f71f1f326b744a5f8a1d73b7d1b96c Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 17:11:26 +0200 Subject: [PATCH 23/23] trigger tests --- sentry_sdk/integrations/boto3/_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index 9457bfb35e..d30770ebe7 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -133,4 +133,4 @@ def sentry_patched_make_api_call( return parsed BaseClient.__init__ = sentry_patched_init # type: ignore - BaseClient._make_api_call = sentry_patched_make_api_call # type: ignore + BaseClient._make_api_call = sentry_patched_make_api_call # type: ignore