Skip to content

Commit da03b2f

Browse files
committed
Support other propagators in Python Layer
1 parent 4965453 commit da03b2f

File tree

3 files changed

+240
-32
lines changed

3 files changed

+240
-32
lines changed

python/src/otel/otel_sdk/opentelemetry/instrumentation/aws_lambda/__init__.py

+100-14
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,27 @@ def lambda_handler(event, context):
4747
import logging
4848
import os
4949
from importlib import import_module
50-
from typing import Collection
51-
from wrapt import wrap_function_wrapper
50+
from typing import Any, Collection
5251

53-
# TODO: aws propagator
54-
from opentelemetry.sdk.extension.aws.trace.propagation.aws_xray_format import (
55-
AwsXRayFormat,
56-
)
52+
from opentelemetry.context.context import Context
5753
from opentelemetry.instrumentation.aws_lambda.package import _instruments
5854
from opentelemetry.instrumentation.aws_lambda.version import __version__
5955
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
6056
from opentelemetry.instrumentation.utils import unwrap
57+
from opentelemetry.propagate import get_global_textmap
58+
from opentelemetry.sdk.extension.aws.trace.propagation.aws_xray_format import (
59+
TRACE_HEADER_KEY,
60+
AwsXRayFormat,
61+
)
6162
from opentelemetry.semconv.trace import SpanAttributes
62-
from opentelemetry.trace import SpanKind, get_tracer, get_tracer_provider
63+
from opentelemetry.trace import (
64+
SpanKind,
65+
Tracer,
66+
get_tracer,
67+
get_tracer_provider,
68+
)
69+
from opentelemetry.trace.propagation import get_current_span
70+
from wrapt import wrap_function_wrapper
6371

6472
logger = logging.getLogger(__name__)
6573

@@ -69,15 +77,22 @@ def instrumentation_dependencies(self) -> Collection[str]:
6977
return _instruments
7078

