Getting Started
Install nestjs-context, register the global module, read the traceId anywhere, and populate the user and tenant from your auth guard.
Getting nestjs-context running is three steps: install the package, register ContextModule.forRoot() once, and start reading the context anywhere in your app. From there, your auth layer drops the current user and tenant into the store so the rest of your code — and the rest of the ecosystem — can read them.
Prerequisites
- Node.js 20+ (the store is built on
node:async_hooks) - NestJS 10+ (both v10 and v11 are supported)
- TypeScript 5+ with
experimentalDecoratorsandemitDecoratorMetadataenabled
Step 1 — Install
pnpm add @dudousxd/nestjs-contextThe package declares the following as peer dependencies — you almost certainly have them already in a NestJS project, but install them if not:
pnpm add @nestjs/common @nestjs/core reflect-metadataStep 2 — Register the module
ContextModule.forRoot() returns a global dynamic module. Import it once at the root of your app and every other module can read the context without re-importing anything.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ContextModule } from '@dudousxd/nestjs-context';
@Module({
imports: [
ContextModule.forRoot(),
// ...the rest of your modules
],
})
export class AppModule {}By default forRoot() also registers an HTTP middleware automatically (autoMiddleware defaults to true). That middleware runs at the very start of the request pipeline and establishes a fresh context for every request: it reads (or generates) a trace id, captures the x-request-id header if present, and enters the store so everything downstream can see it.
forRoot() is global, so you import it once. Feature modules do not need to import it again. If you have a non-HTTP entrypoint (GraphQL subscriptions, gRPC, a queue worker), see Customization → non-HTTP entrypoints.
Async configuration with forRootAsync
When the options depend on other providers — a ConfigService, a feature flag service, anything resolved through DI — use ContextModule.forRootAsync() instead. It mirrors the ecosystem convention (nestjs-filter, nestjs-authz): supply exactly one of useFactory, useClass, or useExisting, plus the modules whose providers you want to inject.
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ContextModule } from '@dudousxd/nestjs-context';
@Module({
imports: [
ConfigModule.forRoot(),
ContextModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
traceHeader: config.get('TRACE_HEADER', 'traceparent'),
carrier: ['traceId', 'tenantId', 'userRef'],
}),
}),
],
})
export class AppModule {}The factory returns the same ContextModuleOptions that forRoot() accepts — the trace-id hook, initialize, the middleware options, the cross-process carrier, baggage, and enrichers are all honoured. If you prefer a class, implement ContextModuleOptionsFactory and pass it as useClass:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type {
ContextModuleOptions,
ContextModuleOptionsFactory,
} from '@dudousxd/nestjs-context';
@Injectable()
export class ContextOptionsFactory implements ContextModuleOptionsFactory {
constructor(private readonly config: ConfigService) {}
createContextOptions(): ContextModuleOptions {
return { traceHeader: this.config.get('TRACE_HEADER', 'traceparent') };
}
}ContextModule.forRootAsync({
imports: [ConfigModule],
useClass: ContextOptionsFactory,
});With forRoot() the cross-process / baggage / enricher config is pushed onto the module-level singleton synchronously at wiring time. With forRootAsync() the options do not exist until DI resolves the factory, so the singleton is configured slightly later — during onModuleInit, which Nest awaits before the app serves traffic. The end result is identical; it just happens once DI has produced your options.
Step 3 — Read the context anywhere
The Context object is a plain singleton — import it and call its accessors. No injection required, which is the whole point: it works in services, interceptors, guards, ORM subscribers, and plain functions alike.
import { Context } from '@dudousxd/nestjs-context';
Context.traceId(); // string | undefined — the request correlation id
Context.tenantId(); // string | undefined — the active tenant
Context.userRef(); // { type, id } | undefined — the acting principal
Context.get(); // the whole ContextStore | undefinedEvery accessor returns undefined when called outside any context (for example during app bootstrap, before any request) — they never throw. A logging interceptor that stamps the trace id on each line looks like this:
// src/logging.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { tap } from 'rxjs';
import { Context } from '@dudousxd/nestjs-context';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(_ctx: ExecutionContext, next: CallHandler) {
const traceId = Context.traceId();
return next.handle().pipe(
tap(() => this.logger.log(`handled request traceId=${traceId}`)),
);
}
}Step 4 — Populate the user and tenant
The middleware establishes the context and the trace id, but it deliberately does not know who the user is — authentication is not this library's job. Your auth guard resolves the principal and writes it into the active store with Context.set():
// src/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Context } from '@dudousxd/nestjs-context';
@Injectable()
export class AuthGuard implements CanActivate {
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest();
// ...your real token verification / session lookup...
const user = await this.resolveUser(req);
if (!user) return false;
// Drop the principal into the already-active context.
Context.set('userRef', { type: 'user', id: user.id });
Context.set('tenantId', user.tenantId);
return true;
}
}Context.set(key, value) mutates the store that the middleware already entered. It is fully typed against ContextStore, so Context.set('userRef', ...) only accepts a UserRef. Called with no active context it is a no-op (it never throws), but it emits a one-shot console.warn — a silently dropped set is a common footgun (e.g. an auth guard running on a route the middleware was excluded from), so the library makes the gap visible. The warning fires once per process; Context.resetSetWarning() re-arms it (primarily for tests).
Why does the guard set the user instead of the middleware? Because authentication runs after the request context exists. The middleware enters the store at the very top of the pipeline; the guard fills in the principal once it has been verified. This split is intentional — see The Store for why the store carries a userRef rather than the full user object.
From this point on, anything downstream — services, ORM subscribers, the audit and filter libraries — can read Context.userRef() and Context.tenantId() with zero wiring.
Why enterWith, not run
This is the one design choice worth understanding, because it explains why a middleware can establish a context that survives into your async handlers.
ALS gives you two ways to set a store:
Context.run(store, fn)runsfnwithstoreactive, and tears the store down the momentfnreturns. It is callback-scoped: everything that should see the context must happen insidefn.Context.enterWith(store)setsstoreas active for the current async execution and all its descendants, with no callback to wrap. The store persists after the call returns.
A NestJS middleware calls next() and then returns. If it used run(), the context would be torn down the instant the middleware function exited — long before your async controller method, guards, or interceptors ever ran. So the middleware uses enterWith: it enters the store and returns, and because the rest of the pipeline executes within the same async context tree, the controller, guards, and interceptors all see it.
// Conceptually, this is what the built-in middleware does:
Context.enterWith({ traceId, requestId });
next(); // returns — but the context is still active for everything downstreamUse Context.run() when you own a clean callback boundary and want the context to disappear when the work finishes — for example, wrapping a single queue job. Use Context.enterWith() when there is no wrapping callback, as in middleware. The built-in HTTP middleware uses enterWith for exactly this reason. See Cross-Process for run-based patterns.
Carrying the context across callbacks with bind
AsyncLocalStorage follows the async execution tree, but a few JavaScript boundaries break out of it: a callback you register now but that runs later — setTimeout / setInterval, an EventEmitter listener, a job callback handed to a third-party library — runs detached from the context that registered it, so Context.traceId() inside that callback would return undefined.
Context.bind(fn) snapshots whatever context is active at bind time and returns a wrapped function that re-enters that snapshot every time it is later invoked. Arguments, this, and the return value all pass through unchanged. It mirrors AsyncResource.bind.
import { Injectable } from '@nestjs/common';
import { EventEmitter } from 'node:events';
import { Context } from '@dudousxd/nestjs-context';
@Injectable()
export class NotificationsService {
constructor(private readonly emitter: EventEmitter) {}
scheduleReminder(): void {
// Without bind, the timer callback runs with no active context.
setTimeout(
Context.bind(() => {
this.send(`reminder traceId=${Context.traceId()}`);
}),
1000,
);
// Same for listeners registered inside a request.
this.emitter.once(
'done',
Context.bind(() => this.send(`done traceId=${Context.traceId()}`)),
);
}
private send(message: string): void {
/* ... */
}
}If nothing is active when you call bind, it returns the function unchanged — so it is always safe to wrap, and the bound function simply runs with no active store.
Next steps
- The Store — the
ContextStoreshape and how to add your own typed fields - Customization — the five levels of customization, from custom fields to swapping the accessor
- Cross-Process — carry the context across queue and durable boundaries
- Testing — run unit tests inside a fake store
Introduction
A shared AsyncLocalStorage context for NestJS that carries user, tenant and traceId across the whole request — and across the ecosystem.
The Store
The ContextStore shape, why it carries a UserRef instead of the full user, the always-present traceId invariant, and how to add your own typed fields.