Skip to content

Commit 055a50c

Browse files
mariojonkeocelotlowais
authored
botocore: Add Lambda extension (#760)
* botocore: Add Lambda extension * add extension to add additional attributes for lambda invoke calls * move lambda specific tests to separate module * changelog Co-authored-by: Diego Hurtado <[email protected]> Co-authored-by: Owais Lone <[email protected]>
1 parent 10d8e26 commit 055a50c

File tree

6 files changed

+315
-123
lines changed

6 files changed

+315
-123
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7474
([#735](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/735))
7575
- `opentelemetry-sdk-extension-aws` & `opentelemetry-propagator-aws` Remove unnecessary dependencies on `opentelemetry-test`
7676
([#752](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/752))
77+
- `opentelemetry-instrumentation-botocore` Add Lambda extension
78+
([#760](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/760))
7779

7880
## [1.6.0-0.25b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.6.0-0.25b0) - 2021-10-13
7981
### Added

instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/__init__.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ def response_hook(span, service_name, operation_name, result):
7878
ec2.describe_instances()
7979
"""
8080

81-
import json
8281
import logging
8382
from typing import Any, Callable, Collection, Dict, Optional, Tuple
8483

@@ -161,27 +160,6 @@ def _uninstrument(self, **kwargs):
161160
unwrap(BaseClient, "_make_api_call")
162161
unwrap(Endpoint, "prepare_request")
163162

164-
@staticmethod
165-
def _is_lambda_invoke(call_context: _AwsSdkCallContext):
166-
return (
167-
call_context.service == "lambda"
168-
and call_context.operation == "Invoke"
169-
and isinstance(call_context.params, dict)
170-
and "Payload" in call_context.params
171-
)
172-
173-
@staticmethod
174-
def _patch_lambda_invoke(api_params):
175-
try:
176-
payload_str = api_params["Payload"]
177-
payload = json.loads(payload_str)
178-
headers = payload.get("headers", {})
179-
inject(headers)
180-
payload["headers"] = headers
181-
api_params["Payload"] = json.dumps(payload)
182-
except ValueError:
183-
pass
184-
185163
# pylint: disable=too-many-branches
186164
def _patched_api_call(self, original_func, instance, args, kwargs):
187165
if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
@@ -210,10 +188,6 @@ def _patched_api_call(self, original_func, instance, args, kwargs):
210188
kind=call_context.span_kind,
211189
attributes=attributes,
212190
) as span:
213-
# inject trace context into payload headers for lambda Invoke
214-
if BotocoreInstrumentor._is_lambda_invoke(call_context):
215-
BotocoreInstrumentor._patch_lambda_invoke(call_context.params)
216-
217191
_safe_invoke(extension.before_service_call, span)
218192
self._call_request_hook(span, call_context)
219193

instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/extensions/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def loader():
3333

3434
_KNOWN_EXTENSIONS = {
3535
"dynamodb": _lazy_load(".dynamodb", "_DynamoDbExtension"),
36+
"lambda": _lazy_load(".lmbd", "_LambdaExtension"),
3637
"sqs": _lazy_load(".sqs", "_SqsExtension"),
3738
}
3839

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import abc
16+
import inspect
17+
import json
18+
import re
19+
from typing import Dict
20+
21+
from opentelemetry.instrumentation.botocore.extensions.types import (
22+
_AttributeMapT,
23+
_AwsSdkCallContext,
24+
_AwsSdkExtension,
25+
)
26+
from opentelemetry.propagate import inject
27+
from opentelemetry.semconv.trace import SpanAttributes
28+
from opentelemetry.trace.span import Span
29+
30+
31+
class _LambdaOperation(abc.ABC):
32+
@classmethod
33+
@abc.abstractmethod
34+
def operation_name(cls):
35+
pass
36+
37+
@classmethod
38+
def prepare_attributes(
39+
cls, call_context: _AwsSdkCallContext, attributes: _AttributeMapT
40+
):
41+
pass
42+
43+
@classmethod
44+
def before_service_call(cls, call_context: _AwsSdkCallContext, span: Span):
45+
pass
46+
47+
48+
class _OpInvoke(_LambdaOperation):
49+
# https://docs.aws.amazon.com/lambda/latest/dg/API_Invoke.html#API_Invoke_RequestParameters
50+
ARN_LAMBDA_PATTERN = re.compile(
51+
"(?:arn:(?:aws[a-zA-Z-]*)?:lambda:)?"
52+
"(?:[a-z]{2}(?:-gov)?-[a-z]+-\\d{1}:)?(?:\\d{12}:)?"
53+
"(?:function:)?([a-zA-Z0-9-_\\.]+)(?::(?:\\$LATEST|[a-zA-Z0-9-_]+))?"
54+
)
55+
56+
@classmethod
57+
def operation_name(cls):
58+
return "Invoke"
59+
60+
@classmethod
61+
def extract_attributes(
62+
cls, call_context: _AwsSdkCallContext, attributes: _AttributeMapT
63+
):
64+
attributes[SpanAttributes.FAAS_INVOKED_PROVIDER] = "aws"
65+
attributes[
66+
SpanAttributes.FAAS_INVOKED_NAME
67+
] = cls._parse_function_name(call_context)
68+
attributes[SpanAttributes.FAAS_INVOKED_REGION] = call_context.region
69+
70+
@classmethod
71+
def _parse_function_name(cls, call_context: _AwsSdkCallContext):
72+
function_name_or_arn = call_context.params.get("FunctionName")
73+
matches = cls.ARN_LAMBDA_PATTERN.match(function_name_or_arn)
74+
function_name = matches.group(1)
75+
return function_name_or_arn if function_name is None else function_name
76+
77+
@classmethod
78+
def before_service_call(cls, call_context: _AwsSdkCallContext, span: Span):
79+
cls._inject_current_span(call_context)
80+
81+
@classmethod
82+
def _inject_current_span(cls, call_context: _AwsSdkCallContext):
83+
payload_str = call_context.params.get("Payload")
84+
if payload_str is None:
85+
return
86+
87+
# TODO: reconsider propagation via payload as it manipulates input of the called lambda function
88+
try:
89+
payload = json.loads(payload_str)
90+
headers = payload.get("headers", {})
91+
inject(headers)
92+
payload["headers"] = headers
93+
call_context.params["Payload"] = json.dumps(payload)
94+
except ValueError:
95+
pass
96+
97+
98+
################################################################################
99+
# Lambda extension
100+
################################################################################
101+
102+
_OPERATION_MAPPING = {
103+
op.operation_name(): op
104+
for op in globals().values()
105+
if inspect.isclass(op)
106+
and issubclass(op, _LambdaOperation)
107+
and not inspect.isabstract(op)
108+
} # type: Dict[str, _LambdaOperation]
109+
110+
111+
class _LambdaExtension(_AwsSdkExtension):
112+
def __init__(self, call_context: _AwsSdkCallContext):
113+
super().__init__(call_context)
114+
self._op = _OPERATION_MAPPING.get(call_context.operation)
115+
116+
def extract_attributes(self, attributes: _AttributeMapT):
117+
if self._op is None:
118+
return
119+
120+
self._op.extract_attributes(self._call_context, attributes)
121+
122+
def before_service_call(self, span: Span):
123+
if self._op is None:
124+
return
125+
126+
self._op.before_service_call(self._call_context, span)

instrumentation/opentelemetry-instrumentation-botocore/tests/test_botocore_instrumentation.py

Lines changed: 0 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,20 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
import io
1514
import json
16-
import sys
17-
import zipfile
1815
from unittest.mock import Mock, patch
1916

2017
import botocore.session
2118
from botocore.exceptions import ParamValidationError
2219
from moto import ( # pylint: disable=import-error
2320
mock_ec2,
24-
mock_iam,
2521
mock_kinesis,
2622
mock_kms,
27-
mock_lambda,
2823
mock_s3,
2924
mock_sqs,
3025
mock_sts,
3126
mock_xray,
3227
)
33-
from pytest import mark
3428

3529
from opentelemetry import trace as trace_api
3630
from opentelemetry.context import attach, detach, set_value
@@ -44,24 +38,6 @@
4438
_REQUEST_ID_REGEX_MATCH = r"[A-Z0-9]{52}"
4539

4640

47-
def get_as_zip_file(file_name, content):
48-
zip_output = io.BytesIO()
49-
with zipfile.ZipFile(zip_output, "w", zipfile.ZIP_DEFLATED) as zip_file:
50-
zip_file.writestr(file_name, content)
51-
zip_output.seek(0)
52-
return zip_output.read()
53-
54-
55-
def return_headers_lambda_str():
56-
pfunc = """
57-
def lambda_handler(event, context):
58-
print("custom log event")
59-
headers = event.get('headers', event.get('attributes', {}))
60-
return headers
61-
"""
62-
return pfunc
63-
64-
6541
# pylint:disable=too-many-public-methods
6642
class TestBotocoreInstrumentor(TestBase):
6743
"""Botocore integration testsuite"""
@@ -278,79 +254,6 @@ def test_double_patch(self):
278254
"SQS", "ListQueues", request_id=_REQUEST_ID_REGEX_MATCH
279255
)
280256

281-
@mock_lambda
282-
def test_lambda_client(self):
283-
lamb = self._make_client("lambda")
284-
285-
lamb.list_functions()
286-
self.assert_span("Lambda", "ListFunctions")
287-
288-
@mock_iam
289-
def get_role_name(self):
290-
iam = self._make_client("iam")
291-
return iam.create_role(
292-
RoleName="my-role",
293-
AssumeRolePolicyDocument="some policy",
294-
Path="/my-path/",
295-
)["Role"]["Arn"]
296-
297-
@mark.skipif(
298-
sys.platform == "win32",
299-
reason="requires docker and Github CI Windows does not have docker installed by default",
300-
)
301-
@mock_lambda
302-
def test_lambda_invoke_propagation(self):
303-
304-
previous_propagator = get_global_textmap()
305-
try:
306-
set_global_textmap(MockTextMapPropagator())
307-
308-
lamb = self._make_client("lambda")
309-
lamb.create_function(
310-
FunctionName="testFunction",
311-
Runtime="python3.8",
312-
Role=self.get_role_name(),
313-
Handler="lambda_function.lambda_handler",
314-
Code={
315-
"ZipFile": get_as_zip_file(
316-
"lambda_function.py", return_headers_lambda_str()
317-
)
318-
},
319-
Description="test lambda function",
320-
Timeout=3,
321-
MemorySize=128,
322-
Publish=True,
323-
)
324-
# 2 spans for create IAM + create lambda
325-
self.assertEqual(2, len(self.memory_exporter.get_finished_spans()))
326-
self.memory_exporter.clear()
327-
328-
response = lamb.invoke(
329-
Payload=json.dumps({}),
330-
FunctionName="testFunction",
331-
InvocationType="RequestResponse",
332-
)
333-
334-
span = self.assert_span(
335-
"Lambda", "Invoke", request_id=_REQUEST_ID_REGEX_MATCH
336-
)
337-
span_context = span.get_span_context()
338-
339-
# assert injected span
340-
results = response["Payload"].read().decode("utf-8")
341-
headers = json.loads(results)
342-
343-
self.assertEqual(
344-
str(span_context.trace_id),
345-
headers[MockTextMapPropagator.TRACE_ID_KEY],
346-
)
347-
self.assertEqual(
348-
str(span_context.span_id),
349-
headers[MockTextMapPropagator.SPAN_ID_KEY],
350-
)
351-
finally:
352-
set_global_textmap(previous_propagator)
353-
354257
@mock_kms
355258
def test_kms_client(self):
356259
kms = self._make_client("kms")

0 commit comments

Comments
 (0)