Skip to content

Commit b29edad

Browse files
author
Ayushi Ahjolia
authored
feat(otel): add trace-context types + resolver
1 parent 933e6d6 commit b29edad

5 files changed

Lines changed: 396 additions & 3 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from __future__ import annotations
44

55
import os
6+
from dataclasses import dataclass
7+
from enum import Enum
68
from typing import TYPE_CHECKING, Callable
79

810
from opentelemetry import context as otel_context, propagate
@@ -13,6 +15,45 @@
1315

1416
from aws_durable_execution_sdk_python.plugin import InvocationStartInfo
1517

18+
19+
class Sampling(Enum):
20+
"""Sampling decision propagated by the durable execution backend."""
21+
22+
SAMPLED = "sampled"
23+
NOT_SAMPLED = "not_sampled"
24+
UNDECIDED = "undecided"
25+
26+
27+
@dataclass(frozen=True)
28+
class ExtractedContext:
29+
"""Trace context extracted from the durable execution backend.
30+
31+
Attributes:
32+
trace_id: OTel 128-bit trace ID, or ``None`` when no valid trace ID was
33+
present.
34+
parent_span_id: OTel 64-bit parent span ID, or ``None`` when no valid
35+
parent was present.
36+
sampling: Explicit backend sampling decision, or ``UNDECIDED`` when
37+
the backend header did not include one.
38+
"""
39+
40+
trace_id: int | None
41+
parent_span_id: int | None
42+
sampling: Sampling = Sampling.UNDECIDED
43+
44+
@property
45+
def has_valid_trace_id(self) -> bool:
46+
return self.trace_id is not None and 0 < self.trace_id < 2**128
47+
48+
@property
49+
def has_valid_parent_span_id(self) -> bool:
50+
return self.parent_span_id is not None and 0 < self.parent_span_id < 2**64
51+
52+
@property
53+
def has_complete_remote_parent(self) -> bool:
54+
return self.has_valid_trace_id and self.has_valid_parent_span_id
55+
56+
1657
ContextExtractor = Callable[["InvocationStartInfo"], "Context"]
1758

