Skip to content

feat: share context with the OpenTelemetry SDK - #29

Open
bengl wants to merge 1 commit into
nodejs:mainfrom
bengl:bengl/otel-sdk-context
Open

bengl wants to merge 1 commit into
nodejs:mainfrom
bengl:bengl/otel-sdk-context

Conversation

@bengl

@bengl bengl commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

microtel and the official OpenTelemetry SDK can now run in the same process and share one context, so a trace stays connected across spans from both.

How. When the SDK has registered a context manager with attach(), microtel keeps the current span in the SDK's context store instead of its own.

flowchart LR
    handler["A microtel handler needs<br/>the current span"] --> check{"Has the SDK registered<br/>a context manager<br/>with attach()?"}
    check -- "no" --> own["microtel's own<br/>AsyncLocalStorage,<br/>as before"]
    check -- "yes" --> sdk["The SDK's context store,<br/>shared by both"]
Loading
  • A server handler attaches a context holding a bridge span over microtel's server span. Spans the app starts through the SDK while handling the request become its children.
  • A client or undici handler takes its parent from whatever span the SDK's store holds, so microtel's client spans become children of the app's spans.
  • The same works when the SDK's HTTP instrumentation traces inbound requests and microtel traces the outbound ones.

When microtel traces the inbound request:

sequenceDiagram
    participant Caller
    participant M as microtel
    participant Store as SDK context store
    participant App as App and SDK
    participant Next as Next service

    Caller->>M: request with a traceparent
    M->>M: create server span S
    M->>Store: attach() a context holding a bridge span for S
    App->>Store: startActiveSpan('work') finds S
    App->>Store: with() a context holding W, a child of S
    App->>M: http.get() while W is current
    M->>Store: read the current span, finds W
    M->>M: create client span C, a child of W
    M->>Next: request with C's traceparent
Loading

When the SDK's HTTP instrumentation traces it instead, microtel only reads the store:

sequenceDiagram
    participant Caller
    participant SDK as SDK HTTP instrumentation
    participant Store as SDK context store
    participant App
    participant M as microtel
    participant Next as Next service

    Caller->>SDK: request with a traceparent
    SDK->>Store: with() a context holding server span S
    App->>Store: startActiveSpan('work') makes W, a child of S
    App->>M: http.get() while W is current
    M->>Store: read the current span, finds W
    M->>M: create client span C, a child of W
    M->>Next: request with C's traceparent
Loading

The SDK is found through the global the OpenTelemetry API registers itself on, globalThis[Symbol.for('opentelemetry.js.api.1')], and its context keys are Symbol.for strings, so microtel takes no dependency on @opentelemetry/api. attach() is needed because the handlers run in diagnostics_channel subscribers, before the code they trace, so they cannot wrap it in with(). @opentelemetry/context-async-hooks has supported it since 2.11.0. Without it, microtel keeps its own context, as before.

The bridge span. While microtel traces a request, trace.getActiveSpan() returns microtel's server span. Attributes, events, status, exceptions, and name changes made through it land on that span until the span ends. Status and exceptions follow the SDK's rules, so an ok status is final, for microtel's own writes too. end() is ignored, since microtel ends its own spans, and links are dropped. OTel code can now add events, so a span keeps at most 128 of them, the SDK's default limit.

flowchart LR
    code["OTel code calls<br/>trace.getActiveSpan()"] --> bridge["bridge span"]
    bridge -- "setAttribute, addEvent,<br/>setStatus, recordException,<br/>updateName" --> span["microtel's span,<br/>until it ends"]
    bridge -. "end() and links" .-> dropped["ignored"]
Loading

Sampling. microtel's spans under an SDK span take its sampling decision and tracestate. The SDK's spans under microtel's take microtel's through its default parent-based sampler, including the ot=th threshold that a sampleRatio below 1 adds. As in the SDK, a span whose trace isn't sampled records nothing written through the bridge, and its isRecording() returns false. A parent from the SDK's store is remote only when its span context says so, the way the SDK decides for its own spans, so a client span under an SDK span in the same process exports without the remote bit.

What stays the same. Each side propagates trace context the way it normally does. microtel forwards the baggage of requests it received itself, even when an SDK span is current, and baggage the SDK extracted is not added to the requests microtel traces. Requests the SDK suppresses tracing for, such as its own exports, are not traced. Routes reported by the SDK's framework instrumentations are not picked up yet, and a TODO in lib/context.js says how they could be.

Tests. The new tests run the real SDK, pinned as devDependencies, in child processes: with microtel tracing inbound requests, with the SDK tracing them, and with a context manager that has no attach(). Breaking any of the behavior above fails them, which a mutation check confirmed for each piece.

@bengl
bengl marked this pull request as ready for review November 2, 2026 15:58
@bengl
bengl requested a review from a team November 2, 2026 17:00
Comment thread lib/context.js

const { STATUS_ERROR, STATUS_UNSET } = require('./span');

const kOtelApi = Symbol.for('opentelemetry.js.api.1');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

question for the OTEL team, how likely is this to change?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is very unlikely to change in @opentelemetry/api@^1.0.0.
Future major versions may do something else, but there's nothing in the pipeline yet.

When the OpenTelemetry SDK runs in the same process, microtel now keeps
the current span in the SDK's context store instead of its own, so spans
from either side parent spans from the other. Server handlers attach a
context holding a bridge span over microtel's span, and client handlers
take their parent from whatever span the SDK's store holds. That also
covers the SDK's HTTP instrumentation tracing inbound requests while
microtel traces the outbound ones.

A parent taken from the SDK's store is remote only when its span
context says so, the way the SDK decides for its own spans. So a client
span under an SDK span in the same process exports without the remote
bit.

The SDK is found through the global that the OpenTelemetry API
registers its parts on, and its context keys are Symbol.for strings, so
there is no dependency on @opentelemetry/api. Sharing needs a context
manager with attach(), since the handlers run in diagnostics_channel
subscribers that cannot wrap the code they trace in with(). Without
one, microtel keeps its own context.

The bridge span takes attributes, events, status, exceptions, and name
changes from OTel code while the span is recording, ignores end(), and
drops links. As in the SDK, a span whose trace isn't sampled is not
recording. Status and exceptions follow the SDK's rules, so an ok
status is final for microtel's own writes too. Events are capped at
the SDK's default limit of 128, since OTel code can now add them.
Requests the SDK suppresses tracing for, such as its own exports, are
not traced.

Each side keeps propagating trace context the way it normally does.
microtel's span rides along in the SDK's context, so the baggage
microtel received still reaches its client spans under an SDK span.

The tests run the real SDK, pinned as devDependencies, in child
processes.

Signed-off-by: Bryan English <bryan@bryanenglish.com>
@bengl
bengl force-pushed the bengl/otel-sdk-context branch from 1787ab0 to f6be673 Compare November 4, 2026 19:55
@bengl bengl mentioned this pull request Nov 4, 2026
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.

3 participants