Skip to content

Commit 9952f89

Browse files
committed
fix(otel): scope deterministic IDs to plugin tracers
1 parent c7b494e commit 9952f89

8 files changed

Lines changed: 425 additions & 260 deletions

File tree

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

Lines changed: 50 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,26 @@
66
import hashlib
77
import os
88
import re
9+
from collections.abc import Iterator
10+
from contextlib import contextmanager
11+
from dataclasses import dataclass
912
from datetime import UTC, datetime
1013
from typing import TYPE_CHECKING
1114

1215
from opentelemetry.sdk.trace import IdGenerator, RandomIdGenerator
1316

1417

1518
if TYPE_CHECKING:
16-
from opentelemetry.sdk.trace import TracerProvider
19+
from opentelemetry.sdk.trace import Tracer as SdkTracer
1720

1821

1922
HASHED_ID_PATTERN = re.compile(r"^[0-9a-f]{16}$")
2023

21-
# Scoping the pending span ID to the execution context ensures concurrent
22-
# operations cannot consume each other's deterministic span ID.
23-
_next_span_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
24-
"next_span_id", default=None
25-
)
24+
25+
@dataclass(frozen=True)
26+
class _IdOverride:
27+
trace_id: int | None
28+
span_id: int | None
2629

2730

2831
def _parse_xray_root_trace_id(trace_header: str | None) -> str | None:
@@ -112,13 +115,12 @@ def derive_workflow_span_id(durable_execution_arn: str) -> int:
112115

113116

114117
class DeterministicIdGenerator(RandomIdGenerator):
115-
"""An ID generator that produces deterministic span IDs when a pending
116-
operation ID is set, and falls back to the provided generator otherwise.
118+
"""An ID generator with invocation-scoped deterministic ID overrides.
117119
118-
Trace IDs are deterministic when an execution ARN is set, ensuring all
119-
invocations of the same durable execution share a single trace. When no
120-
deterministic ID is available, generation is delegated to the fallback
121-
generator (the tracer provider's original ID generator by default).
120+
Deterministic IDs are active only inside :meth:`use_ids`. All other
121+
generation is delegated to the fallback generator. The override is stored
122+
in a context variable so concurrent threads and async tasks cannot consume
123+
or overwrite each other's IDs.
122124
123125
Trace IDs embed a real timestamp so they satisfy the X-Ray format
124126
requirement (first 8 hex chars = Unix epoch seconds).
@@ -129,54 +131,56 @@ class DeterministicIdGenerator(RandomIdGenerator):
129131
"""
130132

131133
def __init__(self, fallback_id_generator: IdGenerator | None = None) -> None:
132-
self._execution_trace_id: int | None = None
133134
self._fallback_id_generator = fallback_id_generator or RandomIdGenerator()
135+
self._id_override: contextvars.ContextVar[_IdOverride | None] = (
136+
contextvars.ContextVar("durable_execution_id_override", default=None)
137+
)
134138

135139
@classmethod
136-
def install_on_provider(cls, provider: TracerProvider) -> DeterministicIdGenerator:
137-
"""Return the provider's deterministic generator, installing one if needed.
140+
def install_on_tracer(cls, tracer: SdkTracer) -> DeterministicIdGenerator:
141+
"""Return the tracer's deterministic generator, installing one if needed.
138142
139-
OpenTelemetry tracers capture the provider's ID generator when they are
140-
created. Reusing an installed generator ensures multiple plugin instances
141-
with the same instrumentation scope configure the generator referenced by
142-
the provider's cached tracer.
143+
Installing on the plugin's tracer keeps unrelated instrumentation scopes
144+
on the provider's original generator. Reusing an installed generator also
145+
supports SDK versions that cache tracers by instrumentation scope.
143146
"""
144-
current_generator = provider.id_generator
147+
current_generator = tracer.id_generator
145148
if isinstance(current_generator, cls):
146149
return current_generator
147150

148151
generator = cls(fallback_id_generator=current_generator)
149-
provider.id_generator = generator
152+
tracer.id_generator = generator
150153
return generator
151154

152-
def set_next_span_id(self, span_id: int | None) -> None:
153-
"""Set the operation ID to use for the next span's ID.
154-
155-
After one span is created, it resets to random.
156-
"""
157-
_next_span_id.set(span_id)
158-
159-
def set_trace_id(
160-
self, execution_arn: str, start_timestamp: datetime | None
161-
) -> None:
162-
"""Compute and cache the deterministic trace ID for this execution.
163-
164-
Args:
165-
execution_arn: The durable execution ARN (used for the hash portion).
166-
start_timestamp: start time of invocation
167-
"""
168-
self._execution_trace_id = _to_otel_trace_id(execution_arn, start_timestamp)
155+
@contextmanager
156+
def use_ids(self, *, trace_id: int | None, span_id: int | None) -> Iterator[None]:
157+
"""Temporarily override IDs generated in the current execution context."""
158+
token = self._id_override.set(_IdOverride(trace_id, span_id))
159+
try:
160+
yield
161+
finally:
162+
self._id_override.reset(token)
169163

170164
def generate_trace_id(self) -> int:
171165
"""Generate a 128-bit trace ID."""
172-
return (
173-
self._execution_trace_id or self._fallback_id_generator.generate_trace_id()
174-
)
166+
override = self._id_override.get()
167+
if override is not None and override.trace_id is not None:
168+
return override.trace_id
169+
return self._fallback_id_generator.generate_trace_id()
175170

176171
def generate_span_id(self) -> int:
177172
"""Generate a 64-bit span ID."""
178-
span_id = _next_span_id.get()
179-
# Consume once: the deterministic ID applies only to the next span
180-
# created in this context; subsequent spans fall back to random.
181-
_next_span_id.set(None)
182-
return span_id or self._fallback_id_generator.generate_span_id()
173+
override = self._id_override.get()
174+
if override is not None and override.span_id is not None:
175+
return override.span_id
176+
return self._fallback_id_generator.generate_span_id()
177+
178+
def is_trace_id_random(self) -> bool:
179+
"""Report whether the current trace ID is randomly generated."""
180+
override = self._id_override.get()
181+
if override is not None and override.trace_id is not None:
182+
return False
183+
fallback_method = getattr(
184+
self._fallback_id_generator, "is_trace_id_random", None
185+
)
186+
return bool(fallback_method()) if fallback_method is not None else False

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

Lines changed: 53 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
)
5858
from aws_durable_execution_sdk_python_otel.deterministic_id_generator import (
5959
DeterministicIdGenerator,
60+
_to_otel_trace_id,
6061
derive_workflow_span_id,
6162
operation_id_to_span_id,
6263
)
@@ -104,40 +105,39 @@ def __init__(self, config: OtelPluginConfig | None = None) -> None:
104105
)
105106
self._workflow_span_name = self._config.workflow_span_name
106107

