55 Workflow -> Operation -> Attempt
66
77that 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
1414This is the Python adaptation of the JS ``ExecutionOtelPlugin`` from
1515aws-durable-execution-sdk-js#729. Because the Python plugin interface differs
4747from opentelemetry import trace
4848from opentelemetry .context import Context
4949from opentelemetry .sdk .trace import Tracer as SdkTracer
50+ from opentelemetry .sdk .trace .sampling import Sampler
5051from opentelemetry .trace import (
5152 Link ,
53+ NonRecordingSpan ,
5254 Span ,
5355 SpanContext ,
5456 SpanKind ,
5860
5961from aws_durable_execution_sdk_python_otel .context_extractors import (
6062 ContextExtractor ,
63+ ExtractedContext ,
64+ _ensure_extracted_context ,
6165 xray_context_extractor ,
6266)
6367from 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+ )
6983from aws_durable_execution_sdk_python_otel .otel_plugin_config import OtelPluginConfig
7084from aws_durable_execution_sdk_python_otel .log_filter import install_log_filter
7185from 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