Skip to content

Commit 2be288e

Browse files
author
Ayushi Ahjolia
committed
feat(otel): parent durable spans to shared trace
1 parent 52a82a5 commit 2be288e

11 files changed

Lines changed: 1289 additions & 225 deletions

packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
from aws_durable_execution_sdk_python_otel.__about__ import __version__
44
from aws_durable_execution_sdk_python_otel.context_extractors import (
55
ContextExtractor,
6+
ExtractedContext,
7+
Sampling,
68
w3c_client_context_extractor,
79
xray_context_extractor,
810
)
911
from aws_durable_execution_sdk_python_otel.deterministic_id_generator import (
1012
DeterministicIdGenerator,
13+
derive_execution_root_span_id,
1114
derive_workflow_span_id,
1215
operation_id_to_span_id,
1316
)
@@ -35,11 +38,14 @@
3538
"ContextExtractor",
3639
"DeterministicIdGenerator",
3740
"ExecutionOtelPlugin",
41+
"ExtractedContext",
3842
"OtelPluginConfig",
3943
"InvocationOtelPlugin",
4044
"OtelContextLogFilter",
45+
"Sampling",
4146
"ProviderResult",
4247
"create_tracer_provider",
48+
"derive_execution_root_span_id",
4349
"derive_workflow_span_id",
4450
"install_log_filter",
4551
"operation_id_to_span_id",

packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/context_extractors.py

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Context extractors for propagating trace context into durable executions."""
1+
"""Trace-context extractors for durable execution telemetry."""
22

33
from __future__ import annotations
44

@@ -7,12 +7,8 @@
77
from enum import Enum
88
from typing import TYPE_CHECKING, Callable
99

10-
from opentelemetry import context as otel_context, propagate
11-
1210

1311
if TYPE_CHECKING:
14-
from opentelemetry.context import Context
15-
1612
from aws_durable_execution_sdk_python.plugin import InvocationStartInfo
1713

1814

@@ -54,28 +50,83 @@ def has_complete_remote_parent(self) -> bool:
5450
return self.has_valid_trace_id and self.has_valid_parent_span_id
5551

5652

57-
ContextExtractor = Callable[["InvocationStartInfo"], "Context"]
53+
ContextExtractor = Callable[["InvocationStartInfo"], ExtractedContext | None]
54+
55+
56+
def _ensure_extracted_context(extracted: object) -> ExtractedContext | None:
57+
"""Validate a context extractor result."""
58+
if extracted is None or isinstance(extracted, ExtractedContext):
59+
return extracted
60+
msg = "context extractor must return ExtractedContext or None"
61+
raise TypeError(msg)
62+
63+
64+
def _parse_xray_trace_id(root: str | None) -> int | None:
65+
if root is None:
66+
return None
67+
parts = root.split("-")
68+
if len(parts) != 3 or parts[0] != "1":
69+
return None
70+
trace_id_hex = f"{parts[1]}{parts[2]}"
71+
if len(trace_id_hex) != 32:
72+
return None
73+
try:
74+
trace_id = int(trace_id_hex, 16)
75+
except ValueError:
76+
return None
77+
return trace_id if 0 < trace_id < 2**128 else None
5878

5979

60-
def xray_context_extractor(info: "InvocationStartInfo") -> "Context":
61-
"""Read the X-Ray trace header from the _X_AMZN_TRACE_ID environment variable.
80+
def _parse_span_id(span_id_hex: str | None) -> int | None:
81+
if span_id_hex is None or len(span_id_hex) != 16:
82+
return None
83+
try:
84+
span_id = int(span_id_hex, 16)
85+
except ValueError:
86+
return None
87+
return span_id if 0 < span_id < 2**64 else None
6288

