Skip to content

Commit 70218f1

Browse files
committed
feat(boto3): Add S3 extensio
1 parent 1a6a41e commit 70218f1

1 file changed

Lines changed: 109 additions & 0 deletions

File tree

  • sentry_sdk/integrations/boto3/_services
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import json
2+
from typing import TYPE_CHECKING
3+
4+
from sentry_sdk.consts import SPANDATA
5+
from sentry_sdk.integrations.boto3._services._attribute_extraction import (
6+
_as_integer,
7+
_as_string,
8+
_extract_attributes,
9+
)
10+
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
11+
12+
if TYPE_CHECKING:
13+
from typing import Any, Optional, Sequence
14+
15+
from sentry_sdk._types import Attributes
16+
from sentry_sdk.integrations.boto3._context import AwsCallContext
17+
from sentry_sdk.integrations.boto3._services._attribute_extraction import (
18+
_AttributeSpec,
19+
)
20+
21+
_RESPONSE_BODY_SIZE_OPERATIONS = frozenset(
22+
(
23+
"GetObject",
24+
"GetObjectAnnotation",
25+
)
26+
)
27+
28+
_RESPONSE_OBJECT_SIZE_FIELDS = {
29+
"GetObjectAttributes": "ObjectSize",
30+
"PutObject": "Size",
31+
}
32+
33+
34+
def _json_dict(value: "Any") -> "Optional[str]":
35+
if not isinstance(value, dict):
36+
return None
37+
38+
try:
39+
return json.dumps(
40+
value,
41+
allow_nan=False,
42+
separators=(",", ":"),
43+
sort_keys=True,
44+
)
45+
except (TypeError, ValueError):
46+
return None
47+
48+
49+
_REQUEST_ATTRIBUTES: "Sequence[_AttributeSpec]" = (
50+
# s3-specific attributes defined by OTel SemConv. Specified as a tuple of
51+
# (param_name, attribute_name, converter_func). the `converter_func` is
52+
# used to 1. validate the value (otherwise omitted) and 2. convert it to
53+
# the appropriate type.
54+
# https://opentelemetry.io/docs/specs/semconv/object-stores/s3/
55+
("Bucket", SPANDATA.AWS_S3_BUCKET, _as_string),
56+
("CopySource", SPANDATA.AWS_S3_COPY_SOURCE, _as_string),
57+
("Delete", SPANDATA.AWS_S3_DELETE, _json_dict),
58+
("Key", SPANDATA.AWS_S3_KEY, _as_string),
59+
("PartNumber", SPANDATA.AWS_S3_PART_NUMBER, _as_integer),
60+
("UploadId", SPANDATA.AWS_S3_UPLOAD_ID, _as_string),
61+
)
62+
63+
64+
class _S3Extension(_ServiceExtension):
65+
__slots__ = ()
66+
67+
def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes":
68+
attributes: "Attributes" = _extract_attributes(ctx.params, _REQUEST_ATTRIBUTES)
69+
70+
if ctx.operation_name == "CompleteMultipartUpload":
71+
object_size = _as_integer(ctx.params.get("MpuObjectSize"))
72+
if object_size is not None and object_size >= 0:
73+
attributes[SPANDATA.AWS_S3_OBJECT_SIZE] = object_size
74+
75+
return attributes
76+
77+
def get_response_attributes(
78+
self, ctx: "AwsCallContext", response: "Any"
79+
) -> "Attributes":
80+
if not isinstance(response, dict):
81+
return {}
82+
83+
attributes: "Attributes" = {}
84+
operation_name = ctx.operation_name
85+
86+
if operation_name in _RESPONSE_BODY_SIZE_OPERATIONS:
87+
# `ContentLength` is the size of the HTTP body returned, which may be a range.
88+
content_length = _as_integer(response.get("ContentLength"))
89+
if content_length is not None and content_length >= 0:
90+
attributes[SPANDATA.HTTP_RESPONSE_BODY_SIZE] = content_length
91+
92+
# these fields report the total S3 object size, not the HTTP body size.
93+
object_size_field = _RESPONSE_OBJECT_SIZE_FIELDS.get(operation_name)
94+
if object_size_field is not None:
95+
object_size = _as_integer(response.get(object_size_field))
96+
if object_size is not None and object_size >= 0:
97+
attributes[SPANDATA.AWS_S3_OBJECT_SIZE] = object_size
98+
99+
if (
100+
operation_name == "HeadObject"
101+
and "Range" not in ctx.params
102+
and "PartNumber" not in ctx.params
103+
):
104+
# an un-ranged `HEAD` has no body, so `ContentLength` is the object size.
105+
object_size = _as_integer(response.get("ContentLength"))
106+
if object_size is not None and object_size >= 0:
107+
attributes[SPANDATA.AWS_S3_OBJECT_SIZE] = object_size
108+
109+
return attributes

0 commit comments

Comments
 (0)