Skip to content

Commit ae57d8b

Browse files
committed
wip
1 parent d5767c4 commit ae57d8b

8 files changed

Lines changed: 180 additions & 216 deletions

File tree

‎sentry_sdk/integrations/boto3/_client.py‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ def sentry_patched_make_api_call(
9696
ctx.add_metadata(self)
9797
service_ext = _resolve_service(ctx.service_name)
9898
span = _start_client_span(ctx, service_ext)
99-
if service_ext is not None:
100-
with capture_internal_exceptions():
101-
api_params = service_ext.get_request_params(ctx, span) or api_params
99+
with capture_internal_exceptions():
100+
# override the request params for e.g. trace propagation.
101+
api_params = service_ext.get_request_params(ctx, span) or api_params
102102

103103
if span is None:
104104
return orig_make_api_call(self, operation_name, api_params)
@@ -110,7 +110,7 @@ def sentry_patched_make_api_call(
110110
try:
111111
parsed = orig_make_api_call(self, operation_name, api_params)
112112
except BaseException as error:
113-
if service_ext is not None and isinstance(error, ClientError):
113+
if isinstance(error, ClientError):
114114
with capture_internal_exceptions():
115115
attributes.update(
116116
service_ext.get_response_attributes(ctx, error.response)
@@ -120,11 +120,8 @@ def sentry_patched_make_api_call(
120120
with capture_internal_exceptions():
121121
span.set_attributes(attributes)
122122
raise
123-
if service_ext is not None:
124-
with capture_internal_exceptions():
125-
attributes.update(
126-
service_ext.get_response_attributes(ctx, parsed)
127-
)
123+
with capture_internal_exceptions():
124+
attributes.update(service_ext.get_response_attributes(ctx, parsed))
128125
with capture_internal_exceptions():
129126
attributes.update(_get_response_attributes(parsed))
130127
with capture_internal_exceptions():
@@ -139,7 +136,7 @@ def sentry_patched_make_api_call(
139136

140137
streaming_body_instrumented = False
141138
with capture_internal_exceptions():
142-
if service_ext is None or service_ext.has_streaming_response(ctx):
139+
if service_ext.has_streaming_response(ctx):
143140
streaming_body_instrumented = _instrument_streaming_body(span, parsed)
144141

145142
# `StreamingBody`s finish their span when consumed or closed.

‎sentry_sdk/integrations/boto3/_instrumentation.py‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def _get_error_attributes(exception: "BaseException") -> "Attributes":
148148

149149
def _start_client_span(
150150
ctx: "AwsCallContext",
151-
service_ext: "Optional[_ServiceExtension]" = None,
151+
service_ext: "_ServiceExtension",
152152
) -> "Optional[Span]":
153153
if sentry_sdk.get_current_span() is None:
154154
return None
@@ -171,14 +171,13 @@ def _start_client_span(
171171
# with `get_span_op()`.
172172
span_op = OP.RPC
173173

174-
if service_ext is not None:
175-
with capture_internal_exceptions():
176-
service_op = service_ext.get_span_op(ctx)
177-
if service_op is not None:
178-
span_op = service_op
174+
with capture_internal_exceptions():
175+
service_op = service_ext.get_span_op(ctx)
176+
if service_op is not None:
177+
span_op = service_op
179178

180-
with capture_internal_exceptions():
181-
attributes.update(service_ext.get_request_attributes(ctx))
179+
with capture_internal_exceptions():
180+
attributes.update(service_ext.get_request_attributes(ctx))
182181

183182
# `start_span()` evaluates `ignore_spans` against the initial attributes.
184183
# https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ class _ServiceExtension:
1212
"""
1313
Optional hooks for adding service-specific behavior to AWS client
1414
span; non-overridden methods keep the generic instrumentation.
15-
Services without a registered extension in `_SERVICE_EXTENSIONS` continue
16-
to use the generic instrumentation.
15+
Services without a registered extension in `_SERVICE_EXTENSIONS` use this
16+
class as is, i.e. the generic instrumentation.
1717
"""
1818

1919
__slots__ = ()
@@ -26,22 +26,24 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes":
2626
"""Return request attributes to add before the AWS request is made."""
2727
return {}
2828

29+
def get_response_attributes(
30+
self, ctx: "AwsCallContext", response: "Any"
31+
) -> "Attributes":
32+
"""Return response attributes to add after the AWS request is made."""
33+
return {}
34+
2935
def get_request_params(
3036
self, ctx: "AwsCallContext", span: "Optional[Span]"
3137
) -> "Optional[Dict[str, Any]]":
3238
"""
3339
Return params to send instead of `ctx.params`, e.g. with trace propagation added,
34-
or `None` to send them unchanged. Runs for every call, also when no span was
35-
started (`span` is `None`), and must not mutate `ctx.params`.
40+
or `None` to send them unchanged; must not mutate `ctx.params`.
3641
"""
3742
return None
3843

39-
def get_response_attributes(
40-
self, ctx: "AwsCallContext", response: "Any"
41-
) -> "Attributes":
42-
"""Return response attributes to add after the AWS request is made."""
43-
return {}
44-
4544
def has_streaming_response(self, ctx: "AwsCallContext") -> bool:
46-
"""Return whether a `StreamingBody` response is read over time, so the span ends when it's read or closed, or `False` to end it with the call."""
45+
"""
46+
Return whether a `StreamingBody` response is read over time, so the span ends
47+
when it's read or closed, or `False` to end it with the call.
48+
"""
4749
return True

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

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,61 +5,72 @@
55
from sentry_sdk.consts import SPANDATA
66
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
77
from sentry_sdk.integrations.boto3._utils import (
8-
_extract_attributes,
98
_get_aws_arn_attributes,
109
_get_propagation_headers,
1110
)
1211
from sentry_sdk.integrations.boto3.consts import CLOUD_PROVIDER
1312
from sentry_sdk.utils import capture_internal_exceptions
1413

1514
if TYPE_CHECKING:
16-
from typing import Any, Dict, Optional, Sequence
15+
from typing import Any, Dict, Optional
1716

1817
from sentry_sdk._types import Attributes
1918
from sentry_sdk.integrations.boto3._context import AwsCallContext
20-
from sentry_sdk.integrations.boto3._utils import _AttributeSpec
2119
from sentry_sdk.traces import Span
2220

23-
# set on both request and response of the event source mapping apis.
24-
_RESOURCE_MAPPING_ATTRIBUTES: "Sequence[_AttributeSpec]" = (
25-
("UUID", SPANDATA.AWS_LAMBDA_RESOURCE_MAPPING_ID),
26-
)
27-
2821

2922
class _LambdaExtension(_ServiceExtension):
3023
def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes":
31-
attributes: "Attributes" = _extract_attributes(
32-
ctx.params, _RESOURCE_MAPPING_ATTRIBUTES
33-
)
24+
attributes: "Attributes" = {}
25+
26+
# event source mapping apis.
27+
if "UUID" in ctx.params:
28+
attributes[SPANDATA.AWS_LAMBDA_RESOURCE_MAPPING_ID] = ctx.params["UUID"]
3429
if "FunctionName" not in ctx.params:
3530
return attributes
3631

37-
# a name, a partial arn (`<account>:function:<name>`) or an arn, each optionally
38-
# followed by `:<version or alias>`.
32+
# a name, a partial arn (`<account>:function:<name>`) or an
33+
# arn (`arn:<partition>:lambda:<region>:<account>:function:<name>`)
34+
# each optionally followed by `:<version or alias>`.
3935
function_name = ctx.params["FunctionName"]
4036
name = function_name.split(":function:")[-1].split(":", 1)[0]
41-
invoke = ctx.operation_name in (
37+
38+
is_invoke = ctx.operation_name in (
4239
"Invoke",
4340
"InvokeAsync",
4441
"InvokeWithResponseStream",
4542
)
46-
if invoke:
43+
if is_invoke:
4744
attributes[SPANDATA.FAAS_INVOKED_NAME] = name
4845
attributes[SPANDATA.FAAS_INVOKED_PROVIDER] = CLOUD_PROVIDER
4946
if ctx.region_name is not None:
5047
attributes[SPANDATA.FAAS_INVOKED_REGION] = ctx.region_name
5148
else:
5249
attributes[SPANDATA.AWS_LAMBDA_TARGET_FUNCTION_NAME] = name
5350

54-
# params aren't validated yet by botocore.
5551
with capture_internal_exceptions():
5652
arn_attributes = _get_aws_arn_attributes((function_name,), ("lambda",))
5753
attributes.update(arn_attributes)
58-
if invoke and arn_attributes:
54+
if is_invoke and arn_attributes:
5955
attributes[SPANDATA.FAAS_INVOKED_REGION] = function_name.split(":")[3]
6056

6157
return attributes
6258

59+
def get_response_attributes(
60+
self, ctx: "AwsCallContext", response: "Any"
61+
) -> "Attributes":
62+
attributes: "Attributes" = {}
63+
64+
if "UUID" in response:
65+
attributes[SPANDATA.AWS_LAMBDA_RESOURCE_MAPPING_ID] = response["UUID"]
66+
67+
# `Invoke` returns `200 OK` when the function fails; reading it would leave
68+
# the caller with an empty body.
69+
if "FunctionError" in response:
70+
attributes[SPANDATA.ERROR_TYPE] = "lambda_function_error"
71+
72+
return attributes
73+
6374
def get_request_params(
6475
self, ctx: "AwsCallContext", span: "Optional[Span]"
6576
) -> "Optional[Dict[str, Any]]":
@@ -72,6 +83,8 @@ def get_request_params(
7283
return None
7384

7485
headers = _get_propagation_headers(ctx, span)
86+
87+
# https://docs.aws.amazon.com/lambda/latest/dg/python-context.html
7588
client_context = (
7689
json.loads(base64.b64decode(ctx.params["ClientContext"]))
7790
if "ClientContext" in ctx.params
@@ -86,24 +99,15 @@ def get_request_params(
8699
encoded = base64.b64encode(
87100
json.dumps(client_context, separators=(",", ":")).encode()
88101
).decode()
89-
# it takes at most 3,583 bytes of base64.
102+
103+
# lambda rejects more than 3,583 bytes of base64; skip propagation rather
104+
# than fail the call.
90105
if len(encoded) > 3583:
91106
return None
107+
92108
return {**ctx.params, "ClientContext": encoded}
93109

94110
def has_streaming_response(self, ctx: "AwsCallContext") -> bool:
95-
# `Invoke` returns once the function finished; its `Payload` is the complete
96-
# result, not a stream. ending with the call keeps the span of a caller that
97-
# never reads it.
111+
# `Invoke`'s `Payload` is a `StreamingBody`, but span
112+
# shouldn't be kept open until the caller reads it.
98113
return ctx.operation_name != "Invoke"
99-
100-
def get_response_attributes(
101-
self, ctx: "AwsCallContext", response: "Any"
102-
) -> "Attributes":
103-
attributes = _extract_attributes(response, _RESOURCE_MAPPING_ATTRIBUTES)
104-
# `Invoke` returns `200 OK` when the function fails, with `FunctionError` set. the
105-
# payload holds the error and is never read.
106-
# https://docs.aws.amazon.com/lambda/latest/api/API_Invoke.html#lambda-Invoke-response-FunctionError
107-
if "FunctionError" in response:
108-
attributes[SPANDATA.ERROR_TYPE] = "lambda_function_error"
109-
return attributes

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import TYPE_CHECKING
22

3+
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
34
from sentry_sdk.integrations.boto3._services.dynamodb import _DynamoDBExtension
45
from sentry_sdk.integrations.boto3._services.kinesis import _KinesisExtension
56
from sentry_sdk.integrations.boto3._services.lambda_ import _LambdaExtension
@@ -16,14 +17,12 @@
1617
if TYPE_CHECKING:
1718
from typing import Dict, Optional
1819

19-
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
20-
2120

2221
# add a ServiceExtension here when one is implemented. for example:
2322
# _SERVICE_EXTENSIONS = {"s3": _S3Extension()}
2423
# when py 3.15 drops, we might want to take a look at using
2524
# a lazy-loading approach using the new `lazy` keyword.
26-
_SERVICE_EXTENSIONS: "Dict[str, _ServiceExtension]" = {
25+
_SERVICE_EXTENSIONS: "Dict[Optional[str], _ServiceExtension]" = {
2726
"dynamodb": _DynamoDBExtension(),
2827
"kinesis": _KinesisExtension(),
2928
"lambda": _LambdaExtension(),
@@ -37,8 +36,6 @@
3736

3837
def _resolve_service(
3938
service_name: "Optional[str]",
40-
) -> "Optional[_ServiceExtension]":
41-
"""Return the extension for a service, or `None` for generic instrumentation."""
42-
if service_name is None:
43-
return None
44-
return _SERVICE_EXTENSIONS.get(service_name)
39+
) -> "_ServiceExtension":
40+
"""Return the extension for a service, or `_ServiceExtension()` for generic instrumentation."""
41+
return _SERVICE_EXTENSIONS.get(service_name, _ServiceExtension())

‎sentry_sdk/integrations/boto3/_utils.py‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,16 @@ def _get_aws_arn_attributes(
4848
def _get_propagation_headers(
4949
ctx: "AwsCallContext", span: "Optional[Span]"
5050
) -> "Dict[str, str]":
51-
"""
52-
Return the `sentry-trace` and `baggage` (and `traceparent`, if enabled) a call
53-
propagates to its consumer, from its span or else the scope, like the HTTP headers.
54-
Empty when `trace_propagation_targets` excludes the endpoint.
55-
"""
51+
"""Return the `sentry-trace` and `baggage` headers a call propagates to its consumer."""
5652
if ctx.endpoint_url is None or not should_propagate_trace(
5753
sentry_sdk.get_client(), ctx.endpoint_url
5854
):
5955
return {}
56+
6057
# `NoOpSpan` is never sent, use scope's span instead.
61-
# which should be the parent normally.
6258
if isinstance(span, NoOpSpan):
6359
span = None
60+
6461
return dict(
6562
sentry_sdk.get_current_scope().iter_trace_propagation_headers(span=span)
6663
)

‎tests/integrations/boto3/test_client.py‎

Lines changed: 9 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -177,39 +177,11 @@ def record_client_span(request, **kwargs):
177177
assert span.get("end_timestamp") is not None
178178

179179

180-
@pytest.mark.parametrize(
181-
"service_name,method_name,api_params,payload_field",
182-
[
183-
pytest.param(
184-
"polly",
185-
"synthesize_speech",
186-
{"Text": "hi", "OutputFormat": "mp3", "VoiceId": "Joanna"},
187-
"AudioStream",
188-
id="audio-stream",
189-
),
190-
pytest.param(
191-
"s3",
192-
"get_object_annotation",
193-
{
194-
"Bucket": "bucket",
195-
"Key": "file.txt",
196-
"AnnotationName": "annotation",
197-
},
198-
"AnnotationPayload",
199-
id="annotation-payload",
200-
),
201-
],
202-
)
203-
def test_non_body_stream_delays_client_span(
204-
capture_items,
205-
client_factory,
206-
service_name,
207-
method_name,
208-
api_params,
209-
payload_field,
210-
):
211-
client = client_factory(service_name)
212-
require_botocore_model_fields(client, method_name, output_fields=(payload_field,))
180+
def test_non_body_stream_delays_client_span(capture_items, client_factory):
181+
client = client_factory("s3")
182+
require_botocore_model_fields(
183+
client, "get_object_annotation", output_fields=("AnnotationPayload",)
184+
)
213185
request_client_spans = []
214186

215187
def record_client_span(request, **kwargs):
@@ -220,8 +192,10 @@ def record_client_span(request, **kwargs):
220192
def invoke():
221193
parent = sentry_sdk.get_current_span()
222194
with MockResponse(client, 200, {"content-length": "1"}, b"x"):
223-
response = getattr(client, method_name)(**api_params)
224-
body = response[payload_field]
195+
response = client.get_object_annotation(
196+
Bucket="bucket", Key="file.txt", AnnotationName="annotation"
197+
)
198+
body = response["AnnotationPayload"]
225199
assert isinstance(body, StreamingBody)
226200
(request_client_span,) = request_client_spans
227201
assert request_client_span.end_timestamp is None

0 commit comments

Comments
 (0)