2626 UserFunctionStartInfo ,
2727)
2828from opentelemetry import baggage , trace
29+ from opentelemetry .context import Context
2930from opentelemetry .sdk .trace import TracerProvider
3031from opentelemetry .sdk .trace .export import SimpleSpanProcessor
3132from opentelemetry .sdk .trace .export .in_memory_span_exporter import InMemorySpanExporter
33+ from opentelemetry .trace import (
34+ NonRecordingSpan ,
35+ SpanContext ,
36+ TraceFlags ,
37+ TraceState ,
38+ )
3239
3340from aws_durable_execution_sdk_python_otel .deterministic_id_generator import (
41+ _to_otel_trace_id ,
3442 derive_execution_root_span_id ,
3543 derive_workflow_span_id ,
3644 operation_id_to_span_id ,
@@ -343,14 +351,20 @@ def test_operation_parented_under_workflow_and_linked_to_invocation():
343351 assert invocation .context .span_id in linked_span_ids
344352
345353
346- def test_cross_invocation_operation_end_uses_deterministic_span_id ():
354+ def test_cross_invocation_operation_end_links_previous_logical_operation ():
355+ """A continuation uses a fresh ID and links the deterministic operation."""
347356 plugin , exporter = _create_plugin ()
348357 plugin .on_invocation_start (_invocation_start_info ())
358+ operation_id = "step-earlier"
359+ random_span_id = int ("1234567890abcdef" , 16 )
360+ plugin ._id_generator ._fallback_id_generator .generate_span_id = lambda : (
361+ random_span_id
362+ )
349363
350364 # Backend-updated completion for an operation started in a prior invocation.
351365 plugin .on_operation_end (
352366 OperationEndInfo (
353- operation_id = "step-earlier" ,
367+ operation_id = operation_id ,
354368 operation_type = OperationType .STEP ,
355369 sub_type = None ,
356370 name = "earlier-step" ,
@@ -365,12 +379,16 @@ def test_cross_invocation_operation_end_uses_deterministic_span_id():
365379 plugin .on_invocation_end (_invocation_end_info ())
366380
367381 matching = [s for s in exporter .get_finished_spans () if s .name == "earlier-step" ]
368- # Exported exactly once, using the deterministic logical-operation span ID
369- # (no separate continuation span) .
382+ # Exported exactly once, with a fresh span ID (not the deterministic one) so
383+ # a later terminal completion cannot collide with this segment .
370384 assert len (matching ) == 1
371- assert matching [0 ].context .span_id == operation_id_to_span_id (
372- EXECUTION_ARN , "step-earlier"
385+ assert matching [0 ].context .span_id == random_span_id
386+ assert matching [0 ].context .span_id != operation_id_to_span_id (
387+ EXECUTION_ARN , operation_id
373388 )
389+ # Links back to the deterministic logical operation context for correlation.
390+ linked_span_ids = {link .context .span_id for link in matching [0 ].links }
391+ assert operation_id_to_span_id (EXECUTION_ARN , operation_id ) in linked_span_ids
374392
375393
376394@pytest .mark .parametrize (
@@ -621,6 +639,144 @@ def test_open_operation_span_ended_at_invocation_end():
621639 assert "wait-for-signal" in exported
622640
623641
642+ def test_suspend_then_resume_operation_exports_unique_span_ids ():
643+ """An operation that suspends then resumes never reuses a span ID.
644+
645+ Invocation N starts the operation and suspends: its deterministic-ID span
646+ is ended at invocation end. Invocation N+1 replays the still-open operation
647+ (on_operation_replay -> on_operation_start with is_replayed=True) and then
648+ completes it. Each exported segment must carry a distinct span ID so no two
649+ exported spans collide on one span_id/trace_id.
650+ """
651+ plugin , exporter = _create_plugin ()
652+ operation_id = "wait-across-invocations"
653+ replay_span_id = int ("1111111111111111" , 16 )
654+ resume_span_id = int ("2222222222222222" , 16 )
655+
656+ # Invocation N: operation starts and suspends (non-terminal invocation end).
657+ plugin .on_invocation_start (_invocation_start_info ())
658+ plugin .on_operation_start (
659+ OperationStartInfo (
660+ operation_id = operation_id ,
661+ operation_type = OperationType .WAIT ,
662+ sub_type = OperationSubType .WAIT ,
663+ name = "long-wait" ,
664+ parent_id = None ,
665+ start_time = START_TIME ,
666+ is_replayed = False ,
667+ status = OperationStatus .STARTED ,
668+ )
669+ )
670+ plugin .on_invocation_end (_invocation_end_info (status = InvocationStatus .PENDING ))
671+
672+ # Invocation N+1: the still-open operation is replayed, then completes.
673+ plugin .on_invocation_start (_invocation_start_info ())
674+ plugin ._id_generator ._fallback_id_generator .generate_span_id = lambda : (
675+ replay_span_id
676+ )
677+ plugin .on_operation_start (
678+ OperationStartInfo (
679+ operation_id = operation_id ,
680+ operation_type = OperationType .WAIT ,
681+ sub_type = OperationSubType .WAIT ,
682+ name = "long-wait" ,
683+ parent_id = None ,
684+ start_time = START_TIME ,
685+ is_replayed = True ,
686+ status = OperationStatus .STARTED ,
687+ )
688+ )
689+ plugin ._id_generator ._fallback_id_generator .generate_span_id = lambda : (
690+ resume_span_id
691+ )
692+ plugin .on_operation_end (
693+ OperationEndInfo (
694+ operation_id = operation_id ,
695+ operation_type = OperationType .WAIT ,
696+ sub_type = OperationSubType .WAIT ,
697+ name = "long-wait" ,
698+ parent_id = None ,
699+ start_time = START_TIME ,
700+ is_replayed = False ,
701+ status = OperationStatus .SUCCEEDED ,
702+ end_time = END_TIME ,
703+ error = None ,
704+ )
705+ )
706+ plugin .on_invocation_end (_invocation_end_info (status = InvocationStatus .SUCCEEDED ))
707+
708+ operation_spans = [
709+ s for s in exporter .get_finished_spans () if s .name == "long-wait"
710+ ]
711+ span_ids = [s .context .span_id for s in operation_spans ]
712+ # Every exported segment has a unique span ID (no reuse across invocations).
713+ assert len (span_ids ) == len (set (span_ids ))
714+ # The first segment used the deterministic ID; every continuation used a
715+ # fresh ID and links back to the deterministic logical operation context.
716+ deterministic_id = operation_id_to_span_id (EXECUTION_ARN , operation_id )
717+ assert deterministic_id in span_ids
718+ for span in operation_spans :
719+ if span .context .span_id != deterministic_id :
720+ linked = {link .context .span_id for link in span .links }
721+ assert deterministic_id in linked
722+
723+
724+ def test_pre_terminal_placeholder_preserves_same_trace_tracestate ():
725+ """The Workflow placeholder and operation links carry ambient tracestate.
726+
727+ A same-trace ambient span carries vendor ``tracestate``. On a non-terminal
728+ invocation the recording Workflow span is never exported, so the placeholder
729+ is the only Workflow context that parents operation spans; it (and the
730+ deterministic operation link a continuation emits) must carry that resolved
731+ tracestate rather than the empty ancestor state.
732+ """
733+ plugin , exporter = _create_plugin ()
734+ canonical = _to_otel_trace_id (EXECUTION_ARN , START_TIME )
735+ trace_state = TraceState ([("vendor" , "opaque" )])
736+ ambient_context = SpanContext (
737+ trace_id = canonical ,
738+ span_id = int ("1234567890abcdef" , 16 ),
739+ is_remote = False ,
740+ trace_flags = TraceFlags (TraceFlags .SAMPLED ),
741+ trace_state = trace_state ,
742+ )
743+ ambient = NonRecordingSpan (ambient_context )
744+ token = otel_context .attach (trace .set_span_in_context (ambient , Context ()))
745+ try :
746+ plugin .on_invocation_start (_invocation_start_info ())
747+ # Placeholder Workflow span carries the resolved tracestate.
748+ assert plugin ._workflow_span is not None
749+ assert plugin ._workflow_span .get_span_context ().trace_state == trace_state
750+ # A cross-invocation completion links the deterministic operation
751+ # context, which must also carry the resolved tracestate.
752+ plugin .on_operation_end (
753+ OperationEndInfo (
754+ operation_id = "wait-existing" ,
755+ operation_type = OperationType .WAIT ,
756+ sub_type = OperationSubType .WAIT ,
757+ name = "existing-wait" ,
758+ parent_id = None ,
759+ start_time = START_TIME ,
760+ is_replayed = False ,
761+ status = OperationStatus .SUCCEEDED ,
762+ end_time = END_TIME ,
763+ error = None ,
764+ )
765+ )
766+ plugin .on_invocation_end (_invocation_end_info (status = InvocationStatus .PENDING ))
767+ finally :
768+ otel_context .detach (token )
769+
770+ span = next (s for s in exporter .get_finished_spans () if s .name == "existing-wait" )
771+ operation_link = next (
772+ link
773+ for link in span .links
774+ if link .context .span_id
775+ == operation_id_to_span_id (EXECUTION_ARN , "wait-existing" )
776+ )
777+ assert operation_link .context .trace_state == trace_state
778+
779+
624780@pytest .mark .parametrize (
625781 ("status" , "expected_code" ),
626782 [
0 commit comments