From 64bf8c651a4fee2106ff72bd391e92b382217d81 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 14 Oct 2026 20:13:53 +0200 Subject: [PATCH] feat(boto3): Use `object.*` ops for S3 Object operations --- sentry_sdk/consts.py | 10 +++++ sentry_sdk/integrations/boto3/_services/s3.py | 29 ++++++++++++- tests/integrations/boto3/test_client.py | 41 +++++++++++-------- tests/integrations/boto3/test_s3.py | 5 +-- 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 5ff880ab73..2fd5ab60e5 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1435,6 +1435,16 @@ class OP: HUGGINGFACE_HUB_CHAT_COMPLETIONS_CREATE = ( "ai.chat_completions.create.huggingface_hub" ) + OBJECT_COPY = "object.copy" + OBJECT_DELETE = "object.delete" + OBJECT_GET = "object.get" + OBJECT_HEAD = "object.head" + OBJECT_LIST = "object.list" + OBJECT_MULTIPART_UPLOAD_ABORT = "object.multipart_upload.abort" + OBJECT_MULTIPART_UPLOAD_COMPLETE = "object.multipart_upload.complete" + OBJECT_MULTIPART_UPLOAD_CREATE = "object.multipart_upload.create" + OBJECT_PUT = "object.put" + OBJECT_UPLOAD_PART = "object.upload_part" QUEUE_PROCESS = "queue.process" QUEUE_PUBLISH = "queue.publish" QUEUE_SUBMIT_ARQ = "queue.submit.arq" diff --git a/sentry_sdk/integrations/boto3/_services/s3.py b/sentry_sdk/integrations/boto3/_services/s3.py index 17c47fa4c1..caea75ef82 100644 --- a/sentry_sdk/integrations/boto3/_services/s3.py +++ b/sentry_sdk/integrations/boto3/_services/s3.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import TYPE_CHECKING -from sentry_sdk.consts import SPANDATA +from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3._services.base import _ServiceExtension from sentry_sdk.integrations.boto3._utils import ( _extract_attributes, @@ -11,7 +11,7 @@ from sentry_sdk.utils import capture_internal_exceptions if TYPE_CHECKING: - from typing import Any, Sequence + from typing import Any, Dict, Optional, Sequence from sentry_sdk._types import Attributes from sentry_sdk.integrations.boto3._context import AwsCallContext @@ -19,6 +19,28 @@ _AttributeSpec, ) +# s3 object apis get `object.*` ops, every other s3 api, e.g. `ListBuckets`, keeps the default `rpc`. +# https://github.com/getsentry/sentry-conventions/blob/main/model/op/object.json +_OBJECT_OPS: "Dict[str, str]" = { + "GetObject": OP.OBJECT_GET, + "HeadObject": OP.OBJECT_HEAD, + # `GetObjectAttributes` combines `HeadObject` and `ListParts`. + # https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectAttributes.html + "GetObjectAttributes": OP.OBJECT_HEAD, + "PutObject": OP.OBJECT_PUT, + "CopyObject": OP.OBJECT_COPY, + "DeleteObject": OP.OBJECT_DELETE, + "DeleteObjects": OP.OBJECT_DELETE, + "ListObjects": OP.OBJECT_LIST, + "ListObjectsV2": OP.OBJECT_LIST, + "ListObjectVersions": OP.OBJECT_LIST, + "UploadPart": OP.OBJECT_UPLOAD_PART, + "UploadPartCopy": OP.OBJECT_UPLOAD_PART, + "CreateMultipartUpload": OP.OBJECT_MULTIPART_UPLOAD_CREATE, + "CompleteMultipartUpload": OP.OBJECT_MULTIPART_UPLOAD_COMPLETE, + "AbortMultipartUpload": OP.OBJECT_MULTIPART_UPLOAD_ABORT, +} + # maps s3 operations to the response field that contains the complete file size. _RESPONSE_FILE_SIZE_FIELDS = { "GetObjectAttributes": "ObjectSize", @@ -36,6 +58,9 @@ class _S3Extension(_ServiceExtension): __slots__ = () + def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": + return _OBJECT_OPS.get(ctx.operation_name) + def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": attributes: "Attributes" = _extract_attributes(ctx.params, _REQUEST_ATTRIBUTES) with capture_internal_exceptions(): diff --git a/tests/integrations/boto3/test_client.py b/tests/integrations/boto3/test_client.py index 999d3876f4..5239f02127 100644 --- a/tests/integrations/boto3/test_client.py +++ b/tests/integrations/boto3/test_client.py @@ -16,6 +16,7 @@ from sentry_sdk.integrations.boto3 import Boto3Integration from sentry_sdk.integrations.boto3._services.base import _ServiceExtension from sentry_sdk.integrations.boto3._services.registry import _SERVICE_EXTENSIONS +from sentry_sdk.integrations.boto3._services.s3 import _S3Extension from sentry_sdk.integrations.boto3.consts import ( AWS_RPC_SYSTEM_NAME, CLOUD_PROVIDER, @@ -146,7 +147,7 @@ def record_client_span(request, **kwargs): if span["name"] == "S3.GetObject" and ( span.get("attributes", {}).get(SPANDATA.SENTRY_ORIGIN) == ORIGIN - and span.get("attributes", {}).get(SPANDATA.SENTRY_OP) == OP.RPC + and span.get("attributes", {}).get(SPANDATA.SENTRY_OP) == OP.OBJECT_GET ) ] http_spans = [ @@ -251,7 +252,7 @@ def test_omit_url_data_if_parsing_fails(capture_items, client_factory): ) parse_url.assert_called() - (span,) = spans_by_op[OP.RPC] + (span,) = spans_by_op[OP.OBJECT_HEAD] attributes = span.get("attributes", {}) assert SPANDATA.URL_FULL not in attributes assert SPANDATA.URL_FRAGMENT not in attributes @@ -326,7 +327,7 @@ def test_url_query_data_collection( (span,) = [ item.payload for item in items - if item.payload.get("attributes", {}).get(SPANDATA.SENTRY_OP) == OP.RPC + if item.payload.get("attributes", {}).get(SPANDATA.SENTRY_OP) == OP.OBJECT_LIST ] attributes = span.get("attributes", {}) @@ -428,9 +429,8 @@ def _capture_stubbed_client_span( ) stubber.assert_no_pending_responses() - client_spans = spans_by_op.get(OP.RPC, []) - assert len(client_spans) == 1 - return client_spans[0] + ((client_span,),) = spans_by_op.values() + return client_span def test_service_extension_customizes_client_span( @@ -503,6 +503,7 @@ def get_response_attributes(self, ctx, response): "method_name", "api_params", "span_name", + "expected_op", "rpc_service", "rpc_method", "endpoint_url", @@ -515,6 +516,7 @@ def get_response_attributes(self, ctx, response): "head_object", {"Bucket": "bucket", "Key": "foo"}, "S3.HeadObject", + OP.OBJECT_HEAD, "S3", "HeadObject", "http://localhost:4566", @@ -526,6 +528,7 @@ def get_response_attributes(self, ctx, response): "list_event_buses", {}, "EventBridge.ListEventBuses", + OP.RPC, "EventBridge", "ListEventBuses", None, @@ -537,6 +540,7 @@ def get_response_attributes(self, ctx, response): "get_rest_apis", {}, "APIGateway.GetRestApis", + OP.RPC, "APIGateway", "GetRestApis", None, @@ -552,6 +556,7 @@ def test_client_call_has_common_attributes( method_name, api_params, span_name, + expected_op, rpc_service, rpc_method, endpoint_url, @@ -576,7 +581,7 @@ def test_client_call_has_common_attributes( assert span["name"] == span_name assert span.get("end_timestamp") is not None - assert attributes[SPANDATA.SENTRY_OP] == OP.RPC + assert attributes[SPANDATA.SENTRY_OP] == expected_op assert attributes[SPANDATA.SENTRY_ORIGIN] == ORIGIN assert attributes[SPANDATA.SENTRY_KIND] == "client" assert attributes[SPANDATA.CLOUD_PROVIDER] == CLOUD_PROVIDER @@ -647,7 +652,7 @@ def test_client_call_has_response_header_attributes( capture_items, ) - spans = spans_by_op[OP.RPC] + spans = spans_by_op[OP.OBJECT_HEAD] assert len(spans) == 1 attributes = spans[0].get("attributes", {}) assert attributes[SPANDATA.HTTP_STATUS_CODE] == 200 @@ -666,7 +671,7 @@ def test_retry_attempts_share_one_client_span( spans_by_op = _capture_boto3_spans_by_op( lambda: client.head_object(Bucket="bucket", Key="foo"), capture_items ) - client_spans = spans_by_op.get(OP.RPC, []) + client_spans = spans_by_op.get(OP.OBJECT_HEAD, []) assert len(request_span_ids) == attempt_count # all `AWSRequest` instances created during retries reference the same client span. @@ -689,7 +694,7 @@ def attempt_failed_head_object_call(): spans_by_op = _capture_boto3_spans_by_op( attempt_failed_head_object_call, capture_items ) - client_spans = spans_by_op.get(OP.RPC, []) + client_spans = spans_by_op.get(OP.OBJECT_HEAD, []) assert len(request_span_ids) == 2 assert len(set(request_span_ids)) == 1 @@ -706,7 +711,7 @@ def test_client_error_has_response_attributes_and_is_unchanged( monkeypatch, with_service_extension, ): - class TestServiceExtension(_ServiceExtension): + class TestServiceExtension(_S3Extension): def get_response_attributes(self, ctx, response): return { "aws.test.error": response["Error"]["Code"], @@ -745,14 +750,14 @@ def invoke_failing_client_method(): spans_by_op = _capture_boto3_spans_by_op( invoke_failing_client_method, capture_items ) - client_spans = spans_by_op.get(OP.RPC, []) + client_spans = spans_by_op.get(OP.OBJECT_HEAD, []) _assert_one_failed_span(client_spans) attributes = client_spans[0].get("attributes", {}) span = client_spans[0] assert span["name"] == "S3.HeadObject" assert span.get("end_timestamp") is not None - assert attributes[SPANDATA.SENTRY_OP] == OP.RPC + assert attributes[SPANDATA.SENTRY_OP] == OP.OBJECT_HEAD assert attributes[SPANDATA.SENTRY_ORIGIN] == ORIGIN assert attributes[SPANDATA.SENTRY_KIND] == "client" assert attributes[SPANDATA.CLOUD_PROVIDER] == CLOUD_PROVIDER @@ -806,7 +811,7 @@ def invoke_failing_client_method(): spans_by_op = _capture_boto3_spans_by_op( invoke_failing_client_method, capture_items ) - client_spans = spans_by_op.get(OP.RPC, []) + client_spans = spans_by_op.get(OP.OBJECT_HEAD, []) _assert_one_failed_span(client_spans) expected_error_type = ( @@ -852,7 +857,7 @@ def invoke_client_method(): stubber.add_response("head_object", original_response, api_params) spans_by_op = _capture_boto3_spans_by_op(invoke_client_method, capture_items) - client_spans = spans_by_op.get(OP.RPC, []) + client_spans = spans_by_op.get(OP.OBJECT_HEAD, []) assert returned_responses == [original_response] assert returned_responses[0] is original_response if failing_instrumentation == "_get_response_attributes": @@ -889,7 +894,7 @@ def invoke_failing_client_method(): spans_by_op = _capture_boto3_spans_by_op( invoke_failing_client_method, capture_items ) - client_spans = spans_by_op.get(OP.RPC, []) + client_spans = spans_by_op.get(OP.OBJECT_HEAD, []) assert len(client_spans) == 1 assert client_spans[0]["status"] == "error" @@ -922,7 +927,7 @@ def invoke_client_method_and_read_body(): spans_by_op = _capture_boto3_spans_by_op( invoke_client_method_and_read_body, capture_items ) - client_spans = spans_by_op.get(OP.RPC, []) + client_spans = spans_by_op.get(OP.OBJECT_GET, []) stream_spans = spans_by_op.get(OP.HTTP_CLIENT_STREAM, []) assert len(client_spans) == 1 @@ -971,7 +976,7 @@ def invoke_client_method_and_read_body(): spans_by_op = _capture_boto3_spans_by_op( invoke_client_method_and_read_body, capture_items ) - client_spans = spans_by_op.get(OP.RPC, []) + client_spans = spans_by_op.get(OP.OBJECT_GET, []) stream_spans = spans_by_op.get(OP.HTTP_CLIENT_STREAM, []) assert len(client_spans) == 1 diff --git a/tests/integrations/boto3/test_s3.py b/tests/integrations/boto3/test_s3.py index 0e1ef6a6e5..de70f3a4af 100644 --- a/tests/integrations/boto3/test_s3.py +++ b/tests/integrations/boto3/test_s3.py @@ -5,7 +5,7 @@ import pytest from botocore.stub import Stubber -from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.boto3.consts import AWS_RPC_SYSTEM_NAME, ORIGIN from tests.integrations.boto3.helpers import ( capture_spans_by_op, @@ -26,12 +26,11 @@ def _stubbed_span(client, capture_items, method, params, response): lambda: getattr(client, method)(**params), capture_items ) stubber.assert_no_pending_responses() - (span,) = spans[OP.RPC] + ((span,),) = spans.values() operation = client.meta.method_to_api_mapping[method] assert span["name"] == "S3.%s" % operation assert span.get("end_timestamp") is not None attributes = span.get("attributes", {}) - assert attributes[SPANDATA.SENTRY_OP] == OP.RPC assert attributes[SPANDATA.SENTRY_ORIGIN] == ORIGIN assert attributes[SPANDATA.SENTRY_KIND] == "client" assert attributes[SPANDATA.CLOUD_PROVIDER] == "aws"