Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d32a43f
ref(boto3): simplify client span lifecycle and header handling
pabloDeputter Sep 18, 2026
822438b
feat(boto3): add attributes to `consts.py`
pabloDeputter Sep 14, 2026
46d24b2
merge changes
pabloDeputter Sep 15, 2026
30d1e3f
add tests
pabloDeputter Sep 17, 2026
250699d
patch correct methods
pabloDeputter Sep 15, 2026
d9edef5
lint
pabloDeputter Sep 18, 2026
90383dc
use ordered search for request id and hist id
pabloDeputter Sep 18, 2026
d482500
ref(boto3): remove redundant span checks during enrichment
pabloDeputter Sep 18, 2026
26fdcb1
fix duplicates when merging
pabloDeputter Sep 18, 2026
eeac30a
use consts and string lookup for `client.getintegration`
pabloDeputter Sep 21, 2026
8b61aac
lint
pabloDeputter Sep 22, 2026
308eb5f
ruff
pabloDeputter Sep 22, 2026
8e4ce5a
ref(boto3): add permalinks
pabloDeputter Sep 29, 2026
832d90d
fix(boto3): add `error.type` to both span kinds
pabloDeputter Sep 29, 2026
0ee5f14
ref(boto3): specify docstring for `_set_span_attributes()` further
pabloDeputter Sep 29, 2026
a8f521e
ref(boto3): use f-string instead
pabloDeputter Sep 29, 2026
2d92b4e
ref(boto3): remove comment regarding `HTTP_REQUEST_RESEND_COUNT`
pabloDeputter Sep 29, 2026
12dd5e6
ref(boto3): remove `else` block
pabloDeputter Sep 29, 2026
21794bf
tests(boto3): remove uncessary testcases
pabloDeputter Sep 29, 2026
7859e97
ref(boto3): remove uncessary defensive conditionals
pabloDeputter Sep 29, 2026
1f4e936
fix flaky test
pabloDeputter Sep 30, 2026
d5d1faf
trigger tests
pabloDeputter Sep 30, 2026
064eab6
trigger tests
pabloDeputter Sep 30, 2026
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
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
17 changes: 13 additions & 4 deletions sentry_sdk/integrations/boto3/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
from sentry_sdk.integrations.boto3._context import AwsCallContext
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.integrations.boto3.consts import IDENTIFIER
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
from sentry_sdk.utils import capture_internal_exceptions

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,14 @@ 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
with capture_internal_exceptions():
_set_span_attributes(span, _get_response_attributes(parsed))
except BaseException as error:
_finish_span(span, error)
raise
Expand Down
94 changes: 91 additions & 3 deletions sentry_sdk/integrations/boto3/_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@
)

if TYPE_CHECKING:
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Mapping, Optional, Union

from sentry_sdk._types import Attributes
from sentry_sdk.integrations.boto3._context import AwsCallContext

try:
from botocore.awsrequest import AWSRequest
from botocore.exceptions import ClientError
Comment thread
sentry-warden[bot] marked this conversation as resolved.
from botocore.response import StreamingBody
except ImportError:
raise DidNotEnable("botocore not installed")
Expand All @@ -40,6 +41,11 @@
def _set_span_attributes(
span: "Union[Span, StreamedSpan]", attributes: "Attributes"
) -> None:
"""
Will be removed in the next major version (3.0). This helper makes
it easier to migrate to `StreamedSpan` without having to remove
multiple conditional blocks intertwined with other logic.
"""
if isinstance(span, StreamedSpan):
span.set_attributes(attributes)
return
Expand Down Expand Up @@ -86,6 +92,82 @@ def _get_client_attributes(
return attributes


def _get_response_attributes(response: "Mapping[str, Any]") -> "Attributes":
metadata = response.get("ResponseMetadata", {})
attributes: "Attributes" = {}

# botocore injects HTTP status into `ResponseMetadata` after parsing.
# https://github.com/boto/botocore/blob/358f8eec8c76201bb1a7a35644abcbc9036de7ed/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", 0)
if retry_attempts > 0:
attributes[SPANDATA.HTTP_REQUEST_RESEND_COUNT] = retry_attempts

headers = metadata.get("HTTPHeaders", {})

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_code: "Optional[str]" = exception.response.get("Error", {}).get("Code")
if 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 f"{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]]":
Expand Down Expand Up @@ -200,6 +282,12 @@ def finish_span(error: "Optional[BaseException]" = None) -> None:

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)
_set_span_attributes(span, attributes)

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

Expand Down Expand Up @@ -336,7 +424,7 @@ def _sentry_request_created(
"""

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 @@ -364,7 +452,7 @@ def _sentry_before_sign(
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