feat: add context attach/detach#6387
Conversation
Codecov Report❌ Patch coverage is
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
🚀 New features to boost your workflow:
|
dyladan
left a comment
There was a problem hiding this comment.
this looks really good to me. I think it's well past time we implemented something like this
| * @returns A Token that can be used to restore the previous Context | ||
| * @since 1.10.0 | ||
| */ | ||
| attach?(context: Context): Token { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| * This is an optional global operation that allows context to be set | ||
| * imperatively rather than using with(). | ||
| * | ||
| * @param context The Context to attach |
| * This is an optional global operation that allows context to be set | ||
| * imperatively rather than using with(). | ||
| * | ||
| * @param context The Context to attach |
| * @returns A Token that can be used to restore the previous Context | ||
| * @since 1.10.0 | ||
| */ | ||
| attach?(context: Context): Token; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
symbol doesn't work because it can't key a WeakMap. I ended up using Context return here https://github.com/dynatrace-oss-contrib/opentelemetry-js/blob/zone-attach-detach/packages/opentelemetry-context-zone-peer-dep/src/ZoneContextManager.ts
There was a problem hiding this comment.
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)
| attach(context: Context): Token { | ||
| const previousContext = this.active(); | ||
| this._asyncLocalStorage.enterWith(context); | ||
| return previousContext as unknown as Token; | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
This allows to detach several times. I think the token should be marked somehow to make this idempotent.
|
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 found one edge case around attaching context on I made a small repro: https://github.com/logaretm/otel-graphql-tracing-channel-lab I think the fix for this is to not |
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 I wonder: shouldn't the 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;
}
);
});
} |
@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 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 @Qard do you have thoughts on this? is the suggested change to |
|
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. |
|
I submitted graphql/graphql-js#4824 which should work fine with the attach/detach API |
Pull request dashboard status
|
Important
While this PR is intended to show possible use of attach/detach functionality for
tracingChannelinstrumentation, as an alternative to proposal #6088, It does not feature a production ready implementation of itIt'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
AsyncLocalStoragefrom the outside, for the purpose of binding it to aTracingChannel, so that context propagation works properly. While this is works in practice it has a few downsides:AsyncLocalStorageis internal to avoid tampering, misuse can lead to catastrophic effects and can be hard to debugContextManageris aAsyncLocalStorageContextManager, so many do not have anAsyncLocalStorage. As such, we also cannot add it to the@opentelemetry/apipackage. FurtherAsyncLocalStorageis a Node.js type, and the@opentelemetry/apiand its types must remain compatible with the BrowserMy counter-proposal is implementing the spec-defined
attach()anddetach()operations to achieve a similar effect. This works by manually attaching context when theTracingChannelemits its events, storing the state on the message, and detaching the context once the end message is received.This implementation:
ContextManager)The downsides:
AsyncLocalStorage, context attach/detach can have potentially catastrophic effects (however, I think it's easier to document that than with exposingAsyncLocalStorageand 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/apiis 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