Skip to content

Commit 6ea0078

Browse files
committed
feat(boto3): add common OTel AWS client attributes
1 parent 4efce4b commit 6ea0078

6 files changed

Lines changed: 281 additions & 14 deletions

File tree

‎sentry_sdk/consts.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,12 @@ class SPANDATA:
414414
Example: "10.1.2.80"
415415
"""
416416

417+
CLOUD_REGION = "cloud.region"
418+
"""
419+
The geographical region the resource is running.
420+
Example: "us-east-1"
421+
"""
422+
417423
CODE_FILEPATH = "code.filepath"
418424
"""
419425
.. deprecated::
@@ -977,12 +983,24 @@ class SPANDATA:
977983
Example: "com.example.ExampleService/exampleMethod"
978984
"""
979985

986+
RPC_SERVICE = "rpc.service"
987+
"""
988+
The full (logical) name of the service being called, including its package name, if applicable.
989+
Example: "myService.BestService"
990+
"""
991+
980992
RPC_RESPONSE_STATUS_CODE = "rpc.response.status_code"
981993
"""
982994
Status code of the RPC returned by the RPC server or generated by the client.
983995
Example: "DEADLINE_EXCEEDED"
984996
"""
985997

998+
RPC_SYSTEM_NAME = "rpc.system.name"
999+
"""
1000+
A string identifying the remoting system.
1001+
Example: "aws-api"
1002+
"""
1003+
9861004
SERVER_ADDRESS = "server.address"
9871005
"""
9881006
Name of the database host.

