Skip to content

Commit 9837c88

Browse files
fix(boto3): Trace the complete botocore client-call lifecycle (#7538)
### Description Move boto3 span creation from individual HTTP request attempts to full botocore client-call lifecycle. Previously, the client span was created from the botocore `request-created` event; since botocore creates a new `AwsRequest` event for every retry, these spans represent individual retries rather than the full event. Following OTel ([https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span](<https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span>)) the span should cover the entire call lifecycle including all retries. Wrapping `_make_api_call()` covers all retries performed by botocore, including serialization, endpoint resolution, the final response or failures ([https://github.com/boto/botocore/blob/develop/botocore/client.py:999](<https://github.com/boto/botocore/blob/develop/botocore/client.py:999>)). * patch `BaseClient._make_api_call()` so one span represents a single boto operation; this span is also kept active across every retry attempt. * `request-created` is kept for breadcrumbs, HTTP request attributes, and trace propagation. * client spans that fail will preserve the original exception. * responses that return a `StreamingBody` are kept open until body is consumed or closed. * fix the correct parent/child relationship between boto, HTTP, and streaming spans.<br><br> #### Issues Resolves #7474
1 parent 500f10e commit 9837c88

7 files changed

Lines changed: 751 additions & 194 deletions

File tree

‎sentry_sdk/consts.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,18 @@ class SPANDATA:
11721172
Used in inbound filters.
11731173
"""
11741174

1175+
SENTRY_OP = "sentry.op"
1176+
"""
1177+
The operation of a span.
1178+
Example: "http.client"
1179+
"""
1180+
1181+
SENTRY_ORIGIN = "sentry.origin"
1182+
"""
1183+
The origin of the instrumentation (e.g. span, log, etc.)
1184+
Example: "auto.http.otel.fastify"
1185+
"""
1186+
11751187
SENTRY_RELEASE = "sentry.release"
11761188
"""
11771189
The Sentry release.
Lines changed: 103 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,127 @@
1-
from functools import partial
1+
from contextlib import contextmanager
22
from typing import TYPE_CHECKING
33

4+
import sentry_sdk
45
from sentry_sdk.integrations import DidNotEnable
6+
from sentry_sdk.integrations.boto3._context import AwsCallContext
57
from sentry_sdk.integrations.boto3._instrumentation import (
6-
_sentry_after_call,
7-
_sentry_after_call_error,
8+
_finish_span,
9+
_instrument_streaming_body,
810
_sentry_before_sign,
911
_sentry_request_created,
12+
_start_client_span,
1013
)
14+
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
15+
from sentry_sdk.utils import capture_internal_exceptions
1116

1217
if TYPE_CHECKING:
13-
from typing import Any
18+
from typing import Any, Iterator, Optional, Union
19+
20+
from sentry_sdk.tracing import Span
1421

1522
try:
1623
from botocore.client import BaseClient
1724
except ImportError:
18-
raise DidNotEnable("botocore is not installed")
25+
raise DidNotEnable("botocore not installed")
26+
27+
28+
@contextmanager
29+
def _activate_client_span(
30+
span: "Union[Span, StreamedSpan]",
31+
) -> "Iterator[Union[Span, StreamedSpan]]":
32+
"""
33+
Activate the boto span temporarily during `_make_api_call()` without ending it.
34+
35+
Botocore returns a `StreamingBody` before its bytes are consumed. Using the
36+
context manager would finish it as soon as `_make_api_call()` returns, so
37+
restore the caller's span here and let the `StreamingBody` wrapper finish
38+
the boto span when body is consumed/closed.
39+
40+
faulty: desired:
41+
boto3 [_make_api_call] boto3 [_make_api_call------]
42+
http [request] http [request]
43+
stream [read] stream [read]
44+
"""
45+
if isinstance(span, NoOpStreamedSpan):
46+
yield span
47+
return
48+
49+
scope = sentry_sdk.get_current_scope()
50+
if not isinstance(span, StreamedSpan):
51+
previous_span = scope.span
52+
scope.span = span
53+
try:
54+
yield span
55+
finally:
56+
scope.span = previous_span
57+
return
58+
59+
previous_streamed_span = scope.streamed_span
60+
scope.streamed_span = span
61+
try:
62+
yield span
63+
finally:
64+
scope.streamed_span = previous_streamed_span
1965

2066

2167
def _patch_botocore_client() -> None:
68+
from sentry_sdk.integrations.boto3 import Boto3Integration
69+
2270
orig_init = BaseClient.__init__
71+
orig_make_api_call = BaseClient._make_api_call # type: ignore
2372

2473
def sentry_patched_init(self: "BaseClient", *args: "Any", **kwargs: "Any") -> None:
2574
orig_init(self, *args, **kwargs)
2675
meta = self.meta
27-
service_id = meta.service_model.service_id
28-
meta.events.register(
29-
"request-created",
30-
partial(_sentry_request_created, service_id=service_id),
31-
)
32-
# run after other `before-sign` handlers, allowing it to see and preserve existing baggage.
76+
meta.events.register("request-created", _sentry_request_created)
77+
# run after other `before-sign` handlers so existing baggage is preserved.
3378
meta.events.register_last("before-sign", _sentry_before_sign)
34-
meta.events.register("after-call", _sentry_after_call)
35-
meta.events.register("after-call-error", _sentry_after_call_error)
79+
80+
def sentry_patched_make_api_call(
81+
self: "BaseClient", operation_name: str, api_params: "Any"
82+
) -> "Any":
83+
"""
84+
Track a single API call, including retries, serialization, and endpoint
85+
resolution. For streaming responses, keep the span open until the
86+
response body is consumed or closed.
87+
https://github.com/boto/botocore/blob/358f8eec8c76201bb1a7a35644abcbc9036de7ed/botocore/client.py
88+
https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span
89+
"""
90+
client = sentry_sdk.get_client()
91+
if client.get_integration(Boto3Integration) is None:
92+
return orig_make_api_call(self, operation_name, api_params)
93+
94+
ctx = AwsCallContext(operation_name)
95+
96+
# add optional metadata to context.
97+
with capture_internal_exceptions():
98+
ctx.add_metadata(self)
99+
100+
span: "Optional[Union[Span, StreamedSpan]]" = None
101+
with capture_internal_exceptions():
102+
span = _start_client_span(ctx)
103+
104+
if span is None:
105+
return orig_make_api_call(self, operation_name, api_params)
106+
107+
# activate without finishing; a streaming response may outlive the call.
108+
span_ctx = _activate_client_span(span)
109+
110+
try:
111+
with span_ctx:
112+
parsed = orig_make_api_call(self, operation_name, api_params)
113+
except BaseException as error:
114+
_finish_span(span, error)
115+
raise
116+
117+
streaming_body_instrumented = False
118+
with capture_internal_exceptions():
119+
streaming_body_instrumented = _instrument_streaming_body(span, parsed)
120+
121+
# `StreamingBody`s finish their span when consumed or closed.
122+
if not streaming_body_instrumented:
123+
_finish_span(span)
124+
return parsed
36125

37126
BaseClient.__init__ = sentry_patched_init # type: ignore
127+
BaseClient._make_api_call = sentry_patched_make_api_call # type: ignore
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from typing import TYPE_CHECKING
2+
3+
from sentry_sdk.integrations import DidNotEnable
4+
from sentry_sdk.utils import capture_internal_exceptions
5+
6+
if TYPE_CHECKING:
7+
from typing import Any, Optional
8+
9+
try:
10+
from botocore.client import BaseClient
11+
except ImportError:
12+
raise DidNotEnable("botocore not installed")
13+
14+
15+
class AwsCallContext:
16+
__slots__ = (
17+
"service_id",
18+
"service_id_hyphenized",
19+
"operation_name",
20+
)
21+
22+
def __init__(self, operation_name: str) -> None:
23+
self.operation_name: str = operation_name
24+
self.service_id: "Optional[str]" = None
25+
self.service_id_hyphenized: "Optional[str]" = None
26+
27+
def add_metadata(self, client: "BaseClient") -> None:
28+
def _get_attr(obj: "Any", name: str) -> "Any":
29+
if obj is None:
30+
return None
31+
32+
with capture_internal_exceptions():
33+
return getattr(obj, name)
34+
35+
client_meta = _get_attr(client, "meta")
36+
service_model = _get_attr(client_meta, "service_model")
37+
38+
# modeled AWS service identity used in span names, e.g. `API Gateway`.
39+
service_id = _get_attr(service_model, "service_id")
40+
if service_id is not None:
41+
with capture_internal_exceptions():
42+
self.service_id = str(service_id)
43+
with capture_internal_exceptions():
44+
self.service_id_hyphenized = service_id.hyphenize()

0 commit comments

Comments
 (0)