NestJS CLS
A continuation-local storage module compatible with NestJS's dependency injection.
Note: For versions < 1.2, this package used cls-hooked as a peer dependency, now it uses AsyncLocalStorage from Node's async_hooks
directly. The API stays the same for now but I'll consider making it more friendly for version 2.
Outline
Install
npm install nestjs-cls
yarn add nestjs-cls
Note: This module requires additional peer deps, like the nestjs core and common libraries, but it is assumed those are already installed.
Quick Start
Below is an example of storing the client's IP address in an interceptor and retrieving it in a service without explicitly passing it along.
Note: This example assumes you are using HTTP and therefore can use middleware. For usage with non-HTTP transports, keep reading.
@Module({
imports: [
ClsModule.register({
global: true,
middleware: { mount: true }
}),
],
providers: [AppService],
controllers: [AppController],
})
export class TestHttpApp {}
@Injectable()
export class UserIpInterceptor implements NestInterceptor {
constructor(
private readonly cls: ClsService
)
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
cosnt userIp = req.connection.remoteAddress;
this.cls.set('ip', userIp);
return next.handle();
}
}
@UseInterceptors(UserIpInterceptor)
@Injectable()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('/hello')
hello() {
return this.appService.sayHello();
}
}
@Injectable()
export class AppService {
constructor(
private readonly cls: ClsService
) {}
sayHello() {
return 'Hello ' + this.cls.get<string>('ip') + '!';
}
}
How it works
Continuation-local storage provides a common space for storing and retrieving data throughout the life of a function/callback call chain. In NestJS, this allows for sharing request data across the lifetime of a single request - without the need for request-scoped providers. It also makes it easy to track and log request ids throughout the whole application.
To make CLS work, it is required to set up a cls context first. This is done by calling cls.run()
(or cls.enter()
) somewhere in the app. Once that is set up, anything that is called within the same callback chain has access to the same storage with cls.set()
and cls.get()
.
HTTP
Since in NestJS, HTTP middleware is the first thing to run when a request arrives, it is an ideal place to initialise the cls context. This package provides ClsMidmidleware
that can be mounted to all (or selected) routes inside which the context is set up before the next()
All you have to do is mount it to routes in which you want to use CLS, or pass middleware: { mount: true }
to the ClsModule.register
options which automatically mounts it to all routes.
Once that is set up, the ClsService
will have access to a common storage in all Guards, Interceptors, Pipes, Controllers, Services and Exception Filters that are called within that route.
Manually mounting the middleware
Sometimes, you might want to only use CLS on certain routes. In that case, you can bind the ClsMiddleware manually in the module:
export class TestHttpApp implements NestModule {
configure(consumer: MiddlewareConsumer) {
apply(ClsMiddleware).forRoutes(AppController);
}
}
Sometimes, however, that won't be enough, because the middleware could be mounted too late and you won't be able to use it in other middlewares (as is the case of GQL resolvers). In that case, you can mount it directly in the bootstrap method:
function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(
new ClsMiddleware({
}).use,
);
await app.listen(3000);
}
Please note: If you bind the middleware using app.use()
, it will not respect middleware settings passed to ClsModule.forRoot()
, so you will have to provide them yourself in the constructor.
Non-HTTP
For all other transports that don't use middleware, this package provides a ClsGuard
to set up the CLS context. To use it, pass its configuration to the guard
property to the ClsModule.register
options:
ClsModule.register({
guard: { generateId: true, mount: true }
}),
Please note: using the ClsGuard comes with some security considerations!
API
The injectable ClsService
provides the following API to manipulate the cls context:
set
<T>(key: string, value: T): T
Set a value on the CLS context.get
<T>(key: string): T
Retrieve a value from the CLS context by key.getId
(): string;
Retrieve the request ID (a shorthand for cls.get(CLS_ID)
)getStore
(): any
Retrieve the object containing all properties of the current CLS context.enter
(): void;
Run any following code in a shared CLS context.run
(callback: () => T): T;
Run the callback in a shared CLS context.isActive
(): boolean
Whether the current code runs within an active CLS context.
Options
The ClsModule.register()
method takes the following options:
-
ClsModuleOptions
namespaceName
: string
The name of the cls namespace. This is the namespace that will be used by the ClsService and ClsMiddleware (most of the time you will not need to touch this setting)global:
boolean
(default false
)
Whether to make the module global, so you do to import ClsModule
in other modules.middleware:
ClsMiddlewareOptions
An object with additional middleware options, see belowguard:
ClsGuardOptions
An object with additional guard options, see below
The ClsMiddleware
takes the following options (either set up in ClsModuleOptions
or directly when instantiating it manually):
-
ClsMiddlewareOptions
mount
: boolean
(default false
)
Whether to automatically mount the middleware to every route (not applicable when instantiating manually)generateId
: bolean
(default false
)
Whether to automatically generate request IDs.idGenerator
: (req: Request) => string | Promise<string>
An optional function for generating the request ID. It takes the Request
object as an argument and (synchronously or asynchronously) returns a string. The default implementation uses Math.random()
to generate a string of 8 characters.saveReq
: boolean
(default true
)
Whether to store the Request object to the context. It will be available under the CLS_REQ
key.saveRes
: boolean
(default false
)
Whether to store the Response object to the context. It will be available under the CLS_RES
keyuseEnterWith
: boolean
(default false
)
Set to true
to set up the context using a call to AsyncLocalStorage#enterWith
instead of wrapping the next()
call with the safer AsyncLocalStorage#run
. Most of the time this should not be necessary, but some frameworks are known to lose the context with run
.
-
ClsGuardOptions
mount
: boolean
(default false
)
Whether to automatically mount the guard as APP_GUARDgenerateId
: bolean
(default false
)
Whether to automatically generate request IDs.idGenerator
: (context: ExecutionContext) => string | Promise<string>
An optional function for generating the request ID. It takes the ExecutionContext
object as an argument and (synchronously or asynchronously) returns a string. The default implementation uses Math.random()
to generate a string of 8 characters.
Request ID
Because of a shared storage, CLS is an ideal tool for tracking request (correlation) ids for the purpose of logging. This package provides an option to automatically generate request ids in the middleware/guard, if you pass { generateId: true }
to the middleware/guard options. By default, the generated ID is a string based on Math.random()
, but you can provide a custom function in the idGenerator
option.
This function receives the Request
(or ExecutionContext
in case a ClsGuard
is used) as the first parameter, which can be used in the generation process and should return a string id that will be stored in the CLS for later use.
Below is an example of retrieving the request ID from the request header with a fallback to an autogenerated one.
ClsModule.register({
middleware: {
mount: true,
generateId: true
idGenerator: (req: Request) =>
req.headers['X-Correlation-Id'] ?? uuid();
}
})
The ID is stored under the CLS_ID
constant in the context. ClsService
provides a shorthand method getId
to quickly retrieve it anywhere. It can be for example used in a custom logger:
@Injectable()
class MyLogger {
constructor(private readonly cls: ClsService) {}
log(message: string) {
console.log(`<${this.cls.getId()}> ${message}`);
}
}
@Injectable()
class MyService {
constructor(private readonly logger: MyLogger);
hello() {
this.logger.log('Hello');
}
}
Additional CLS Setup
The CLS middleware/guard provide some default functionality, but sometimes you might want to store more thing in the context by default. This can be of course done in a custom enhancer bound after, but for this scenario the ClsMiddleware/ClsGuard
options expose the setup
function, which will be executed in the middleware/guard after the CLS context is set up.
The function receives the ClsService
and the Request
(or ExecutionContext
) object, and can be asynchronous.
ClsModule.register({
middleware: {
mount: true,
setup: (cls, req) => {
cls.set('AUTH', { authenticated: false });
},
},
});
Breaking out of DI
While this package aims to be compatible with NestJS's DI, it is also possible to access the CLS context outside of it. For that, it provides the static ClsServiceManager
class that exposes the getClsService()
method.
function helper() {
const cls = ClsServiceManager.getClsService();
console.log(cls.getId());
}
Please note: Only use this feature where absolutely necessary. Using this technique instead of dependency injection will make it difficult to mock the ClsService and your code will become harder to test.
Security considerations
It is often discussed whether AsyncLocalStorage
is safe to use for concurrent requests (because of a possible context leak) and whether the context could be lost throughout the life duration of a request.
The ClsMiddleware
by default uses the safe run()
method, so it should be possible to leak context, however, that only works for REST Controllers
.
GraphQL Resolvers
, cause the context to be lost and therefore require using the less safe enterWith()
method. The same applies to using ClsGuard
to set up the context, since there's no callback to wrap with the run()
call (so the context would be not available outside of the guard otherwise).
This has one consequence that should be taken into account:
When the enterWith
method is used, any consequent requests get access to the context of the previous one until the request hits the enterWith
call.
That means, when using ClsMiddleware
with the useEnterWith
option, or ClsGuard
to set up context, be sure to mount them as early in the request lifetime as possible and do not use any other enhancers that rely on ClsService
before them. For ClsGuard
, that means you should probably manually mount it in AppModule
if you require any other guard to run after it.
Compatibility considerations
REST
This package is 100% compatible with Nest-supported REST controllers when you use the ClsMiddleware
with the mount
option.
GraphQL
For GraphQL, the ClsMiddleware
needs to be mounted manually with app.use(...)
in order to correctly set up the context for resolvers. Additionally, you have to pass useEnterWith: true
to the ClsMiddleware
options, because the context gets lost otherwise.
- ⚠ Apollo (Express)
- ⚠ Mercurius (Fastify)
Others
Use the ClsGuard
to set up context with any other platform. This is still experimental, as there are no test and I can't guarantee it will work with your platform of choice.
Namespaces (experimental)
Warning: Namespace support is currently experimental and has no tests. While the API is mostly stable now, it can still change any time.
The default CLS namespace that the ClsService
provides should be enough for most application, but should you need it, this package provides a way to use multiple CLS namespaces in order to be fully compatible with cls-hooked
.
Note: Since cls-hooked was ditched in version 1.2, it is no longer necessary to strive for compatibility with it. Still, the namespace support was there and there's no reason to remove it.
To use custom namespace provider, use ClsModule.forFeature('my-namespace')
.
@Module({
imports: [ClsModule.forFeature('hello-namespace')],
providers: [HelloService],
controllers: [HelloController],
})
export class HelloModule {}
This creates a namespaces ClsService
provider that you can inject using @InjectCls
@Injectable()
class HelloService {
constructor(
@InjectCls('hello-namespace')
private readonly myCls: ClsService,
) {}
sayHello() {
return this.myCls.get('hi');
}
}
Note: @InjectCls('x')
is equivalent to @Inject(getNamespaceToken('x'))
. If you don't pass an argument to @InjectCls()
, the default ClsService will be injected and is equivalent to omitting the decorator altogether.
@Injectable()
export class HelloController {
constructor(
@InjectCls('hello-namespace')
private readonly myCls: ClsService,
private readonly helloService: HelloService,
);
@Get('/hello')
hello2() {
return this.myCls.run(() => {
this.myCls.set('hi', 'Hello');
return this.helloService.sayHello();
});
}
}