Skip to content

Commit 3ddb25e

Browse files
committed
feat(otel): parent durable spans to shared execution trace
1 parent 5b5b096 commit 3ddb25e

16 files changed

Lines changed: 1579 additions & 248 deletions

.github/scripts/tests/test_opentelemetry_conformance_workflow.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
WORKFLOW_PATH = (
55
Path(__file__).parents[2] / "workflows" / "opentelemetry-conformance-tests.yml"
66
)
7-
EXAMPLES_DIR = (
8-
".build/durable-sdk/packages/aws-durable-execution-sdk-python-conformance-tests-otel"
9-
)
7+
EXAMPLES_DIR = ".build/durable-sdk/packages/aws-durable-execution-sdk-python-conformance-tests-otel"
108

119

1210
def test_opentelemetry_conformance_caller_uses_current_workflow_contract() -> None:
@@ -66,8 +64,7 @@ def test_opentelemetry_conformance_runs_when_the_handlers_change() -> None:
6664
workflow = WORKFLOW_PATH.read_text()
6765

6866
trigger_path = (
69-
" - "
70-
'"packages/aws-durable-execution-sdk-python-conformance-tests-otel/**"'
67+
' - "packages/aws-durable-execution-sdk-python-conformance-tests-otel/**"'
7168
)
7269
# Once for pull_request, once for push.
7370
assert workflow.count(trigger_path) == 2

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

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
# AWS Durable Execution SDK - OpenTelemetry Plugin
22

