Skip to content
24 changes: 24 additions & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,18 @@ class SPANDATA:
Example: ["Token limit exceeded"]
"""

AWS_EXTENDED_REQUEST_ID = "aws.extended_request_id"
"""
The AWS extended request ID as returned in the response headers.
Example: "wzHcyEWfmOGDIE5QOhTAqFDoDWP3y8IUvpNINCwL9N4TEHbUw0/gZJ+VZTmCNCWR7fezEN3eCiQ="
"""

AWS_REQUEST_ID = "aws.request_id"
"""
The AWS request ID as returned in the response headers.
Example: "79b9da39-b7ae-508a-a6bc-864b2829c622"
"""

CACHE_HIT = "cache.hit"
"""
A boolean indicating whether the requested data was found in the cache.
Expand Down Expand Up @@ -547,6 +559,12 @@ class SPANDATA:
Example: my_user
"""

ERROR_TYPE = "error.type"
"""
Describes a class of error the operation ended with.
Example: "timeout"
"""

GEN_AI_AGENT_NAME = "gen_ai.agent.name"
"""
The name of the agent being used.
Expand Down Expand Up @@ -886,6 +904,12 @@ class SPANDATA:
Example: GET
"""

HTTP_REQUEST_RESEND_COUNT = "http.request.resend_count"
"""
The ordinal number of request resending attempt (for any reason, including redirects).
Example: 2
"""

HTTP_ROUTE = "http.route"
"""
The matched route, that is, the path template used to match the request.
Expand Down
18 changes: 14 additions & 4 deletions sentry_sdk/integrations/boto3/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
import sentry_sdk
from sentry_sdk.integrations import DidNotEnable
from sentry_sdk.integrations.boto3._context import AwsCallContext
from sentry_sdk.integrations.boto3.consts import IDENTIFIER
from sentry_sdk.integrations.boto3._instrumentation import (
_finish_span,
_get_error_attributes,
_get_response_attributes,
_instrument_streaming_body,
_sentry_before_sign,
_sentry_request_created,
_set_span_attributes,
_start_client_span,
)
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
Expand Down Expand Up @@ -65,8 +69,6 @@ def _activate_client_span(


def _patch_botocore_client() -> None:
from sentry_sdk.integrations.boto3 import Boto3Integration

orig_init = BaseClient.__init__
orig_make_api_call = BaseClient._make_api_call # type: ignore

Expand All @@ -88,7 +90,7 @@ def sentry_patched_make_api_call(
https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span
"""
client = sentry_sdk.get_client()
if client.get_integration(Boto3Integration) is None:
if client.get_integration(IDENTIFIER) is None:
return orig_make_api_call(self, operation_name, api_params)

ctx = AwsCallContext(operation_name, api_params)
Expand All @@ -109,7 +111,15 @@ def sentry_patched_make_api_call(

try:
with span_ctx:
parsed = orig_make_api_call(self, operation_name, api_params)
try:
parsed = orig_make_api_call(self, operation_name, api_params)
except BaseException as error:
with capture_internal_exceptions():
_set_span_attributes(span, _get_error_attributes(error))
raise
else:
with capture_internal_exceptions():
_set_span_attributes(span, _get_response_attributes(parsed))
except BaseException as error:
_finish_span(span, error)
raise
Expand Down
110 changes: 106 additions & 4 deletions sentry_sdk/integrations/boto3/_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import sentry_sdk
from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS
from sentry_sdk.integrations import DidNotEnable
from sentry_sdk.integrations.boto3.consts import ORIGIN
from sentry_sdk.integrations.boto3.consts import IDENTIFIER, ORIGIN
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, Span
from sentry_sdk.tracing_utils import (
Expand All @@ -27,6 +27,7 @@

try:
from botocore.awsrequest import AWSRequest
from botocore.exceptions import ClientError
from botocore.response import StreamingBody
except ImportError:
raise DidNotEnable("botocore not installed")
Expand All @@ -38,6 +39,7 @@
def _set_span_attributes(
span: "Union[Span, StreamedSpan]", attributes: "Attributes"
) -> None:
"""Will be removed in the major."""
if isinstance(span, StreamedSpan):
span.set_attributes(attributes)
return
Expand Down Expand Up @@ -89,11 +91,104 @@
return attributes


def _get_response_attributes(response: "Any") -> "Attributes":
if not isinstance(response, dict):
return {}

metadata = response.get("ResponseMetadata")
if not isinstance(metadata, dict):
return {}
attributes: "Attributes" = {}

# botocore injects HTTP status into `ResponseMetadata` after parsing.
# https://github.com/boto/botocore/blob/develop/botocore/parsers.py#L273-L284
status_code = metadata.get("HTTPStatusCode")
if isinstance(status_code, int) and 100 <= status_code <= 599:
attributes[SPANDATA.HTTP_STATUS_CODE] = status_code

retry_attempts = metadata.get("RetryAttempts")
# botocore represents retries as `attempts - 1`; OTel suggests "if and only if", so skip zero.
# https://github.com/boto/botocore/blob/develop/botocore/endpoint.py#L221-L229
# https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span
if (
isinstance(retry_attempts, int)
# avoid emitting `resend_count=True`.
and not isinstance(retry_attempts, bool)
and retry_attempts > 0
):
attributes[SPANDATA.HTTP_REQUEST_RESEND_COUNT] = retry_attempts

headers = metadata.get("HTTPHeaders")
if not isinstance(headers, dict):
headers = {}

request_id = next(
(
value
for value in (
metadata.get("RequestId"),
headers.get("x-amzn-requestid"),
headers.get("x-amzn-request-id"),
headers.get("x-amz-request-id"),
)
if isinstance(value, str) and value
),
None,
)
if request_id is not None:
attributes[SPANDATA.AWS_REQUEST_ID] = request_id

# S3's `HostId` is the extended request ID returned in `x-amz-id-2`.
# https://docs.aws.amazon.com/AmazonS3/latest/developerguide/get-request-ids.html
extended_request_id = next(
(
value
for value in (metadata.get("HostId"), headers.get("x-amz-id-2"))
if isinstance(value, str) and value
),
None,
)
if extended_request_id is not None:
attributes[SPANDATA.AWS_EXTENDED_REQUEST_ID] = extended_request_id

return attributes


def _get_error_type(exception: "BaseException") -> str:
if isinstance(exception, ClientError):
# `ClientError` wraps AWS service errors; `Error.Code` identifies the
# actual service error, e.g. `AccessDeniedException`.
# https://docs.aws.amazon.com/boto3/latest/guide/error-handling.html
error = exception.response.get("Error")
if isinstance(error, dict):
error_code = error.get("Code")
if isinstance(error_code, str) and error_code:
return error_code

# failures before a service response have no AWS error code.
# https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/
exception_type = type(exception)
exception_name = exception_type.__qualname__
exception_module = exception_type.__module__
if exception_module not in ("builtins", "__builtins__"):
return "%s.%s" % (exception_module, exception_name)
return exception_name


def _get_error_attributes(exception: "BaseException") -> "Attributes":
attributes: "Attributes" = {}
if isinstance(exception, ClientError):
attributes.update(_get_response_attributes(exception.response))

attributes[SPANDATA.ERROR_TYPE] = _get_error_type(exception)
return attributes


def _start_client_span(
ctx: "AwsCallContext",
) -> "Optional[Union[Span, StreamedSpan]]":
client = sentry_sdk.get_client()
if client.get_integration("boto3") is None:
if client.get_integration(IDENTIFIER) is None:
return None

# use unknown if `service_id_hyphenized` so span name can still be created.
Expand Down Expand Up @@ -202,6 +297,13 @@

finished = True
# finish stream span before boto span, and only once across read/close.
if error is not None:
with capture_internal_exceptions():
attributes = _get_error_attributes(error)
_set_span_attributes(streaming_span, attributes)
if isinstance(span, StreamedSpan):
_set_span_attributes(span, attributes)

Check warning on line 305 in sentry_sdk/integrations/boto3/_instrumentation.py

View check run for this annotation

@sentry/warden / warden: code-review

Stream read failures omit error.type on legacy parent spans

Set error attributes on the parent span in both modes; the StreamedSpan guard drops error.type from the legacy boto client span even though that span is still marked internal_error.

Check warning on line 305 in sentry_sdk/integrations/boto3/_instrumentation.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Stream read failures omit error.type on legacy parent spans

Apply error attributes to the parent span in both modes; the StreamedSpan-only guard drops error.type from legacy client spans that are still marked internal_error.
Comment on lines +300 to +305

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream read failures omit error.type on legacy parent spans

Set error attributes on the parent span in both modes; the StreamedSpan guard drops error.type from the legacy boto client span even though that span is still marked internal_error.

Evidence
  • finish_span(error) computes _get_error_attributes(error) and always applies them to streaming_span.
  • Parent update is gated by if isinstance(span, StreamedSpan), so legacy Span parents skip _set_span_attributes.
  • _finish_span(span, error) still sets the legacy parent status to INTERNAL_ERROR without error.type.
  • API-call failures in _client.py set _get_error_attributes on the client span in both modes, so stream-read failures are inconsistent.
  • test_streaming_body_read_failure_finishes_stream_span only asserts ERROR_TYPE on the client span when span_streaming=True.

Identified by Warden · code-review · VG3-QTU


_finish_span(streaming_span, error)
_finish_span(span, error)

Expand Down Expand Up @@ -335,7 +437,7 @@
"""

client = sentry_sdk.get_client()
if client.get_integration("boto3") is None:
if client.get_integration(IDENTIFIER) is None:
return

with capture_internal_exceptions():
Expand Down Expand Up @@ -363,7 +465,7 @@
request: "AWSRequest", signature_version: "Any", **kwargs: "Any"
) -> None:
client = sentry_sdk.get_client()
if client.get_integration("boto3") is None:
if client.get_integration(IDENTIFIER) is None:
return

with capture_internal_exceptions():
Expand Down
Loading
Loading