ContextStore (ctxstore
) is a context decorator library designed for nodejs that synchronizes contexts within a call stack.
Disclaimer: This module has not been tested for multithreading nor designed with multithreading in mind.
Installing with npm:
npm i ctxstore
Use the @Context
decorator to associate a context object with a scope:
import { Context } from 'ctxstore';
class NotificationService {
private userDb: UserDatabase = new UserDatabase();
private emailClient: EmailClient = new EmailClient();
@Context({
// Associate a context to a scope
spanName: 'NotificationService:notifyUser(...)',
endpoint: '/notify/user/{username}',
})
async notifyUser(username: string): Promise<object> {
await this.userDb.getUser(username);
await this.emailClient.publish(username);
return Context.get(); // retrieve the context for this scope
}
}
In this example, a context is being associated with the async notifyUser(username: string): Promise<object>
method. This means that any function invoked in scope of notifyUser(...) { }
will have access to the context(s) defined in @Context(...)
. You can use the Context.get
method to retrieve the context for a function (e.g. notifyUser(...) { }
) as long as at least one ancestor function in the same call stack defines a context with @Context(...)
.
import { Context } from 'ctxstore';
class ExampleService {
@Context({
message: 'hello world',
})
method(): string {
return this.innerOne();
}
innerOne() {
return this.innerTwo();
}
innerTwo() {
return Context.get(); // <--- returns 'hello world'
}
}
In the example above, Context.get()
will still have access to method()
's context because the inner methods are within method()
's scope.
ContextStore handles the management of a Context object similar to how function lifecycles are managed in a CallStack/ StackFrame. When a function completes, its stack frame is naturally popped off the call stack; @Context
functions the same way. When a method with a @Context
is invoked, the context object is pushed onto a CallStack. When a method with a @Context
completes
(irrespective of whether it's synchronous or asynchronous), the context object is popped off the CallStack. Incoming context object keys take precedence over context objects existing on the CallStack. If the incoming context and the context located at the top of the stack both share the same key, the value of the incoming context object will "mask" the value of the context on the stack.
NOTE: A
CallStack
object is instantiated when the@Context
decorator is encountered for the first time in a call stack trace.
Let's take a look at the following examples.
class UserDatabase
:
class UserDatabase {
@Context({
subspanName: 'Database:User',
connector: 'aurora:postgresql',
table: 'user_table',
})
getUser(username: string): Promise<object> {
return Promise.resolve(Context.get());
}
}
class EmailClient
:
class EmailClient {
@Context({
subspanName: 'Client:Email',
connector: 'sns:topic',
datatype: 'json',
})
publish(username: string): Promise<object> {
return Promise.resolve(Context.get());
}
}
class NotificationService
:
class NotificationService {
private userDb: UserDatabase = new UserDatabase();
private emailClient: EmailClient = new EmailClient();
@Context({
spanName: 'NotificationService:notifyUser(...)',
endpoint: '/notify/user/{username}',
})
async notifyUser(username: string): Promise<object> {
await this.userDb.getUser(username);
await this.emailClient.publish(username);
return Context.get();
}
}
If we invoke notifyUser
:
await notifyUser('userId123');
The context object returned by Context.get()
inside of notifyUser
will be:
async notifyUser(username: string): Promise<object> {
...
const ctx = Context.get();
ctx will be:
{
spanName: 'NotificationService:notifyUser(...)',
endpoint: '/notify/user/{username}',
};
}
The context object returned by Context.get()
inside of getUser
will be:
getUser(username: string): Promise<object> {
...
const ctx = Context.get();
ctx will be:
{
spanName: 'NotificationService:notifyUser(...)',
endpoint: '/notify/user/{username}',
subspanName: 'Database:User',
connector: 'aurora:postgresql',
table: 'user_table',
};
}
If getUser
's context had a spanName
property (e.g. UserDatabase:getUser(...)
), then getUser
's spanName
property would override the context from notifyUser
.
If we attempt to concurrently handle multiple notifyUser
invocations:
await Promise.all([notifyUser('userId123'), notifyUser('userId456')]);
A context CallStack is instantiated per notifyUser
invocation since this is the first usage of @Context
within this context scope. Although asynchronous function resolutions (inner and top level) are unpredictable, ctxstore
is capable of synchronizing contexts with their associated CallStacks. In other words, contexts won't get jumbled/ mixed up despite the chaotic nature of asynchrony.
There is a compelling reason to support @Context
as a parameter decorator.
class Lambda {
private userDb: UserDatabase = new UserDatabase();
private emailClient: EmailClient = new EmailClient();
@Context({
method: 'GET',
path: '/organization/{organization}/user/{userId}',
})
async handler(event, @Context context): Promise<Response> {
logger.info('Start Trace Logging...', { trace, context});
return {
body: {
...
},
statusCode: ...
};
}
}
Supporting parameter decorators reduces the overhead of boilerplate and complexity for the user. Unfortunately, parameter decorators do not function similarly to CallStacks, there is no way to determine when the decorated function has completed from the perspective of a parameter decorator. This makes management of a context trace difficult.
Copyright 2021. Licensed MIT.