Skip to content

fix(otel): scope deterministic IDs to plugin tracers - #646

Open
zhongkechen wants to merge 3 commits into
mainfrom
codex/fix-otel-scoped-id-generation
Open

fix(otel): scope deterministic IDs to plugin tracers#646
zhongkechen wants to merge 3 commits into
mainfrom
codex/fix-otel-scoped-id-generation

Conversation

@zhongkechen

@zhongkechen zhongkechen commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR fixes two independent defects that could place multiple root spans in
the same OpenTelemetry trace.

Issue 1: deterministic IDs leaked through the shared provider

DeterministicIdGenerator was installed on the resolved TracerProvider.
A provider is shared by every instrumentation scope that uses it, so unrelated
instrumentation could receive the active durable execution trace ID when it
created a root span. The unrelated span had no durable parent, but appeared as
another root of the durable workflow trace.

The generator also held the current execution trace ID as shared mutable state.
Concurrent plugin instances or executions could therefore replace or consume
one another's deterministic IDs.

Issue 2: the Workflow root reused the ambient Lambda/X-Ray trace ID

The parentless Workflow span derived its trace ID from _X_AMZN_TRACE_ID.
The ambient Lambda span and the plugin's Invocation span used that same trace,
but the Workflow span was deliberately created without a parent. The result
was two disconnected root trees sharing one trace ID:

Lambda/ambient root -> Invocation -> ...

Workflow root -> ...

The same collision occurred without ambient instrumentation when Workflow
and Invocation were independently created as roots with the deterministic
execution trace ID.

Why multiple roots are harmful

OpenTelemetry backends expect a trace to describe one causal tree. Multiple
roots turn it into a disconnected forest. Backend behavior varies, but the
potential consequences include:

  • ambiguous trace names, entry points, status, and root-cause attribution;
  • incorrect duration and latency summaries when the backend chooses one root
    or measures across unrelated root branches;
  • broken critical-path analysis and misleading service or dependency maps;
  • durable and unrelated library spans appearing to be causally related when
    they only share a leaked trace ID;
  • independent root-based sampling decisions, which can retain one branch and
    discard another, leaving a partial trace.

Changes

Scope deterministic generation to plugin spans

  • Install DeterministicIdGenerator on the SDK Tracer used by each durable
    plugin instead of mutating the shared TracerProvider.
  • Use ContextVar-scoped overrides only around the plugin-owned
    start_span() call that needs a deterministic ID.
  • Consume a deterministic span ID before returning it, preserving the previous
    one-shot safeguard for re-entrant generation.
  • Keep execution state on each plugin instance and delegate all non-overridden
    generation to the provider's original generator.
  • Implement is_trace_id_random() so newer OpenTelemetry SDKs set trace flags
    correctly for deterministic and fallback IDs.

Separate workflow and invocation trace identities

  • Derive the durable Workflow trace ID only from the execution ARN and start
    time. It no longer reuses the ambient Lambda/X-Ray trace ID.
  • Parent Invocation spans to the active ambient context in both GLOBAL and
    EXPLICIT modes, using extracted upstream context as a fallback.
  • When no ambient or extracted parent exists, let the provider generate a fresh
    trace ID for the root Invocation span.

The resulting trace models are:

ExecutionOtelPlugin

Workflow trace:  Workflow -> Operation -> Attempt -> instrumented child spans
Ambient trace:   Lambda/ambient -> Invocation
                                      ^
                                      linked from durable operation spans
InvocationOtelPlugin

Workflow trace:  Workflow
                    ^
                    linked from durable operation spans

Ambient trace:   Lambda/ambient -> Invocation -> Operation -> Attempt

Each durable Workflow trace has one root. Each invocation joins its ambient
trace, or creates a provider-generated trace in which Invocation is the sole
root.

Do not fabricate continuation links

InvocationOtelPlugin previously reconstructed a deterministic trace ID and
span ID to link a continuation segment to a prior span for the same logical
operation. The prior SpanContext is not checkpointed, so the plugin cannot
know that the target span was actually created or exported, nor preserve its
real trace flags or trace state.

Continuation and retry segments now receive fresh span IDs and retain the real
link to the durable Workflow span, but do not emit a synthetic link to an
unobserved prior operation span.

Keep provider ownership explicit

  • Retry global provider resolution at invocation start so a plugin constructed
    before ADOT setup can bind after the ProxyTracer resolves.
  • Disable telemetry for the entire invocation when an SDK tracer is still
    unavailable, preventing partial traces, and retry on the next invocation.
  • Remove AUTO_OTLP. Provider/exporter construction, sampling, propagation,
    and HTTP instrumentation remain application or ADOT responsibilities.
  • Keep only GLOBAL and EXPLICIT provider modes.

Result

  • Unrelated root spans keep the provider's normal ID generation and cannot
    enter the durable workflow trace accidentally.
  • Workflow and ambient invocation trees use distinct trace IDs and no longer
    form disconnected roots in one trace.
  • Durable workflow and initial operation IDs remain deterministic across
    replay, while continuation segments use fresh span IDs.
  • Concurrent executions cannot overwrite or consume each other's scoped IDs.
  • Proxy-provider initialization cannot produce partial durable traces.

