Skip to content

feat: add context attach/detach#6387

Draft
pichlermarc wants to merge 11 commits into
open-telemetry:mainfrom
dynatrace-oss-contrib:feat/context-attach-detach-tracing-channel
Draft

feat: add context attach/detach#6387
pichlermarc wants to merge 11 commits into
open-telemetry:mainfrom
dynatrace-oss-contrib:feat/context-attach-detach-tracing-channel

Conversation

@pichlermarc

@pichlermarc pichlermarc commented Feb 9, 2026

Copy link
Copy Markdown
Member

Important

While this PR is intended to show possible use of attach/detach functionality for tracingChannel instrumentation, as an alternative to proposal #6088, It does not feature a production ready implementation of it
It's intended as a means to demonstrate how I expect this could be used to accomplish that use case.

Which problem is this PR solving?

Proposal #6088 is to allow accessing the internal AsyncLocalStorage from the outside, for the purpose of binding it to a TracingChannel, so that context propagation works properly. While this is works in practice it has a few downsides:

  • the internal AsyncLocalStorage is internal to avoid tampering, misuse can lead to catastrophic effects and can be hard to debug
  • not every ContextManager is a AsyncLocalStorageContextManager, so many do not have an AsyncLocalStorage. As such, we also cannot add it to the @opentelemetry/api package. Further AsyncLocalStorage is a Node.js type, and the @opentelemetry/api and its types must remain compatible with the Browser

My counter-proposal is implementing the spec-defined attach() and detach() operations to achieve a similar effect. This works by manually attaching context when the TracingChannel emits its events, storing the state on the message, and detaching the context once the end message is received.

This implementation:

  • is specification compliant (we don't add any additional operations that don't exist in the spec)
  • is runtime agnostic (these operations may be implemented however it makes sense for the ContextManager)
  • (possibly) could open up other use-cases that we currently did not think of

