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.
Note: There has been a breaking change in minor version 1.3 that only affects
GraphQL Apollo
, see Compatibility considerations - GraphQL
Outline
- Install
- Quick Start
- How it works
- API
- Options
- Request ID
- Custom CLS Middleware
- Breaking out of DI
- Compatibility considerations
- Namespaces (experimental)
Install
npm install nestjs-cls
# or
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 controllers, keep reading.
// app.module.ts
@Module({
imports: [
// Register the ClsModule and automatically mount the ClsMiddleware
ClsModule.register({
global: true,
middleware: { mount: true }
}),
],
providers: [AppService],
controllers: [AppController],
})
export class TestHttpApp {}
/* user-ip.interceptor.ts */
@Injectable()
export class UserIpInterceptor implements NestInterceptor {
constructor(
// Inject the ClsService into the interceptor to get
// access to the current shared cls context.
private readonly cls: ClsService
)
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// Extract the client's ip address from the request...
const request = context.switchToHttp().getRequest();
cosnt userIp = req.connection.remoteAddress;
// ...and store it to the cls context.
this.cls.set('ip', userIp);
return next.handle();
}
}
/* app.controller.ts */
// By mounting the interceptor on the controller, it gets access
// to the same shared cls context that the ClsMiddleware set up.
@UseInterceptors(UserIpInterceptor)
@Injectable()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('/hello')
hello() {
return this.appService.sayHello();
}
}
/* app.service.ts */
@Injectable()
export class AppService {
constructor(
// Inject ClsService to be able to retireve data from the cls context.
private readonly cls: ClsService
) {}
sayHello() {
// Here we can extract the value of 'ip' that was
// put into the cls context in the interceptor.
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()
.
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.
Note: Because we use middleware to hook the request callback chain, it follows that this package can only be used with HTTP (express, fastify) with the full functionality. You can still use it with other transports, but you wouldn't be able to use CLS in enhancers (Guards, Interceptors, Pipes, Exception Filters), since I haven't found a way to hook the incoming calls there (yet).
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);
// create and mount the middleware manually here
app.use(
new ClsMiddleware({
/* useEnterWith: true*/
}).use,
);
await app.listen(3000);
}
Please note: If you bind the middleware using
app.use()
, it will not respect middleware settings passed toClsModule.forRoot()
, so you will have to provide them yourself in the constructor.
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 forcls.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
(defaultfalse
)
Whether to make the module global, so you do to importClsModule
in other modules.middleware:
ClsMiddlewareOptions
An object with additional middleware options, see below
The ClsMiddleware
takes the following options (either set up in ClsModuleOptions
or directly when instantiating it manually):
-
ClsMiddlewareOptions
mount
:boolean
(defaultfalse
)
Whether to automatically mount the middleware to every route (not applicable when instantiating manually)generateId
:bolean
(defaultfalse
)
Whether to automatically generate request IDs.idGenerator
:(req: Request) => string | Promise<string>
An optional function for generating the request ID. It takes theRequest
object as an argument and (synchronously or asynchronously) returns a string. The default implementation usesMath.random()
to generate a string of 8 characters.saveReq
:boolean
(defaulttrue
)
Whether to store the Request object to the context. It will be available under theCLS_REQ
key.saveRes
:boolean
(defaultfalse
)
Whether to store the Response object to the context. It will be available under theCLS_RES
keyuseEnterWith
:boolean
(defaultfalse
)
Set totrue
to set up the context using a call toAsyncLocalStorage#enterWith
instead of wrapping thenext()
call with the saferAsyncLocalStorage#run
. Most of the time this should not be necessary, but some frameworks are known to lose the context withrun
.
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, if you pass { generateId: true }
to the middleware options. By default, the generated is a string based on Math.random()
, but you can provide a custom function in the idGenerator
option.
This function receives the Request
as the first parameter, which can be used in the generation process.
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:
// my.logger.ts
@Injectable()
class MyLogger {
constructor(private readonly cls: ClsService) {}
log(message: string) {
console.log(`<${this.cls.getId()}> ${message}`);
}
// [...]
}
// my.service.ts
@Injectable()
class MyService {
constructor(private readonly logger: MyLogger);
hello() {
this.logger.log('Hello');
// -> logs for ex.: "<7tuihq103e> Hello"
}
}
Custom CLS Middleware
The default middleware provides some basic functionality, but you can replace it with a custom one if you need some custom logic handling the initialisation of the cls namespace;
@Injectable()
export class HelloClsMiddleware implements NestMiddleware {
constructor(private readonly cls: ClsService) {}
use(req: Request, res: Response, next: () => NextFunction) {
this.cls.run(() => {
// any custom logic
next();
});
}
}
Note: Middleware options passed to
ClsModule.register
do not apply here, so you will need to implement any custom logic (like the generation of request ids) manually.
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();
// you now have access to the shared storage
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.
Compatibility considerations
REST
This package is 100% compatible with Nest-supported REST controllers.
- ✔ Express
- ✔ Fastify
GraphQL
For GraphQL, the ClsMiddleware needs to be mounted manually with app.use(...)
in order to correctly set up the context for resolvers.
- ⚠ Mercurius (Fastify)
- There's an issue with CLS and Mercurius, so in order to work around it, you have to pass
useEnterWith: true
to theClsMiddleware
options.
- There's an issue with CLS and Mercurius, so in order to work around it, you have to pass
- ⚠ Apollo (Express)
- There's an issue with CLS and Apollo, so in order to work around it, you have to pass
useEnterWith: true
to theClsMiddleware
options.
- There's an issue with CLS and Apollo, so in order to work around it, you have to pass
Others
There's no support for non-http transports yet 🙁, but stay tuned.
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() {
// seting up cls context manually
return this.myCls.run(() => {
this.myCls.set('hi', 'Hello');
return this.helloService.sayHello();
});
}
}