Skip to content

Commit ca07d0d

Browse files
feat(boto3): Use object.* ops for S3 Object operations (#8037)
### Description S3 object operations now override default `rpc` op with `object.*`. Other S3 operations, e.g. `ListBuckets`, stay on `rpc`.
1 parent eb34f42 commit ca07d0d

4 files changed

Lines changed: 62 additions & 23 deletions

File tree

‎sentry_sdk/consts.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1477,6 +1477,16 @@ class OP:
14771477
HUGGINGFACE_HUB_CHAT_COMPLETIONS_CREATE = (
14781478
"ai.chat_completions.create.huggingface_hub"
14791479
)
1480+
OBJECT_COPY = "object.copy"
1481+
OBJECT_DELETE = "object.delete"
1482+
OBJECT_GET = "object.get"
1483+
OBJECT_HEAD = "object.head"
1484+
OBJECT_LIST = "object.list"
1485+
OBJECT_MULTIPART_UPLOAD_ABORT = "object.multipart_upload.abort"
1486+
OBJECT_MULTIPART_UPLOAD_COMPLETE = "object.multipart_upload.complete"
1487+
OBJECT_MULTIPART_UPLOAD_CREATE = "object.multipart_upload.create"
1488+
OBJECT_PUT = "object.put"
1489+
OBJECT_UPLOAD_PART = "object.upload_part"
14801490
QUEUE_PROCESS = "queue.process"
14811491
QUEUE_PUBLISH = "queue.publish"
14821492
QUEUE_SUBMIT_ARQ = "queue.submit.arq"

‎sentry_sdk/integrations/boto3/_services/s3.py‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from datetime import datetime
33
from typing import TYPE_CHECKING
44

5-
from sentry_sdk.consts import SPANDATA
5+
from sentry_sdk.consts import OP, SPANDATA
66
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
77
from sentry_sdk.integrations.boto3._utils import (
88
_extract_attributes,
@@ -11,14 +11,36 @@
1111
from sentry_sdk.utils import capture_internal_exceptions
1212

1313
if TYPE_CHECKING:
14-
from typing import Any, Sequence
14+
from typing import Any, Dict, Optional, Sequence
1515

1616
from sentry_sdk._types import Attributes
1717
from sentry_sdk.integrations.boto3._context import AwsCallContext
1818
from sentry_sdk.integrations.boto3._utils import (
1919
_AttributeSpec,
2020
)
2121

22+
# s3 object apis get `object.*` ops, every other s3 api, e.g. `ListBuckets`, keeps the default `rpc`.
23+
# https://github.com/getsentry/sentry-conventions/blob/main/model/op/object.json
24+
_OBJECT_OPS: "Dict[str, str]" = {
25+
"GetObject": OP.OBJECT_GET,
26+
"HeadObject": OP.OBJECT_HEAD,
27+
# `GetObjectAttributes` combines `HeadObject` and `ListParts`.
28+
# https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectAttributes.html
29+
"GetObjectAttributes": OP.OBJECT_HEAD,
30+
"PutObject": OP.OBJECT_PUT,
31+
"CopyObject": OP.OBJECT_COPY,
32+
"DeleteObject": OP.OBJECT_DELETE,
33+
"DeleteObjects": OP.OBJECT_DELETE,
34+
"ListObjects": OP.OBJECT_LIST,
35+
"ListObjectsV2": OP.OBJECT_LIST,
36+
"ListObjectVersions": OP.OBJECT_LIST,
37+
"UploadPart": OP.OBJECT_UPLOAD_PART,
38+
"UploadPartCopy": OP.OBJECT_UPLOAD_PART,
39+
"CreateMultipartUpload": OP.OBJECT_MULTIPART_UPLOAD_CREATE,
40+
"CompleteMultipartUpload": OP.OBJECT_MULTIPART_UPLOAD_COMPLETE,
41+
"AbortMultipartUpload": OP.OBJECT_MULTIPART_UPLOAD_ABORT,
42+
}
43+
2244
# maps s3 operations to the response field that contains the complete file size.
2345
_RESPONSE_FILE_SIZE_FIELDS = {
2446
"GetObjectAttributes": "ObjectSize",
@@ -36,6 +58,9 @@
3658
class _S3Extension(_ServiceExtension):
3759
__slots__ = ()
3860

61+
def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]":
62+
return _OBJECT_OPS.get(ctx.operation_name)
63+
3964
def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes":
4065
attributes: "Attributes" = _extract_attributes(ctx.params, _REQUEST_ATTRIBUTES)
4166
with capture_internal_exceptions():

‎tests/integrations/boto3/test_client.py‎

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from sentry_sdk.integrations.boto3 import Boto3Integration
1717
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
1818
from sentry_sdk.integrations.boto3._services.registry import _SERVICE_EXTENSIONS
19+
from sentry_sdk.integrations.boto3._services.s3 import _S3Extension
1920
from sentry_sdk.integrations.boto3.consts import (
2021
AWS_RPC_SYSTEM_NAME,
2122
CLOUD_PROVIDER,
@@ -146,7 +147,7 @@ def record_client_span(request, **kwargs):
146147
if span["name"] == "S3.GetObject"
147148
and (
148149
span.get("attributes", {}).get(SPANDATA.SENTRY_ORIGIN) == ORIGIN
149-
and span.get("attributes", {}).get(SPANDATA.SENTRY_OP) == OP.RPC
150+
and span.get("attributes", {}).get(SPANDATA.SENTRY_OP) == OP.OBJECT_GET
150151
)
151152
]
152153
http_spans = [
@@ -251,7 +252,7 @@ def test_omit_url_data_if_parsing_fails(capture_items, client_factory):
251252
)
252253

253254
parse_url.assert_called()
254-
(span,) = spans_by_op[OP.RPC]
255+
(span,) = spans_by_op[OP.OBJECT_HEAD]
255256
attributes = span.get("attributes", {})
256257
assert SPANDATA.URL_FULL not in attributes
257258
assert SPANDATA.URL_FRAGMENT not in attributes
@@ -326,7 +327,7 @@ def test_url_query_data_collection(
326327
(span,) = [
327328
item.payload
328329
for item in items
329-
if item.payload.get("attributes", {}).get(SPANDATA.SENTRY_OP) == OP.RPC
330+
if item.payload.get("attributes", {}).get(SPANDATA.SENTRY_OP) == OP.OBJECT_LIST
330331
]
331332
attributes = span.get("attributes", {})
332333

@@ -428,9 +429,8 @@ def _capture_stubbed_client_span(
428429
)
429430
stubber.assert_no_pending_responses()
430431

431-
client_spans = spans_by_op.get(OP.RPC, [])
432-
assert len(client_spans) == 1
433-
return client_spans[0]
432+
((client_span,),) = spans_by_op.values()
433+
return client_span
434434

435435

436436
def test_service_extension_customizes_client_span(
@@ -503,6 +503,7 @@ def get_response_attributes(self, ctx, response):
503503
"method_name",
504504
"api_params",
505505
"span_name",
506+
"expected_op",
506507
"rpc_service",
507508
"rpc_method",
508509
"endpoint_url",
@@ -515,6 +516,7 @@ def get_response_attributes(self, ctx, response):
515516
"head_object",
516517
{"Bucket": "bucket", "Key": "foo"},
517518
"S3.HeadObject",
519+
OP.OBJECT_HEAD,
518520
"S3",
519521
"HeadObject",
520522
"http://localhost:4566",
@@ -526,6 +528,7 @@ def get_response_attributes(self, ctx, response):
526528
"list_event_buses",
527529
{},
528530
"EventBridge.ListEventBuses",
531+
OP.RPC,
529532
"EventBridge",
530533
"ListEventBuses",
531534
None,
@@ -537,6 +540,7 @@ def get_response_attributes(self, ctx, response):
537540
"get_rest_apis",
538541
{},
539542
"APIGateway.GetRestApis",
543+
OP.RPC,
540544
"APIGateway",
541545
"GetRestApis",
542546
None,
@@ -552,6 +556,7 @@ def test_client_call_has_common_attributes(
552556
method_name,
553557
api_params,
554558
span_name,
559+
expected_op,
555560
rpc_service,
556561
rpc_method,
557562
endpoint_url,
@@ -576,7 +581,7 @@ def test_client_call_has_common_attributes(
576581

577582
assert span["name"] == span_name
578583
assert span.get("end_timestamp") is not None
579-
assert attributes[SPANDATA.SENTRY_OP] == OP.RPC
584+
assert attributes[SPANDATA.SENTRY_OP] == expected_op
580585
assert attributes[SPANDATA.SENTRY_ORIGIN] == ORIGIN
581586
assert attributes[SPANDATA.SENTRY_KIND] == "client"
582587
assert attributes[SPANDATA.CLOUD_PROVIDER] == CLOUD_PROVIDER
@@ -647,7 +652,7 @@ def test_client_call_has_response_header_attributes(
647652
capture_items,
648653
)
649654

650-
spans = spans_by_op[OP.RPC]
655+
spans = spans_by_op[OP.OBJECT_HEAD]
651656
assert len(spans) == 1
652657
attributes = spans[0].get("attributes", {})
653658
assert attributes[SPANDATA.HTTP_STATUS_CODE] == 200
@@ -666,7 +671,7 @@ def test_retry_attempts_share_one_client_span(
666671
spans_by_op = _capture_boto3_spans_by_op(
667672
lambda: client.head_object(Bucket="bucket", Key="foo"), capture_items
668673
)
669-
client_spans = spans_by_op.get(OP.RPC, [])
674+
client_spans = spans_by_op.get(OP.OBJECT_HEAD, [])
670675

671676
assert len(request_span_ids) == attempt_count
672677
# all `AWSRequest` instances created during retries reference the same client span.
@@ -689,7 +694,7 @@ def attempt_failed_head_object_call():
689694
spans_by_op = _capture_boto3_spans_by_op(
690695
attempt_failed_head_object_call, capture_items
691696
)
692-
client_spans = spans_by_op.get(OP.RPC, [])
697+
client_spans = spans_by_op.get(OP.OBJECT_HEAD, [])
693698

694699
assert len(request_span_ids) == 2
695700
assert len(set(request_span_ids)) == 1
@@ -706,7 +711,7 @@ def test_client_error_has_response_attributes_and_is_unchanged(
706711
monkeypatch,
707712
with_service_extension,
708713
):
709-
class TestServiceExtension(_ServiceExtension):
714+
class TestServiceExtension(_S3Extension):
710715
def get_response_attributes(self, ctx, response):
711716
return {
712717
"aws.test.error": response["Error"]["Code"],
@@ -745,14 +750,14 @@ def invoke_failing_client_method():
745750
spans_by_op = _capture_boto3_spans_by_op(
746751
invoke_failing_client_method, capture_items
747752
)
748-
client_spans = spans_by_op.get(OP.RPC, [])
753+
client_spans = spans_by_op.get(OP.OBJECT_HEAD, [])
749754
_assert_one_failed_span(client_spans)
750755
attributes = client_spans[0].get("attributes", {})
751756

752757
span = client_spans[0]
753758
assert span["name"] == "S3.HeadObject"
754759
assert span.get("end_timestamp") is not None
755-
assert attributes[SPANDATA.SENTRY_OP] == OP.RPC
760+
assert attributes[SPANDATA.SENTRY_OP] == OP.OBJECT_HEAD
756761
assert attributes[SPANDATA.SENTRY_ORIGIN] == ORIGIN
757762
assert attributes[SPANDATA.SENTRY_KIND] == "client"
758763
assert attributes[SPANDATA.CLOUD_PROVIDER] == CLOUD_PROVIDER
@@ -806,7 +811,7 @@ def invoke_failing_client_method():
806811
spans_by_op = _capture_boto3_spans_by_op(
807812
invoke_failing_client_method, capture_items
808813
)
809-
client_spans = spans_by_op.get(OP.RPC, [])
814+
client_spans = spans_by_op.get(OP.OBJECT_HEAD, [])
810815
_assert_one_failed_span(client_spans)
811816

812817
expected_error_type = (
@@ -852,7 +857,7 @@ def invoke_client_method():
852857
stubber.add_response("head_object", original_response, api_params)
853858
spans_by_op = _capture_boto3_spans_by_op(invoke_client_method, capture_items)
854859

855-
client_spans = spans_by_op.get(OP.RPC, [])
860+
client_spans = spans_by_op.get(OP.OBJECT_HEAD, [])
856861
assert returned_responses == [original_response]
857862
assert returned_responses[0] is original_response
858863
if failing_instrumentation == "_get_response_attributes":
@@ -889,7 +894,7 @@ def invoke_failing_client_method():
889894
spans_by_op = _capture_boto3_spans_by_op(
890895
invoke_failing_client_method, capture_items
891896
)
892-
client_spans = spans_by_op.get(OP.RPC, [])
897+
client_spans = spans_by_op.get(OP.OBJECT_HEAD, [])
893898

894899
assert len(client_spans) == 1
895900
assert client_spans[0]["status"] == "error"
@@ -922,7 +927,7 @@ def invoke_client_method_and_read_body():
922927
spans_by_op = _capture_boto3_spans_by_op(
923928
invoke_client_method_and_read_body, capture_items
924929
)
925-
client_spans = spans_by_op.get(OP.RPC, [])
930+
client_spans = spans_by_op.get(OP.OBJECT_GET, [])
926931
stream_spans = spans_by_op.get(OP.HTTP_CLIENT_STREAM, [])
927932

928933
assert len(client_spans) == 1
@@ -971,7 +976,7 @@ def invoke_client_method_and_read_body():
971976
spans_by_op = _capture_boto3_spans_by_op(
972977
invoke_client_method_and_read_body, capture_items
973978
)
974-
client_spans = spans_by_op.get(OP.RPC, [])
979+
client_spans = spans_by_op.get(OP.OBJECT_GET, [])
975980
stream_spans = spans_by_op.get(OP.HTTP_CLIENT_STREAM, [])
976981

977982
assert len(client_spans) == 1

‎tests/integrations/boto3/test_s3.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
from botocore.stub import Stubber
77

8-
from sentry_sdk.consts import OP, SPANDATA
8+
from sentry_sdk.consts import SPANDATA
99
from sentry_sdk.integrations.boto3.consts import AWS_RPC_SYSTEM_NAME, ORIGIN
1010
from tests.integrations.boto3.helpers import (
1111
capture_spans_by_op,
@@ -26,12 +26,11 @@ def _stubbed_span(client, capture_items, method, params, response):
2626
lambda: getattr(client, method)(**params), capture_items
2727
)
2828
stubber.assert_no_pending_responses()
29-
(span,) = spans[OP.RPC]
29+
((span,),) = spans.values()
3030
operation = client.meta.method_to_api_mapping[method]
3131
assert span["name"] == "S3.%s" % operation
3232
assert span.get("end_timestamp") is not None
3333
attributes = span.get("attributes", {})
34-
assert attributes[SPANDATA.SENTRY_OP] == OP.RPC
3534
assert attributes[SPANDATA.SENTRY_ORIGIN] == ORIGIN
3635
assert attributes[SPANDATA.SENTRY_KIND] == "client"
3736
assert attributes[SPANDATA.CLOUD_PROVIDER] == "aws"

0 commit comments

Comments
 (0)