Skip to content

Commit 4efce4b

Browse files
committed
fix(boto3): trace the complete client-call lifecycle
1 parent 2522edd commit 4efce4b

7 files changed

Lines changed: 628 additions & 181 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 & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,108 @@
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 import Boto3Integration
7+
from sentry_sdk.integrations.boto3._context import AwsCallContext
58
from sentry_sdk.integrations.boto3._instrumentation import (
6-
_sentry_after_call,
7-
_sentry_after_call_error,
9+
_finish_span,
10+
_instrument_streaming_body,
811
_sentry_before_sign,
912
_sentry_request_created,
13+
_start_client_span,
1014
)
11-
from sentry_sdk.utils import parse_version
15+
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
16+
from sentry_sdk.utils import capture_internal_exceptions
1217

1318
if TYPE_CHECKING:
14-
from typing import Any
19+
from typing import Any, Iterator, Optional, Union
20+
21+
from sentry_sdk.tracing import Span
1522

1623
try:
17-
from botocore import __version__ as BOTOCORE_VERSION
1824
from botocore.client import BaseClient
1925
except ImportError:
20-
raise DidNotEnable("botocore is not installed")
26+
raise DidNotEnable("botocore not installed")
2127

2228

23-
def _patch_botocore_client() -> None:
24-
from sentry_sdk.integrations.boto3 import Boto3Integration
29+
@contextmanager
30+
def _activate_client_span(span: "StreamedSpan") -> "Iterator[StreamedSpan]":
31+
"""Temporarily activate an inactive boto span without ending it."""
32+
if isinstance(span, NoOpStreamedSpan):
33+
yield span
34+
return
2535

26-
version = parse_version(BOTOCORE_VERSION)
27-
_check_minimum_version(Boto3Integration, version, "botocore")
36+
scope = sentry_sdk.get_current_scope()
37+
previous_span = scope.streamed_span
38+
scope.streamed_span = span
39+
try:
40+
yield span
41+
finally:
42+
scope.streamed_span = previous_span
2843

44+
45+
def _patch_botocore_client() -> None:
2946
orig_init = BaseClient.__init__
47+
orig_make_api_call = BaseClient._make_api_call # type: ignore
3048

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

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