Skip to content

Commit 6d55f27

Browse files
committed
Support other propagators in Python Layer
1 parent a51a154 commit 6d55f27

File tree

2 files changed

+240
-24
lines changed

2 files changed

+240
-24
lines changed

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

+102-14
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,30 @@ 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
51+
52+
from opentelemetry.context.context import Context
5253

53-
# TODO: aws propagator
54-
from opentelemetry.sdk.extension.aws.trace.propagation.aws_xray_format import (
55-
AwsXRayFormat,
56-
)
5754
from opentelemetry.instrumentation.aws_lambda.package import _instruments
5855
from opentelemetry.instrumentation.aws_lambda.version import __version__
5956
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
6057
from opentelemetry.instrumentation.utils import unwrap
58+
from opentelemetry.propagate import get_global_textmap
59+
60+
from opentelemetry.sdk.extension.aws.trace.propagation.aws_xray_format import (
61+
AwsXRayFormat,
62+
TRACE_HEADER_KEY,
63+
)
6164
from opentelemetry.semconv.trace import SpanAttributes
62-
from opentelemetry.trace import SpanKind, get_tracer, get_tracer_provider
65+
from opentelemetry.trace import (
66+
SpanKind,
67+
Tracer,
68+
get_tracer,
69+
get_tracer_provider,
70+
)
71+
from opentelemetry.trace.propagation import get_current_span
72+
73+
from wrapt import wrap_function_wrapper
6374

6475
logger = logging.getLogger(__name__)
6576

@@ -69,15 +80,22 @@ def instrumentation_dependencies(self) -> Collection[str]:
6980
return _instruments
7081

7182
def _instrument(self, **kwargs):
72-
"""Instruments Lambda Handlers on AWS Lambda
83+
"""Instruments Lambda Handlers on AWS Lambda.
84+
85+
Read more about how instrumentation is decided:
86+
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/instrumentation/aws-lambda.md#instrumenting-aws-lambda
7387
7488
Args:
7589
**kwargs: Optional arguments
7690
``tracer_provider``: a TracerProvider, defaults to global
91+
``event_context_extractor``: a method which takes the Lambda
92+
Event as input and provides the object which contains the
93+
context as output (usually HTTP headers)
7794
"""
7895
tracer = get_tracer(
7996
__name__, __version__, kwargs.get("tracer_provider")
8097
)
98+
event_context_extractor = kwargs.get("event_context_extractor")
8199

82100
lambda_handler = os.environ.get(
83101
"ORIG_HANDLER", os.environ.get("_HANDLER")
@@ -87,7 +105,10 @@ def _instrument(self, **kwargs):
87105
self._wrapped_function_name = wrapped_names[1]
88106

89107
_instrument(
90-
tracer, self._wrapped_module_name, self._wrapped_function_name
108+
tracer,
109+
self._wrapped_module_name,
110+
self._wrapped_function_name,
111+
event_context_extractor,
91112
)
92113

93114
def _uninstrument(self, **kwargs):
@@ -97,16 +118,83 @@ def _uninstrument(self, **kwargs):
97118
)
98119

99120

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

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})
195+
lambda_event = args[0]
196+
197+
parent_context = _determine_parent_context(lambda_event)
110198

111199
with tracer.start_as_current_span(
112200
name=orig_handler_name, context=parent_context, kind=SpanKind.SERVER

python/src/otel/tests/test_otel.py

+138-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,24 @@ 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+
MOCK_W3C_TRACE_ID = 0x5CE0E9A56015FEC5AADFA328AE398115
59+
MOCK_W3C_PARENT_SPAN_ID = 0xAB54A98CEB1F0AD2
60+
MOCK_W3C_TRACE_CONTEXT_SAMPLED = (
61+
f"00-{MOCK_W3C_TRACE_ID:x}-{MOCK_W3C_PARENT_SPAN_ID:x}-01"
62+
)
63+
64+
MOCK_W3C_TRACE_STATE_KEY = "vendor_specific_key"
65+
MOCK_W3C_TRACE_STATE_VALUE = "test_value"
5066

5167

5268
def mock_aws_lambda_exec_wrapper():
@@ -63,13 +79,20 @@ def mock_aws_lambda_exec_wrapper():
6379
exec(open(os.path.join(INSTRUMENTATION_SRC_DIR, "otel-instrument")).read())
6480

6581

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

7093
module_name, handler_name = os.environ[_HANDLER].split(".")
7194
handler_module = import_module(".".join(module_name.split("/")))
72-
getattr(handler_module, handler_name)("mock_event", MOCK_LAMBDA_CONTEXT)
95+
getattr(handler_module, handler_name)(event, MOCK_LAMBDA_CONTEXT)
7396

7497

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

155283
test_env_patch.stop()

0 commit comments

Comments
 (0)