Skip to content

Commit add0913

Browse files
committed
fix(boto3): trace the complete client-call lifecycle
1 parent 66d4665 commit add0913

7 files changed

Lines changed: 567 additions & 122 deletions

File tree

‎sentry_sdk/consts.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,18 @@ class SPANDATA:
11641164
Example: "prod"
11651165
"""
11661166

1167+
SENTRY_OP = "sentry.op"
1168+
"""
1169+
The operation of a span.
1170+
Example: "http.client"
1171+
"""
1172+
1173+
SENTRY_ORIGIN = "sentry.origin"
1174+
"""
1175+
The origin of the instrumentation (e.g. span, log, etc.)
1176+
Example: "auto.http.otel.fastify"
1177+
"""
1178+
11671179
SENTRY_RELEASE = "sentry.release"
11681180
"""
11691181
The Sentry release.
Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,109 @@
1-
from functools import partial
1+
from contextlib import contextmanager
22
from typing import TYPE_CHECKING
33

4-
from sentry_sdk.integrations import DidNotEnable, _check_minimum_version
4+
import sentry_sdk
5+
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
)
11-
from sentry_sdk.utils import parse_version
14+
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
15+
from sentry_sdk.utils import capture_internal_exceptions
1216

1317
if TYPE_CHECKING:
14-
from typing import Any
18+
from typing import Any, Iterator, Optional, Union
19+
20+
from sentry_sdk.tracing import Span
1521

1622
try:
17-
from botocore import __version__ as BOTOCORE_VERSION
1823
from botocore.client import BaseClient
1924
except ImportError:
20-
raise DidNotEnable("botocore is not installed")
25+
raise DidNotEnable("botocore not installed")
26+
27+
28+
@contextmanager
29+
def _activate_client_span(span: "StreamedSpan") -> "Iterator[StreamedSpan]":
30+
"""Temporarily activate an inactive boto span without ending it."""
31+
if isinstance(span, NoOpStreamedSpan):
32+
yield span
33+
return
34+
35+
scope = sentry_sdk.get_current_scope()
36+
previous_span = scope.streamed_span
37+
scope.streamed_span = span
38+
try:
39+
yield span
40+
finally:
41+
scope.streamed_span = previous_span
2142

2243

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

26-
version = parse_version(BOTOCORE_VERSION)
27-
_check_minimum_version(Boto3Integration, version, "botocore")
28-
2947
orig_init = BaseClient.__init__
48+
orig_make_api_call = BaseClient._make_api_call # type: ignore
3049

3150
def sentry_patched_init(self: "BaseClient", *args: "Any", **kwargs: "Any") -> None:
3251
orig_init(self, *args, **kwargs)
3352
meta = self.meta
34-
service_id = meta.service_model.service_id
35-
meta.events.register(
36-
"request-created",
37-
partial(_sentry_request_created, service_id=service_id),
38-
)
39-
# run after other `before-sign` handlers, allowing it to see and preserve existing baggage.
53+
meta.events.register("request-created", _sentry_request_created)
54+
# run after other `before-sign` handlers so existing baggage is preserved.
4055
meta.events.register_last("before-sign", _sentry_before_sign)
41-
meta.events.register("after-call", _sentry_after_call)
42-
meta.events.register("after-call-error", _sentry_after_call_error)
56+
57+
def sentry_patched_make_api_call(
58+
self: "BaseClient", operation_name: str, api_params: "Any"
59+
) -> "Any":
60+
"""
61+
Track a single API call, including retries, serialization, and endpoint
62+
resolution. For streaming responses, keep the span open until the
63+
response body is consumed or closed.
64+
https://github.com/boto/botocore/blob/develop/botocore/client.py
65+
https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span
66+
"""
67+
client = sentry_sdk.get_client()
68+
if client.get_integration(Boto3Integration) is None:
69+
return orig_make_api_call(self, operation_name, api_params)
70+
71+
ctx = AwsCallContext(operation_name)
72+
73+
# add optional metadata to context.
74+
with capture_internal_exceptions():
75+
ctx.add_metadata(self)
76+
77+
span: "Optional[Union[Span, StreamedSpan]]" = None
78+
with capture_internal_exceptions():
79+
span = _start_client_span(ctx)
80+
81+
if span is None:
82+
return orig_make_api_call(self, operation_name, api_params)
83+
84+
# activate without finishing; a streaming response may outlive the call.
85+
span_ctx = (
86+
_activate_client_span(span) if isinstance(span, StreamedSpan) else span
87+
)
88+
89+
try:
90+
with span_ctx:
91+
parsed = orig_make_api_call(self, operation_name, api_params)
92+
except BaseException as error:
93+
# finish `StreamedSpan` explicitly; static spans are finished by
94+
# their context manager.
95+
if isinstance(span, StreamedSpan):
96+
_finish_span(span, error)
97+
raise
98+
99+
streaming_body_instrumented = False
100+
with capture_internal_exceptions():
101+
streaming_body_instrumented = _instrument_streaming_body(span, parsed)
102+
103+
# `StreamingBody`s finish their span when consumed or closed.
104+
if isinstance(span, StreamedSpan) and not streaming_body_instrumented:
105+
_finish_span(span)
106+
return parsed
43107

44108
BaseClient.__init__ = sentry_patched_init # type: ignore
109+
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)