Skip to content

Commit d55060f

Browse files
committed
feat(boto3): add response, retry, and error span attributes
1 parent 0d61ef8 commit d55060f

4 files changed

Lines changed: 477 additions & 2 deletions

File tree

‎sentry_sdk/consts.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,18 @@ class SPANDATA:
390390
Example: ["Token limit exceeded"]
391391
"""
392392

393+
AWS_EXTENDED_REQUEST_ID = "aws.extended_request_id"
394+
"""
395+
The AWS extended request ID as returned in the response headers.
396+
Example: "wzHcyEWfmOGDIE5QOhTAqFDoDWP3y8IUvpNINCwL9N4TEHbUw0/gZJ+VZTmCNCWR7fezEN3eCiQ="
397+
"""
398+
399+
AWS_REQUEST_ID = "aws.request_id"
400+
"""
401+
The AWS request ID as returned in the response headers.
402+
Example: "79b9da39-b7ae-508a-a6bc-864b2829c622"
403+
"""
404+
393405
CACHE_HIT = "cache.hit"
394406
"""
395407
A boolean indicating whether the requested data was found in the cache.
@@ -547,6 +559,12 @@ class SPANDATA:
547559
Example: my_user
548560
"""
549561

562+
ERROR_TYPE = "error.type"
563+
"""
564+
Describes a class of error the operation ended with.
565+
Example: "timeout"
566+
"""
567+
550568
GEN_AI_AGENT_NAME = "gen_ai.agent.name"
551569
"""
552570
The name of the agent being used.
@@ -886,6 +904,12 @@ class SPANDATA:
886904
Example: GET
887905
"""
888906

907+
HTTP_REQUEST_RESEND_COUNT = "http.request.resend_count"
908+
"""
909+
The ordinal number of request resending attempt (for any reason, including redirects).
910+
Example: 2
911+
"""
912+
889913
HTTP_ROUTE = "http.route"
890914
"""
891915
The matched route, that is, the path template used to match the request.

