Skip to content

Commit 30e7cb3

Browse files
author
Frank Chen
committed
Merge commit 'refs/maintenance-python-20261006/base' into maintenance/python-755-20261006
2 parents 1e02ff6 + 0b8992c commit 30e7cb3

6 files changed

Lines changed: 200 additions & 6 deletions

File tree

‎packages/aws-durable-execution-sdk-python-otel/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,15 @@ The resolved decision is applied to Workflow, Invocation, operation, and attempt
297297
spans. This avoids independently querying stateful or ratio-based samplers for
298298
each durable span in the same invocation.
299299

300+
### Status attributes
301+
302+
`durable.invocation.status` uses `RETRYING` when the core plugin hook reports
303+
`InvocationStatus.RETRY`. The core enum remains unchanged. Operation spans use
304+
OTel `OK` only for `SUCCEEDED`, `ERROR` when error details are delivered, and
305+
`UNSET` for other outcomes without error details, including `FAILED`,
306+
`CANCELLED`, `TIMED_OUT`, and `STOPPED`. The original durable operation status
307+
remains in `durable.operation.status`.
308+
300309
### Log Correlation
301310

302311
When `enrich_logger=True` (the default), the plugin installs a logging filter on

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
InvocationStartInfo,
4444
OperationEndInfo,
4545
OperationStartInfo,
46+
OperationStatus,
4647
OperationType,
4748
UserFunctionEndInfo,
4849
UserFunctionOutcome,
@@ -647,7 +648,9 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
647648
if self._invocation_span is not None:
648649
self._invocation_span.set_attribute(
649650
"durable.invocation.status",
650-
info.status.value if info.status else "",
651+
"RETRYING"
652+
if info.status is InvocationStatus.RETRY
653+
else info.status.value,
651654
)
652655
if info.status in (InvocationStatus.SUCCEEDED, InvocationStatus.PENDING):
653656
self._invocation_span.set_status(StatusCode.OK)
@@ -766,7 +769,7 @@ def on_operation_end(self, info: OperationEndInfo) -> None:
766769
span.record_exception(
767770
Exception(info.error.message or info.error.type or "Unknown error")
768771
)
769-
else:
772+
elif info.status is OperationStatus.SUCCEEDED:
770773
span.set_status(StatusCode.OK)
771774

772775
self._note_parent_end(info.parent_id, end_time)

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
InvocationStartInfo,
1515
OperationEndInfo,
1616
OperationStartInfo,
17+
OperationStatus,
1718
OperationType,
1819
UserFunctionEndInfo,
1920
UserFunctionOutcome,
@@ -716,7 +717,10 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
716717
invocation_span = self._get_span(None)
717718
if invocation_span:
718719
invocation_span.set_attribute(
719-
"durable.invocation.status", info.status.value
720+
"durable.invocation.status",
721+
"RETRYING"
722+
if info.status is InvocationStatus.RETRY
723+
else info.status.value,
720724
)
721725
# Span status mapping: SUCCEEDED/PENDING -> OK, FAILED -> ERROR,
722726
# RETRY -> UNSET. RETRY is left UNSET because the plugin interface
@@ -820,7 +824,7 @@ def on_operation_end(self, info: OperationEndInfo) -> None:
820824
span.record_exception(
821825
Exception(info.error.message or info.error.type or "Unknown error")
822826
)
823-
else:
827+
elif info.status is OperationStatus.SUCCEEDED:
824828
span.set_status(StatusCode.OK)
825829

826830
self._end_span(info.operation_id, info.end_time)

‎packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1123,7 +1123,9 @@ def test_invocation_span_status_kind_and_attributes(status, expected_code):
11231123
invocation = {s.name: s for s in exporter.get_finished_spans()}["Invocation"]
11241124
assert invocation.kind is trace.SpanKind.INTERNAL
11251125
assert invocation.attributes is not None
1126-
assert invocation.attributes["durable.invocation.status"] == status.value
1126+
assert invocation.attributes["durable.invocation.status"] == (
1127+
"RETRYING" if status is InvocationStatus.RETRY else status.value
1128+
)
11271129
assert invocation.attributes["durable.invocation.first"] is True
11281130
assert invocation.status.status_code is expected_code
11291131

