Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion sentry_sdk/integrations/boto3/_services/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
109 changes: 109 additions & 0 deletions sentry_sdk/integrations/boto3/_services/s3.py
Original file line number Diff line number Diff line change
@@ -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
Loading