7179
def _instrument(self, **kwargs):
72-
"""Instruments Lambda Handlers on AWS Lambda
80+
"""Instruments Lambda Handlers on AWS Lambda.
81+
82+
See more:
83+
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/instrumentation/aws-lambda.md#instrumenting-aws-lambda
7384
7485
Args:
7586
**kwargs: Optional arguments
7687
``tracer_provider``: a TracerProvider, defaults to global
88+
``event_context_extractor``: a method which takes the Lambda
89+
Event as input and provides the object which contains the
90+
context as output (usually HTTP headers)
7791
"""
7892
tracer = get_tracer(
7993
__name__, __version__, kwargs.get("tracer_provider")
8094
)
95+
event_context_extractor = kwargs.get("event_context_extractor")
8196

8297
lambda_handler = os.environ.get(
8398
"ORIG_HANDLER", os.environ.get("_HANDLER")
@@ -87,7 +102,10 @@ def _instrument(self, **kwargs):
87102
self._wrapped_function_name = wrapped_names[1]
88103

89104
_instrument(
90-
tracer, self._wrapped_module_name, self._wrapped_function_name
105+
tracer,
106+
self._wrapped_module_name,
107+
self._wrapped_function_name,
108+
event_context_extractor,
91109
)
92110

93111
def _uninstrument(self, **kwargs):
@@ -97,16 +115,84 @@ def _uninstrument(self, **kwargs):
97115
)
98116

99117

100-
def _instrument(tracer, wrapped_module_name, wrapped_function_name):
118+
def _default_event_context_extractor(lambda_event: Any) -> Context:
119+
"""Default way of extracting the context from the Lambda Event.
120+
121+
Assumes the Lambda Event is a map with the headers under the 'headers' key.
122+
This is the mapping to use when the Lambda is invoked by an API Gateway
123+
REST API where API Gateway is acting as a pure proxy for the request.
124+
125+
See more:
126+
https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
127+
128+
Args:
129+
lambda_event: user-defined, so it could be anything, but this
130+
method counts it being a map with a 'headers' key
131+
Returns:
132+
A Context with configuration found in the carrier.
133+
"""
134+
try:
135+
headers = lambda_event["headers"]
136+
except (TypeError, KeyError):
137+
logger.warning("Failed to extract context from Lambda Event.")
138+
headers = {}
139+
return get_global_textmap().extract(headers)
140+
141+
142+
def _instrument(
143+
tracer: Tracer,
144+
wrapped_module_name,
145+
wrapped_function_name,
146+
event_context_extractor=None,
147+
):
148+
def _determine_parent_context(lambda_event: Any) -> Context:
149+
"""Determine the parent context for the current Lambda invocation.
150+
151+
See more:
152+
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/instrumentation/aws-lambda.md#determining-the-parent-of-a-span
153+
154+
Args:
155+
lambda_event: user-defined, so it could be anything, but this
156+
method counts it being a map with a 'headers' key
157+
Returns:
158+
A Context with configuration found in the carrier.
159+
"""
160+
parent_context = None
161+
162+
xray_env_var = os.environ.get("_X_AMZN_TRACE_ID")
163+
164+
if xray_env_var:
165+
parent_context = AwsXRayFormat().extract(
166+
{TRACE_HEADER_KEY: xray_env_var}
167+
)
168+
169+
if (
170+
parent_context
171+
and get_current_span(parent_context)
172+
.get_span_context()
173+
.trace_flags.sampled
174+
):
175+
return parent_context
176+
177+
logger.debug(
178+
"X-Ray propagation failed, using user-configured propagators to extract context from Lambda Event."
179+
)
180+
181+
if event_context_extractor:
182+
parent_context = event_context_extractor(lambda_event)
183+
else:
184+
parent_context = _default_event_context_extractor(lambda_event)
185+
186+
return parent_context
187+
101188
def _instrumented_lambda_handler_call(call_wrapped, instance, args, kwargs):
102189
orig_handler_name = ".".join(
103190
[wrapped_module_name, wrapped_function_name]
104191
)
105192

106-
# TODO: enable propagate from AWS by env variable
107-
xray_trace_id = os.environ.get("_X_AMZN_TRACE_ID", "")
108-
propagator = AwsXRayFormat()
109-
parent_context = propagator.extract({"X-Amzn-Trace-Id": xray_trace_id})
193+
lambda_event = args[0]
194+
195+
parent_context = _determine_parent_context(lambda_event)
110196

111197
with tracer.start_as_current_span(
112198
name=orig_handler_name, context=parent_context, kind=SpanKind.SERVER

python/src/otel/tests/test_otel.py

+140-10
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,18 @@
1717
from unittest import mock
1818

1919
from opentelemetry.instrumentation.aws_lambda import AwsLambdaInstrumentor
20+
from opentelemetry.propagate import get_global_textmap
2021
from opentelemetry.sdk.extension.aws.trace.propagation.aws_xray_format import (
2122
TRACE_ID_FIRST_PART_LENGTH,
23+
TRACE_ID_VERSION,
2224
)
2325
from opentelemetry.semconv.resource import ResourceAttributes
2426
from opentelemetry.semconv.trace import SpanAttributes
2527
from opentelemetry.test.test_base import TestBase
2628
from opentelemetry.trace import SpanKind
2729

2830
_HANDLER = "_HANDLER"
31+
_X_AMZN_TRACE_ID = "_X_AMZN_TRACE_ID"
2932
AWS_LAMBDA_EXEC_WRAPPER = "AWS_LAMBDA_EXEC_WRAPPER"
3033
INSTRUMENTATION_SRC_DIR = os.path.join(
3134
*(os.path.dirname(__file__), "..", "otel_sdk")
@@ -42,11 +45,26 @@ def __init__(self, aws_request_id, invoked_function_arn):
4245
aws_request_id="mock_aws_request_id",
4346
invoked_function_arn="arn://mock-lambda-function-arn",
4447
)
45-
MOCK_TRACE_ID = 0x5FB7331105E8BB83207FA31D4D9CDB4C
46-
MOCK_TRACE_ID_HEX_STR = f"{MOCK_TRACE_ID:32x}"
47-
MOCK_PARENT_SPAN_ID = 0x3328B8445A6DBAD2
48-
MOCK_PARENT_SPAN_ID_STR = f"{MOCK_PARENT_SPAN_ID:32x}"
49-
MOCK_LAMBDA_TRACE_CONTEXT_SAMPLED = f"Root=1-{MOCK_TRACE_ID_HEX_STR[:TRACE_ID_FIRST_PART_LENGTH]}-{MOCK_TRACE_ID_HEX_STR[TRACE_ID_FIRST_PART_LENGTH:]};Parent={MOCK_PARENT_SPAN_ID_STR};Sampled=1"
48+
49+
MOCK_XRAY_TRACE_ID = 0x5FB7331105E8BB83207FA31D4D9CDB4C
50+
MOCK_XRAY_TRACE_ID_STR = f"{MOCK_XRAY_TRACE_ID:x}"
51+
MOCK_XRAY_PARENT_SPAN_ID = 0x3328B8445A6DBAD2
52+
MOCK_XRAY_TRACE_CONTEXT_COMMON = f"Root={TRACE_ID_VERSION}-{MOCK_XRAY_TRACE_ID_STR[:TRACE_ID_FIRST_PART_LENGTH]}-{MOCK_XRAY_TRACE_ID_STR[TRACE_ID_FIRST_PART_LENGTH:]};Parent={MOCK_XRAY_PARENT_SPAN_ID:x}"
53+
MOCK_XRAY_TRACE_CONTEXT_SAMPLED = f"{MOCK_XRAY_TRACE_CONTEXT_COMMON};Sampled=1"
54+
MOCK_XRAY_TRACE_CONTEXT_NOT_SAMPLED = (
55+
f"{MOCK_XRAY_TRACE_CONTEXT_COMMON};Sampled=0"
56+
)
57+
58+
# Read more:
59+
# https://www.w3.org/TR/trace-context/#examples-of-http-traceparent-headers
60+
MOCK_W3C_TRACE_ID = 0x5CE0E9A56015FEC5AADFA328AE398115
61+
MOCK_W3C_PARENT_SPAN_ID = 0xAB54A98CEB1F0AD2
62+
MOCK_W3C_TRACE_CONTEXT_SAMPLED = (
63+
f"00-{MOCK_W3C_TRACE_ID:x}-{MOCK_W3C_PARENT_SPAN_ID:x}-01"
64+
)
65+
66+
MOCK_W3C_TRACE_STATE_KEY = "vendor_specific_key"
67+
MOCK_W3C_TRACE_STATE_VALUE = "test_value"
5068

5169

5270
def mock_aws_lambda_exec_wrapper():
@@ -63,13 +81,20 @@ def mock_aws_lambda_exec_wrapper():
6381
exec(open(os.path.join(INSTRUMENTATION_SRC_DIR, "otel-instrument")).read())
6482

6583

66-
def mock_execute_lambda():
84+
def mock_execute_lambda(event=None):
85+
"""Mocks Lambda importing and then calling the method at the current
86+
`_HANDLER` environment variable. Like the real Lambda, if
87+
`AWS_LAMBDA_EXEC_WRAPPER` is defined, if executes that before `_HANDLER`.
88+
89+
See more:
90+
https://aws-otel.github.io/docs/getting-started/lambda/lambda-python
91+
"""
6792
if os.environ[AWS_LAMBDA_EXEC_WRAPPER]:
6893
globals()[os.environ[AWS_LAMBDA_EXEC_WRAPPER]]()
6994

7095
module_name, handler_name = os.environ[_HANDLER].split(".")
7196
handler_module = import_module(".".join(module_name.split("/")))
72-
getattr(handler_module, handler_name)("mock_event", MOCK_LAMBDA_CONTEXT)
97+
getattr(handler_module, handler_name)(event, MOCK_LAMBDA_CONTEXT)
7398

7499

75100
class TestAwsLambdaInstrumentor(TestBase):
@@ -109,7 +134,7 @@ def test_active_tracing(self):
109134
"os.environ",
110135
{
111136
**os.environ,
112-
"_X_AMZN_TRACE_ID": MOCK_LAMBDA_TRACE_CONTEXT_SAMPLED,
137+
_X_AMZN_TRACE_ID: MOCK_XRAY_TRACE_CONTEXT_SAMPLED,
113138
},
114139
)
115140
test_env_patch.start()
@@ -123,7 +148,7 @@ def test_active_tracing(self):
123148
self.assertEqual(len(spans), 1)
124149
span = spans[0]
125150
self.assertEqual(span.name, os.environ["ORIG_HANDLER"])
126-
self.assertEqual(span.get_span_context().trace_id, MOCK_TRACE_ID)
151+
self.assertEqual(span.get_span_context().trace_id, MOCK_XRAY_TRACE_ID)
127152
self.assertEqual(span.kind, SpanKind.SERVER)
128153
self.assertSpanHasAttributes(
129154
span,
@@ -149,7 +174,112 @@ def test_active_tracing(self):
149174
self.assertEqual(
150175
parent_context.trace_id, span.get_span_context().trace_id
151176
)
152-
self.assertEqual(parent_context.span_id, MOCK_PARENT_SPAN_ID)
177+
self.assertEqual(parent_context.span_id, MOCK_XRAY_PARENT_SPAN_ID)
178+
self.assertTrue(parent_context.is_remote)
179+
180+
test_env_patch.stop()
181+
182+
def test_parent_context_from_lambda_event(self):
183+
test_env_patch = mock.patch.dict(
184+
"os.environ",
185+
{
186+
**os.environ,
187+
# NOT Active Tracing
188+
_X_AMZN_TRACE_ID: MOCK_XRAY_TRACE_CONTEXT_NOT_SAMPLED,
189+
# NOT using the X-Ray Propagator
190+
"OTEL_PROPAGATORS": "tracecontext",
191+
},
192+
)
193+
test_env_patch.start()
194+
195+
mock_execute_lambda(
196+
{
197+
"headers": {
198+
"traceparent": MOCK_W3C_TRACE_CONTEXT_SAMPLED,
199+
"tracestate": f"{MOCK_W3C_TRACE_STATE_KEY}={MOCK_W3C_TRACE_STATE_VALUE},foo=1,bar=2",
200+
}
201+
}
202+
)
203+
204+
spans = self.memory_exporter.get_finished_spans()
205+
206+
assert spans
207+
208+
self.assertEqual(len(spans), 1)
209+
span = spans[0]
210+
self.assertEqual(span.get_span_context().trace_id, MOCK_W3C_TRACE_ID)
211+
212+
parent_context = span.parent
213+
self.assertEqual(
214+
parent_context.trace_id, span.get_span_context().trace_id
215+
)
216+
self.assertEqual(parent_context.span_id, MOCK_W3C_PARENT_SPAN_ID)
217+
self.assertEqual(len(parent_context.trace_state), 3)
218+
self.assertEqual(
219+
parent_context.trace_state.get(MOCK_W3C_TRACE_STATE_KEY),
220+
MOCK_W3C_TRACE_STATE_VALUE,
221+
)
222+
self.assertTrue(parent_context.is_remote)
223+
224+
test_env_patch.stop()
225+
226+
def test_using_custom_extractor(self):
227+
def custom_event_context_extractor(lambda_event):
228+
return get_global_textmap().extract(lambda_event["foo"]["headers"])
229+
230+
test_env_patch = mock.patch.dict(
231+
"os.environ",
232+
{
233+
**os.environ,
234+
# DO NOT use `otel-instrument` script, resort to "manual"
235+
# instrumentation below
236+
AWS_LAMBDA_EXEC_WRAPPER: "",
237+
# NOT Active Tracing
238+
_X_AMZN_TRACE_ID: MOCK_XRAY_TRACE_CONTEXT_NOT_SAMPLED,
239+
# NOT using the X-Ray Propagator
240+
"OTEL_PROPAGATORS": "tracecontext",
241+
},
242+
)
243+
test_env_patch.start()
244+
245+
# NOTE: Instead of using `AWS_LAMBDA_EXEC_WRAPPER` to point `_HANDLER`
246+
# to a module which instruments and calls the user `ORIG_HANDLER`, we
247+
# leave `_HANDLER` as is and replace `AWS_LAMBDA_EXEC_WRAPPER` with this
248+
# line below. This is like "manual" instrumentation for Lambda.
249+
AwsLambdaInstrumentor().instrument(
250+
event_context_extractor=custom_event_context_extractor,
251+
skip_dep_check=True,
252+
)
253+
254+
mock_execute_lambda(
255+
{
256+
"foo": {
257+
"headers": {
258+
"traceparent": MOCK_W3C_TRACE_CONTEXT_SAMPLED,
259+
"tracestate": f"{MOCK_W3C_TRACE_STATE_KEY}={MOCK_W3C_TRACE_STATE_VALUE},foo=1,bar=2",
260+
}
261+
}
262+
}
263+
)
264+
265+
spans = self.memory_exporter.get_finished_spans()
266+
267+
assert spans
268+
269+
self.assertEqual(len(spans), 1)
270+
span = spans[0]
271+
self.assertEqual(span.get_span_context().trace_id, MOCK_W3C_TRACE_ID)
272+
273+
parent_context = span.parent
274+
self.assertEqual(
275+
parent_context.trace_id, span.get_span_context().trace_id
276+
)
277+
self.assertEqual(parent_context.span_id, MOCK_W3C_PARENT_SPAN_ID)
278+
self.assertEqual(len(parent_context.trace_state), 3)
279+
self.assertEqual(
280+
parent_context.trace_state.get(MOCK_W3C_TRACE_STATE_KEY),
281+
MOCK_W3C_TRACE_STATE_VALUE,
282+
)
153283
self.assertTrue(parent_context.is_remote)
154284

155285
test_env_patch.stop()

python/src/tox.ini

-8
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,4 @@ deps =
3232
test: pytest
3333

3434
commands =
35-
<<<<<<< HEAD
36-
<<<<<<< HEAD
3735
test: pytest {posargs}
38-
=======
39-
test: pytest {posargs} -s
40-
>>>>>>> c69d4f3 (Format tests to match upstream format)
41-
=======
42-
test: pytest {posargs}
43-
>>>>>>> 0ff80f7 (Remove -s flag since it is only for logging)

0 commit comments

Comments
 (0)