Skip to content

Commit ea505d1

Browse files
committed
feat(boto3): add ServiceExtension class
1 parent d55060f commit ea505d1

6 files changed

Lines changed: 230 additions & 9 deletions

File tree

‎sentry_sdk/integrations/boto3/_client.py‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,22 @@
1515
_set_span_attributes,
1616
_start_client_span,
1717
)
18+
from sentry_sdk.integrations.boto3._services.registry import (
19+
_resolve_service,
20+
)
1821
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
1922
from sentry_sdk.utils import capture_internal_exceptions
2023

2124
if TYPE_CHECKING:
2225
from typing import Any, Iterator, Optional, Union
2326

27+
from sentry_sdk._types import Attributes
28+
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
2429
from sentry_sdk.tracing import Span
2530

2631
try:
2732
from botocore.client import BaseClient
33+
from botocore.exceptions import ClientError
2834
except ImportError:
2935
raise DidNotEnable("botocore not installed")
3036

@@ -76,9 +82,14 @@ def sentry_patched_make_api_call(
7682
with capture_internal_exceptions():
7783
ctx.add_metadata(self)
7884

85+
service_ext: "Optional[_ServiceExtension]" = None
86+
with capture_internal_exceptions():
87+
# resolve service extension for service-specific enrichment.
88+
service_ext = _resolve_service(ctx.service_name)
89+
7990
span: "Optional[Union[Span, StreamedSpan]]" = None
8091
with capture_internal_exceptions():
81-
span = _start_client_span(ctx)
92+
span = _start_client_span(ctx, service_ext)
8293

8394
if span is None:
8495
return orig_make_api_call(self, operation_name, api_params)
@@ -88,17 +99,33 @@ def sentry_patched_make_api_call(
8899
_activate_client_span(span) if isinstance(span, StreamedSpan) else span
89100
)
90101

102+
attributes: "Attributes" = {}
91103
try:
92104
with span_ctx:
93105
try:
94106
parsed = orig_make_api_call(self, operation_name, api_params)
95107
except BaseException as error:
108+
if service_ext is not None and isinstance(error, ClientError):
109+
with capture_internal_exceptions():
110+
attributes.update(
111+
service_ext.get_response_attributes(ctx, error.response)
112+
)
113+
# generic attributes outweigh service-specific attributes.
96114
with capture_internal_exceptions():
97-
_set_span_attributes(span, _get_error_attributes(error))
115+
attributes.update(_get_error_attributes(error))
98116
raise
99117
else:
118+
if service_ext is not None:
119+
with capture_internal_exceptions():
120+
attributes.update(
121+
service_ext.get_response_attributes(ctx, parsed)
122+
)
123+
with capture_internal_exceptions():
124+
attributes.update(_get_response_attributes(parsed))
125+
finally:
126+
# enrich before the static span's context manager finishes it.
100127
with capture_internal_exceptions():
101-
_set_span_attributes(span, _get_response_attributes(parsed))
128+
_set_span_attributes(span, attributes)
102129
except BaseException as error:
103130
# finish `StreamedSpan` explicitly; static spans are finished by
104131
# their context manager.

‎sentry_sdk/integrations/boto3/_instrumentation.py‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from sentry_sdk._types import Attributes
2626
from sentry_sdk.integrations.boto3._context import AwsCallContext
27+
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
2728

2829
try:
2930
from botocore.awsrequest import AWSRequest
@@ -186,6 +187,7 @@ def _get_error_attributes(exception: "BaseException") -> "Attributes":
186187

187188
def _start_client_span(
188189
ctx: "AwsCallContext",
190+
service_ext: "Optional[_ServiceExtension]" = None,
189191
) -> "Optional[Union[Span, StreamedSpan]]":
190192
client = sentry_sdk.get_client()
191193
if client.get_integration(Boto3Integration) is None:
@@ -195,15 +197,33 @@ def _start_client_span(
195197
# e.g. "aws.unkown.GetObject"
196198
service_name = ctx.service_id_hyphenized or "unknown"
197199
span_name = "aws.%s.%s" % (service_name, ctx.operation_name)
198-
attributes: "Attributes" = {
199-
SPANDATA.RPC_METHOD: ctx.operation_name,
200-
SPANDATA.RPC_SYSTEM_NAME: _AWS_RPC_SYSTEM_NAME,
201-
}
202-
with capture_internal_exceptions():
203-
attributes.update(_get_client_attributes(ctx))
200+
attributes: "Attributes" = {}
204201
span_op = OP.HTTP_CLIENT
205202
span_origin = Boto3Integration.origin
206203

204+
if service_ext is not None:
205+
with capture_internal_exceptions():
206+
config = service_ext.get_span_config(ctx)
207+
if config is not None:
208+
service_op, service_origin = config
209+
if isinstance(service_op, str) and service_op:
210+
span_op = service_op
211+
if isinstance(service_origin, str) and service_origin:
212+
span_origin = service_origin
213+
214+
with capture_internal_exceptions():
215+
attributes.update(service_ext.get_request_attributes(ctx))
216+
217+
# Generic attributes take precedence over service-specific attributes.
218+
attributes.update(
219+
{
220+
SPANDATA.RPC_METHOD: ctx.operation_name,
221+
SPANDATA.RPC_SYSTEM_NAME: _AWS_RPC_SYSTEM_NAME,
222+
}
223+
)
224+
with capture_internal_exceptions():
225+
attributes.update(_get_client_attributes(ctx))
226+
207227
if has_span_streaming_enabled(client.options):
208228
if sentry_sdk.traces.get_current_span() is None:
209229
return None

‎sentry_sdk/integrations/boto3/_services/__init__.py‎

Whitespace-only changes.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from typing import TYPE_CHECKING
2+
3+
if TYPE_CHECKING:
4+
from typing import Any, Optional, Tuple
5+
6+
from sentry_sdk._types import Attributes
7+
from sentry_sdk.integrations.boto3._context import AwsCallContext
8+
9+
10+
class _ServiceExtension:
11+
"""
12+
Specialize generic botocore instrumentation for an AWS service.
13+
"""
14+
15+
__slots__ = ()
16+
17+
def get_span_config(
18+
self, ctx: "AwsCallContext"
19+
) -> "Optional[Tuple[Optional[str], Optional[str]]]":
20+
"""Return an optional `(op, origin)` override for the client span."""
21+
return None
22+
23+
def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes":
24+
"""Return service-specific attributes available before the call."""
25+
return {}
26+
27+
def get_response_attributes(
28+
self, ctx: "AwsCallContext", response: "Any"
29+
) -> "Attributes":
30+
"""Return service-specific attributes derived from the response."""
31+
return {}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from functools import lru_cache
2+
from importlib import import_module
3+
from typing import TYPE_CHECKING
4+
5+
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
6+
from sentry_sdk.utils import capture_internal_exceptions
7+
8+
if TYPE_CHECKING:
9+
from typing import Dict, Optional, Tuple
10+
11+
12+
# service modules are imported lazily.
13+
# e.g. `s3` -> (`sentry_sdk.integrations.boto3._services.s3`, `_S3Extension)
14+
_SERVICE_EXTENSIONS: "Dict[str, Tuple[str, str]]" = {}
15+
16+
17+
@lru_cache(maxsize=None)
18+
def _resolve_service(
19+
service: "str",
20+
) -> "Optional[_ServiceExtension]":
21+
target = _SERVICE_EXTENSIONS.get(service)
22+
if target is None:
23+
return None
24+
25+
with capture_internal_exceptions():
26+
module_name, class_name = target
27+
extension_class = getattr(import_module(module_name), class_name)
28+
candidate = extension_class()
29+
if isinstance(candidate, _ServiceExtension):
30+
return candidate
31+
32+
# preserve generic instrumentation when lookup fails.
33+
return None

‎tests/integrations/boto3/test_client.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
_get_error_attributes,
1717
_get_response_attributes,
1818
)
19+
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
1920
from sentry_sdk.integrations.stdlib import StdlibIntegration
2021
from tests.integrations.boto3.aws_mock import Body
2122

@@ -301,6 +302,115 @@ def _span_attributes(span, span_streaming):
301302
return span["attributes"] if span_streaming else span["data"]
302303

303304

305+
@pytest.mark.parametrize("span_streaming", [True, False])
306+
def test_service_extension_customizes_client_span(
307+
capture_items,
308+
client_factory,
309+
monkeypatch,
310+
span_streaming,
311+
):
312+
class TestServiceExtension(_ServiceExtension):
313+
def get_span_config(self, ctx):
314+
return ("aws.test", None)
315+
316+
def get_request_attributes(self, ctx):
317+
return {
318+
"aws.test.request": ctx.params["Key"],
319+
SPANDATA.RPC_METHOD: "must-not-override",
320+
}
321+
322+
def get_response_attributes(self, ctx, response):
323+
return {
324+
"aws.test.response": response["ResponseMetadata"]["RequestId"],
325+
SPANDATA.HTTP_STATUS_CODE: 418,
326+
}
327+
328+
extension = TestServiceExtension()
329+
monkeypatch.setattr(
330+
"sentry_sdk.integrations.boto3._client._resolve_service",
331+
lambda service_name: extension,
332+
)
333+
client = client_factory()
334+
api_params = {"Bucket": "bucket", "Key": "foo"}
335+
336+
with Stubber(client) as stubber:
337+
stubber.add_response(
338+
"head_object",
339+
{
340+
"ResponseMetadata": {
341+
"HTTPStatusCode": 200,
342+
"RequestId": "request-id",
343+
}
344+
},
345+
api_params,
346+
)
347+
spans_by_op = _capture_boto3_spans_by_op(
348+
lambda: client.head_object(**api_params),
349+
capture_items,
350+
span_streaming,
351+
)
352+
353+
spans = spans_by_op.get("aws.test", [])
354+
assert len(spans) == 1
355+
attributes = _span_attributes(spans[0], span_streaming)
356+
assert attributes["aws.test.request"] == "foo"
357+
assert attributes["aws.test.response"] == "request-id"
358+
assert attributes[SPANDATA.RPC_METHOD] == "HeadObject"
359+
assert attributes[SPANDATA.HTTP_STATUS_CODE] == 200
360+
361+
362+
@pytest.mark.parametrize("span_streaming", [True, False])
363+
def test_service_extension_enriches_client_error(
364+
capture_items,
365+
client_factory,
366+
monkeypatch,
367+
span_streaming,
368+
):
369+
class TestServiceExtension(_ServiceExtension):
370+
def get_response_attributes(self, ctx, response):
371+
return {
372+
"aws.test.error": response["Error"]["Code"],
373+
SPANDATA.ERROR_TYPE: "must-not-override",
374+
SPANDATA.HTTP_STATUS_CODE: 418,
375+
}
376+
377+
monkeypatch.setattr(
378+
"sentry_sdk.integrations.boto3._client._resolve_service",
379+
lambda service_name: TestServiceExtension(),
380+
)
381+
client = client_factory()
382+
error = ClientError(
383+
{
384+
"Error": {"Code": "AccessDeniedException"},
385+
"ResponseMetadata": {"HTTPStatusCode": 403},
386+
},
387+
"HeadObject",
388+
)
389+
390+
def raise_client_error(**kwargs):
391+
raise error
392+
393+
client.meta.events.register("before-parameter-build", raise_client_error)
394+
395+
def invoke_failing_client_method():
396+
with pytest.raises(ClientError) as exc_info:
397+
client.head_object(Bucket="bucket", Key="foo")
398+
assert exc_info.value is error
399+
400+
spans_by_op = _capture_boto3_spans_by_op(
401+
invoke_failing_client_method,
402+
capture_items,
403+
span_streaming,
404+
)
405+
spans = spans_by_op.get(OP.HTTP_CLIENT, [])
406+
407+
_assert_one_failed_span(spans, span_streaming)
408+
attributes = _span_attributes(spans[0], span_streaming)
409+
assert attributes["aws.test.error"] == "AccessDeniedException"
410+
assert attributes[SPANDATA.ERROR_TYPE] == "AccessDeniedException"
411+
assert attributes[SPANDATA.HTTP_STATUS_CODE] == 403
412+
413+
304414
@pytest.mark.parametrize(
305415
("response", "expected"),
306416
[

0 commit comments

Comments
 (0)