Repository navigation
feat(observability): experimental_registerOpenTelemetry() bridges the OpenTelemetry API to Workers tracing - #2752
Conversation
🦋 Changeset detectedLatest commit: 10c0c08 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| const currentTraceId = traceIdOf(current); | ||
| if ( | ||
| this.#traceId !== undefined && | ||
| currentTraceId !== undefined && | ||
| this.#traceId !== currentTraceId | ||
| ) { | ||
| return undefined; | ||
| } | ||
| return this.snapshot; |
There was a problem hiding this comment.
🔴 Cross-request spans acquire the wrong parent
When two requests share a trace ID, reentry accepts a span from the earlier request. Its snapshot moves subsequent native work into that request, attaching spans to the wrong invocation.
Learn more
A span opened with startSpan captures an async-context snapshot. Later, context.with uses reentry to activate that span for native work. This guard only compares native trace IDs. Separate requests in a distributed trace can share an ID, and supported older runtimes expose no span IDs at all. Reentry then runs the second request's work in the first request's captured native context.
Example: Request A opens span a and retains its context. Request B, another invocation of the same distributed trace, runs context.with(trace.setSpan(context.active(), a), () => fetch(...)). The fetch becomes a child of A's span rather than executing in B's native tracing context.
Recommended fix: Associate captured spans with a request-scoped identity that survives native span nesting, and reject cross-invocation reentry even when trace IDs match or are unavailable. Apply that identity check to both Bridge.place and WorkersContextManager.with; add same-trace and no-span-ID cross-invocation tests.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if ( | ||
| contextOwner !== undefined && | ||
| traceOwner !== undefined && | ||
| !ownsContext && | ||
| !ownsTrace | ||
| ) { | ||
| diag.info( | ||
| "agents/observability: OpenTelemetry is already registered; registerOpenTelemetry() left it in place." | ||
| ); | ||
| return "deferred"; |
There was a problem hiding this comment.
🟡 Incompatible registrations silently lose spans
When another API version owns both globals, registerBridge defers without checking compatibility. Instrumentation using an incompatible API copy then records no spans instead of receiving an error.
Learn more
OpenTelemetry registrations reside in a versioned global registry. This path detects an existing context manager and tracer provider but does not check the registry's version before declaring them usable. If this API copy cannot read the existing registrations, its instrumentation sees no working provider even though registration returned normally. The version-mismatch test covers only an empty registry, not a fully registered one.
Example: A compatible-looking global key contains version 1.0.0 plus an existing manager and tracer provider. A Worker using this package's 1.9.0 API calls registerOpenTelemetry(); registration defers, but that API cannot consume the older globals.
Recommended fix: Check registry compatibility before the fully-registered deferral branch, using the API's actual compatibility semantics rather than merely comparing raw version strings. Exercise the mismatch with both globals populated.
Was this helpful? React with 👍 or 👎 to provide feedback.
| @@ -0,0 +1,156 @@ | |||
| # RFC: OpenTelemetry API on Workers tracing | |||
|
|
|||
| Status: proposed | |||
There was a problem hiding this comment.
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
🔴 agents import sizes: 1 entry point over threshold
Changed exports (4)
How this worksEach runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement. Compared |
684744c to
00bd618
Compare
| return parent === undefined && | ||
| !options.root && | ||
| options.startTime === undefined && | ||
| parentContext.getValue(AMBIENT) === true | ||
| ? { in: undefined } |
There was a problem hiding this comment.
🟡 Constructor span links disappear silently
When a constructor span includes links, place returns before the link diagnostic. Native tracing drops those links without warning.
Learn more
A recording span normally reaches the options.links check in place, which reports dropped links through diag. Ambient constructor spans return earlier, so the same unsupported links are dropped without a diagnostic.
Example: In a constructor, tracer.startSpan("initialize", { links: [{ context: priorContext }] }) opens a native span. Its creation link is lost, but the diagnostic logger never receives the link warning.
Recommended fix: Run the creation-link diagnostic for accepted ambient spans too, without changing the existing refusal rules or reporting links for spans that are refused.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (native === undefined) { | ||
| return scope?.context ?? ROOT_CONTEXT.setValue(AMBIENT, true); |
There was a problem hiding this comment.
… OpenTelemetry API to Workers tracing Add experimental_registerOpenTelemetry() to agents/observability (undocumented for now). It registers a global OpenTelemetry context manager and tracer provider whose spans are Workers native spans, so OTel-instrumented libraries (the MCP SDK, AI SDK v6 experimental_telemetry, user tracers) nest with Agents SDK spans and the automatic fetch/KV/D1/DO spans and export through traces.destinations. Every span opens with tracing.startActiveSpan and keeps an async-context snapshot taken while it is active; context.with(trace.setSpan(ctx, span)) re-enters it. Spans native tracing cannot represent faithfully (remote parents, new roots, explicit start times, parents from another request) are non-recording. Registration is idempotent, steps aside when OTel is already fully registered, and throws on a partial setup.
00bd618 to
10c0c08
Compare
| "agents": minor | ||
| --- | ||
|
|
||
| Add `experimental_registerOpenTelemetry()` to `agents/observability`, which records spans from OpenTelemetry-instrumented libraries as Workers trace spans. Experimental and subject to change. |
There was a problem hiding this comment.
🟡 Release note loses required documentation link
The experimental_registerOpenTelemetry() changeset has no documentation link. The changeset rule requires one, and the bridge's RFC is available to link.
Learn more
Package changesets must use a one-line description with a link to relevant documentation, as specified in changeset guidance. The new description is one line but removes its link. The user-facing OpenTelemetry section was removed, so reusing its URL would leave a dead anchor.
Example: Release notes generated from this changeset name the experimental API but give readers no linked explanation of its supported behavior.
Recommended fix: Link the available bridge RFC from this single-line changeset, or add and link an appropriate user-facing document if the experimental release warrants one.
Was this helpful? React with 👍 or 👎 to provide feedback.
| } | ||
| export { genericObservability } from "./emitter"; | ||
| export type { Observability, ObservabilityEvent } from "./emitter"; | ||
| export { experimental_registerOpenTelemetry } from "./otel"; |
There was a problem hiding this comment.
🔍 PR example names the wrong export
The PR description still uses registerOpenTelemetry() and links to a removed documentation section. Its example does not match the exported experimental_registerOpenTelemetry(); update the description before publishing.
Was this helpful? React with 👍 or 👎 to provide feedback.
Adds
experimental_registerOpenTelemetry()toagents/observability, experimental and undocumented for now. One call at module scope turns@opentelemetry/apispans (the MCP SDK, AI SDK v6experimental_telemetry, your own tracers) into Workers trace spans. They nest with Agents SDK spans and the automatic fetch/KV/D1/DO spans, and export throughobservability.traces.destinations.Why:
startSpan()and activates the span later withcontext.with().How it works:
tracing.startActiveSpanand keeps anAsyncLocalStorage.snapshot()taken while it is active.context.with(trace.setSpan(ctx, span), fn)runsfninside that snapshot, so platform and child spans nest under it.blockConcurrencyWhile,tracing.getActiveSpan()returnsundefinedbut native spans still parent to the request; there the bridge lets the runtime pick the parent instead of refusing.root: true, explicit start times, parents from another request, links, array attributes. Span kind isn't recorded.@opentelemetry/apibecomes a dependency ofagents. The mainagentsentry doesn't load the bridge.Design rationale and what it records and refuses:
design/rfc-opentelemetry-bridge.md. No user docs yet; Agents SDK spans stay on native tracing.Unit tests ran (
src/node-tests/otel/).pnpm run test:otel-runtime(opt-in, not in CI) packs the package, runswrangler dev4.153 with AI SDK v6, and checks the span trees through Local Explorer.
Deployed to the agents account and checked with `cf o11y telemetry query`: MCP client and server spans, AI SDK spans, user spans and a renamed root span all nest under the request, with the MCP server side carried into the same trace over a service binding.