‎sentry_sdk/integrations/boto3/_client.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
from sentry_sdk.integrations.boto3._context import AwsCallContext
88
from sentry_sdk.integrations.boto3._instrumentation import (
99
_finish_span,
10+
_get_error_attributes,
11+
_get_response_attributes,
1012
_instrument_streaming_body,
1113
_sentry_before_sign,
1214
_sentry_request_created,
15+
_set_span_attributes,
1316
_start_client_span,
1417
)
1518
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
@@ -87,7 +90,15 @@ def sentry_patched_make_api_call(
8790

8891
try:
8992
with span_ctx:
90-
parsed = orig_make_api_call(self, operation_name, api_params)
93+
try:
94+
parsed = orig_make_api_call(self, operation_name, api_params)
95+
except BaseException as error:
96+
with capture_internal_exceptions():
97+
_set_span_attributes(span, _get_error_attributes(error))
98+
raise
99+
else:
100+
with capture_internal_exceptions():
101+
_set_span_attributes(span, _get_response_attributes(parsed))
91102
except BaseException as error:
92103
# finish `StreamedSpan` explicitly; static spans are finished by
93104
# their context manager.

‎sentry_sdk/integrations/boto3/_instrumentation.py‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
try:
2929
from botocore.awsrequest import AWSRequest
30+
from botocore.exceptions import ClientError
3031
from botocore.response import StreamingBody
3132
except ImportError:
3233
raise DidNotEnable("botocore not installed")
@@ -38,6 +39,7 @@
3839
def _set_span_attributes(
3940
span: "Union[Span, StreamedSpan]", attributes: "Attributes"
4041
) -> None:
42+
"""Will be removed in the major."""
4143
if isinstance(span, StreamedSpan):
4244
span.set_attributes(attributes)
4345
return
@@ -89,6 +91,99 @@ def _get_client_attributes(
8991
return attributes
9092

9193

94+
def _get_response_attributes(response: "Any") -> "Attributes":
95+
if not isinstance(response, dict):
96+
return {}
97+
98+
metadata = response.get("ResponseMetadata")
99+
if not isinstance(metadata, dict):
100+
return {}
101+
attributes: "Attributes" = {}
102+
103+
# botocore injects HTTP status into `ResponseMetadata` after parsing.
104+
# https://github.com/boto/botocore/blob/develop/botocore/parsers.py#L273-L284
105+
status_code = metadata.get("HTTPStatusCode")
106+
if isinstance(status_code, int) and 100 <= status_code <= 599:
107+
attributes[SPANDATA.HTTP_STATUS_CODE] = status_code
108+
109+
retry_attempts = metadata.get("RetryAttempts")
110+
# botocore represents retries as `attempts - 1`; OTel suggests "if and only if", so skip zero.
111+
# https://github.com/boto/botocore/blob/develop/botocore/endpoint.py#L221-L229
112+
# https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-span
113+
if (
114+
isinstance(retry_attempts, int)
115+
# avoid emitting `resend_count=True`.
116+
and not isinstance(retry_attempts, bool)
117+
and retry_attempts > 0
118+
):
119+
attributes[SPANDATA.HTTP_REQUEST_RESEND_COUNT] = retry_attempts
120+
121+
headers = metadata.get("HTTPHeaders")
122+
if not isinstance(headers, dict):
123+
headers = {}
124+
125+
request_id = next(
126+
(
127+
value
128+
for value in (
129+
metadata.get("RequestId"),
130+
headers.get("x-amzn-requestid"),
131+
headers.get("x-amzn-request-id"),
132+
headers.get("x-amz-request-id"),
133+
)
134+
if isinstance(value, str) and value
135+
),
136+
None,
137+
)
138+
if request_id is not None:
139+
attributes[SPANDATA.AWS_REQUEST_ID] = request_id
140+
141+
# S3's `HostId` is the extended request ID returned in `x-amz-id-2`.
142+
# https://docs.aws.amazon.com/AmazonS3/latest/developerguide/get-request-ids.html
143+
extended_request_id = next(
144+
(
145+
value
146+
for value in (metadata.get("HostId"), headers.get("x-amz-id-2"))
147+
if isinstance(value, str) and value
148+
),
149+
None,
150+
)
151+
if extended_request_id is not None:
152+
attributes[SPANDATA.AWS_EXTENDED_REQUEST_ID] = extended_request_id
153+
154+
return attributes
155+
156+
157+
def _get_error_type(exception: "BaseException") -> str:
158+
if isinstance(exception, ClientError):
159+
# `ClientError` wraps AWS service errors; `Error.Code` identifies the
160+
# actual service error, e.g. `AccessDeniedException`.
161+
# https://docs.aws.amazon.com/boto3/latest/guide/error-handling.html
162+
error = exception.response.get("Error")
163+
if isinstance(error, dict):
164+
error_code = error.get("Code")
165+
if isinstance(error_code, str) and error_code:
166+
return error_code
167+
168+
# failures before a service response have no AWS error code.
169+
# https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/
170+
exception_type = type(exception)
171+
exception_name = exception_type.__qualname__
172+
exception_module = exception_type.__module__
173+
if exception_module not in ("builtins", "__builtins__"):
174+
return "%s.%s" % (exception_module, exception_name)
175+
return exception_name
176+
177+
178+
def _get_error_attributes(exception: "BaseException") -> "Attributes":
179+
attributes: "Attributes" = {}
180+
if isinstance(exception, ClientError):
181+
attributes.update(_get_response_attributes(exception.response))
182+
183+
attributes[SPANDATA.ERROR_TYPE] = _get_error_type(exception)
184+
return attributes
185+
186+
92187
def _start_client_span(
93188
ctx: "AwsCallContext",
94189
) -> "Optional[Union[Span, StreamedSpan]]":
@@ -200,6 +295,13 @@ def finish(error: "Optional[BaseException]" = None) -> None:
200295
return
201296

202297
finished = True
298+
if error is not None:
299+
with capture_internal_exceptions():
300+
attributes = _get_error_attributes(error)
301+
_set_span_attributes(streaming_span, attributes)
302+
if isinstance(span, StreamedSpan):
303+
_set_span_attributes(span, attributes)
304+
203305
_finish_span(streaming_span, error)
204306
if isinstance(span, StreamedSpan):
205307
_finish_span(span, error)

0 commit comments

Comments
 (0)