3-
OpenTelemetry instrumentation plugin for the [AWS Durable Execution SDK for Python](https://github.com/aws/aws-durable-execution-sdk-python). Emits durable execution spans with deterministic workflow and operation IDs while keeping invocation spans in the ambient Lambda trace.
3+
OpenTelemetry instrumentation plugin for the [AWS Durable Execution SDK for Python](https://github.com/aws/aws-durable-execution-sdk-python). Emits durable execution spans on one execution trace, with deterministic workflow, synthetic-root, and operation span IDs.
44

55
## Features
66

7-
- **Deterministic Workflow Traces**: Durable operations use an execution-derived trace that is independent of the ambient Lambda/X-Ray trace
8-
- **Ambient Invocation Traces**: Invocation spans inherit the active Lambda or extracted upstream context
7+
- **Shared Execution Trace**: Workflow and Invocation spans share one trace, anchored to a propagated backend parent when available or a deterministic synthetic execution root otherwise
8+
- **Same-Trace Ambient Parenting**: Invocation spans use the active ambient span only when it already belongs to the execution trace
99
- **Span-per-Operation**: Each durable operation (step, wait, invoke) gets its own span with accurate timing
1010
- **Continuation Spans**: Operations completing in another invocation produce a new correlated span without fabricating an unobserved prior span context
1111
- **Log Correlation**: Enrich application logs with trace ID and span ID for end-to-end observability
1212
- **Provider Integration**: Use the global ADOT provider or supply an explicit SDK `TracerProvider`
13-
- **Provider-Managed Sampling**: Use standard OpenTelemetry or ADOT sampling configuration
13+
- **Execution Sampling**: Resolve sampling once per invocation and apply it consistently to Workflow, Invocation, operation, and attempt spans
1414

1515
## Installation
1616

@@ -124,7 +124,7 @@ fn = lambda_.Function(
124124
125125
### 2. AWS X-Ray Active Tracing
126126

127-
Enable active tracing on your Lambda function so the `_X_AMZN_TRACE_ID` environment variable is populated at invocation time. The plugin uses this header to derive deterministic trace IDs that remain consistent across all invocations of the same durable execution.
127+
Enable active tracing on your Lambda function so the `_X_AMZN_TRACE_ID` environment variable is populated at invocation time. The plugin uses this header to anchor the execution trace on the propagated X-Ray `Root`/`Parent` when both are valid, and preserves `Sampled=1` or `Sampled=0` as the backend sampling decision.
128128

129129
**AWS Console:** Lambda → Configuration → Monitoring and operations tools → Active tracing → Enable
130130

@@ -191,7 +191,7 @@ The function's execution role needs the `AWSXRayDaemonWriteAccess` managed polic
191191
| `OTEL_TRACES_SAMPLER` | Sampler to use (e.g., `traceidratio` for ratio-based sampling) | `always_on` |
192192
| `OTEL_TRACES_SAMPLER_ARG` | Argument for the sampler (e.g., `0.3` to sample 30% of traces) ||
193193

194-
See the [ADOT sampling configuration](https://aws-otel.github.io/docs/getting-started/lambda#sampling-configuration) for more details.
194+
See the [ADOT sampling configuration](https://aws-otel.github.io/docs/getting-started/lambda#sampling-configuration) for more details. When the backend header contains an explicit `Sampled` value, that backend decision takes precedence over local sampler configuration for durable spans.
195195

196196
## Configuration
197197

@@ -220,7 +220,15 @@ plugin = InvocationOtelPlugin(
220220

221221
### Context Extractors
222222

223-
The plugin supports multiple strategies for extracting upstream trace context:
223+
Context extractors return an `ExtractedContext` object, or `None` when no
224+
durable execution trace context is available. The object carries:
225+
226+
- `trace_id`: 128-bit OpenTelemetry trace ID
227+
- `parent_span_id`: 64-bit OpenTelemetry parent span ID
228+
- `sampling`: `Sampling.SAMPLED`, `Sampling.NOT_SAMPLED`, or `Sampling.UNDECIDED`
229+
230+
The plugin supports multiple strategies for extracting durable execution trace
231+
context:
224232

225233
```python
226234
from aws_durable_execution_sdk_python_otel import (
@@ -230,13 +238,65 @@ from aws_durable_execution_sdk_python_otel import (
230238
xray_context_extractor,
231239
)
232240

233-
# Default: X-Ray trace header (recommended for most Lambda deployments)
241+
# Default: X-Ray trace header (recommended for most Lambda deployments).
234242
InvocationOtelPlugin(OtelPluginConfig(context_extractor=xray_context_extractor))
235243

236-
# W3C Trace Context via clientContext (requires backend propagation support)
244+
# W3C Trace Context via clientContext (placeholder for backend propagation support).
237245
InvocationOtelPlugin(OtelPluginConfig(context_extractor=w3c_client_context_extractor))
238246
```
239247

248+
Custom extractors should return `ExtractedContext`, not an OpenTelemetry
249+
`Context`.
250+
251+
### Trace Structure
252+
253+
Both bundled plugins use the same execution ancestor:
254+
255+
- a propagated backend parent when `_X_AMZN_TRACE_ID` contains a valid `Root`
256+
and `Parent`
257+
- otherwise a deterministic, non-recording synthetic root derived from the
258+
durable execution ARN
259+
260+
`InvocationOtelPlugin` keeps durable operation spans under the Invocation span
261+
and links operations to Workflow:
262+
263+
```text
264+
Execution ancestor
265+
├── Workflow
266+
└── Invocation
267+
└── operation
268+
└── operation attempt 1
269+
```
270+
271+
`ExecutionOtelPlugin` keeps operation spans under Workflow and links operations
272+
to the current Invocation span:
273+
274+
```text
275+
Execution ancestor
276+
├── Workflow
277+
│ └── operation
278+
│ └── operation attempt 1
279+
└── Invocation
280+
```
281+
282+
If an ambient Lambda span is active and already has the execution trace ID, the
283+
Invocation span uses that ambient span as its parent. Ambient spans on a
284+
different trace are ignored for durable parenting so Invocation remains on the
285+
execution trace.
286+
287+
### Sampling
288+
289+
Sampling is resolved once per invocation and carried to every durable span in
290+
that invocation. Precedence is:
291+
292+
1. `Sampled=1` or `Sampled=0` from `_X_AMZN_TRACE_ID`
293+
2. a same-trace ambient span's recording/sampled state
294+
3. the configured OpenTelemetry sampler
295+
296+
The resolved decision is applied to Workflow, Invocation, operation, and attempt
297+
spans. This avoids independently querying stateful or ratio-based samplers for
298+
each durable span in the same invocation.
299+
240300
### Log Correlation
241301

242302
When `enrich_logger=True` (the default), the plugin installs a logging filter on
@@ -256,8 +316,9 @@ After deploying your function with the plugin configured:
256316

257317
1. **Invoke your durable function** — trigger at least one execution that includes multiple steps or a wait/resume cycle.
258318

259-
2. **Check the CloudWatch console** — Navigate to CloudWatch → Traces in the AWS Console. You should see a trace with:
260-
- An "invocation" span per invocation
319+
2. **Check the CloudWatch console** — Navigate to CloudWatch → Traces in the AWS Console. You should see an execution trace with:
320+
- A "Workflow" span exported on the terminal invocation
321+
- An "Invocation" span per invocation
261322
- Child spans for each durable operation (named after your step names)
262323
- All invocations of the same execution grouped under one trace ID
263324

@@ -272,15 +333,15 @@ After deploying your function with the plugin configured:
272333
| Symptom | Likely Cause |
273334
| --------------------------------- | --------------------------------------------------------------- |
274335
| No traces appear | ADOT layer not configured, or `AWS_LAMBDA_EXEC_WRAPPER` not set |
275-
| Traces appear but are fragmented | X-Ray active tracing not enabled on the Lambda function |
336+
| Traces appear but are fragmented | Backend trace context is not propagated to every invocation |
276337
| Missing spans for some operations | `OTEL_TRACES_SAMPLER_ARG` set below 1.0 |
277338
| `_X_AMZN_TRACE_ID` not populated | X-Ray active tracing not enabled |
278339

279340
## API Reference
280341

281342
### `InvocationOtelPlugin`
282343

283-
The main plugin class. Implements `DurableInstrumentationPlugin` from `aws_durable_execution_sdk_python`.
344+
Invocation-rooted view. Implements `DurableInstrumentationPlugin` from `aws_durable_execution_sdk_python`.
284345

285346
```python
286347
InvocationOtelPlugin(
@@ -297,21 +358,35 @@ InvocationOtelPlugin(
297358
Pass `tracer_provider=...` when the application owns the OpenTelemetry SDK
298359
provider. When omitted, the globally configured provider is used.
299360

361+
### `ExecutionOtelPlugin`
362+
363+
Execution-rooted view. Uses the same execution ancestor and sampling behavior as
364+
`InvocationOtelPlugin`, but parents operation spans under Workflow and links
365+
them to Invocation.
366+
300367
### `DeterministicIdGenerator`
301368

302369
A custom OpenTelemetry `IdGenerator` that produces reproducible trace and span IDs from execution metadata. Exported for advanced use cases.
303370

304371
### `xray_context_extractor`
305372

306-
Default context extractor. Reads the `_X_AMZN_TRACE_ID` environment variable to derive trace context.
373+
Default context extractor. Reads the `_X_AMZN_TRACE_ID` environment variable and
374+
returns `ExtractedContext` containing parsed `Root`, `Parent`, and `Sampled`
375+
fields when present.
307376

308377
### `w3c_client_context_extractor`
309378

310-
Alternative context extractor. Reads W3C `traceparent` from `context.clientContext.custom.traceparent`. Requires backend `clientContext` propagation to be enabled.
379+
Alternative context extractor placeholder. Returns `None` until backend W3C
380+
`traceparent` propagation is supported.
311381

312382
### `ContextExtractor`
313383

314-
Type alias for custom context extractor functions.
384+
Type alias for custom context extractor functions:
385+
`Callable[[InvocationStartInfo], ExtractedContext | None]`.
386+
387+
### `ExtractedContext` / `Sampling`
388+
389+
Structured trace context and sampling decision returned by context extractors.
315390

316391
### `OtelContextLogFilter` / `install_log_filter`
317392

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
from aws_durable_execution_sdk_python_otel.__about__ import __version__
44
from aws_durable_execution_sdk_python_otel.context_extractors import (
55
ContextExtractor,
6+
ExtractedContext,
7+
Sampling,
68
w3c_client_context_extractor,
79
xray_context_extractor,
810
)
911
from aws_durable_execution_sdk_python_otel.deterministic_id_generator import (
1012
DeterministicIdGenerator,
13+
derive_execution_root_span_id,
1114
derive_workflow_span_id,
1215
operation_id_to_span_id,
1316
)
@@ -35,11 +38,14 @@
3538
"ContextExtractor",
3639
"DeterministicIdGenerator",
3740
"ExecutionOtelPlugin",
41+
"ExtractedContext",
3842
"OtelPluginConfig",
3943
"InvocationOtelPlugin",
4044
"OtelContextLogFilter",
45+
"Sampling",
4146
"ProviderResult",
4247
"create_tracer_provider",
48+
"derive_execution_root_span_id",
4349
"derive_workflow_span_id",
4450
"install_log_filter",
4551
"operation_id_to_span_id",
Lines changed: 113 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,132 @@
1-
"""Context extractors for propagating trace context into durable executions."""
1+
"""Trace-context extractors for durable execution telemetry."""
22

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

8-
from opentelemetry import context as otel_context, propagate
9-
1010

1111
if TYPE_CHECKING:
12-
from opentelemetry.context import Context
13-
1412
from aws_durable_execution_sdk_python.plugin import InvocationStartInfo
1513

16-
ContextExtractor = Callable[["InvocationStartInfo"], "Context"]
1714

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

19-
def xray_context_extractor(info: "InvocationStartInfo") -> "Context":
20-
"""Read the X-Ray trace header from the _X_AMZN_TRACE_ID environment variable.
2155

22-
The durable execution backend propagates the same Root trace ID to every
23-
invocation, so all invocations share one traceId.
56+
def _ensure_extracted_context(extracted: object) -> ExtractedContext | None:
57+
"""Validate a context extractor result."""
58+
if extracted is None or isinstance(extracted, ExtractedContext):
59+
return extracted
60+
msg = "context extractor must return ExtractedContext or None"
61+
raise TypeError(msg)
62+
63+
64+
def _parse_xray_trace_id(root: str | None) -> int | None:
65+
if root is None:
66+
return None
67+
parts = root.split("-")
68+
if len(parts) != 3 or parts[0] != "1":
69+
return None
70+
trace_id_hex = f"{parts[1]}{parts[2]}"
71+
if len(trace_id_hex) != 32:
72+
return None
73+
try:
74+
trace_id = int(trace_id_hex, 16)
75+
except ValueError:
76+
return None
77+
return trace_id if 0 < trace_id < 2**128 else None
78+
79+
80+
def _parse_span_id(span_id_hex: str | None) -> int | None:
81+
if span_id_hex is None or len(span_id_hex) != 16:
82+
return None
83+
try:
84+
span_id = int(span_id_hex, 16)
85+
except ValueError:
86+
return None
87+
return span_id if 0 < span_id < 2**64 else None
88+
89+
90+
def _parse_sampling(value: str | None) -> Sampling:
91+
if value == "1":
92+
return Sampling.SAMPLED
93+
if value == "0":
94+
return Sampling.NOT_SAMPLED
95+
return Sampling.UNDECIDED
96+
97+
98+
def xray_context_extractor(info: "InvocationStartInfo") -> ExtractedContext | None:
99+
"""Read durable execution trace context from ``_X_AMZN_TRACE_ID``.
100+
101+
The Lambda durable execution backend propagates an X-Ray style header. A
102+
valid ``Root`` anchors the execution trace; a valid ``Parent`` becomes the
103+
remote execution ancestor; and ``Sampled`` is preserved as the backend's
104+
explicit sampling decision.
24105
"""
25106
trace_header = os.environ.get("_X_AMZN_TRACE_ID")
26107
if not trace_header:
27-
return otel_context.get_current()
28-
return propagate.extract(
29-
carrier={"X-Amzn-Trace-Id": trace_header},
30-
context=otel_context.get_current(),
31-
)
108+
return None
32109

110+
parts: dict[str, str] = {}
111+
for segment in trace_header.split(";"):
112+
key, separator, value = segment.partition("=")
113+
if separator:
114+
parts[key.strip()] = value.strip()
33115

34-
def w3c_client_context_extractor(info: "InvocationStartInfo") -> "Context":
35-
"""Read W3C traceparent from context.clientContext.custom.traceparent.
116+
trace_id = _parse_xray_trace_id(parts.get("Root"))
117+
parent_span_id = _parse_span_id(parts.get("Parent"))
118+
sampling = _parse_sampling(parts.get("Sampled"))
119+
if trace_id is None and parent_span_id is None and sampling is Sampling.UNDECIDED:
120+
return None
121+
return ExtractedContext(
122+
trace_id=trace_id,
123+
parent_span_id=parent_span_id,
124+
sampling=sampling,
125+
)
36126

37-
Requires the backend clientContext propagation to be enabled.
38-
This extractor is a placeholder for when backend propagation is supported.
39-
"""
40-
return otel_context.get_current()
127+
128+
def w3c_client_context_extractor(
129+
info: "InvocationStartInfo",
130+
) -> ExtractedContext | None:
131+
"""Placeholder for future W3C traceparent propagation support."""
132+
return None

0 commit comments

Comments
 (0)