The downsides:

  • similar to exposing AsyncLocalStorage, context attach/detach can have potentially catastrophic effects (however, I think it's easier to document that than with exposing AsyncLocalStorage and other language SIGs have been managing fine with this operation available)

You can find an example how tracing channel instrumentation would work at examples/tracing-channels. I'm planning to merge this as an example here first, then once @opentelemetry/api is released, I'd like to create a contrib package that includes tracing channel instrumentation utils based on that example.

Previous work on context attach/detach

Fixes #3558

@codecov

codecov Bot commented Feb 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.96970% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.94%. Comparing base (d70e7df) to head (77618cd).

Files with missing lines Patch % Lines
api/src/api/context.ts 88.88% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #6387   +/-   ##
=======================================
  Coverage   94.93%   94.94%           
=======================================
  Files         383      383           
  Lines       13007    13072   +65     
  Branches     2974     2987   +13     
=======================================
+ Hits        12348    12411   +63     
- Misses        659      661    +2     
Files with missing lines Coverage Δ
api/src/context/NoopContextManager.ts 100.00% <100.00%> (ø)
...sync-hooks/src/AbstractAsyncHooksContextManager.ts 97.26% <ø> (ø)
...ontext-async-hooks/src/AsyncHooksContextManager.ts 100.00% <100.00%> (ø)
...async-hooks/src/AsyncLocalStorageContextManager.ts 100.00% <100.00%> (ø)
...ry-context-zone-peer-dep/src/ZoneContextManager.ts 88.88% <100.00%> (+4.60%) ⬆️
...telemetry-sdk-trace-web/src/StackContextManager.ts 95.12% <100.00%> (+0.83%) ⬆️
api/src/api/context.ts 90.47% <88.88%> (-1.20%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dyladan dyladan left a comment

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.

this looks really good to me. I think it's well past time we implemented something like this

Comment thread api/src/api/context.ts Outdated
* @returns A Token that can be used to restore the previous Context
* @since 1.10.0
*/
attach?(context: Context): Token {

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.

Does attach/detach need to be optional here? I think it only needs to be optional in the type the SDK implements and we can do the null check before calling the SDK impl

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The prototype is just quickly cobbled together to show tracing channel instrumentation works this way (providing a use-case to justify adding it); I'll look into making it non-optional.

Comment thread api/src/api/context.ts
* This is an optional global operation that allows context to be set
* imperatively rather than using with().
*
* @param context The Context to attach

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.

include @experimental?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

added.

Comment thread api/src/context/types.ts
* This is an optional global operation that allows context to be set
* imperatively rather than using with().
*
* @param context The Context to attach

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.

include @experimental?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

added.

Comment thread api/src/context/types.ts
* @returns A Token that can be used to restore the previous Context
* @since 1.10.0
*/
attach?(context: Context): Token;

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.

Maybe attach should return a disposable so that it can be used with the using directive https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/using

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think we'd have problems using disposable since we're on an old TypeScript version and Symbol.dispose is not baseline widely available.

Also we'd have to wrap the Token in another object which can make the operation more expensive. Should we introduce a context.attachDisposable() function once we're bumped to TypeScript 5.2? According to our policy we should be able to do that, going to 5.5 would be okay.

That could live fully in @opentelemtry/api as a utility that does something like

export function attachDisposable(contextToAttach: Context): Disposable {
  return {
    _token: context.attach(contextToAttach),
    [Symbol.dispose]() {
      context.detach(this._token);
    },
  };
}

Though it would be nicer to have just one function that does all of that. We could rotate through a pool of wrappers though to avoid allocating one each time so that it stabilizes after a warm-up period. Though that would add a lot of complexity.

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.

Can't comment on files not in the diff so i'm leaving this here in order to make it a thread:

Did you choose not to implement in ZoneContextManager because it was impossible or just to make the PR easier and save time? I think we would do well to include a browser-compatible version of this if possible when the feature is released.

@dyladan dyladan Feb 27, 2026

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I just wanted to save some time to show it's feasible on Node.js; I've since added a best-effort implementation for both Stack and Zone context managers (somewhat based on your code)

Comment on lines +72 to +76
attach(context: Context): Token {
const previousContext = this.active();
this._asyncLocalStorage.enterWith(context);
return previousContext as unknown as Token;
}

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.

Any reason you chose to return the real context here rather than an opaque symbol as a token? It looks like you're going out of your way to make it opaque with types.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I did this mostly to avoid allocating an extra object for each attach() call. I suppose with opaque Symbol you mean this, right? ⬇️

const detachableContextSymbol = Symbol.for('otel.detachable-context');

attach(context: Context): Token {
    const previousContext = this.active();
    this._asyncLocalStorage.enterWith(context);
    return { [detachableContextSymbol]: previousContext };
}

* contextManager.detach(token); // Restore previous context
* ```
*/
attach(context: Context): Token {

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.

Another way to impl may be to return a detach function which encapsulates the token. Keeps the implementation opaque and reduces the API to a single method addition, avoiding bugs caused by potentially calling detach with invalid input.

* ```
*/
detach(token: Token): void {
this._asyncLocalStorage.enterWith(token as unknown as Context);

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.

This allows to detach several times. I think the token should be marked somehow to make this idempotent.

@logaretm

Copy link
Copy Markdown

Sorry for the late feedback on this. I’ve been testing this with libraries adopting tracing channels, and overall it works well.

However, I tested it against graphql@17.0.0-rc.0, which introduces tracing channels. I thought the graphql:resolve channel would be a good stress test because resolver execution is high-volume, concurrent, and mixed sync/async. If anything was going to expose lifecycle issues, it would likely be there.

found one edge case around attaching context on asyncStart. If one async resolver is still pending and a sibling resolver starts before the first emits asyncEnd, the sibling can inherit the first resolver span as its parent.

I made a small repro: https://github.com/logaretm/otel-graphql-tracing-channel-lab

I think the fix for this is to not attach/detach on asyncStart/asyncEnd, we don't really need them for context propagation 🤔

@pichlermarc

Copy link
Copy Markdown
Member Author

Sorry for the late feedback on this. I’ve been testing this with libraries adopting tracing channels, and overall it works well.

However, I tested it against graphql@17.0.0-rc.0, which introduces tracing channels. I thought the graphql:resolve channel would be a good stress test because resolver execution is high-volume, concurrent, and mixed sync/async. If anything was going to expose lifecycle issues, it would likely be there.

found one edge case around attaching context on asyncStart. If one async resolver is still pending and a sibling resolver starts before the first emits asyncEnd, the sibling can inherit the first resolver span as its parent.

I made a small repro: https://github.com/logaretm/otel-graphql-tracing-channel-lab

I think the fix for this is to not attach/detach on asyncStart/asyncEnd, we don't really need them for context propagation 🤔

Thanks for looking into it @logaretm. 🙌 It's great to have a real library to test against.

Hmm, I need to look into this a bit more. This is how the events are emitted in graphql, right?
https://github.com/graphql/graphql-js/blob/3f3895f37cead900d15b0e311bf9c7da1a4ce26f/src/diagnostics.ts#L395-L446

I wonder: shouldn't the asyncStart event happen async during handling of the promise so that the active context is the one of the async chain that's currently being handled? So something like

export function traceMixed(channel, contextInput, fn) {
    const context = contextInput;
    return channel.start.runStores(context, () => {
        let result;
        try {
            result = fn();
        }  
        catch (err) {
            context.error = err;
            channel.error.publish(context);
            channel.end.publish(context);
            throw err;
        }
        if (!isPromiseLike(result)) {
            context.result = result;
            channel.end.publish(context);
            return result;
        }
        channel.end.publish(context);

    // Ensure asyncStart runs inside a Promise; there's probably a better way to do it but just to illustrate 
    return Promise.resolve()
      .then(() => {
        channel.asyncStart.publish(context); 
        return Promise.resolve(result); // normalize thenable to real Promise
      })
      .then(
        (value) => {
          context.result = value;
          channel.asyncEnd.publish(context);
          return value;
        },
        (err) => {
          context.error = err;
          channel.error.publish(context);
          channel.asyncEnd.publish(context);
          throw err;
        }
      );
  });
}

@logaretm

logaretm commented Jun 16, 2026

Copy link
Copy Markdown

It's great to have a real library to test against.

@pichlermarc, yea I held back on giving feedback until we have a really good stress library to try. GraphQL is pretty good for that.

I wonder: shouldn't the asyncStart event happen async during handling of [...]

I remember while working on this with them we had to optimize a lot, one of the things we needed to do is avoid introducing additional ticks.

I think we can do something like this upstream in GraphQL:

channel.end.publish(context);
- channel.asyncStart.publish(context);

return result.then(
  value => {
    context.result = value;
+  channel.asyncStart.publish(context);
    channel.asyncEnd.publish(context);
    return value;
  },
  err => {
    context.error = err;
    channel.error.publish(context);
+  channel.asyncStart.publish(context);
    channel.asyncEnd.publish(context);
    throw err;
  },

Which would work in this case, but I'm unsure if that mirrors how tracePromise works. Having asyncStart/asyncEnd emit at the same time feels weird. Having said that, all instrumentations I'm working on right now do not use asyncStart at all so maybe that's why I never noticed it.

@Qard do you have thoughts on this? is the suggested change to asyncStart above safe? If so I can PR it.

@Qard

Qard commented Jun 16, 2026

Copy link
Copy Markdown

Yes, that is how it is supposed to be. A bit odd, but it's because it's supposed to correspond to how callbacks work. The asyncStart happens before the continuation and in theory the asyncEnd would happen after, but there's not really an end to a continuation apart from garbage collection, which would be expensive and unreliable for an "end" condition so we elected to just emit both at the same time.

@logaretm

logaretm commented Jun 16, 2026

Copy link
Copy Markdown

I submitted graphql/graphql-js#4824 which should work fine with the attach/detach API

@pichlermarc pichlermarc changed the title feat: prototype context attach/detach feat: implement context attach/detach Jun 18, 2026
@pichlermarc pichlermarc changed the title feat: implement context attach/detach feat: add context attach/detach Jun 18, 2026
@opentelemetry-pr-dashboard

Copy link
Copy Markdown

Pull request dashboard status

  • Waiting on: Author
  • Next step: Move out of draft to request review.

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.

how to set the current active context and current active span without callbacks

5 participants