107-
self._id_generator = DeterministicIdGenerator()
108-
result = create_tracer_provider(
109-
self._config,
110-
id_generator=self._id_generator,
111-
)
108+
result = create_tracer_provider(self._config)
112109
self._provider = result.tracer_provider
113110
# GLOBAL (ADOT) mode parents the Invocation span to the ambient Lambda
114111
# invocation span instead of the Workflow span (see
115112
# _start_invocation_span).
116113
self._provider_source = result.source
117114

118-
# Deterministic stitching requires an SDK provider exposing id_generator.
119-
from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
115+
self._tracer: Tracer = self._provider.get_tracer(self._config.instrument_name)
116+
117+
# Deterministic stitching is scoped to this instrumentation tracer so
118+
# unrelated tracers on the same provider keep their original generator.
119+
from opentelemetry.sdk.trace import Tracer as SdkTracer
120120

121-
if isinstance(self._provider, SdkTracerProvider):
122-
self._id_generator = DeterministicIdGenerator.install_on_provider(
123-
self._provider
121+
self._id_generator = DeterministicIdGenerator()
122+
if isinstance(self._tracer, SdkTracer):
123+
self._id_generator = DeterministicIdGenerator.install_on_tracer(
124+
self._tracer
124125
)
125126
else:
126127
logger.warning(
127-
"ExecutionOtelPlugin expected an SDK TracerProvider but got %s; "
128+
"ExecutionOtelPlugin expected an SDK Tracer but got %s; "
128129
"spans will not use deterministic IDs.",
129-
type(self._provider).__name__,
130+
type(self._tracer).__name__,
130131
)
131132

132-
self._tracer: Tracer = self._provider.get_tracer(self._config.instrument_name)
133-
134133
try:
135134
register_standalone_instrumentations(self._config, result)
136135
except Exception:
137136
logger.exception("Failed to register standalone instrumentations")
138137

139138
# Per-invocation state.
140139
self._execution_arn = ""
140+
self._execution_trace_id: int | None = None
141141
self._extracted_context: Context | None = None
142142
self._workflow_span: Span | None = None
143143
self._invocation_span: Span | None = None
@@ -205,8 +205,10 @@ def _resolve_parent(self, parent_id: str | None) -> Span | None:
205205
def on_invocation_start(self, info: InvocationStartInfo) -> None:
206206
logger.debug("Durable invocation started: %s", info)
207207
self._execution_arn = info.execution_arn or ""
208+
self._execution_trace_id = _to_otel_trace_id(
209+
self._execution_arn, info.execution_start_time
210+
)
208211
self._extracted_context = self._context_extractor(info)
209-
self._id_generator.set_trace_id(self._execution_arn, info.execution_start_time)
210212

211213
self._start_workflow_span(info)
212214
# Create the Invocation span in both modes. In default-provider mode it
@@ -224,23 +226,23 @@ def _start_workflow_span(self, info: InvocationStartInfo) -> None:
224226
if not self._execution_arn:
225227
logger.warning("No execution ARN; skipping Workflow span creation")
226228
return
227-
self._id_generator.set_next_span_id(
228-
derive_workflow_span_id(self._execution_arn)
229-
)
230229
start_time = _to_otel_timestamp(
231230
info.execution_start_time
232231
) or _to_otel_timestamp(datetime.datetime.now(datetime.UTC))
233232
# Empty context => root span with no parent.
234-
self._workflow_span = self._tracer.start_span(
235-
name=self._workflow_span_name,
236-
kind=SpanKind.INTERNAL,
237-
attributes={"durable.execution.arn": self._execution_arn},
238-
start_time=start_time,
239-
context=Context(),
240-
)
233+
with self._id_generator.use_ids(
234+
trace_id=self._execution_trace_id,
235+
span_id=derive_workflow_span_id(self._execution_arn),
236+
):
237+
self._workflow_span = self._tracer.start_span(
238+
name=self._workflow_span_name,
239+
kind=SpanKind.INTERNAL,
240+
attributes={"durable.execution.arn": self._execution_arn},
241+
start_time=start_time,
242+
context=Context(),
243+
)
241244

242245
def _start_invocation_span(self, info: InvocationStartInfo) -> None:
243-
self._id_generator.set_next_span_id(None)
244246
attributes: dict[str, Any]
245247
if self._provider_source is ProviderSource.GLOBAL:
246248
# Default-provider mode: parent the Invocation span to the ambient
@@ -264,12 +266,15 @@ def _start_invocation_span(self, info: InvocationStartInfo) -> None:
264266
"durable.execution.arn": self._execution_arn,
265267
"durable.invocation.first": info.is_first_invocation,
266268
}
267-
self._invocation_span = self._tracer.start_span(
268-
name="Invocation",
269-
kind=SpanKind.INTERNAL,
270-
attributes=attributes,
271-
context=parent_ctx,
272-
)
269+
with self._id_generator.use_ids(
270+
trace_id=self._execution_trace_id, span_id=None
271+
):
272+
self._invocation_span = self._tracer.start_span(
273+
name="Invocation",
274+
kind=SpanKind.INTERNAL,
275+
attributes=attributes,
276+
context=parent_ctx,
277+
)
273278
self._set_span(_INVOCATION_KEY, self._invocation_span)
274279