‎sentry_sdk/integrations/boto3/_client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def sentry_patched_make_api_call(
6767
if client.get_integration(Boto3Integration) is None:
6868
return orig_make_api_call(self, operation_name, api_params)
6969

70-
ctx = AwsCallContext(operation_name)
70+
ctx = AwsCallContext(operation_name, api_params)
7171

7272
# add optional metadata to context.
7373
with capture_internal_exceptions():

‎sentry_sdk/integrations/boto3/_context.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from sentry_sdk.utils import capture_internal_exceptions
55

66
if TYPE_CHECKING:
7-
from typing import Any, Optional
7+
from typing import Any, Dict, Optional
88

99
try:
1010
from botocore.client import BaseClient
@@ -14,15 +14,31 @@
1414

1515
class AwsCallContext:
1616
__slots__ = (
17+
"service_name",
1718
"service_id",
1819
"service_id_hyphenized",
1920
"operation_name",
21+
"region_name",
22+
"endpoint_url",
23+
"params",
2024
)
2125

22-
def __init__(self, operation_name: str) -> None:
26+
def __init__(
27+
self,
28+
operation_name: str,
29+
params: "Any",
30+
) -> None:
2331
self.operation_name: str = operation_name
32+
self.params: "Dict[str, Any]" = {}
33+
self.service_name: "Optional[str]" = None
2434
self.service_id: "Optional[str]" = None
2535
self.service_id_hyphenized: "Optional[str]" = None
36+
self.region_name: "Optional[str]" = None
37+
self.endpoint_url: "Optional[str]" = None
38+
39+
if isinstance(params, dict):
40+
with capture_internal_exceptions():
41+
self.params = dict(params)
2642

2743
def add_metadata(self, client: "BaseClient") -> None:
2844
def _get_attr(obj: "Any", name: str) -> "Any":
@@ -35,10 +51,16 @@ def _get_attr(obj: "Any", name: str) -> "Any":
3551
client_meta = _get_attr(client, "meta")
3652
service_model = _get_attr(client_meta, "service_model")
3753

54+
# botocore's internal identifier, e.g. `apigateway`.
55+
self.service_name = _get_attr(service_model, "service_name")
56+
3857
# modeled AWS service identity used in span names, e.g. `API Gateway`.
3958
service_id = _get_attr(service_model, "service_id")
4059
if service_id is not None:
4160
with capture_internal_exceptions():
4261
self.service_id = str(service_id)
4362
with capture_internal_exceptions():
4463
self.service_id_hyphenized = service_id.hyphenize()
64+
65+
self.region_name = _get_attr(client_meta, "region_name")
66+
self.endpoint_url = _get_attr(client_meta, "endpoint_url")

‎sentry_sdk/integrations/boto3/_instrumentation.py‎

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import TYPE_CHECKING
2+
from urllib.parse import urlsplit
23

34
import sentry_sdk
45
from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS
@@ -31,6 +32,63 @@
3132
raise DidNotEnable("botocore not installed")
3233

3334

35+
_AWS_RPC_SYSTEM_NAME = "aws-api"
36+
37+
38+
def _set_span_attributes(
39+
span: "Union[Span, StreamedSpan]", attributes: "Attributes"
40+
) -> None:
41+
if isinstance(span, StreamedSpan):
42+
span.set_attributes(attributes)
43+
return
44+
45+
for key, value in attributes.items():
46+
span.set_data(key, value)
47+
48+
49+
def _get_server_attributes(endpoint_url: "Optional[str]") -> "Attributes":
50+
if not endpoint_url:
51+
return {}
52+
53+
default_ports = {
54+
"http": 80,
55+
"https": 443,
56+
}
57+
58+
try:
59+
parsed_url = urlsplit(endpoint_url)
60+
if parsed_url.scheme not in default_ports or not parsed_url.hostname:
61+
return {}
62+
63+
# `server.port` is only defined together with `server.address`.
64+
# Infer the effective port when the configured HTTP(S) endpoint omits it.
65+
# https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/
66+
return {
67+
SPANDATA.SERVER_ADDRESS: parsed_url.hostname,
68+
SPANDATA.SERVER_PORT: parsed_url.port or default_ports[parsed_url.scheme],
69+
}
70+
71+
except (TypeError, UnicodeError, ValueError):
72+
# Invalid client metadata must not prevent the AWS call from running.
73+
return {}
74+
75+
76+
def _get_client_attributes(
77+
ctx: "AwsCallContext",
78+
) -> "Attributes":
79+
attributes: "Attributes" = {}
80+
81+
# `rpc.service` is deprecated in OTel, but js still uses it.
82+
if ctx.service_id:
83+
attributes[SPANDATA.RPC_SERVICE] = ctx.service_id
84+
85+
if ctx.region_name:
86+
attributes[SPANDATA.CLOUD_REGION] = ctx.region_name
87+
88+
attributes.update(_get_server_attributes(ctx.endpoint_url))
89+
return attributes
90+
91+
3492
def _start_client_span(
3593
ctx: "AwsCallContext",
3694
) -> "Optional[Union[Span, StreamedSpan]]":
@@ -42,22 +100,27 @@ def _start_client_span(
42100
# e.g. "aws.unkown.GetObject"
43101
service_name = ctx.service_id_hyphenized or "unknown"
44102
span_name = "aws.%s.%s" % (service_name, ctx.operation_name)
103+
attributes: "Attributes" = {
104+
SPANDATA.RPC_METHOD: ctx.operation_name,
105+
SPANDATA.RPC_SYSTEM_NAME: _AWS_RPC_SYSTEM_NAME,
106+
}
107+
with capture_internal_exceptions():
108+
attributes.update(_get_client_attributes(ctx))
45109
span_op = OP.HTTP_CLIENT
46110
span_origin = Boto3Integration.origin
47111

48112
if has_span_streaming_enabled(client.options):
49113
if sentry_sdk.traces.get_current_span() is None:
50114
return None
51115

52-
attributes: "Attributes" = {
53-
SPANDATA.SENTRY_OP: span_op,
54-
SPANDATA.SENTRY_ORIGIN: span_origin,
55-
}
56-
if ctx.service_id:
57-
attributes[SPANDATA.RPC_METHOD] = "%s/%s" % (
58-
ctx.service_id,
59-
ctx.operation_name,
60-
)
116+
# `start_span()` evaluates `ignore_spans` against the initial attributes.
117+
# https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span
118+
attributes.update(
119+
{
120+
SPANDATA.SENTRY_OP: span_op,
121+
SPANDATA.SENTRY_ORIGIN: span_origin,
122+
}
123+
)
61124
return sentry_sdk.traces.start_span(
62125
name=span_name,
63126
attributes=attributes,
@@ -71,6 +134,8 @@ def _start_client_span(
71134
op=span_op,
72135
origin=span_origin,
73136
)
137+
with capture_internal_exceptions():
138+
_set_span_attributes(span, attributes)
74139
with capture_internal_exceptions():
75140
if ctx.service_id_hyphenized:
76141
span.set_tag("aws.service_id", ctx.service_id_hyphenized)

‎tests/integrations/boto3/test_client.py‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from botocore.config import Config
88
from botocore.exceptions import ClientError, EndpointConnectionError
99
from botocore.response import StreamingBody
10+
from botocore.stub import Stubber
1011

1112
import sentry_sdk
1213
from sentry_sdk.consts import OP, SPANDATA
@@ -268,6 +269,165 @@ def _assert_one_failed_span(spans, span_streaming):
268269
_assert_span_finished(spans[0], span_streaming)
269270

270271

272+
def _capture_stubbed_client_span(
273+
client,
274+
method_name,
275+
api_params,
276+
capture_items,
277+
span_streaming,
278+
):
279+
with Stubber(client) as stubber:
280+
stubber.add_response(method_name, {}, api_params)
281+
spans_by_op = _capture_boto3_spans_by_op(
282+
lambda: getattr(client, method_name)(**api_params),
283+
capture_items,
284+
span_streaming,
285+
)
286+
287+
client_spans = spans_by_op.get(OP.HTTP_CLIENT, [])
288+
assert len(client_spans) == 1
289+
return client_spans[0]
290+
291+
292+
def _span_attributes(span, span_streaming):
293+
return span["attributes"] if span_streaming else span["data"]
294+
295+
296+
@pytest.mark.parametrize(
297+
(
298+
"service_name",
299+
"method_name",
300+
"api_params",
301+
"span_name",
302+
"rpc_service",
303+
"rpc_method",
304+
"endpoint_url",
305+
"server_address",
306+
"server_port",
307+
),
308+
[
309+
(
310+
"s3",
311+
"head_object",
312+
{"Bucket": "bucket", "Key": "foo"},
313+
"aws.s3.HeadObject",
314+
"S3",
315+
"HeadObject",
316+
"http://localhost:4566",
317+
"localhost",
318+
4566,
319+
),
320+
(
321+
"events",
322+
"list_event_buses",
323+
{},
324+
"aws.eventbridge.ListEventBuses",
325+
"EventBridge",
326+
"ListEventBuses",
327+
None,
328+
"events.eu-north-1.amazonaws.com",
329+
443,
330+
),
331+
],
332+
)
333+
@pytest.mark.parametrize("span_streaming", [True, False])
334+
def test_client_call_has_common_attributes(
335+
capture_items,
336+
client_factory,
337+
span_streaming,
338+
service_name,
339+
method_name,
340+
api_params,
341+
span_name,
342+
rpc_service,
343+
rpc_method,
344+
endpoint_url,
345+
server_address,
346+
server_port,
347+
):
348+
client = client_factory(service_name=service_name, endpoint_url=endpoint_url)
349+
span = _capture_stubbed_client_span(
350+
client,
351+
method_name,
352+
api_params,
353+
capture_items,
354+
span_streaming,
355+
)
356+
attributes = _span_attributes(span, span_streaming)
357+
358+
assert span["name" if span_streaming else "description"] == span_name
359+
assert attributes[SPANDATA.RPC_SERVICE] == rpc_service
360+
assert attributes[SPANDATA.RPC_METHOD] == rpc_method
361+
assert attributes[SPANDATA.RPC_SYSTEM_NAME] == "aws-api"
362+
assert attributes[SPANDATA.CLOUD_REGION] == "eu-north-1"
363+
assert attributes[SPANDATA.SERVER_ADDRESS] == server_address
364+
assert attributes[SPANDATA.SERVER_PORT] == server_port
365+
366+
367+
def test_client_call_attributes_are_available_at_span_creation(
368+
sentry_init, capture_items
369+
):
370+
# attribute-based filtering happens during span creation, at the same boundary
371+
# where creation attributes are made available for sampling decisions.
372+
sentry_init(
373+
traces_sample_rate=1.0,
374+
integrations=[Boto3Integration()],
375+
trace_lifecycle="stream",
376+
ignore_spans=[
377+
{
378+
"attributes": {
379+
SPANDATA.RPC_METHOD: "HeadObject",
380+
SPANDATA.RPC_SERVICE: "S3",
381+
SPANDATA.RPC_SYSTEM_NAME: "aws-api",
382+
SPANDATA.SERVER_ADDRESS: "s3.eu-north-1.amazonaws.com",
383+
SPANDATA.SERVER_PORT: 443,
384+
}
385+
}
386+
],
387+
)
388+
client = session.client("s3")
389+
items = capture_items("span")
390+
391+
with Stubber(client) as stubber:
392+
stubber.add_response("head_object", {}, {"Bucket": "bucket", "Key": "foo"})
393+
with sentry_sdk.traces.start_span(name="parent"):
394+
client.head_object(Bucket="bucket", Key="foo")
395+
396+
sentry_sdk.flush()
397+
client_spans = [
398+
item.payload
399+
for item in items
400+
if item.payload["attributes"].get(SPANDATA.SENTRY_ORIGIN)
401+
== Boto3Integration.origin
402+
]
403+
assert client_spans == []
404+
405+
406+
def test_client_call_omits_missing_region(
407+
sentry_init,
408+
capture_items,
409+
monkeypatch,
410+
):
411+
sentry_init(
412+
traces_sample_rate=1.0,
413+
integrations=[Boto3Integration()],
414+
trace_lifecycle="stream",
415+
server_name="",
416+
)
417+
client = session.client("s3")
418+
monkeypatch.setattr(type(client.meta), "region_name", property(lambda _: None))
419+
420+
span = _capture_stubbed_client_span(
421+
client,
422+
"head_object",
423+
{"Bucket": "bucket", "Key": "foo"},
424+
capture_items,
425+
span_streaming=True,
426+
)
427+
428+
assert SPANDATA.CLOUD_REGION not in span["attributes"]
429+
430+
271431
@pytest.mark.parametrize("span_streaming", [True, False])
272432
def test_retry_attempts_share_one_client_span(
273433
capture_items,

0 commit comments

Comments
 (0)