From a158f91a80c4b4bdc81de5e9ef0e2e7cdf78d09b Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 22 Sep 2026 16:39:47 +0200 Subject: [PATCH 1/4] feat(attributes): Add s3 relevant attributes to `SPANDATA` --- sentry_sdk/consts.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index eaa4649885..10e0cfbb57 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -402,6 +402,48 @@ class SPANDATA: Example: "79b9da39-b7ae-508a-a6bc-864b2829c622" """ + AWS_S3_BUCKET = "aws.s3.bucket" + """ + The S3 bucket name the request refers to. + Example: "ot-demo-test" + """ + + AWS_S3_COPY_SOURCE = "aws.s3.copy_source" + """ + The source object (in the form bucket/key) for the copy operation. + Example: "someFile.yml" + """ + + AWS_S3_DELETE = "aws.s3.delete" + """ + The delete request container that specifies the objects to be deleted. + Example: "Objects=[{Key=string,VersionId=string},{Key=string,VersionId=string}],Quiet=boolean" + """ + + AWS_S3_KEY = "aws.s3.key" + """ + The S3 object key the request refers to. Corresponds to the --key parameter of the S3 API operations. + Example: "someFile.yml" + """ + + AWS_S3_OBJECT_SIZE = "aws.s3.object_size" + """ + The size of the S3 object in bytes. + Example: 434234 + """ + + AWS_S3_PART_NUMBER = "aws.s3.part_number" + """ + The part number of the part being uploaded in a multipart-upload operation. This is a positive integer between 1 and 10,000. + Example: 3456 + """ + + AWS_S3_UPLOAD_ID = "aws.s3.upload_id" + """ + Upload ID that identifies the multipart upload. + Example: "dfRtDYWFbkRONycy.Yxwh66Yjlx.cph0gtNBtJ" + """ + CACHE_HIT = "cache.hit" """ A boolean indicating whether the requested data was found in the cache. @@ -922,6 +964,12 @@ class SPANDATA: Example: ?foo=bar&bar=baz """ + HTTP_RESPONSE_BODY_SIZE = "http.response.body.size" + """ + The encoded body size of the response (in bytes). + Example: 123 + """ + HTTP_STATUS_CODE = "http.response.status_code" """ The HTTP status code as an integer. From 23047914f40fb588d7eb90995468e434031461b0 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 22 Sep 2026 16:40:08 +0200 Subject: [PATCH 2/4] feat(boto3): Register s3 to `_SERVICE_EXTENSIONS` --- sentry_sdk/integrations/boto3/_services/registry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/boto3/_services/registry.py b/sentry_sdk/integrations/boto3/_services/registry.py index 57f53c6b9b..02d7336976 100644 --- a/sentry_sdk/integrations/boto3/_services/registry.py +++ b/sentry_sdk/integrations/boto3/_services/registry.py @@ -11,7 +11,9 @@ # service modules are imported lazily. # e.g. `s3` -> (`sentry_sdk.integrations.boto3._services.s3`, `_S3Extension) -_SERVICE_EXTENSIONS: "Dict[str, Tuple[str, str]]" = {} +_SERVICE_EXTENSIONS: "Dict[str, Tuple[str, str]]" = { + "s3": ("sentry_sdk.integrations.boto3._services.s3", "_S3Extension"), +} @lru_cache(maxsize=None) From 1a6a41ec5a2d50221d8b9d11c3b6575d1fbef831 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 22 Sep 2026 16:47:26 +0200 Subject: [PATCH 3/4] feat(boto3): Add attribute extraction helpers --- .../boto3/_services/_attribute_extraction.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 sentry_sdk/integrations/boto3/_services/_attribute_extraction.py diff --git a/sentry_sdk/integrations/boto3/_services/_attribute_extraction.py b/sentry_sdk/integrations/boto3/_services/_attribute_extraction.py new file mode 100644 index 0000000000..ff57dc5065 --- /dev/null +++ b/sentry_sdk/integrations/boto3/_services/_attribute_extraction.py @@ -0,0 +1,36 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable, Optional, Sequence, Tuple + + from sentry_sdk._types import Attributes + + _Converter = Callable[[Any], Optional[Any]] + # e.g. ("Limit", "aws.dynamodb.limit", _as_integer) converts + # {"Limit": 10} into {"aws.dynamodb.limit": 10} using `_extract_attributes()` + _AttributeSpec = Tuple[str, str, _Converter] + + +def _as_integer(value: "Any") -> "Optional[int]": + if isinstance(value, int) and not isinstance(value, bool): + return value + return None + + +def _as_string(value: "Any") -> "Optional[str]": + return value if isinstance(value, str) and value else None + + +def _extract_attributes( + source: "Any", specs: "Sequence[_AttributeSpec]" +) -> "Attributes": + if not isinstance(source, dict): + return {} + + attributes = {} + for param, attribute, convert in specs: + value = convert(source.get(param)) + # an unexpected type results in that attribute being omitted. + if value is not None: + attributes[attribute] = value + return attributes From 70218f13163d953a99c290635b7fe27a24245a98 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 22 Sep 2026 16:47:36 +0200 Subject: [PATCH 4/4] feat(boto3): Add S3 extensio --- sentry_sdk/integrations/boto3/_services/s3.py | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 sentry_sdk/integrations/boto3/_services/s3.py diff --git a/sentry_sdk/integrations/boto3/_services/s3.py b/sentry_sdk/integrations/boto3/_services/s3.py new file mode 100644 index 0000000000..ed4f201d30 --- /dev/null +++ b/sentry_sdk/integrations/boto3/_services/s3.py @@ -0,0 +1,109 @@ +import json +from typing import TYPE_CHECKING + +from sentry_sdk.consts import SPANDATA +from sentry_sdk.integrations.boto3._services._attribute_extraction import ( + _as_integer, + _as_string, + _extract_attributes, +) +from sentry_sdk.integrations.boto3._services.base import _ServiceExtension + +if TYPE_CHECKING: + from typing import Any, Optional, Sequence + + from sentry_sdk._types import Attributes + from sentry_sdk.integrations.boto3._context import AwsCallContext + from sentry_sdk.integrations.boto3._services._attribute_extraction import ( + _AttributeSpec, + ) + +_RESPONSE_BODY_SIZE_OPERATIONS = frozenset( + ( + "GetObject", + "GetObjectAnnotation", + ) +) + +_RESPONSE_OBJECT_SIZE_FIELDS = { + "GetObjectAttributes": "ObjectSize", + "PutObject": "Size", +} + + +def _json_dict(value: "Any") -> "Optional[str]": + if not isinstance(value, dict): + return None + + try: + return json.dumps( + value, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ) + except (TypeError, ValueError): + return None + + +_REQUEST_ATTRIBUTES: "Sequence[_AttributeSpec]" = ( + # s3-specific attributes defined by OTel SemConv. Specified as a tuple of + # (param_name, attribute_name, converter_func). the `converter_func` is + # used to 1. validate the value (otherwise omitted) and 2. convert it to + # the appropriate type. + # https://opentelemetry.io/docs/specs/semconv/object-stores/s3/ + ("Bucket", SPANDATA.AWS_S3_BUCKET, _as_string), + ("CopySource", SPANDATA.AWS_S3_COPY_SOURCE, _as_string), + ("Delete", SPANDATA.AWS_S3_DELETE, _json_dict), + ("Key", SPANDATA.AWS_S3_KEY, _as_string), + ("PartNumber", SPANDATA.AWS_S3_PART_NUMBER, _as_integer), + ("UploadId", SPANDATA.AWS_S3_UPLOAD_ID, _as_string), +) + + +class _S3Extension(_ServiceExtension): + __slots__ = () + + def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": + attributes: "Attributes" = _extract_attributes(ctx.params, _REQUEST_ATTRIBUTES) + + if ctx.operation_name == "CompleteMultipartUpload": + object_size = _as_integer(ctx.params.get("MpuObjectSize")) + if object_size is not None and object_size >= 0: + attributes[SPANDATA.AWS_S3_OBJECT_SIZE] = object_size + + return attributes + + def get_response_attributes( + self, ctx: "AwsCallContext", response: "Any" + ) -> "Attributes": + if not isinstance(response, dict): + return {} + + attributes: "Attributes" = {} + operation_name = ctx.operation_name + + if operation_name in _RESPONSE_BODY_SIZE_OPERATIONS: + # `ContentLength` is the size of the HTTP body returned, which may be a range. + content_length = _as_integer(response.get("ContentLength")) + if content_length is not None and content_length >= 0: + attributes[SPANDATA.HTTP_RESPONSE_BODY_SIZE] = content_length + + # these fields report the total S3 object size, not the HTTP body size. + object_size_field = _RESPONSE_OBJECT_SIZE_FIELDS.get(operation_name) + if object_size_field is not None: + object_size = _as_integer(response.get(object_size_field)) + if object_size is not None and object_size >= 0: + attributes[SPANDATA.AWS_S3_OBJECT_SIZE] = object_size + + if ( + operation_name == "HeadObject" + and "Range" not in ctx.params + and "PartNumber" not in ctx.params + ): + # an un-ranged `HEAD` has no body, so `ContentLength` is the object size. + object_size = _as_integer(response.get("ContentLength")) + if object_size is not None and object_size >= 0: + attributes[SPANDATA.AWS_S3_OBJECT_SIZE] = object_size + + return attributes