Description
I had a call with @andreubotella the other day explaining some of what APM vendors want from context management that we don't have now, explaining the use cases, how we are limited presently, and how some aspects of the proposal at present somewhat limit us too. One of the main issues discussed was that capturing context when an await happens and then restoring at that point cuts off a branch of the graph which we are interested in.
Some examples:
function readFile(path) {
// Imagine there's something here to create and store a span for this call scope...
// We want to produce another span by instrumenting within the openFile function.
// This span should have a child-of (OpenTelemetry concept) relationship to
// the readFile span. This is fine as the context will flow into the call.
const fd = await openFile(path)
// We want readFd to have a follows-from (OpenTelemetry concept) relationship
// to the openFile span so we need for that span to be the context value here.
// This is a problem if we snapshot the context at the await and restore after as
// the context value would be the readFile span instead of the openFile span.
const data = await readFd(fd)
// As with readFd, close should receive the context from the path through readFd,
// meaning it should have a follows-from relationship to the readFd span.
await closeFd(fd)
return data
}
In this scenario we want the execution branches which merge back through an await to also merge back their context. If we instead just relied on context flowing through the promise resolve we would get this automatically and, if no context adjustment was made within openFile, we would still get the same flow expected by binding at awaits.
Another real-world scenario is that we instrument aws-sdk to capture SQS jobs being processed and linking them into a distributed trace. A job processor would look something like this:
const { SQSClient, ReceiveMessageCommand } = require("@aws-sdk/client-sqs")
const client = new SQSClient(clientConfig)
const command = new ReceiveMessageCommand(commandConfig)
async function processor() {
while (true) {
// The job received here has distributed tracing headers passed from another service.
// We want to create a span when client.send(...) receives the result of the command
// and finds distributed tracing headers. This span should then be stored in the context
// so subsequent execution can connect to that span.
const command = new ReceiveMessageCommand(input)
const response = await client.send(command)
// Do some processing...
}
}
In the above example we want each await client.send(...)
to flow the span created within out to the async function it's being called within. Subsequent execution within that function until the next await client.send(...)
should be considered as continuations of that context. Because of promises flowing context through the resolve path, if this were to be rewritten with promise.then(...)
nesting instead of async/await this would behave as expected, which leads me to believe that binding around awaits is actually incorrect. What we should be doing is just letting the context flow into the functions being called and merged back out of whatever the resolution of the awaited promises are. Context is meant to follow execution behaviour, so if execution is merging back from elsewhere so too should context.
If we have a way to access the calling context rather than the bound context then this would be a non-issue, but if we're aiming for a single blessed path then this would make the current design of context flow completely unusable for observability products.
As it is presently, context within an async function is effectively no different from local variables. The stack restores in the same way as the context binding over an await does, so they are functionally the same at that point. The whole point of adding context management to the language is that it can flow values through paths which existing code can not.