From abc53760f17b89a634593dcfbd74d3b261f4a727 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 10:15:38 +0200 Subject: [PATCH 01/22] add base `ServiceExtension` class --- .../integrations/boto3/_services/base.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 sentry_sdk/integrations/boto3/_services/base.py diff --git a/sentry_sdk/integrations/boto3/_services/base.py b/sentry_sdk/integrations/boto3/_services/base.py new file mode 100644 index 0000000000..0c5e3be964 --- /dev/null +++ b/sentry_sdk/integrations/boto3/_services/base.py @@ -0,0 +1,31 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Optional, Tuple + + from sentry_sdk._types import Attributes + from sentry_sdk.integrations.boto3._context import AwsCallContext + + +class _ServiceExtension: + """ + Specialize generic botocore instrumentation for an AWS service. + """ + + __slots__ = () + + def get_span_config( + self, ctx: "AwsCallContext" + ) -> "Optional[Tuple[Optional[str], Optional[str]]]": + """Return an optional `(op, origin)` override for the client span.""" + return None + + def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": + """Return service-specific attributes available before the call.""" + return {} + + def get_response_attributes( + self, ctx: "AwsCallContext", response: "Any" + ) -> "Attributes": + """Return service-specific attributes derived from the response.""" + return {} From 3b0a8925f59ed7cce5f5b4792456b24e03f17c64 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 11:16:57 +0200 Subject: [PATCH 02/22] feat(boto3): Add service registry --- .../integrations/boto3/_services/registry.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 sentry_sdk/integrations/boto3/_services/registry.py diff --git a/sentry_sdk/integrations/boto3/_services/registry.py b/sentry_sdk/integrations/boto3/_services/registry.py new file mode 100644 index 0000000000..18d1f08553 --- /dev/null +++ b/sentry_sdk/integrations/boto3/_services/registry.py @@ -0,0 +1,34 @@ +from functools import lru_cache +from importlib import import_module +from typing import TYPE_CHECKING + +from sentry_sdk.integrations.boto3._services.base import _ServiceExtension +from sentry_sdk.utils import capture_internal_exceptions + +if TYPE_CHECKING: + from typing import Dict, Optional, Tuple + + +# service modules are imported lazily. +# e.g. `s3` -> (`sentry_sdk.integrations.boto3._services.s3`, `_S3Extension) +_SERVICE_EXTENSIONS: "Dict[str, Tuple[str, str]]" = {} + + +@lru_cache(maxsize=None) +def _resolve_service( + service: "str", +) -> "Optional[_ServiceExtension]": + target = _SERVICE_EXTENSIONS.get(service) + if target is None: + return None + + # preserve generic instrumentation when lookup fails. + extension = None + with capture_internal_exceptions(): + module_name, class_name = target + extension_class = getattr(import_module(module_name), class_name) + candidate = extension_class() + if isinstance(candidate, _ServiceExtension): + extension = candidate + + return extension From 6a25b5af9f2144f94c4062815e5787d57d7146fd Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 11:34:18 +0200 Subject: [PATCH 03/22] add missing `__init__.py` file --- sentry_sdk/integrations/boto3/_services/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 sentry_sdk/integrations/boto3/_services/__init__.py diff --git a/sentry_sdk/integrations/boto3/_services/__init__.py b/sentry_sdk/integrations/boto3/_services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From 442d70cb2eef453ecf749e5d570926f853bca37f Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 13:11:10 +0200 Subject: [PATCH 04/22] simplify `_resolve_service()` logic --- sentry_sdk/integrations/boto3/_services/registry.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/registry.py b/sentry_sdk/integrations/boto3/_services/registry.py index 18d1f08553..57f53c6b9b 100644 --- a/sentry_sdk/integrations/boto3/_services/registry.py +++ b/sentry_sdk/integrations/boto3/_services/registry.py @@ -22,13 +22,12 @@ def _resolve_service( if target is None: return None - # preserve generic instrumentation when lookup fails. - extension = None with capture_internal_exceptions(): module_name, class_name = target extension_class = getattr(import_module(module_name), class_name) candidate = extension_class() if isinstance(candidate, _ServiceExtension): - extension = candidate + return candidate - return extension + # preserve generic instrumentation when lookup fails. + return None From 2e7886135fd179224f322442356a6d65b4e19cbd Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 13:12:54 +0200 Subject: [PATCH 05/22] add service extension logic to `_instrumentation.py` --- .../integrations/boto3/_instrumentation.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 74f8cce948..153d51180e 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -29,6 +29,7 @@ from sentry_sdk._types import Attributes from sentry_sdk.integrations.boto3._context import AwsCallContext + from sentry_sdk.integrations.boto3._services.base import _ServiceExtension try: from botocore.awsrequest import AWSRequest @@ -170,6 +171,7 @@ def _get_error_attributes(exception: "BaseException") -> "Attributes": def _start_client_span( ctx: "AwsCallContext", + service_ext: "Optional[_ServiceExtension]" = None, ) -> "Optional[Union[Span, StreamedSpan]]": client = sentry_sdk.get_client() @@ -189,6 +191,32 @@ def _start_client_span( span_op = OP.HTTP_CLIENT span_origin = ORIGIN + if service_ext is not None: + service_span_config = None + with capture_internal_exceptions(): + service_span_config = service_ext.get_span_config(ctx) + + with capture_internal_exceptions(): + if service_span_config is not None: + service_op, service_origin = service_span_config + if isinstance(service_op, str) and service_op: + span_op = service_op + if isinstance(service_origin, str) and service_origin: + span_origin = service_origin + + with capture_internal_exceptions(): + attributes.update(service_ext.get_request_attributes(ctx)) + + # Generic attributes take precedence over service-specific attributes. + attributes.update( + { + SPANDATA.RPC_METHOD: ctx.operation_name, + SPANDATA.RPC_SYSTEM_NAME: _AWS_RPC_SYSTEM_NAME, + } + ) + with capture_internal_exceptions(): + attributes.update(_get_client_attributes(ctx)) + if has_span_streaming_enabled(client.options): if sentry_sdk.traces.get_current_span() is None: return None From 10f8b8a6483562aa9bb8110fe1568268a197bb8e Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 13:17:06 +0200 Subject: [PATCH 06/22] combine span-config extraction and validation in `_start_client_span()` --- sentry_sdk/integrations/boto3/_instrumentation.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 153d51180e..dd05ccd5f7 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -192,13 +192,10 @@ def _start_client_span( span_origin = ORIGIN if service_ext is not None: - service_span_config = None with capture_internal_exceptions(): - service_span_config = service_ext.get_span_config(ctx) - - with capture_internal_exceptions(): - if service_span_config is not None: - service_op, service_origin = service_span_config + config = service_ext.get_span_config(ctx) + if config is not None: + service_op, service_origin = config if isinstance(service_op, str) and service_op: span_op = service_op if isinstance(service_origin, str) and service_origin: From b58af72aa30a0e1c4b49c25adf411b7ab0c7bc9f Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 11:19:33 +0200 Subject: [PATCH 07/22] add service extension logic to `_client.py` --- sentry_sdk/integrations/boto3/_client.py | 32 +++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index d30770ebe7..a947d1dc91 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -14,6 +14,9 @@ _set_span_attributes, _start_client_span, ) +from sentry_sdk.integrations.boto3._services.registry import ( + _resolve_service, +) from sentry_sdk.integrations.boto3.consts import IDENTIFIER from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan from sentry_sdk.utils import capture_internal_exceptions @@ -21,10 +24,13 @@ if TYPE_CHECKING: from typing import Any, Iterator, Optional, Union + from sentry_sdk._types import Attributes + from sentry_sdk.integrations.boto3._services.base import _ServiceExtension from sentry_sdk.tracing import Span try: from botocore.client import BaseClient + from botocore.exceptions import ClientError except ImportError: raise DidNotEnable("botocore not installed") @@ -99,9 +105,14 @@ def sentry_patched_make_api_call( with capture_internal_exceptions(): ctx.add_metadata(self) + service_ext: "Optional[_ServiceExtension]" = None + with capture_internal_exceptions(): + # resolve service extension for service-specific enrichment. + service_ext = _resolve_service(ctx.service_name) + span: "Optional[Union[Span, StreamedSpan]]" = None with capture_internal_exceptions(): - span = _start_client_span(ctx) + span = _start_client_span(ctx, service_ext) if span is None: return orig_make_api_call(self, operation_name, api_params) @@ -109,16 +120,31 @@ def sentry_patched_make_api_call( # activate without finishing; a streaming response may outlive the call. span_ctx = _activate_client_span(span) + attributes: "Attributes" = {} try: with span_ctx: try: parsed = orig_make_api_call(self, operation_name, api_params) except BaseException as error: + if service_ext is not None and isinstance(error, ClientError): + with capture_internal_exceptions(): + attributes.update( + service_ext.get_response_attributes(ctx, error.response) + ) with capture_internal_exceptions(): - _set_span_attributes(span, _get_error_attributes(error)) + attributes.update(_get_error_attributes(error)) + with capture_internal_exceptions(): + _set_span_attributes(span, attributes) raise + if service_ext is not None: + with capture_internal_exceptions(): + attributes.update( + service_ext.get_response_attributes(ctx, parsed) + ) + with capture_internal_exceptions(): + attributes.update(_get_response_attributes(parsed)) with capture_internal_exceptions(): - _set_span_attributes(span, _get_response_attributes(parsed)) + _set_span_attributes(span, attributes) except BaseException as error: _finish_span(span, error) raise From a907d7e31626005505cc7cb9601845464dcb57c9 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 18 Sep 2026 15:19:42 +0200 Subject: [PATCH 08/22] add tests --- tests/integrations/boto3/test_client.py | 110 ++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index 9b2d502115..5d11512fb6 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -16,6 +16,7 @@ _get_response_attributes, _instrument_streaming_body, ) +from sentry_sdk.integrations.boto3._services.base import _ServiceExtension 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 @@ -389,6 +390,115 @@ def _span_attributes(span, span_streaming): return span["attributes"] if span_streaming else span["data"] +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_service_extension_customizes_client_span( + capture_items, + client_factory, + monkeypatch, + span_streaming, +): + class TestServiceExtension(_ServiceExtension): + def get_span_config(self, ctx): + return ("aws.test", None) + + def get_request_attributes(self, ctx): + return { + "aws.test.request": ctx.params["Key"], + SPANDATA.RPC_METHOD: "must-not-override", + } + + def get_response_attributes(self, ctx, response): + return { + "aws.test.response": response["ResponseMetadata"]["RequestId"], + SPANDATA.HTTP_STATUS_CODE: 418, + } + + extension = TestServiceExtension() + monkeypatch.setattr( + "sentry_sdk.integrations.boto3._client._resolve_service", + lambda service_name: extension, + ) + client = client_factory() + api_params = {"Bucket": "bucket", "Key": "foo"} + + with Stubber(client) as stubber: + stubber.add_response( + "head_object", + { + "ResponseMetadata": { + "HTTPStatusCode": 200, + "RequestId": "request-id", + } + }, + api_params, + ) + spans_by_op = _capture_boto3_spans_by_op( + lambda: client.head_object(**api_params), + capture_items, + span_streaming, + ) + + spans = spans_by_op.get("aws.test", []) + assert len(spans) == 1 + attributes = _span_attributes(spans[0], span_streaming) + assert attributes["aws.test.request"] == "foo" + assert attributes["aws.test.response"] == "request-id" + assert attributes[SPANDATA.RPC_METHOD] == "HeadObject" + assert attributes[SPANDATA.HTTP_STATUS_CODE] == 200 + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_service_extension_enriches_client_error( + capture_items, + client_factory, + monkeypatch, + span_streaming, +): + class TestServiceExtension(_ServiceExtension): + def get_response_attributes(self, ctx, response): + return { + "aws.test.error": response["Error"]["Code"], + SPANDATA.ERROR_TYPE: "must-not-override", + SPANDATA.HTTP_STATUS_CODE: 418, + } + + monkeypatch.setattr( + "sentry_sdk.integrations.boto3._client._resolve_service", + lambda service_name: TestServiceExtension(), + ) + client = client_factory() + error = ClientError( + { + "Error": {"Code": "AccessDeniedException"}, + "ResponseMetadata": {"HTTPStatusCode": 403}, + }, + "HeadObject", + ) + + def raise_client_error(**kwargs): + raise error + + 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 error + + spans_by_op = _capture_boto3_spans_by_op( + invoke_failing_client_method, + capture_items, + span_streaming, + ) + spans = spans_by_op.get(OP.HTTP_CLIENT, []) + + _assert_one_failed_span(spans, span_streaming) + attributes = _span_attributes(spans[0], span_streaming) + assert attributes["aws.test.error"] == "AccessDeniedException" + assert attributes[SPANDATA.ERROR_TYPE] == "AccessDeniedException" + assert attributes[SPANDATA.HTTP_STATUS_CODE] == 403 + + @pytest.mark.parametrize( ("response", "expected"), [ From 9688571b516e7bb2f7c49750ad722a674f5d80ff Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 22 Sep 2026 11:04:52 +0200 Subject: [PATCH 09/22] fix(boto3): Remove "hardcoded" origin - otherwise service-specific spans with different origins would be skipped (e.g. DynamoDB) --- sentry_sdk/integrations/boto3/_instrumentation.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index dd05ccd5f7..cf52837b20 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -463,10 +463,9 @@ def _sentry_request_created( if span is None: return - # an ignored streamed span is not activated; avoid enriching its parent. - if isinstance(span, StreamedSpan): - if not (span.get_attributes().get(SPANDATA.SENTRY_ORIGIN) == ORIGIN): - return + # an ignored streamed span is not active; avoid enriching its parent. + if isinstance(span, StreamedSpan) and span.active: + return _set_request_attributes(span, request) # each attempt has a fresh `request.context`; carry the active client span. From 5cb454f04c0a1c358fee990d96a9abbfd086e57c Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 22 Sep 2026 15:12:02 +0200 Subject: [PATCH 10/22] feat(boto3): Add `sentry.kind` attribute to boto span --- sentry_sdk/consts.py | 6 ++++++ sentry_sdk/integrations/boto3/_instrumentation.py | 1 + tests/integrations/boto3/test_client.py | 3 +++ 3 files changed, 10 insertions(+) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 90e89afc95..5b037a66d0 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1214,6 +1214,12 @@ class SPANDATA: Used in inbound filters. """ + SENTRY_KIND = "sentry.kind" + """ + Used to clarify the relationship between parents and children, or to distinguish between spans, e.g. a `server` and `client` span with the same name. + Example: "client", "server", "producer", "consumer", "internal" + """ + SENTRY_OP = "sentry.op" """ The operation of a span. diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index cf52837b20..9e602aa8a1 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -185,6 +185,7 @@ def _start_client_span( attributes: "Attributes" = { SPANDATA.RPC_METHOD: ctx.operation_name, SPANDATA.RPC_SYSTEM_NAME: AWS_RPC_SYSTEM_NAME, + SPANDATA.SENTRY_KIND: "client", } with capture_internal_exceptions(): attributes.update(_get_client_attributes(ctx)) diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index 5d11512fb6..fc2fff6988 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -404,6 +404,7 @@ def get_span_config(self, ctx): def get_request_attributes(self, ctx): return { "aws.test.request": ctx.params["Key"], + SPANDATA.SENTRY_KIND: "producer", SPANDATA.RPC_METHOD: "must-not-override", } @@ -443,6 +444,7 @@ def get_response_attributes(self, ctx, response): attributes = _span_attributes(spans[0], span_streaming) assert attributes["aws.test.request"] == "foo" assert attributes["aws.test.response"] == "request-id" + assert attributes[SPANDATA.SENTRY_KIND] == "producer" assert attributes[SPANDATA.RPC_METHOD] == "HeadObject" assert attributes[SPANDATA.HTTP_STATUS_CODE] == 200 @@ -658,6 +660,7 @@ def test_client_call_has_common_attributes( assert attributes[SPANDATA.RPC_SERVICE] == rpc_service assert attributes[SPANDATA.RPC_METHOD] == rpc_method assert attributes[SPANDATA.RPC_SYSTEM_NAME] == AWS_RPC_SYSTEM_NAME + assert attributes[SPANDATA.SENTRY_KIND] == "client" assert attributes[SPANDATA.CLOUD_REGION] == "eu-north-1" assert attributes[SPANDATA.SERVER_ADDRESS] == server_address assert attributes[SPANDATA.SERVER_PORT] == server_port From d0224f137f5e9e842b44583d900f8b28c0ffc950 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 22 Sep 2026 15:21:03 +0200 Subject: [PATCH 11/22] ref(boto3): Add comment explaining --- sentry_sdk/integrations/boto3/_instrumentation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 9e602aa8a1..6bbdb33835 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -185,6 +185,8 @@ def _start_client_span( attributes: "Attributes" = { SPANDATA.RPC_METHOD: ctx.operation_name, SPANDATA.RPC_SYSTEM_NAME: AWS_RPC_SYSTEM_NAME, + # all client call spans are by default "client" spans. + # https://opentelemetry.io/docs/specs/semconv/cloud-providers/aws-sdk/#aws-sdk-spans SPANDATA.SENTRY_KIND: "client", } with capture_internal_exceptions(): From e9e349c7d73ecb8bc44c896d825566ffb77c40e7 Mon Sep 17 00:00:00 2001 From: Pablo Deputter <71842639+pabloDeputter@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:08:17 +0200 Subject: [PATCH 12/22] Update sentry_sdk/integrations/boto3/_services/base.py ref(boto3): improve docstring for `get_request_attributes()` Co-authored-by: Erica Pisani --- sentry_sdk/integrations/boto3/_services/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/boto3/_services/base.py b/sentry_sdk/integrations/boto3/_services/base.py index 0c5e3be964..bb5d648a9f 100644 --- a/sentry_sdk/integrations/boto3/_services/base.py +++ b/sentry_sdk/integrations/boto3/_services/base.py @@ -21,7 +21,7 @@ def get_span_config( return None def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": - """Return service-specific attributes available before the call.""" + """Return service-specific attributes available before the request is made.""" return {} def get_response_attributes( From ba5a0f5011d25fa8b2aee094dd0cad466b065dfe Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 13:09:20 +0200 Subject: [PATCH 13/22] fix(boto3): correct `AWS_RPC_SYSTEM_NAME` constant --- 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 6bbdb33835..23149a5968 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -211,7 +211,7 @@ def _start_client_span( attributes.update( { SPANDATA.RPC_METHOD: ctx.operation_name, - SPANDATA.RPC_SYSTEM_NAME: _AWS_RPC_SYSTEM_NAME, + SPANDATA.RPC_SYSTEM_NAME: AWS_RPC_SYSTEM_NAME, } ) with capture_internal_exceptions(): From e3eb77a53d017110605c47b0c3c106eca1d2edee Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 13:39:19 +0200 Subject: [PATCH 14/22] ref(boto3): refactor `get_span_config()` into `get_span_op()` and `get_span_origin()` --- .../integrations/boto3/_instrumentation.py | 15 ++++++----- .../integrations/boto3/_services/base.py | 12 +++++---- tests/integrations/boto3/test_client.py | 26 +++++++++++++++---- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 23149a5968..d2109cab24 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -196,13 +196,14 @@ def _start_client_span( if service_ext is not None: with capture_internal_exceptions(): - config = service_ext.get_span_config(ctx) - if config is not None: - service_op, service_origin = config - if isinstance(service_op, str) and service_op: - span_op = service_op - if isinstance(service_origin, str) and service_origin: - span_origin = service_origin + service_op = service_ext.get_span_op(ctx) + if service_op is not None: + span_op = service_op + + with capture_internal_exceptions(): + service_origin = service_ext.get_span_origin(ctx) + if service_origin is not None: + span_origin = service_origin with capture_internal_exceptions(): attributes.update(service_ext.get_request_attributes(ctx)) diff --git a/sentry_sdk/integrations/boto3/_services/base.py b/sentry_sdk/integrations/boto3/_services/base.py index bb5d648a9f..86cb8dfc8e 100644 --- a/sentry_sdk/integrations/boto3/_services/base.py +++ b/sentry_sdk/integrations/boto3/_services/base.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Optional, Tuple + from typing import Any, Optional from sentry_sdk._types import Attributes from sentry_sdk.integrations.boto3._context import AwsCallContext @@ -14,10 +14,12 @@ class _ServiceExtension: __slots__ = () - def get_span_config( - self, ctx: "AwsCallContext" - ) -> "Optional[Tuple[Optional[str], Optional[str]]]": - """Return an optional `(op, origin)` override for the client span.""" + def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": + """Return an optional `sentry.op` override for the client span.""" + return None + + def get_span_origin(self, ctx: "AwsCallContext") -> "Optional[str]": + """Return an optional `sentry.origin` override for the client span.""" return None def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index fc2fff6988..7458393788 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -321,7 +321,12 @@ def respond(request, **kwargs): return request_span_ids -def _capture_boto3_spans_by_op(invoke_client_method, capture_items, span_streaming): +def _capture_bot^o3_spans_by_op( + invoke_client_method, + capture_items, + span_streaming, + expected_origin=ORIGIN, +): items = capture_items() if span_streaming: @@ -333,14 +338,17 @@ def _capture_boto3_spans_by_op(invoke_client_method, capture_items, span_streami item.payload for item in items if item.type == "span" - and item.payload["attributes"].get(SPANDATA.SENTRY_ORIGIN) == ORIGIN + and item.payload["attributes"].get(SPANDATA.SENTRY_ORIGIN) + == expected_origin ] else: with sentry_sdk.start_transaction(): invoke_client_method() transaction = next(item.payload for item in items if item.type == "transaction") - spans = [span for span in transaction["spans"] if span["origin"] == ORIGIN] + spans = [ + span for span in transaction["spans"] if span["origin"] == expected_origin + ] spans_by_op = {} for span in spans: @@ -398,8 +406,11 @@ def test_service_extension_customizes_client_span( span_streaming, ): class TestServiceExtension(_ServiceExtension): - def get_span_config(self, ctx): - return ("aws.test", None) + def get_span_op(self, ctx): + return "aws.test" + + def get_span_origin(self, ctx): + return "auto.aws.test" def get_request_attributes(self, ctx): return { @@ -437,6 +448,7 @@ def get_response_attributes(self, ctx, response): lambda: client.head_object(**api_params), capture_items, span_streaming, + expected_origin="auto.aws.test", ) spans = spans_by_op.get("aws.test", []) @@ -447,6 +459,10 @@ def get_response_attributes(self, ctx, response): assert attributes[SPANDATA.SENTRY_KIND] == "producer" assert attributes[SPANDATA.RPC_METHOD] == "HeadObject" assert attributes[SPANDATA.HTTP_STATUS_CODE] == 200 + if span_streaming: + assert attributes[SPANDATA.SENTRY_ORIGIN] == "auto.aws.test" + else: + assert spans[0]["origin"] == "auto.aws.test" @pytest.mark.parametrize("span_streaming", [True, False]) From 4e21a85fb6a2ddaadf23b5a01ac3e831e69827a9 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 29 Sep 2026 14:03:26 +0200 Subject: [PATCH 15/22] ref(boto3): remove attribute-precedence --- sentry_sdk/integrations/boto3/_instrumentation.py | 10 ---------- tests/integrations/boto3/test_client.py | 3 +-- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index d2109cab24..c486bfb005 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -208,16 +208,6 @@ def _start_client_span( with capture_internal_exceptions(): attributes.update(service_ext.get_request_attributes(ctx)) - # Generic attributes take precedence over service-specific attributes. - attributes.update( - { - SPANDATA.RPC_METHOD: ctx.operation_name, - SPANDATA.RPC_SYSTEM_NAME: AWS_RPC_SYSTEM_NAME, - } - ) - with capture_internal_exceptions(): - attributes.update(_get_client_attributes(ctx)) - if has_span_streaming_enabled(client.options): if sentry_sdk.traces.get_current_span() is None: return None diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index 7458393788..f11e064740 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -321,7 +321,7 @@ def respond(request, **kwargs): return request_span_ids -def _capture_bot^o3_spans_by_op( +def _capture_boto3_spans_by_op( invoke_client_method, capture_items, span_streaming, @@ -416,7 +416,6 @@ def get_request_attributes(self, ctx): return { "aws.test.request": ctx.params["Key"], SPANDATA.SENTRY_KIND: "producer", - SPANDATA.RPC_METHOD: "must-not-override", } def get_response_attributes(self, ctx, response): From 25b893e9116ae0524b3da1ee635059edee8e5849 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 10:41:47 +0200 Subject: [PATCH 16/22] ref(boto3): remove lazy-loading approach; replace with static map --- .../integrations/boto3/_services/registry.py | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/registry.py b/sentry_sdk/integrations/boto3/_services/registry.py index 57f53c6b9b..e8616d5d86 100644 --- a/sentry_sdk/integrations/boto3/_services/registry.py +++ b/sentry_sdk/integrations/boto3/_services/registry.py @@ -1,33 +1,21 @@ -from functools import lru_cache -from importlib import import_module from typing import TYPE_CHECKING -from sentry_sdk.integrations.boto3._services.base import _ServiceExtension -from sentry_sdk.utils import capture_internal_exceptions - if TYPE_CHECKING: - from typing import Dict, Optional, Tuple + from typing import Dict, Optional + + from sentry_sdk.integrations.boto3._services.base import _ServiceExtension -# service modules are imported lazily. -# e.g. `s3` -> (`sentry_sdk.integrations.boto3._services.s3`, `_S3Extension) -_SERVICE_EXTENSIONS: "Dict[str, Tuple[str, str]]" = {} +# when py 3.15 drops, we might want to take a look at using +# a lazy-loading approach using the new `lazy` keyword. +# e.g. {"s3": _S3Extension} +_SERVICE_EXTENSIONS: "Dict[str, _ServiceExtension]" = {} -@lru_cache(maxsize=None) def _resolve_service( - service: "str", + service_name: "str", ) -> "Optional[_ServiceExtension]": - target = _SERVICE_EXTENSIONS.get(service) - if target is None: - return None - - with capture_internal_exceptions(): - module_name, class_name = target - extension_class = getattr(import_module(module_name), class_name) - candidate = extension_class() - if isinstance(candidate, _ServiceExtension): - return candidate - + if service_name in _SERVICE_EXTENSIONS: + return _SERVICE_EXTENSIONS[service_name] # preserve generic instrumentation when lookup fails. return None From 7e175d407171572e824709708a69cc6a470aee3d Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 10:50:29 +0200 Subject: [PATCH 17/22] fix(boto3): make logic clearer --- sentry_sdk/integrations/boto3/_client.py | 6 +++++- sentry_sdk/integrations/boto3/_services/registry.py | 6 ++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index a947d1dc91..66ae7b5f9f 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -108,7 +108,11 @@ def sentry_patched_make_api_call( service_ext: "Optional[_ServiceExtension]" = None with capture_internal_exceptions(): # resolve service extension for service-specific enrichment. - service_ext = _resolve_service(ctx.service_name) + service_ext = ( + _resolve_service(ctx.service_name) + if ctx.service_name is not None + else None + ) span: "Optional[Union[Span, StreamedSpan]]" = None with capture_internal_exceptions(): diff --git a/sentry_sdk/integrations/boto3/_services/registry.py b/sentry_sdk/integrations/boto3/_services/registry.py index e8616d5d86..7cad0c64a9 100644 --- a/sentry_sdk/integrations/boto3/_services/registry.py +++ b/sentry_sdk/integrations/boto3/_services/registry.py @@ -8,14 +8,12 @@ # when py 3.15 drops, we might want to take a look at using # a lazy-loading approach using the new `lazy` keyword. -# e.g. {"s3": _S3Extension} +# e.g. {"s3": _S3Extension()} _SERVICE_EXTENSIONS: "Dict[str, _ServiceExtension]" = {} def _resolve_service( service_name: "str", ) -> "Optional[_ServiceExtension]": - if service_name in _SERVICE_EXTENSIONS: - return _SERVICE_EXTENSIONS[service_name] # preserve generic instrumentation when lookup fails. - return None + return _SERVICE_EXTENSIONS.get(service_name) From 46ab8078ccff50689c27c2c1d02ba9d6cba4fdc3 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 13:25:27 +0200 Subject: [PATCH 18/22] ref(boto3): change comment's and simplify logic --- .../integrations/boto3/_services/base.py | 13 ++++++++----- .../integrations/boto3/_services/registry.py | 19 ++++++++++++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/base.py b/sentry_sdk/integrations/boto3/_services/base.py index 86cb8dfc8e..0d9cb1c24d 100644 --- a/sentry_sdk/integrations/boto3/_services/base.py +++ b/sentry_sdk/integrations/boto3/_services/base.py @@ -9,25 +9,28 @@ class _ServiceExtension: """ - Specialize generic botocore instrumentation for an AWS service. + Optional hooks for adding service-specific behavior to AWS client + span; non-overridden methods keep the generic instrumentation. + Services without a registered extension in `_SERVICE_EXTENSIONS` continue + to use the generic instrumentation. """ __slots__ = () def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": - """Return an optional `sentry.op` override for the client span.""" + """Return an optional `sentry.op` override, or `None` to keep the default.""" return None def get_span_origin(self, ctx: "AwsCallContext") -> "Optional[str]": - """Return an optional `sentry.origin` override for the client span.""" + """Return an optional `sentry.origin` override, or `None` to keep the default.""" return None def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": - """Return service-specific attributes available before the request is made.""" + """Return request attributes to add before the AWS request is made.""" return {} def get_response_attributes( self, ctx: "AwsCallContext", response: "Any" ) -> "Attributes": - """Return service-specific attributes derived from the response.""" + """Return response attributes to add after the AWS request is made.""" return {} diff --git a/sentry_sdk/integrations/boto3/_services/registry.py b/sentry_sdk/integrations/boto3/_services/registry.py index 7cad0c64a9..4e7b868f54 100644 --- a/sentry_sdk/integrations/boto3/_services/registry.py +++ b/sentry_sdk/integrations/boto3/_services/registry.py @@ -1,3 +1,13 @@ +"""Registry for the optional service extensions. + +The registry maps botocore service names, such as ``s3``, to extension +classes. It is intentionally static: the number of extensions is small, and +loading service modules dynamically would add complexity for little benefit. + +Not every AWS service needs an extension. When a service is not in this map, +the caller receives ``None`` and keeps the generic instrumentation. +""" + from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -6,14 +16,17 @@ from sentry_sdk.integrations.boto3._services.base import _ServiceExtension +# add a ServiceExtension here when one is implemented. for example: +# _SERVICE_EXTENSIONS = {"s3": _S3Extension()} # when py 3.15 drops, we might want to take a look at using # a lazy-loading approach using the new `lazy` keyword. -# e.g. {"s3": _S3Extension()} _SERVICE_EXTENSIONS: "Dict[str, _ServiceExtension]" = {} def _resolve_service( - service_name: "str", + service_name: "Optional[str]", ) -> "Optional[_ServiceExtension]": - # preserve generic instrumentation when lookup fails. + """Return the extension for a service, or `None` for generic instrumentation.""" + if service_name is None: + return None return _SERVICE_EXTENSIONS.get(service_name) From 023a8918c8942acf351f470875782eff4034bea6 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 13:39:34 +0200 Subject: [PATCH 19/22] ref(boto3): remove defensive checks --- sentry_sdk/integrations/boto3/_client.py | 37 +++++++----------- sentry_sdk/integrations/boto3/_context.py | 47 ++++++++--------------- 2 files changed, 30 insertions(+), 54 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index 66ae7b5f9f..32015c802e 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -22,10 +22,9 @@ from sentry_sdk.utils import capture_internal_exceptions if TYPE_CHECKING: - from typing import Any, Iterator, Optional, Union + from typing import Any, Dict, Iterator, Optional, Union from sentry_sdk._types import Attributes - from sentry_sdk.integrations.boto3._services.base import _ServiceExtension from sentry_sdk.tracing import Span try: @@ -40,12 +39,12 @@ def _activate_client_span( span: "Union[Span, StreamedSpan]", ) -> "Iterator[Union[Span, StreamedSpan]]": """ - Activate the boto span temporarily during `_make_api_call()` without ending it. + Activate the client span temporarily during `_make_api_call()` without ending it. Botocore returns a `StreamingBody` before its bytes are consumed. Using the context manager would finish it as soon as `_make_api_call()` returns, so restore the caller's span here and let the `StreamingBody` wrapper finish - the boto span when body is consumed/closed. + the client span when the body is consumed or closed. faulty: desired: boto3 [_make_api_call] boto3 [_make_api_call------] @@ -80,13 +79,13 @@ def _patch_botocore_client() -> None: def sentry_patched_init(self: "BaseClient", *args: "Any", **kwargs: "Any") -> None: orig_init(self, *args, **kwargs) - meta = self.meta - meta.events.register("request-created", _sentry_request_created) - # run after other `before-sign` handlers so existing baggage is preserved. - meta.events.register_last("before-sign", _sentry_before_sign) + with capture_internal_exceptions(): + self.meta.events.register("request-created", _sentry_request_created) + # run after other `before-sign` handlers so existing baggage is preserved. + self.meta.events.register_last("before-sign", _sentry_before_sign) def sentry_patched_make_api_call( - self: "BaseClient", operation_name: str, api_params: "Any" + self: "BaseClient", operation_name: str, api_params: "Dict[str, Any]" ) -> "Any": """ Track a single API call, including retries, serialization, and endpoint @@ -99,23 +98,13 @@ def sentry_patched_make_api_call( if client.get_integration(IDENTIFIER) is None: return orig_make_api_call(self, operation_name, api_params) - ctx = AwsCallContext(operation_name, api_params) - - # add optional metadata to context. - with capture_internal_exceptions(): - ctx.add_metadata(self) - - service_ext: "Optional[_ServiceExtension]" = None - with capture_internal_exceptions(): - # resolve service extension for service-specific enrichment. - service_ext = ( - _resolve_service(ctx.service_name) - if ctx.service_name is not None - else None - ) - span: "Optional[Union[Span, StreamedSpan]]" = None with capture_internal_exceptions(): + ctx = AwsCallContext(operation_name, api_params) + with capture_internal_exceptions(): + # add optional metadata to the context, e.g. service-name, region-name, etc. + ctx.add_metadata(self) + service_ext = _resolve_service(ctx.service_name) span = _start_client_span(ctx, service_ext) if span is None: diff --git a/sentry_sdk/integrations/boto3/_context.py b/sentry_sdk/integrations/boto3/_context.py index 83867d20b4..4ab95eb366 100644 --- a/sentry_sdk/integrations/boto3/_context.py +++ b/sentry_sdk/integrations/boto3/_context.py @@ -23,40 +23,27 @@ class AwsCallContext: "params", ) - def __init__(self, operation_name: str, params: "Any") -> None: - self.operation_name: str = operation_name - self.params: "Dict[str, Any]" = {} + def __init__(self, operation_name: str, params: "Dict[str, Any]") -> None: + self.operation_name: "str" = operation_name + self.params: "Dict[str, Any]" = dict(params) self.service_name: "Optional[str]" = None self.service_id: "Optional[str]" = None self.service_id_hyphenized: "Optional[str]" = None self.region_name: "Optional[str]" = None self.endpoint_url: "Optional[str]" = None - if isinstance(params, dict): - with capture_internal_exceptions(): - self.params = dict(params) - def add_metadata(self, client: "BaseClient") -> None: - def _get_attr(obj: "Any", name: str) -> "Any": - if obj is None: - return None - - with capture_internal_exceptions(): - return getattr(obj, name) - - client_meta = _get_attr(client, "meta") - service_model = _get_attr(client_meta, "service_model") - - # botocore's internal identifier, e.g. `apigateway`. - self.service_name = _get_attr(service_model, "service_name") - - # modeled AWS service identity used in span names, e.g. `API Gateway`. - service_id = _get_attr(service_model, "service_id") - if service_id is not None: - with capture_internal_exceptions(): - self.service_id = str(service_id) - with capture_internal_exceptions(): - self.service_id_hyphenized = service_id.hyphenize() - - self.region_name = _get_attr(client_meta, "region_name") - self.endpoint_url = _get_attr(client_meta, "endpoint_url") + with capture_internal_exceptions(): + service_model = client.meta.service_model + # botocore's internal identifier, e.g. `apigateway`. + self.service_name = service_model.service_name + service_id = service_model.service_id + # modeled AWS service identity used in span names, e.g. `API Gateway`. + self.service_id = str(service_id) + self.service_id_hyphenized = service_id.hyphenize() + + with capture_internal_exceptions(): + self.region_name = client.meta.region_name + + with capture_internal_exceptions(): + self.endpoint_url = client.meta.endpoint_url From 7fab0a82ad0951f2fa07cda75533e1606b6bbe49 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 14:47:18 +0200 Subject: [PATCH 20/22] ref(boto3): remove defensive checks and cleanup comments --- .../integrations/boto3/_instrumentation.py | 84 +++++++------------ 1 file changed, 31 insertions(+), 53 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index c486bfb005..3c180a025c 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from urllib.parse import urlsplit import sentry_sdk @@ -83,7 +83,7 @@ def _get_client_attributes( attributes: "Attributes" = {} # `rpc.service` is deprecated in OTel, but js still uses it. - if ctx.service_id: + if ctx.service_id is not None: attributes[SPANDATA.RPC_SERVICE] = ctx.service_id if ctx.region_name: @@ -100,7 +100,7 @@ def _get_response_attributes(response: "Mapping[str, Any]") -> "Attributes": # botocore injects HTTP status into `ResponseMetadata` after parsing. # 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: + if status_code is not None: attributes[SPANDATA.HTTP_STATUS_CODE] = status_code retry_attempts = metadata.get("RetryAttempts", 0) @@ -109,33 +109,19 @@ def _get_response_attributes(response: "Mapping[str, Any]") -> "Attributes": headers = metadata.get("HTTPHeaders", {}) - 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, + request_id = ( + metadata.get("RequestId") + or headers.get("x-amzn-requestid") + or headers.get("x-amzn-request-id") + or headers.get("x-amz-request-id") ) - if request_id is not None: + if 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 = 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: + extended_request_id = metadata.get("HostId") or headers.get("x-amz-id-2") + if extended_request_id: attributes[SPANDATA.AWS_EXTENDED_REQUEST_ID] = extended_request_id return attributes @@ -175,10 +161,8 @@ def _start_client_span( ) -> "Optional[Union[Span, StreamedSpan]]": client = sentry_sdk.get_client() - if client.get_integration(IDENTIFIER) is None: - return None - # use unknown if `service_id_hyphenized` is not set so span name can still be created. + # use "unknown" if `service_id_hyphenized` is not set so span name can still be created. # e.g. "aws.unknown.GetObject" service_name = ctx.service_id_hyphenized or "unknown" span_name = f"aws.{service_name}.{ctx.operation_name}" @@ -191,6 +175,9 @@ def _start_client_span( } with capture_internal_exceptions(): attributes.update(_get_client_attributes(ctx)) + + # `sentry.span_op` and `sentry.span_origin` are set to generic defaults; + # a service extension can override them with `get_span_op()` and `get_span_origin()`. span_op = OP.HTTP_CLIENT span_origin = ORIGIN @@ -236,7 +223,7 @@ def _start_client_span( with capture_internal_exceptions(): _set_span_attributes(span, attributes) with capture_internal_exceptions(): - if ctx.service_id_hyphenized: + if ctx.service_id_hyphenized is not None: span.set_tag("aws.service_id", ctx.service_id_hyphenized) span.set_tag("aws.operation_name", ctx.operation_name) return span @@ -273,10 +260,11 @@ def _instrument_streaming_body( if isinstance(span, StreamedSpan): streaming_span = sentry_sdk.traces.start_span( name=span.name, - # keep stream span under the boto span after `_make_api_call()` returns. + # keep the stream span under the client span after + # `_make_api_call()` returns. parent_span=span, # the body may outlive the api call, so keep it inactive. Otherwise it - # 1. could restore the already-finished boto span when it ends; 2. make + # 1. could restore the already-finished client span when it ends; 2. make # unrelated new spans attach to the stream span since it's the current span. active=False, attributes={ @@ -300,7 +288,8 @@ def finish_span(error: "Optional[BaseException]" = None) -> None: return finished = True - # finish stream span before boto span, and only once across read/close. + # finish the stream span before the client span, and only once across + # read and close. if error is not None: with capture_internal_exceptions(): attributes = _get_error_attributes(error) @@ -311,13 +300,8 @@ def finish_span(error: "Optional[BaseException]" = None) -> None: _finish_span(span, error) def content_length_reached() -> bool: - content_length = getattr(body, "_content_length", None) - amount_read = getattr(body, "_amount_read", None) - return ( - content_length is not None - and amount_read is not None - and amount_read >= int(content_length) - ) + content_length = body._content_length # type: ignore[attr-defined] + return content_length is not None and body._amount_read >= int(content_length) # type: ignore[attr-defined] def sentry_streaming_body_read(*args: "Any", **kwargs: "Any") -> bytes: nonlocal read_in_progress @@ -381,17 +365,15 @@ def _set_request_attributes( client = sentry_sdk.get_client() parsed_url = None - if request.url is not None: - with capture_internal_exceptions(): - parsed_url = parse_url(request.url, sanitize=False) + with capture_internal_exceptions(): + parsed_url = parse_url(cast(str, request.url), sanitize=False) # overwrite server attributes when actual request URL is resolved. _set_span_attributes(span, _get_server_attributes(request.url)) if isinstance(span, StreamedSpan): span.set_attributes(get_url_attributes(client, parsed_url)) - if request.method is not None: - span.set_attribute(SPANDATA.HTTP_REQUEST_METHOD, request.method) + span.set_attribute(SPANDATA.HTTP_REQUEST_METHOD, cast(str, request.method)) return if parsed_url is not None: @@ -399,24 +381,21 @@ def _set_request_attributes( span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - if request.method is not None: - span.set_data(SPANDATA.HTTP_METHOD, request.method) + span.set_data(SPANDATA.HTTP_METHOD, request.method) def _add_request_breadcrumb(request: "AWSRequest") -> None: client = sentry_sdk.get_client() parsed_url = None - if request.url is not None: - with capture_internal_exceptions(): - parsed_url = parse_url(request.url, sanitize=False) + with capture_internal_exceptions(): + parsed_url = parse_url(cast(str, request.url), sanitize=False) breadcrumb: "dict[str, Any]" = {} if has_span_streaming_enabled(client.options): breadcrumb.update(get_url_attributes(client, parsed_url)) - if request.method is not None: - breadcrumb[SPANDATA.HTTP_REQUEST_METHOD] = request.method + breadcrumb[SPANDATA.HTTP_REQUEST_METHOD] = request.method else: if parsed_url is not None: breadcrumb.update( @@ -427,8 +406,7 @@ def _add_request_breadcrumb(request: "AWSRequest") -> None: } ) - if request.method is not None: - breadcrumb[SPANDATA.HTTP_METHOD] = request.method + breadcrumb[SPANDATA.HTTP_METHOD] = request.method add_http_breadcrumb(None, breadcrumb) From 5e64de750284db8c250994294f7011bfef514069 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 15:25:21 +0200 Subject: [PATCH 21/22] cleanup tests --- tests/integrations/boto3/test_client.py | 336 +++++------------------- 1 file changed, 73 insertions(+), 263 deletions(-) diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index f11e064740..c309549316 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -12,16 +12,13 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3 import Boto3Integration -from sentry_sdk.integrations.boto3._instrumentation import ( - _get_response_attributes, - _instrument_streaming_body, -) from sentry_sdk.integrations.boto3._services.base import _ServiceExtension +from sentry_sdk.integrations.boto3._services.registry import _SERVICE_EXTENSIONS 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 from sentry_sdk.tracing import Span -from tests.integrations.boto3.aws_mock import Body +from tests.integrations.boto3.aws_mock import Body, MockResponse session = boto3.Session( # type: ignore[attr-defined] aws_access_key_id="-", @@ -189,57 +186,6 @@ def record_client_span(request, **kwargs): assert span[end_timestamp] is not None -@pytest.mark.parametrize("span_streaming", [True, False]) -def test_streaming_body_instrumentation_setup_failure_finishes_stream_span( - sentry_init, - capture_items, - span_streaming, -): - sentry_init( - traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", - integrations=[Boto3Integration()], - server_name="", - ) - - class _RawStreamLookupFailingBody(StreamingBody): - @property - def _raw_stream(self): - raise RuntimeError("raw stream lookup failed") - - @_raw_stream.setter - def _raw_stream(self, raw_stream): - self._raw_stream_value = raw_stream - - body = _RawStreamLookupFailingBody(Body(b"x"), "1") - - def invoke(): - if not span_streaming: - with sentry_sdk.start_span( - name="client", op=OP.HTTP_CLIENT, origin=ORIGIN - ) as span: - with pytest.raises(RuntimeError, match="raw stream lookup failed"): - _instrument_streaming_body(span, {"Body": body}) - return - - span = sentry_sdk.traces.start_span( # type: ignore[attr-defined] - name="client", - attributes={ - SPANDATA.SENTRY_OP: OP.HTTP_CLIENT, - SPANDATA.SENTRY_ORIGIN: ORIGIN, - }, - active=False, - ) - with pytest.raises(RuntimeError, match="raw stream lookup failed"): - _instrument_streaming_body(span, {"Body": body}) - - spans_by_op = _capture_boto3_spans_by_op(invoke, capture_items, span_streaming) - stream_spans = spans_by_op.get(OP.HTTP_CLIENT_STREAM, []) - - assert len(stream_spans) == 1 - _assert_span_finished(stream_spans[0], span_streaming) - - def test_non_body_stream_does_not_delay_client_span(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, @@ -424,11 +370,7 @@ def get_response_attributes(self, ctx, response): SPANDATA.HTTP_STATUS_CODE: 418, } - extension = TestServiceExtension() - monkeypatch.setattr( - "sentry_sdk.integrations.boto3._client._resolve_service", - lambda service_name: extension, - ) + monkeypatch.setitem(_SERVICE_EXTENSIONS, "s3", TestServiceExtension()) client = client_factory() api_params = {"Bucket": "bucket", "Key": "foo"} @@ -439,6 +381,7 @@ def get_response_attributes(self, ctx, response): "ResponseMetadata": { "HTTPStatusCode": 200, "RequestId": "request-id", + "HostId": "extended-request-id", } }, api_params, @@ -458,157 +401,14 @@ def get_response_attributes(self, ctx, response): assert attributes[SPANDATA.SENTRY_KIND] == "producer" assert attributes[SPANDATA.RPC_METHOD] == "HeadObject" assert attributes[SPANDATA.HTTP_STATUS_CODE] == 200 + assert attributes[SPANDATA.AWS_EXTENDED_REQUEST_ID] == "extended-request-id" + _assert_span_finished(spans[0], span_streaming) if span_streaming: assert attributes[SPANDATA.SENTRY_ORIGIN] == "auto.aws.test" else: assert spans[0]["origin"] == "auto.aws.test" -@pytest.mark.parametrize("span_streaming", [True, False]) -def test_service_extension_enriches_client_error( - capture_items, - client_factory, - monkeypatch, - span_streaming, -): - class TestServiceExtension(_ServiceExtension): - def get_response_attributes(self, ctx, response): - return { - "aws.test.error": response["Error"]["Code"], - SPANDATA.ERROR_TYPE: "must-not-override", - SPANDATA.HTTP_STATUS_CODE: 418, - } - - monkeypatch.setattr( - "sentry_sdk.integrations.boto3._client._resolve_service", - lambda service_name: TestServiceExtension(), - ) - client = client_factory() - error = ClientError( - { - "Error": {"Code": "AccessDeniedException"}, - "ResponseMetadata": {"HTTPStatusCode": 403}, - }, - "HeadObject", - ) - - def raise_client_error(**kwargs): - raise error - - 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 error - - spans_by_op = _capture_boto3_spans_by_op( - invoke_failing_client_method, - capture_items, - span_streaming, - ) - spans = spans_by_op.get(OP.HTTP_CLIENT, []) - - _assert_one_failed_span(spans, span_streaming) - attributes = _span_attributes(spans[0], span_streaming) - assert attributes["aws.test.error"] == "AccessDeniedException" - assert attributes[SPANDATA.ERROR_TYPE] == "AccessDeniedException" - assert attributes[SPANDATA.HTTP_STATUS_CODE] == 403 - - -@pytest.mark.parametrize( - ("response", "expected"), - [ - ({}, {}), - ( - { - "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), - ], -) -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( ( "service_name", @@ -668,6 +468,13 @@ def test_client_call_has_common_attributes( api_params, capture_items, span_streaming, + response={ + "ResponseMetadata": { + "HTTPStatusCode": 200, + "RequestId": "request-id", + "RetryAttempts": 0, + } + }, ) attributes = _span_attributes(span, span_streaming) @@ -679,6 +486,11 @@ def test_client_call_has_common_attributes( assert attributes[SPANDATA.CLOUD_REGION] == "eu-north-1" assert attributes[SPANDATA.SERVER_ADDRESS] == server_address assert attributes[SPANDATA.SERVER_PORT] == server_port + assert attributes[SPANDATA.HTTP_STATUS_CODE] == 200 + assert attributes[SPANDATA.AWS_REQUEST_ID] == "request-id" + assert SPANDATA.HTTP_REQUEST_RESEND_COUNT not in attributes + assert SPANDATA.ERROR_TYPE not in attributes + _assert_span_finished(span, span_streaming) def test_client_call_attributes_are_available_at_span_creation( @@ -705,10 +517,13 @@ def test_client_call_attributes_are_available_at_span_creation( client = session.client("s3") items = capture_items("span") - with Stubber(client) as stubber: - stubber.add_response("head_object", {}, {"Bucket": "bucket", "Key": "foo"}) - with sentry_sdk.traces.start_span(name="parent"): - client.head_object(Bucket="bucket", Key="foo") + with MockResponse(client, 200, {}, b""): + with sentry_sdk.traces.start_span(name="parent") as parent: + response = client.head_object(Bucket="bucket", Key="foo") + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert sentry_sdk.traces.get_current_span() is parent + assert SPANDATA.RPC_METHOD not in parent.get_attributes() + assert SPANDATA.HTTP_REQUEST_METHOD not in parent.get_attributes() sentry_sdk.flush() client_spans = [ @@ -719,60 +534,29 @@ def test_client_call_attributes_are_available_at_span_creation( assert client_spans == [] -def test_client_call_omits_missing_region( - sentry_init, - capture_items, - monkeypatch, -): - sentry_init( - traces_sample_rate=1.0, - integrations=[Boto3Integration()], - trace_lifecycle="stream", - server_name="", - ) - client = session.client("s3") - monkeypatch.setattr(type(client.meta), "region_name", property(lambda _: None)) - - span = _capture_stubbed_client_span( - client, - "head_object", - {"Bucket": "bucket", "Key": "foo"}, - capture_items, - span_streaming=True, - ) - - 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, +@pytest.mark.parametrize( + "request_id_header", ["x-amzn-requestid", "x-amzn-request-id", "x-amz-request-id"] +) +def test_client_call_has_response_header_attributes( + capture_items, client_factory, span_streaming, request_id_header ): 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) + headers = {request_id_header: "request-id", "x-amz-id-2": "extended-request-id"} + with MockResponse(client, 200, headers, b""): + spans_by_op = _capture_boto3_spans_by_op( + lambda: client.head_object(Bucket="bucket", Key="foo"), + capture_items, + span_streaming, + ) + spans = spans_by_op[OP.HTTP_CLIENT] + assert len(spans) == 1 + attributes = _span_attributes(spans[0], 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]) @@ -827,11 +611,24 @@ def attempt_failed_head_object_call(): @pytest.mark.parametrize("span_streaming", [True, False]) +@pytest.mark.parametrize("with_service_extension", [False, True]) def test_client_error_has_response_attributes_and_is_unchanged( capture_items, client_factory, + monkeypatch, span_streaming, + with_service_extension, ): + class TestServiceExtension(_ServiceExtension): + def get_response_attributes(self, ctx, response): + return { + "aws.test.error": response["Error"]["Code"], + SPANDATA.ERROR_TYPE: "must-not-override", + SPANDATA.HTTP_STATUS_CODE: 418, + } + + if with_service_extension: + monkeypatch.setitem(_SERVICE_EXTENSIONS, "s3", TestServiceExtension()) client = client_factory() original_exception = ClientError( { @@ -869,6 +666,8 @@ def invoke_failing_client_method(): assert attributes[SPANDATA.HTTP_STATUS_CODE] == 403 assert attributes[SPANDATA.HTTP_REQUEST_RESEND_COUNT] == 1 assert attributes[SPANDATA.ERROR_TYPE] == "AccessDeniedException" + if with_service_extension: + assert attributes["aws.test.error"] == "AccessDeniedException" assert "Error.Message" not in attributes assert "exception.message" not in attributes assert "error.message" not in attributes @@ -923,23 +722,31 @@ def invoke_failing_client_method(): @pytest.mark.tests_internal_exceptions @pytest.mark.parametrize("span_streaming", [True, False]) -def test_response_attribute_extraction_failure_does_not_change_response( +@pytest.mark.parametrize( + "failing_instrumentation", + [ + "_start_client_span", + "_get_response_attributes", + ], +) +def test_instrumentation_failure_does_not_change_response( capture_items, client_factory, monkeypatch, span_streaming, + failing_instrumentation, ): 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") + def fail_instrumentation(*args, **kwargs): + raise RuntimeError("instrumentation failed") monkeypatch.setattr( - "sentry_sdk.integrations.boto3._client._get_response_attributes", - fail_attribute_extraction, + f"sentry_sdk.integrations.boto3._client.{failing_instrumentation}", + fail_instrumentation, ) def invoke_client_method(): @@ -954,8 +761,11 @@ def invoke_client_method(): 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) + if failing_instrumentation == "_get_response_attributes": + assert len(client_spans) == 1 + _assert_span_finished(client_spans[0], span_streaming) + else: + assert client_spans == [] @pytest.mark.tests_internal_exceptions From 2eef01e854c6bc1c8236d847ef27c8d9ee37119e Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 30 Sep 2026 15:36:47 +0200 Subject: [PATCH 22/22] remove AI comment --- sentry_sdk/integrations/boto3/_services/registry.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/registry.py b/sentry_sdk/integrations/boto3/_services/registry.py index 4e7b868f54..b387db0f26 100644 --- a/sentry_sdk/integrations/boto3/_services/registry.py +++ b/sentry_sdk/integrations/boto3/_services/registry.py @@ -1,13 +1,3 @@ -"""Registry for the optional service extensions. - -The registry maps botocore service names, such as ``s3``, to extension -classes. It is intentionally static: the number of extensions is small, and -loading service modules dynamically would add complexity for little benefit. - -Not every AWS service needs an extension. When a service is not in this map, -the caller receives ``None`` and keeps the generic instrumentation. -""" - from typing import TYPE_CHECKING if TYPE_CHECKING: