diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py index b326ec77..837fe6b9 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py @@ -239,6 +239,7 @@ def _start_workflow_span(self, info: InvocationStartInfo) -> None: # Empty context => root span with no parent. self._workflow_span = self._tracer.start_span( name=self._workflow_span_name, + kind=SpanKind.INTERNAL, attributes={"durable.execution.arn": self._execution_arn}, start_time=start_time, context=Context(), @@ -291,9 +292,14 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None: # clears the span map below. # End the invocation span regardless of terminal status. Record the - # invocation status and map it to a span status: SUCCEEDED/PENDING are OK - # (this invocation did its work, whether it completed or suspended), - # RETRY/FAILED are ERROR (this invocation failed). + # invocation status and map it to a span status: + # SUCCEEDED/PENDING -> OK (this invocation did its work, whether it + # completed the execution or cleanly suspended) + # FAILED -> ERROR + # RETRY -> UNSET + # RETRY is left UNSET because the plugin interface cannot tell whether the + # execution/workflow was STOPPED or TIMED_OUT: a RETRY invocation is not a + # definitive failure of the execution, so we avoid marking the span ERROR. if self._invocation_span is not None: self._invocation_span.set_attribute( "durable.invocation.status", @@ -301,7 +307,7 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None: ) if info.status in (InvocationStatus.SUCCEEDED, InvocationStatus.PENDING): self._invocation_span.set_status(StatusCode.OK) - elif info.status in (InvocationStatus.RETRY, InvocationStatus.FAILED): + elif info.status is InvocationStatus.FAILED: self._invocation_span.set_status( StatusCode.ERROR, info.error.message if info.error else "" ) diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py index 675bc027..95f2b1f2 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py @@ -350,9 +350,14 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None: invocation_span.set_attribute( "durable.invocation.status", info.status.value ) + # Span status mapping: SUCCEEDED/PENDING -> OK, FAILED -> ERROR, + # RETRY -> UNSET. RETRY is left UNSET because the plugin interface + # cannot tell whether the execution/workflow was STOPPED or + # TIMED_OUT, so a RETRY invocation is not treated as a definitive + # failure of the execution. if info.status in (InvocationStatus.SUCCEEDED, InvocationStatus.PENDING): invocation_span.set_status(StatusCode.OK) - elif info.status in (InvocationStatus.RETRY, InvocationStatus.FAILED): + elif info.status is InvocationStatus.FAILED: invocation_span.set_status( StatusCode.ERROR, info.error.message if info.error else "" ) diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py index 7cd544eb..bfc6fd4d 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py @@ -324,13 +324,13 @@ def test_open_operation_span_not_exported_at_invocation_end(): [ (InvocationStatus.PENDING, trace.StatusCode.OK), (InvocationStatus.SUCCEEDED, trace.StatusCode.OK), - (InvocationStatus.RETRY, trace.StatusCode.ERROR), + (InvocationStatus.RETRY, trace.StatusCode.UNSET), (InvocationStatus.FAILED, trace.StatusCode.ERROR), ], ) def test_invocation_span_status_kind_and_attributes(status, expected_code): """Invocation span is INTERNAL, carries status/first; SUCCEEDED/PENDING are - OK and RETRY/FAILED are ERROR.""" + OK, FAILED is ERROR, and RETRY is UNSET (STOPPED/TIMED_OUT indistinguishable).""" plugin, exporter = _create_plugin() plugin.on_invocation_start(_invocation_start_info()) plugin.on_invocation_end(_invocation_end_info(status=status)) diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py index 8a243503..e906e681 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py @@ -175,7 +175,7 @@ def test_invocation_span_records_subsequent_invocation(): ("invocation_status", "expected_span_status"), [ (InvocationStatus.PENDING, StatusCode.OK), - (InvocationStatus.RETRY, StatusCode.ERROR), + (InvocationStatus.RETRY, StatusCode.UNSET), (InvocationStatus.SUCCEEDED, StatusCode.OK), (InvocationStatus.FAILED, StatusCode.ERROR), ],