Skip to content

Commit f0c9dcb

Browse files
botocore hooks
1 parent db636a4 commit f0c9dcb

File tree

3 files changed

+205
-43
lines changed

3 files changed

+205
-43
lines changed

Diff for: CHANGELOG.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
### Added
1313
- `opentelemetry-instrumentation-elasticsearch` Added `response_hook` and `request_hook` callbacks
1414
([#670](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/670))
15-
16-
### Added
1715
- `opentelemetry-instrumentation-redis` added request_hook and response_hook callbacks passed as arguments to the instrument method.
1816
([#669](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/669))
17+
- `opentelemetry-instrumentation-botocore` add request_hooks and response_hooks
18+
([679](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/679))
1919

2020
### Changed
2121
- `opentelemetry-instrumentation-botocore` Unpatch botocore Endpoint.prepare_request on uninstrument

Diff for: instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/__init__.py

+120-41
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,40 @@
4444
4545
API
4646
---
47+
48+
The `instrument` method accepts the following keyword args:
49+
50+
tracer_provider (TracerProvider) - an optional tracer provider
51+
request_hooks (dict) - a mapping between service names their respective callable request hooks
52+
* a request hook signature is: def request_hook(span: Span, operation_name: str, api_params: dict) -> None
53+
response_hooks (dict) - a mapping between service names their respective callable response hooks
54+
* a response hook signature is: def response_hook(span: Span, operation_name: str, result: dict) -> None
55+
56+
for example:
57+
58+
.. code: python
59+
60+
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
61+
import botocore
62+
63+
def ec2_request_hook(span, operation_name, api_params):
64+
# request hook logic
65+
66+
def ec2_response_hook(span, operation_name, result):
67+
# response hook logic
68+
69+
# Instrument Botocore with hooks
70+
BotocoreInstrumentor().instrument(
71+
request_hooks={"ec2": ec2_request_hook}, response_hooks={"ec2": ec2_response_hook}
72+
)
73+
74+
# This will create a span with Botocore-specific attributes, including custom attributes added from the hooks
75+
session = botocore.session.get_session()
76+
session.set_credentials(
77+
access_key="access-key", secret_key="secret-key"
78+
)
79+
ec2 = self.session.create_client("ec2", region_name="us-west-2")
80+
ec2.describe_instances()
4781
"""
4882

4983
import json
@@ -91,16 +125,29 @@ class BotocoreInstrumentor(BaseInstrumentor):
91125
See `BaseInstrumentor`
92126
"""
93127

128+
def __init__(self):
129+
super().__init__()
130+
self.request_hooks = dict()
131+
self.response_hooks = dict()
132+
94133
def instrumentation_dependencies(self) -> Collection[str]:
95134
return _instruments
96135

97136
def _instrument(self, **kwargs):
98-
99137
# pylint: disable=attribute-defined-outside-init
100138
self._tracer = get_tracer(
101139
__name__, __version__, kwargs.get("tracer_provider")
102140
)
103141

142+
request_hooks = kwargs.get("request_hooks")
143+
response_hooks = kwargs.get("response_hooks")
144+
145+
if isinstance(request_hooks, dict):
146+
self.request_hooks = request_hooks
147+
148+
if isinstance(response_hooks, dict):
149+
self.response_hooks = response_hooks
150+
104151
wrap_function_wrapper(
105152
"botocore.client",
106153
"BaseClient._make_api_call",
@@ -159,21 +206,18 @@ def _patched_api_call(self, original_func, instance, args, kwargs):
159206
):
160207
BotocoreInstrumentor._patch_lambda_invoke(api_params)
161208

162-
if span.is_recording():
163-
span.set_attribute("aws.operation", operation_name)
164-
span.set_attribute("aws.region", instance.meta.region_name)
165-
span.set_attribute("aws.service", service_name)
166-
if "QueueUrl" in api_params:
167-
span.set_attribute("aws.queue_url", api_params["QueueUrl"])
168-
if "TableName" in api_params:
169-
span.set_attribute(
170-
"aws.table_name", api_params["TableName"]
171-
)
209+
self._set_api_call_attributes(
210+
span, instance, service_name, operation_name, api_params
211+
)
172212

173213
token = context_api.attach(
174214
context_api.set_value(_SUPPRESS_HTTP_INSTRUMENTATION_KEY, True)
175215
)
176216

217+
self.apply_request_hook(
218+
span, service_name, operation_name, api_params
219+
)
220+
177221
try:
178222
result = original_func(*args, **kwargs)
179223
except ClientError as ex:
@@ -184,38 +228,73 @@ def _patched_api_call(self, original_func, instance, args, kwargs):
184228
if error:
185229
result = error.response
186230

187-
if span.is_recording():
188-
if "ResponseMetadata" in result:
189-
metadata = result["ResponseMetadata"]
190-
req_id = None
191-
if "RequestId" in metadata:
192-
req_id = metadata["RequestId"]
193-
elif "HTTPHeaders" in metadata:
194-
headers = metadata["HTTPHeaders"]
195-
if "x-amzn-RequestId" in headers:
196-
req_id = headers["x-amzn-RequestId"]
197-
elif "x-amz-request-id" in headers:
198-
req_id = headers["x-amz-request-id"]
199-
elif "x-amz-id-2" in headers:
200-
req_id = headers["x-amz-id-2"]
201-
202-
if req_id:
203-
span.set_attribute(
204-
"aws.request_id", req_id,
205-
)
206-
207-
if "RetryAttempts" in metadata:
208-
span.set_attribute(
209-
"retry_attempts", metadata["RetryAttempts"],
210-
)
211-
212-
if "HTTPStatusCode" in metadata:
213-
span.set_attribute(
214-
SpanAttributes.HTTP_STATUS_CODE,
215-
metadata["HTTPStatusCode"],
216-
)
231+
self.apply_response_hook(
232+
span, service_name, operation_name, result
233+
)
234+
235+
self._set_api_call_result_attributes(span, result)
217236

218237
if error:
219238
raise error
220239

221240
return result
241+
242+
@staticmethod
243+
def _set_api_call_attributes(
244+
span, instance, service_name, operation_name, api_params
245+
):
246+
if span.is_recording():
247+
span.set_attribute("aws.operation", operation_name)
248+
span.set_attribute("aws.region", instance.meta.region_name)
249+
span.set_attribute("aws.service", service_name)
250+
if "QueueUrl" in api_params:
251+
span.set_attribute("aws.queue_url", api_params["QueueUrl"])
252+
if "TableName" in api_params:
253+
span.set_attribute("aws.table_name", api_params["TableName"])
254+
255+
@staticmethod
256+
def _set_api_call_result_attributes(span, result):
257+
if span.is_recording():
258+
if "ResponseMetadata" in result:
259+
metadata = result["ResponseMetadata"]
260+
req_id = None
261+
if "RequestId" in metadata:
262+
req_id = metadata["RequestId"]
263+
elif "HTTPHeaders" in metadata:
264+
headers = metadata["HTTPHeaders"]
265+
if "x-amzn-RequestId" in headers:
266+
req_id = headers["x-amzn-RequestId"]
267+
elif "x-amz-request-id" in headers:
268+
req_id = headers["x-amz-request-id"]
269+
elif "x-amz-id-2" in headers:
270+
req_id = headers["x-amz-id-2"]
271+
272+
if req_id:
273+
span.set_attribute(
274+
"aws.request_id", req_id,
275+
)
276+
277+
if "RetryAttempts" in metadata:
278+
span.set_attribute(
279+
"retry_attempts", metadata["RetryAttempts"],
280+
)
281+
282+
if "HTTPStatusCode" in metadata:
283+
span.set_attribute(
284+
SpanAttributes.HTTP_STATUS_CODE,
285+
metadata["HTTPStatusCode"],
286+
)
287+
288+
def apply_request_hook(
289+
self, span, service_name, operation_name, api_params
290+
):
291+
if service_name in self.request_hooks:
292+
request_hook = self.request_hooks.get(service_name)
293+
if callable(request_hook):
294+
request_hook(span, operation_name, api_params)
295+
296+
def apply_response_hook(self, span, service_name, operation_name, result):
297+
if service_name in self.response_hooks:
298+
response_hook = self.response_hooks.get(service_name)
299+
if callable(response_hook):
300+
response_hook(span, operation_name, result)

Diff for: instrumentation/opentelemetry-instrumentation-botocore/tests/test_botocore_instrumentation.py

+83
Original file line numberDiff line numberDiff line change
@@ -629,3 +629,86 @@ def test_dynamodb_client(self):
629629
SpanAttributes.HTTP_STATUS_CODE: 200,
630630
},
631631
)
632+
633+
@mock_dynamodb2
634+
def test_hooks(self):
635+
request_hook_operation_attribute_name = "request_hook.operation_name"
636+
request_hook_api_params_attribute_name = "request_hook.api_params"
637+
response_hook_operation_attribute_name = "response_hook.operation_name"
638+
response_hook_result_attribute_name = "response_hook.result"
639+
640+
def request_hook(span, operation_name, api_params):
641+
hook_attributes = {
642+
request_hook_operation_attribute_name: operation_name,
643+
request_hook_api_params_attribute_name: json.dumps(api_params),
644+
}
645+
if span and span.is_recording():
646+
span.set_attributes(hook_attributes)
647+
648+
def response_hook(span, operation_name, result):
649+
if span and span.is_recording():
650+
span.set_attribute(
651+
response_hook_operation_attribute_name, operation_name,
652+
)
653+
span.set_attribute(
654+
response_hook_result_attribute_name, list(result.keys()),
655+
)
656+
657+
BotocoreInstrumentor().uninstrument()
658+
BotocoreInstrumentor().instrument(
659+
request_hooks={"dynamodb": request_hook},
660+
response_hooks={"dynamodb": response_hook},
661+
)
662+
663+
self.session = botocore.session.get_session()
664+
self.session.set_credentials(
665+
access_key="access-key", secret_key="secret-key"
666+
)
667+
668+
ddb = self.session.create_client("dynamodb", region_name="us-west-2")
669+
670+
test_table_name = "test_table_name"
671+
672+
ddb.create_table(
673+
AttributeDefinitions=[
674+
{"AttributeName": "id", "AttributeType": "S"},
675+
],
676+
KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
677+
ProvisionedThroughput={
678+
"ReadCapacityUnits": 5,
679+
"WriteCapacityUnits": 5,
680+
},
681+
TableName=test_table_name,
682+
)
683+
684+
item = {"id": {"S": "test_key"}}
685+
686+
ddb.put_item(TableName=test_table_name, Item=item)
687+
688+
spans = self.memory_exporter.get_finished_spans()
689+
assert spans
690+
self.assertEqual(len(spans), 2)
691+
get_item_attributes = spans[1].attributes
692+
693+
expected_api_params = json.dumps(
694+
{"TableName": test_table_name, "Item": item}
695+
)
696+
697+
expected_result_keys = ("ConsumedCapacity", "ResponseMetadata")
698+
699+
self.assertEqual(
700+
"PutItem",
701+
get_item_attributes.get(request_hook_operation_attribute_name),
702+
)
703+
self.assertEqual(
704+
expected_api_params,
705+
get_item_attributes.get(request_hook_api_params_attribute_name),
706+
)
707+
self.assertEqual(
708+
"PutItem",
709+
get_item_attributes.get(response_hook_operation_attribute_name),
710+
)
711+
self.assertEqual(
712+
expected_result_keys,
713+
get_item_attributes.get(response_hook_result_attribute_name),
714+
)

0 commit comments

Comments
 (0)