@@ -133,6 +133,7 @@ def __init__(self, config: OtelPluginConfig | None = None) -> None:
133133 # Per-invocation state.
134134 self ._execution_arn = ""
135135 self ._execution_trace_id : int | None = None
136+ self ._execution_start_time : datetime .datetime | None = None
136137 self ._extracted_context : ExtractedContext | None = None
137138 self ._execution_trace_context : ExecutionTraceContext | None = None
138139 self ._sampling_intent : DurableSamplingIntent | None = None
@@ -346,6 +347,7 @@ def on_invocation_start(self, info: InvocationStartInfo) -> None:
346347 )
347348 self ._tracing_enabled = False
348349 return
350+ self ._execution_start_time = info .execution_start_time
349351 self ._extracted_context = _ensure_extracted_context (
350352 self ._context_extractor (info )
351353 )
@@ -393,11 +395,40 @@ def on_invocation_start(self, info: InvocationStartInfo) -> None:
393395 )
394396
395397 def _start_workflow_span (self , info : InvocationStartInfo ) -> None :
398+ """Install a non-recording placeholder for the execution-scoped Workflow span.
399+
400+ The Workflow span spans the whole durable execution and is exported once,
401+ on the terminal invocation. During every invocation the plugin only needs
402+ its deterministic SpanContext -- to parent operation spans, to keep the
403+ Workflow current so auto-instrumented spans join the execution trace, and
404+ for log correlation. A non-recording placeholder fills that role so a
405+ non-terminal invocation never abandons a recording span. The recording
406+ span is created and ended once by :meth:`_export_workflow_span`.
407+ """
396408 if not self ._execution_arn :
397409 logger .warning ("No execution ARN; skipping Workflow span creation" )
398410 return
399411 if self ._execution_trace_context is None :
400412 return
413+ workflow_span_context = SpanContext (
414+ trace_id = self ._execution_trace_context .trace_id ,
415+ span_id = derive_workflow_span_id (self ._execution_arn ),
416+ is_remote = False ,
417+ trace_flags = self ._execution_trace_context .trace_flags ,
418+ trace_state = self ._execution_trace_context .execution_ancestor .trace_state ,
419+ )
420+ self ._workflow_span = NonRecordingSpan (workflow_span_context )
421+
422+ def _export_workflow_span (self , info : InvocationEndInfo ) -> None :
423+ """Create and end the recording Workflow span once, on a terminal status.
424+
425+ Uses the same deterministic span ID as the placeholder and the shared
426+ execution ancestor as its parent, so the exported Workflow span stays on
427+ the execution trace and correlates with every operation span across all
428+ invocations. Anchored at the execution start time.
429+ """
430+ if not self ._execution_arn or self ._execution_trace_context is None :
431+ return
401432 parent_context = self ._with_sampling (
402433 trace .set_span_in_context (
403434 NonRecordingSpan (self ._execution_trace_context .execution_ancestor ),
@@ -408,13 +439,41 @@ def _start_workflow_span(self, info: InvocationStartInfo) -> None:
408439 trace_id = None ,
409440 span_id = derive_workflow_span_id (self ._execution_arn ),
410441 ):
411- self . _workflow_span = self ._tracer .start_span (
442+ workflow_span = self ._tracer .start_span (
412443 name = self ._workflow_span_name ,
413444 kind = SpanKind .INTERNAL ,
414- attributes = {"durable.execution.arn" : self ._execution_arn },
415- start_time = _to_otel_timestamp (info .execution_start_time ),
445+ attributes = {
446+ "durable.execution.arn" : self ._execution_arn ,
447+ "durable.execution.status" : (
448+ info .status .value if info .status else ""
449+ ),
450+ },
451+ start_time = _to_otel_timestamp (self ._execution_start_time ),
416452 context = parent_context ,
417453 )
454+ if info .status is InvocationStatus .FAILED :
455+ workflow_span .set_status (
456+ StatusCode .ERROR , info .error .message if info .error else ""
457+ )
458+ elif info .status is InvocationStatus .SUCCEEDED :
459+ workflow_span .set_status (StatusCode .OK )
460+ workflow_span .end ()
461+
462+ def _end_open_operation_spans (self ) -> None :
463+ """End every operation span still open at invocation end (except invocation).
464+
465+ Spans are registered parent-first, so ending them in reverse keeps each
466+ child contained within its parent. The invocation span is ended
467+ separately by the caller.
468+ """
469+ with self ._lock :
470+ keys = list (reversed (self ._operation_spans ))
471+ for key in keys :
472+ if key == _INVOCATION_KEY :
473+ continue
474+ popped = self ._pop_span (key )
475+ if popped is not None :
476+ popped .end ()
418477
419478 def _start_invocation_span (self , info : InvocationStartInfo ) -> None :
420479 self ._invocation_span = self ._tracer .start_span (
@@ -434,12 +493,6 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
434493 self ._reset_state ()
435494 return
436495
437- # Operation spans still open here belong to operations that suspended
438- # (e.g. PENDING/RETRYING) rather than completed this invocation. They are
439- # ended only by on_operation_end; drop the references without ending them
440- # so they are not exported as if completed. _reset_state
441- # clears the span map below.
442-
443496 # End the invocation span regardless of terminal status. Record the
444497 # invocation status and map it to a span status:
445498 # SUCCEEDED/PENDING -> OK (this invocation did its work, whether it
@@ -462,23 +515,20 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
462515 )
463516 self ._invocation_span .end ()
464517
465- # The Workflow span (execution view) is exported only on a terminal
466- # status; otherwise its reference is dropped without ending it. Its span
467- # status reflects the execution outcome: SUCCEEDED -> OK, FAILED -> ERROR
468- # (RETRY/PENDING are non-terminal and never reach here -> UNSET).
469- if self ._workflow_span is not None :
470- if info .status in _TERMINAL_INVOCATION_STATUSES :
471- self ._workflow_span .set_attribute (
472- "durable.execution.status" ,
473- info .status .value if info .status else "" ,
474- )
475- if info .status is InvocationStatus .FAILED :
476- self ._workflow_span .set_status (
477- StatusCode .ERROR , info .error .message if info .error else ""
478- )
479- elif info .status is InvocationStatus .SUCCEEDED :
480- self ._workflow_span .set_status (StatusCode .OK )
481- self ._workflow_span .end ()
518+ # Operation spans still open here belong to operations that suspended
519+ # (e.g. PENDING/RETRYING) rather than completed this invocation. End them
520+ # so no recording span is abandoned; the authoritative span for an
521+ # operation that resumes in a later invocation is created and ended by
522+ # on_operation_end at that time.
523+ self ._end_open_operation_spans ()
524+
525+ # The Workflow span (execution view) is a non-recording placeholder
526+ # during the invocation, so only a terminal status materializes and ends
527+ # the recording span. Its span status reflects the execution outcome:
528+ # SUCCEEDED -> OK, FAILED -> ERROR (RETRY/PENDING are non-terminal and
529+ # leave the Workflow span unexported until a later terminal invocation).
530+ if info .status in _TERMINAL_INVOCATION_STATUSES :
531+ self ._export_workflow_span (info )
482532
483533 self ._reset_state ()
484534
@@ -495,6 +545,7 @@ def _reset_state(self) -> None:
495545 self ._extracted_context = None
496546 self ._execution_trace_context = None
497547 self ._sampling_intent = None
548+ self ._execution_start_time = None
498549 self ._workflow_span = None
499550 self ._invocation_span = None
500551 with self ._lock :
0 commit comments