63-
The durable execution backend propagates the same Root trace ID to every
64-
invocation, so all invocations share one traceId.
89+
90+
def _parse_sampling(value: str | None) -> Sampling:
91+
if value == "1":
92+
return Sampling.SAMPLED
93+
if value == "0":
94+
return Sampling.NOT_SAMPLED
95+
return Sampling.UNDECIDED
96+
97+
98+
def xray_context_extractor(info: "InvocationStartInfo") -> ExtractedContext | None:
99+
"""Read durable execution trace context from ``_X_AMZN_TRACE_ID``.
100+
101+
The Lambda durable execution backend propagates an X-Ray style header. A
102+
valid ``Root`` anchors the execution trace; a valid ``Parent`` becomes the
103+
remote execution ancestor; and ``Sampled`` is preserved as the backend's
104+
explicit sampling decision.
65105
"""
66106
trace_header = os.environ.get("_X_AMZN_TRACE_ID")
67107
if not trace_header:
68-
return otel_context.get_current()
69-
return propagate.extract(
70-
carrier={"X-Amzn-Trace-Id": trace_header},
71-
context=otel_context.get_current(),
108+
return None
109+
110+
parts: dict[str, str] = {}
111+
for segment in trace_header.split(";"):
112+
key, separator, value = segment.partition("=")
113+
if separator:
114+
parts[key.strip()] = value.strip()
115+
116+
trace_id = _parse_xray_trace_id(parts.get("Root"))
117+
parent_span_id = _parse_span_id(parts.get("Parent"))
118+
sampling = _parse_sampling(parts.get("Sampled"))
119+
if trace_id is None and parent_span_id is None and sampling is Sampling.UNDECIDED:
120+
return None
121+
return ExtractedContext(
122+
trace_id=trace_id,
123+
parent_span_id=parent_span_id,
124+
sampling=sampling,
72125
)
73126

74127

75-
def w3c_client_context_extractor(info: "InvocationStartInfo") -> "Context":
76-
"""Read W3C traceparent from context.clientContext.custom.traceparent.
77-
78-
Requires the backend clientContext propagation to be enabled.
79-
This extractor is a placeholder for when backend propagation is supported.
80-
"""
81-
return otel_context.get_current()
128+
def w3c_client_context_extractor(
129+
info: "InvocationStartInfo",
130+
) -> ExtractedContext | None:
131+
"""Placeholder for future W3C traceparent propagation support."""
132+
return None

packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py

Lines changed: 104 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
Workflow -> Operation -> Attempt
66
77
that stitches a single trace across every Lambda invocation of one durable
8-
execution. The Workflow span is the root (created in an empty context so it
9-
never has a parent) and is exported exactly once, when the execution reaches a
10-
terminal status. Operations are parented under the Workflow span (or their
11-
parent operation) and *linked* to the current Invocation span. The Invocation
12-
span belongs to the ambient Lambda trace instead of the Workflow trace.
8+
execution. Workflow and Invocation spans parent onto the same execution
9+
ancestor: a propagated backend parent when present, otherwise a deterministic
10+
synthetic root. The Workflow span is exported exactly once, when the execution
11+
reaches a terminal status. Operations are parented under the Workflow span (or
12+
their parent operation) and *linked* to the current Invocation span.
1313
1414
This is the Python adaptation of the JS ``ExecutionOtelPlugin`` from
1515
aws-durable-execution-sdk-js#729. Because the Python plugin interface differs
@@ -47,8 +47,10 @@
4747
from opentelemetry import trace
4848
from opentelemetry.context import Context
4949
from opentelemetry.sdk.trace import Tracer as SdkTracer
50+
from opentelemetry.sdk.trace.sampling import Sampler
5051
from opentelemetry.trace import (
5152
Link,
53+
NonRecordingSpan,
5254
Span,
5355
SpanContext,
5456
SpanKind,
@@ -58,14 +60,26 @@
5860

5961
from aws_durable_execution_sdk_python_otel.context_extractors import (
6062
ContextExtractor,
63+
ExtractedContext,
64+
_ensure_extracted_context,
6165
xray_context_extractor,
6266
)
6367
from aws_durable_execution_sdk_python_otel.deterministic_id_generator import (
6468
DeterministicIdGenerator,
65-
_to_otel_trace_id,
6669
derive_workflow_span_id,
6770
operation_id_to_span_id,
6871
)
72+
from aws_durable_execution_sdk_python_otel.durable_sampling import (
73+
DurableSampler,
74+
DurableSamplingIntent,
75+
is_sampled,
76+
resolve_sampling_result,
77+
store_sampling_intent,
78+
)
79+
from aws_durable_execution_sdk_python_otel.execution_trace_context import (
80+
ExecutionTraceContext,
81+
canonical_trace_id,
82+
)
6983
from aws_durable_execution_sdk_python_otel.otel_plugin_config import OtelPluginConfig
7084
from aws_durable_execution_sdk_python_otel.log_filter import install_log_filter
7185
from aws_durable_execution_sdk_python_otel.provider import create_tracer_provider
@@ -113,12 +127,15 @@ def __init__(self, config: OtelPluginConfig | None = None) -> None:
113127

114128
self._tracer: Tracer = self._provider.get_tracer(self._config.instrument_name)
115129
self._id_generator = DeterministicIdGenerator()
130+
self._sampling_delegate: Sampler | None = None
116131
self._bind_sdk_tracer()
117132

118133
# Per-invocation state.
119134
self._execution_arn = ""
120135
self._execution_trace_id: int | None = None
121-
self._extracted_context: Context | None = None
136+
self._extracted_context: ExtractedContext | None = None
137+
self._execution_trace_context: ExecutionTraceContext | None = None
138+
self._sampling_intent: DurableSamplingIntent | None = None
122139
self._workflow_span: Span | None = None
123140
self._invocation_span: Span | None = None
124141
self._operation_spans: dict[str, Span] = {}
@@ -135,6 +152,7 @@ def __init__(self, config: OtelPluginConfig | None = None) -> None:
135152

136153
def _bind_sdk_tracer(self) -> bool:
137154
"""Bind to an SDK tracer, retrying a deferred global provider."""
155+
self._sampling_delegate = None
138156
tracer = self._tracer
139157
if not isinstance(tracer, SdkTracer):
140158
if self._uses_global_provider:
@@ -147,6 +165,7 @@ def _bind_sdk_tracer(self) -> bool:
147165
# Deterministic stitching is scoped to this instrumentation tracer so
148166
# unrelated tracers on the same provider keep their original generator.
149167
self._id_generator = DeterministicIdGenerator.install_on_tracer(tracer)
168+
self._sampling_delegate = DurableSampler.install_on_tracer(tracer).delegate
150169
return True
151170

152171
# ------------------------------------------------------------------
@@ -274,14 +293,26 @@ def _resolve_parent(self, parent_id: str | None) -> Span | None:
274293
return self._workflow_span
275294

276295
def _invocation_parent_context(self) -> Context:
277-
"""Return the active ambient context, then extracted upstream context."""
278-
ambient_context = otel_context.get_current()
279-
ambient_span_context = trace.get_current_span(
280-
ambient_context
281-
).get_span_context()
282-
if ambient_span_context.is_valid:
283-
return ambient_context
284-
return self._extracted_context or ambient_context
296+
"""Return same-trace ambient context, else execution ancestor context."""
297+
execution_trace_context = self._execution_trace_context
298+
if execution_trace_context is None:
299+
return self._with_sampling(Context())
300+
301+
ambient_span = trace.get_current_span()
302+
ambient_context = ambient_span.get_span_context()
303+
if (
304+
ambient_context.is_valid
305+
and ambient_context.trace_id == execution_trace_context.trace_id
306+
):
307+
return self._with_sampling(
308+
trace.set_span_in_context(ambient_span, Context())
309+
)
310+
311+
ancestor = NonRecordingSpan(execution_trace_context.execution_ancestor)
312+
return self._with_sampling(trace.set_span_in_context(ancestor, Context()))
313+
314+
def _with_sampling(self, parent_context: Context) -> Context:
315+
return store_sampling_intent(parent_context, self._sampling_intent)
285316

286317
# ------------------------------------------------------------------
287318
# Invocation lifecycle
@@ -307,13 +338,46 @@ def on_invocation_start(self, info: InvocationStartInfo) -> None:
307338
return
308339

309340
self._execution_arn = info.execution_arn or ""
310-
self._execution_trace_id = _to_otel_trace_id(
311-
self._execution_arn, info.execution_start_time
341+
if not self._execution_arn:
342+
logger.warning(
343+
"ExecutionOtelPlugin requires InvocationStartInfo.execution_arn "
344+
"to derive a deterministic execution root; telemetry is disabled "
345+
"for this invocation."
346+
)
347+
self._tracing_enabled = False
348+
return
349+
self._extracted_context = _ensure_extracted_context(
350+
self._context_extractor(info)
351+
)
352+
self._execution_trace_id = canonical_trace_id(
353+
extracted=self._extracted_context,
354+
execution_arn=self._execution_arn,
355+
execution_start_time=info.execution_start_time,
356+
)
357+
if self._sampling_delegate is None:
358+
logger.warning(
359+
"No sampler available; telemetry is disabled for this invocation."
360+
)
361+
self._tracing_enabled = False
362+
return
363+
sampling_result = resolve_sampling_result(
364+
extracted=self._extracted_context,
365+
ambient_span=trace.get_current_span(),
366+
canonical_trace_id=self._execution_trace_id,
367+
sampler=self._sampling_delegate,
368+
span_name=self._workflow_span_name,
369+
attributes={"durable.execution.arn": self._execution_arn},
370+
)
371+
self._sampling_intent = DurableSamplingIntent(sampling_result)
372+
self._execution_trace_context = ExecutionTraceContext.resolve(
373+
extracted=self._extracted_context,
374+
canonical_trace_id=self._execution_trace_id,
375+
execution_arn=self._execution_arn,
376+
root_sampled=lambda: is_sampled(sampling_result),
312377
)
313-
self._extracted_context = self._context_extractor(info)
314378

315379
self._start_workflow_span(info)
316-
# Keep the invocation in the ambient Lambda trace in both provider modes.
380+
# Keep the invocation on the shared execution trace.
317381
self._start_invocation_span(info)
318382

319383
# Make the Workflow span the active span so auto-instrumented spans
@@ -323,24 +387,33 @@ def on_invocation_start(self, info: InvocationStartInfo) -> None:
323387
if self._workflow_span is not None:
324388
self._attach_context(
325389
_INVOCATION_CONTEXT_KEY,
326-
trace.set_span_in_context(self._workflow_span, self._extracted_context),
390+
trace.set_span_in_context(
391+
self._workflow_span, otel_context.get_current()
392+
),
327393
)
328394

329395
def _start_workflow_span(self, info: InvocationStartInfo) -> None:
330396
if not self._execution_arn:
331397
logger.warning("No execution ARN; skipping Workflow span creation")
332398
return
333-
# Empty context => root span with no parent.
399+
if self._execution_trace_context is None:
400+
return
401+
parent_context = self._with_sampling(
402+
trace.set_span_in_context(
403+
NonRecordingSpan(self._execution_trace_context.execution_ancestor),
404+
Context(),
405+
)
406+
)
334407
with self._id_generator.use_ids(
335-
trace_id=self._execution_trace_id,
408+
trace_id=None,
336409
span_id=derive_workflow_span_id(self._execution_arn),
337410
):
338411
self._workflow_span = self._tracer.start_span(
339412
name=self._workflow_span_name,
340413
kind=SpanKind.INTERNAL,
341414
attributes={"durable.execution.arn": self._execution_arn},
342415
start_time=_to_otel_timestamp(info.execution_start_time),
343-
context=Context(),
416+
context=parent_context,
344417
)
345418

346419
def _start_invocation_span(self, info: InvocationStartInfo) -> None:
@@ -420,6 +493,8 @@ def _reset_state(self) -> None:
420493
self._execution_arn = ""
421494
self._execution_trace_id = None
422495
self._extracted_context = None
496+
self._execution_trace_context = None
497+
self._sampling_intent = None
423498
self._workflow_span = None
424499
self._invocation_span = None
425500
with self._lock:
@@ -500,12 +575,12 @@ def _start_span(
500575
)
501576

502577
if parent is None:
503-
parent_ctx = self._extracted_context or Context()
578+
parent_ctx = self._with_sampling(Context())
504579
else:
505-
parent_ctx = trace.set_span_in_context(parent, self._extracted_context)
506-
with self._id_generator.use_ids(
507-
trace_id=self._execution_trace_id, span_id=span_id
508-
):
580+
parent_ctx = self._with_sampling(
581+
trace.set_span_in_context(parent, Context())
582+
)
583+
with self._id_generator.use_ids(trace_id=None, span_id=span_id):
509584
span = self._tracer.start_span(
510585
name=name,
511586
attributes=self._operation_attributes(info),
@@ -552,7 +627,7 @@ def on_user_function_start(self, info: UserFunctionStartInfo) -> None:
552627
start_time=info.start_time,
553628
)
554629
self._attach_context(
555-
key, trace.set_span_in_context(span, self._extracted_context)
630+
key, trace.set_span_in_context(span, otel_context.get_current())
556631
)
557632

558633
def on_user_function_end(self, info: UserFunctionEndInfo) -> None:

0 commit comments

Comments
 (0)