1859

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ class _IdOverride:
2626
def _to_otel_trace_id(execution_arn: str, start_timestamp: datetime) -> int:
2727
"""Build a deterministic OTel-compatible execution trace ID (128 bits).
2828
29-
The ID is independent of ambient Lambda or X-Ray trace context so the
30-
parentless Workflow span remains the only root of the durable execution
31-
trace. Invocation spans inherit ambient context separately.
29+
The ID is used when the backend does not provide a valid trace ID. In that
30+
case a deterministic synthetic execution root anchors the durable execution
31+
trace across reinvocations.
3232
3333
Raises:
3434
ValueError: If the execution start timestamp is missing.
@@ -80,6 +80,22 @@ def derive_workflow_span_id(durable_execution_arn: str) -> int:
8080
return span_id or 1
8181

8282

83+
def derive_execution_root_span_id(durable_execution_arn: str) -> int:
84+
"""Derive the deterministic synthetic execution-root span ID.
85+
86+
The synthetic root is a non-recording parent context used when the backend
87+
does not provide a complete remote parent. Its ID is stable across
88+
reinvocations and uses a namespace distinct from Workflow and operation
89+
span IDs.
90+
"""
91+
if not durable_execution_arn:
92+
raise ValueError("execution ARN is required to derive an execution root ID")
93+
plain_value = f"execution-root:{durable_execution_arn}"
94+
hashed = hashlib.blake2b(plain_value.encode()).hexdigest()[:16]
95+
span_id = int(hashed, 16)
96+
return span_id or 1
97+
98+
8399
class DeterministicIdGenerator(RandomIdGenerator):
84100
"""An ID generator with invocation-scoped deterministic ID overrides.
85101
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Execution trace ancestry for durable execution telemetry."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable
6+
from dataclasses import dataclass
7+
from datetime import datetime
8+
9+
from opentelemetry.trace import SpanContext, TraceFlags, TraceState
10+
11+
from aws_durable_execution_sdk_python_otel.context_extractors import (
12+
ExtractedContext,
13+
Sampling,
14+
)
15+
from aws_durable_execution_sdk_python_otel.deterministic_id_generator import (
16+
_to_otel_trace_id,
17+
derive_execution_root_span_id,
18+
)
19+
20+
21+
@dataclass(frozen=True)
22+
class ExecutionTraceContext:
23+
"""Common ancestor for Workflow and Invocation spans."""
24+
25+
execution_ancestor: SpanContext
26+
27+
@property
28+
def trace_id(self) -> int:
29+
return self.execution_ancestor.trace_id
30+
31+
@property
32+
def trace_flags(self) -> TraceFlags:
33+
return self.execution_ancestor.trace_flags
34+
35+
@classmethod
36+
def resolve(
37+
cls,
38+
*,
39+
extracted: ExtractedContext | None,
40+
canonical_trace_id: int,
41+
execution_arn: str,
42+
root_sampled: Callable[[], bool],
43+
) -> "ExecutionTraceContext":
44+
"""Resolve the execution ancestor.
45+
46+
A complete extracted remote parent is authoritative. Otherwise a
47+
deterministic synthetic root anchors all invocations of the execution on
48+
the same trace.
49+
"""
50+
sampling = extracted.sampling if extracted is not None else Sampling.UNDECIDED
51+
trace_flags = _trace_flags(sampling, root_sampled)
52+
if extracted is not None and extracted.has_complete_remote_parent:
53+
return cls(
54+
SpanContext(
55+
trace_id=canonical_trace_id,
56+
span_id=extracted.parent_span_id or 0,
57+
is_remote=True,
58+
trace_flags=trace_flags,
59+
trace_state=TraceState(),
60+
)
61+
)
62+
63+
return cls(
64+
SpanContext(
65+
trace_id=canonical_trace_id,
66+
span_id=derive_execution_root_span_id(execution_arn),
67+
is_remote=False,
68+
trace_flags=trace_flags,
69+
trace_state=TraceState(),
70+
)
71+
)
72+
73+
74+
def canonical_trace_id(
75+
*,
76+
extracted: ExtractedContext | None,
77+
execution_arn: str,
78+
execution_start_time: datetime,
79+
) -> int:
80+
"""Return the stable trace ID for this durable execution."""
81+
if extracted is not None and extracted.has_valid_trace_id:
82+
return extracted.trace_id or 0
83+
return _to_otel_trace_id(execution_arn, execution_start_time)
84+
85+
86+
def _trace_flags(
87+
sampling: Sampling,
88+
root_sampled: Callable[[], bool],
89+
) -> TraceFlags:
90+
if sampling is Sampling.SAMPLED:
91+
return TraceFlags(TraceFlags.SAMPLED)
92+
if sampling is Sampling.NOT_SAMPLED:
93+
return TraceFlags(TraceFlags.DEFAULT)
94+
return (
95+
TraceFlags(TraceFlags.SAMPLED)
96+
if root_sampled()
97+
else TraceFlags(TraceFlags.DEFAULT)
98+
)

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from aws_durable_execution_sdk_python_otel.deterministic_id_generator import (
1313
DeterministicIdGenerator,
1414
_to_otel_trace_id,
15+
derive_execution_root_span_id,
16+
derive_workflow_span_id,
1517
operation_id_to_span_id,
1618
)
1719

@@ -303,3 +305,37 @@ async def main() -> tuple[tuple[int, int], tuple[int, int]]:
303305

304306
assert result_a == (task_a_trace_id, task_a_span_id)
305307
assert result_b == (task_b_trace_id, task_b_span_id)
308+
309+
310+
# ---------------------------------------------------------------------------
311+
# derive_execution_root_span_id
312+
# ---------------------------------------------------------------------------
313+
_ROOT_ARN = "test-arn/execution-root"
314+
315+
316+
def test_derive_execution_root_span_id_is_deterministic():
317+
assert derive_execution_root_span_id(_ROOT_ARN) == derive_execution_root_span_id(
318+
_ROOT_ARN
319+
)
320+
321+
322+
def test_derive_execution_root_span_id_differs_by_arn():
323+
assert derive_execution_root_span_id(_ROOT_ARN) != derive_execution_root_span_id(
324+
_ROOT_ARN + "-other"
325+
)
326+
327+
328+
def test_derive_execution_root_span_id_is_64_bit():
329+
span_id = derive_execution_root_span_id(_ROOT_ARN)
330+
assert 0 < span_id < 2**64
331+
332+
333+
def test_derive_execution_root_span_id_rejects_empty_arn():
334+
with pytest.raises(ValueError, match="execution ARN is required"):
335+
derive_execution_root_span_id("")
336+
337+
338+
def test_derive_execution_root_span_id_differs_from_workflow_span_id():
339+
assert derive_execution_root_span_id(_ROOT_ARN) != derive_workflow_span_id(
340+
_ROOT_ARN
341+
)

0 commit comments

Comments
 (0)