‎packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,11 @@ def test_invocation_span_status_reflects_execution_status(
563563
invocation = next(s for s in spans if s.name == "Invocation")
564564
attributes = invocation.attributes
565565
assert attributes is not None
566-
assert attributes["durable.invocation.status"] == invocation_status.value
566+
assert attributes["durable.invocation.status"] == (
567+
"RETRYING"
568+
if invocation_status is InvocationStatus.RETRY
569+
else invocation_status.value
570+
)
567571
assert invocation.status.status_code is expected_span_status
568572

569573

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Shared status regressions through both public plugin lifecycles."""
2+
3+
from collections.abc import Iterator
4+
from datetime import UTC, datetime, timedelta
5+
from typing import Any
6+
7+
import pytest
8+
from aws_durable_execution_sdk_python.lambda_service import (
9+
ErrorObject,
10+
OperationStatus,
11+
OperationSubType,
12+
)
13+
from aws_durable_execution_sdk_python.plugin import (
14+
InvocationEndInfo,
15+
InvocationStartInfo,
16+
InvocationStatus,
17+
OperationEndInfo,
18+
OperationStartInfo,
19+
OperationType,
20+
)
21+
from opentelemetry import context
22+
from opentelemetry.sdk.trace import TracerProvider
23+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
24+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
25+
from opentelemetry.trace import StatusCode
26+
27+
from aws_durable_execution_sdk_python_otel import (
28+
ExecutionOtelPlugin,
29+
InvocationOtelPlugin,
30+
OtelPluginConfig,
31+
)
32+
33+
34+
START = datetime(2026, 10, 1, tzinfo=UTC)
35+
END = START + timedelta(seconds=1)
36+
ARN = (
37+
"arn:aws:lambda:us-west-2:123456789012:function:status:1/durable-execution/test/id"
38+
)
39+
40+
41+
InstrumentedView = tuple[
42+
ExecutionOtelPlugin | InvocationOtelPlugin, InMemorySpanExporter
43+
]
44+
45+
46+
@pytest.fixture(params=[ExecutionOtelPlugin, InvocationOtelPlugin])
47+
def instrumented_view(request: pytest.FixtureRequest) -> Iterator[InstrumentedView]:
48+
exporter = InMemorySpanExporter()
49+
provider = TracerProvider()
50+
provider.add_span_processor(SimpleSpanProcessor(exporter))
51+
plugin = request.param(
52+
OtelPluginConfig(
53+
tracer_provider=provider,
54+
context_extractor=lambda _: None,
55+
enrich_logger=False,
56+
)
57+
)
58+
original = context.get_current()
59+
yield plugin, exporter
60+
assert context.get_current() == original
61+
provider.shutdown()
62+
63+
64+
@pytest.mark.parametrize(
65+
"status",
66+
[
67+
OperationStatus.SUCCEEDED,
68+
OperationStatus.FAILED,
69+
OperationStatus.CANCELLED,
70+
OperationStatus.TIMED_OUT,
71+
OperationStatus.STOPPED,
72+
],
73+
)
74+
@pytest.mark.parametrize("with_error", [False, True])
75+
@pytest.mark.parametrize("started_here", [False, True])
76+
def test_operation_status_and_exception(
77+
instrumented_view: InstrumentedView,
78+
status: OperationStatus,
79+
with_error: bool,
80+
started_here: bool,
81+
) -> None:
82+
"""A delivered terminal hook must not label an unsuccessful operation OK."""
83+
plugin, exporter = instrumented_view
84+
invocation: dict[str, Any] = dict(
85+
request_id="first",
86+
execution_arn=ARN,
87+
execution_start_time=START,
88+
is_first_invocation=True,
89+
)
90+
plugin.on_invocation_start(InvocationStartInfo(**invocation))
91+
operation: dict[str, Any] = dict(
92+
operation_id="wait",
93+
name="wait",
94+
parent_id=None,
95+
operation_type=OperationType.WAIT,
96+
sub_type=OperationSubType.WAIT,
97+
start_time=START,
98+
is_replayed=False,
99+
)
100+
if started_here:
101+
plugin.on_operation_start(
102+
OperationStartInfo(**operation, status=OperationStatus.STARTED)
103+
)
104+
error = (
105+
ErrorObject(
106+
message="deadline exceeded",
107+
type="TimeoutError",
108+
data=None,
109+
stack_trace=None,
110+
)
111+
if with_error
112+
else None
113+
)
114+
plugin.on_operation_end(
115+
OperationEndInfo(**operation, end_time=END, status=status, error=error)
116+
)
117+
plugin.on_invocation_end(
118+
InvocationEndInfo(**invocation, status=InvocationStatus.SUCCEEDED)
119+
)
120+
span = next(s for s in exporter.get_finished_spans() if s.name == "wait")
121+
assert span.attributes is not None
122+
assert span.attributes["durable.operation.status"] == status.value
123+
expected = (
124+
StatusCode.ERROR
125+
if with_error
126+
else (
127+
StatusCode.OK if status is OperationStatus.SUCCEEDED else StatusCode.UNSET
128+
)
129+
)
130+
assert span.status.status_code is expected
131+
if with_error:
132+
assert span.status.description == "deadline exceeded"
133+
assert len(span.events) == 1
134+
assert span.events[0].name == "exception"
135+
assert span.events[0].attributes is not None
136+
assert span.events[0].attributes["exception.message"] == "deadline exceeded"
137+
else:
138+
assert not span.events
139+
140+
141+
def test_retry_then_resume_exports_retrying(
142+
instrumented_view: InstrumentedView,
143+
) -> None:
144+
"""Telemetry normalizes RETRY without changing the core enum or next invocation."""
145+
plugin, exporter = instrumented_view
146+
original = context.get_current()
147+
for index, status in enumerate(
148+
[InvocationStatus.RETRY, InvocationStatus.SUCCEEDED]
149+
):
150+
invocation: dict[str, Any] = dict(
151+
request_id=f"request-{index}",
152+
execution_arn=ARN,
153+
execution_start_time=START,
154+
is_first_invocation=index == 0,
155+
)
156+
plugin.on_invocation_start(InvocationStartInfo(**invocation))
157+
plugin.on_invocation_end(InvocationEndInfo(**invocation, status=status))
158+
assert context.get_current() == original
159+
spans = [s for s in exporter.get_finished_spans() if s.name == "Invocation"]
160+
assert [
161+
s.attributes["durable.invocation.status"]
162+
for s in spans
163+
if s.attributes is not None
164+
] == [
165+
"RETRYING",
166+
"SUCCEEDED",
167+
]
168+
assert [s.status.status_code for s in spans] == [StatusCode.UNSET, StatusCode.OK]
169+
assert InvocationStatus.RETRY.value == "RETRY"
170+
assert spans[0].context is not None
171+
assert spans[1].context is not None
172+
assert spans[0].context.trace_id == spans[1].context.trace_id

0 commit comments

Comments
 (0)