275280
def on_invocation_end(self, info: InvocationEndInfo) -> None:
@@ -330,6 +335,7 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
330335

331336
def _reset_state(self) -> None:
332337
self._execution_arn = ""
338+
self._execution_trace_id = None
333339
self._extracted_context = None
334340
self._workflow_span = None
335341
self._invocation_span = None
@@ -399,27 +405,26 @@ def _start_span(
399405
key = span_key if span_key is not None else operation_id
400406
with self._lock:
401407
links = self._build_invocation_links()
402-
if deterministic:
403-
# Operation spans always use the deterministic logical-operation
404-
# span ID so a suspended-then-completed operation exports a
405-
# single span (on completion) with a stable ID across invocations.
406-
self._id_generator.set_next_span_id(
407-
operation_id_to_span_id(self._execution_arn, operation_id)
408-
)
409-
else:
410-
self._id_generator.set_next_span_id(None)
408+
span_id = (
409+
operation_id_to_span_id(self._execution_arn, operation_id)
410+
if deterministic
411+
else None
412+
)
411413

412414
if parent is None:
413415
parent_ctx = self._extracted_context or Context()
414416
else:
415417
parent_ctx = trace.set_span_in_context(parent, self._extracted_context)
416-
span = self._tracer.start_span(
417-
name=name,
418-
attributes=self._operation_attributes(info),
419-
start_time=_to_otel_timestamp(start_time),
420-
context=parent_ctx,
421-
links=links,
422-
)
418+
with self._id_generator.use_ids(
419+
trace_id=self._execution_trace_id, span_id=span_id
420+
):
421+
span = self._tracer.start_span(
422+
name=name,
423+
attributes=self._operation_attributes(info),
424+
start_time=_to_otel_timestamp(start_time),
425+
context=parent_ctx,
426+
links=links,
427+
)
423428
self._operation_spans[key] = span
424429
return span
425430

0 commit comments

Comments
 (0)