Testing

  • hatch run dev-otel:test (115 passed)
  • OTel package type check (23 source files)
  • Ruff lint and format checks
  • focused generator, provider, and plugin integration tests against
    OpenTelemetry SDK 1.20.0 (39 passed)

Closes #644

@zhongkechen
zhongkechen force-pushed the codex/fix-otel-scoped-id-generation branch from 9952f89 to 2807a76 Compare August 14, 2026 20:37
@zhongkechen
zhongkechen force-pushed the codex/fix-otel-scoped-id-generation branch from 2807a76 to 77f6ba9 Compare August 14, 2026 20:45
@zhongkechen
zhongkechen force-pushed the codex/fix-otel-scoped-id-generation branch from 77f6ba9 to 669c3f5 Compare August 14, 2026 20:52
@zhongkechen
zhongkechen force-pushed the codex/fix-otel-scoped-id-generation branch from 669c3f5 to aa4a594 Compare August 14, 2026 20:56
@zhongkechen
zhongkechen force-pushed the codex/fix-otel-scoped-id-generation branch from aa4a594 to 1114a2e Compare August 14, 2026 21:34
@zhongkechen
zhongkechen force-pushed the codex/fix-otel-scoped-id-generation branch from 1114a2e to 798cb0c Compare August 14, 2026 21:37
Comment on lines +49 to +50
``trace.get_tracer_provider()``). ``EXPLICIT`` uses
``tracer_provider`` as-is and skips instrumentation registration.

This comment was marked as outdated.

@github-actions

This comment has been minimized.

@zhongkechen
zhongkechen marked this pull request as ready for review August 14, 2026 22:30
@zhongkechen
zhongkechen had a problem deploying to ai-pr-review-runtime August 14, 2026 23:11 — with GitHub Actions Failure
@zhongkechen
zhongkechen temporarily deployed to ai-pr-review-runtime August 14, 2026 23:11 — with GitHub Actions Inactive
*,
id_generator: IdGenerator | None = None,
) -> ProviderResult:
def create_tracer_provider(config: OtelPluginConfig) -> ProviderResult:

This comment was marked as outdated.

Comment on lines +108 to +113
current_generator = tracer.id_generator
if isinstance(current_generator, cls):
return current_generator

generator = cls(fallback_id_generator=current_generator)
provider.id_generator = generator
tracer.id_generator = generator

This comment was marked as outdated.

@github-actions

This comment has been minimized.

@zhongkechen
zhongkechen deployed to ai-pr-review-runtime August 15, 2026 21:04 — with GitHub Actions Active
Comment on lines +35 to 39
omitted, the globally configured provider is used (for example, the
provider installed by the ADOT Lambda layer). Standalone
instrumentation registration is skipped for an application-owned
provider.
context_extractor: Upstream trace-context extractor. Defaults to the

This comment was marked as outdated.

Comment on lines +207 to +209
if ambient_span_context.is_valid:
return ambient_context
return self._extracted_context or ambient_context

This comment was marked as outdated.

## Features

- **Deterministic Trace IDs**: All invocations of the same durable execution share a single trace, derived from the X-Ray trace header or execution ARN
- **Deterministic Workflow Traces**: Durable operations use an execution-derived trace that is independent of the ambient Lambda/X-Ray trace

This comment was marked as outdated.

@github-actions

This comment has been minimized.

@zhongkechen
zhongkechen deployed to ai-pr-review-runtime August 15, 2026 21:50 — with GitHub Actions Active
Comment on lines +34 to +38
tracer_provider: An application-owned provider to use as-is. When
omitted, the globally configured provider is used (for example, the
provider installed by the ADOT Lambda layer). Standalone
instrumentation registration is skipped for an application-owned
provider.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review

[P1] Preserve the released configuration API

Removing ProviderSource, ExporterConfig, and the associated OtelPluginConfig fields makes documented existing configurations fail during import or construction after an upgrade. AUTO_OTLP users also lose provider setup entirely. Retain these exported names and arguments as deprecated compatibility shims, preserving their behavior until a major release, and add upgrade-compatibility tests.

Comment on lines +193 to +200
def _invocation_parent_context(self) -> Context:
"""Return the active ambient context, then extracted upstream context."""
ambient_context = otel_context.get_current()
ambient_span_context = trace.get_current_span(
ambient_context
).get_span_context()
if ambient_span_context.is_valid:
return ambient_context

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review

[P1] Do not treat the previous Workflow span as ambient context

ExecutionOtelPlugin attaches its Workflow span but never detaches it. Without an outer OTel wrapper, the next warm invocation therefore sees the previous Workflow as current here and parents its Invocation span to the prior execution, defeating the trace separation this change introduces. Store the attachment token and detach it at invocation end and before defensive reset/start; add a test covering two sequential invocations on one plugin instance.

@github-actions

Copy link
Copy Markdown
Contributor

Codex AI review

Two blocking regressions found. Warm reuse and upgrade compatibility remain uncovered by the in-process tests.

Reviewed commit 1e39ed07cb1218ef3fa2a819bdac4feca3658206. Workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[otel] Scope deterministic trace IDs without replacing provider-wide generation

1 participant