NestJS Integration
Integrate ImmutableLog into any NestJS application with an Interceptor that uses RxJS to capture the complete lifecycle of each request. Supports global, per-module, or per-controller registration, following NestJS dependency injection patterns.
Installation
The integration only uses dependencies already present in any standard NestJS project: `@nestjs/common`, `@nestjs/core`, and `rxjs`.
npm install @nestjs/common @nestjs/core rxjsInterceptor Code
Save the files below in your project. The `ImmutableLogInterceptor` implements `NestInterceptor` and uses the RxJS `tap`/`catchError` pipe to capture responses and errors. The `ImmutableLogModule` encapsulates the configuration to facilitate global registration.
immutablelog.interceptor.ts
// src/immutablelog/immutablelog.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, tap, catchError, throwError } from 'rxjs';
import crypto from 'crypto';
const MAX_PAYLOAD_BYTES = 12_000;
function clampPayload(payload: object): string {
let s = JSON.stringify(payload);
if (Buffer.byteLength(s, 'utf8') <= MAX_PAYLOAD_BYTES) return s;
const p = { ...(payload as Record<string, unknown>) };
delete p.error;
s = JSON.stringify(p);
if (Buffer.byteLength(s, 'utf8') <= MAX_PAYLOAD_BYTES) return s;
return JSON.stringify({
id: (p as Record<string, unknown>).id,
kind: (p as Record<string, unknown>).kind,
message: String((p as Record<string, unknown>).message ?? 'event').slice(0, 500),
timestamp: (p as Record<string, unknown>).timestamp,
});
}
function severityFrom(status: number | null, err: Error | null): string {
if (err) return 'error';
if (!status) return 'info';
if (status >= 400) return 'error';
if (status >= 300) return 'info';
if (status >= 200) return 'success';
return 'info';
}
export interface ImmutableLogOptions {
apiKey: string;
apiUrl?: string;
serviceName?: string;
env?: string;
skipPaths?: string[];
}
export const IMTBL_EVENT_NAME = 'imtbl:eventName';
@Injectable()
export class ImmutableLogInterceptor implements NestInterceptor {
private readonly skip: Set<string>;
constructor(
private readonly opts: ImmutableLogOptions,
private readonly reflector: Reflector,
) {
this.skip = new Set(opts.skipPaths ?? ['/health', '/healthz']);
}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const req = http.getRequest<Request & { ip: string; headers: Record<string, string> }>();
const res = http.getResponse<{ statusCode: number }>();
const url = (req as unknown as { url: string }).url ?? '';
if (this.skip.has(url)) return next.handle();
const startedAt = Date.now();
const requestId =
req.headers['x-request-id'] ?? crypto.randomUUID();
const eventName =
this.reflector.get<string>(IMTBL_EVENT_NAME, context.getHandler()) ??
this.reflector.get<string>(IMTBL_EVENT_NAME, context.getClass()) ??
`http.${(req as unknown as { method: string }).method}.${url}`;
return next.handle().pipe(
tap(() => {
this._emit(req, res.statusCode, null, startedAt, requestId, eventName);
}),
catchError((err: Error) => {
const status = (err as unknown as { status?: number }).status ?? 500;
this._emit(req, status, err, startedAt, requestId, eventName);
return throwError(() => err);
}),
);
}
private _emit(
req: unknown,
statusCode: number,
err: Error | null,
startedAt: number,
requestId: string,
eventName: string,
) {
const r = req as Record<string, unknown>;
const latencyMs = Date.now() - startedAt;
const kind = severityFrom(statusCode, err);
const method = String(r['method'] ?? 'GET');
const url = String(r['url'] ?? '/');
const headers = (r['headers'] as Record<string, string>) ?? {};
const payload: Record<string, unknown> = {
id: crypto.randomUUID(),
kind,
message: err
? `${method} ${url} failed: ${err.constructor.name}`
: kind === 'success'
? `${method} ${url} completed successfully`
: `${method} ${url} processed`,
timestamp: new Date().toISOString(),
context: {
ip: String(r['ip'] ?? 'unknown'),
userAgent: headers['user-agent'] ?? 'unknown',
},
request: { requestId, method, path: url },
metrics: { latencyMs, statusCode },
severity: kind === 'error' ? 'high' : 'low',
};
if (kind === 'error') {
payload.error = {
statusCode,
retryable: [408, 429, 500, 502, 503, 504].includes(statusCode),
...(err && { exception: err.constructor.name, exceptionMessage: err.message }),
};
} else if (kind === 'success') {
payload.success = { statusCode, result: statusCode === 200 ? 'ok' : 'processed' };
}
const event = {
payload: clampPayload(payload),
meta: {
type: kind,
event_name: eventName,
service: this.opts.serviceName ?? 'nestjs-service',
request_id: requestId,
env: this.opts.env ?? 'production',
},
};
const apiUrl = this.opts.apiUrl ?? 'https://api.immutablelog.com';
fetch(`${apiUrl}/v1/events`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.opts.apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': `${eventName}-${requestId}`,
'Request-Id': requestId,
},
body: JSON.stringify(event),
signal: AbortSignal.timeout(5000),
}).catch((e: Error) =>
console.warn('[ImmutableLog] emit failed:', e.message),
);
}
}immutablelog.module.ts
// src/immutablelog/immutablelog.module.ts
import { DynamicModule, Global, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { Reflector } from '@nestjs/core';
import { ImmutableLogInterceptor, ImmutableLogOptions } from './immutablelog.interceptor';
@Global()
@Module({})
export class ImmutableLogModule {
static forRoot(opts: ImmutableLogOptions): DynamicModule {
return {
module: ImmutableLogModule,
providers: [
Reflector,
{ provide: 'IMTBL_OPTIONS', useValue: opts },
{
provide: APP_INTERCEPTOR,
useFactory: (reflector: Reflector) =>
new ImmutableLogInterceptor(opts, reflector),
inject: [Reflector],
},
],
exports: [],
};
}
}Global Registration
Register the interceptor globally in `main.ts` or `AppModule`. Global registration ensures all routes are automatically audited without needing to decorate each controller.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ImmutableLogModule } from './immutablelog/immutablelog.module';
@Module({
imports: [
ImmutableLogModule.forRoot({
apiKey: process.env.IMTBL_API_KEY!,
apiUrl: process.env.IMTBL_URL ?? 'https://api.immutablelog.com',
serviceName: process.env.IMTBL_SERVICE_NAME ?? 'nestjs-service',
env: process.env.IMTBL_ENV ?? 'production',
skipPaths: ['/health', '/healthz', '/metrics'],
}),
// ... outros módulos / other modules
],
})
export class AppModule {}Use NestJS `ConfigModule` or environment variables to safely load `IMTBL_API_KEY`. Never hardcode the token.
Per-controller Registration
To audit only specific controllers, use the `@UseInterceptors()` decorator directly on the controller or on individual methods.
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { ImmutableLogInterceptor } from '../immutablelog/immutablelog.interceptor';
import { Reflector } from '@nestjs/core';
// Por controller inteiro / Per entire controller
@UseInterceptors(new ImmutableLogInterceptor(
{ apiKey: process.env.IMTBL_API_KEY! },
new Reflector(),
))
@Controller('payments')
export class PaymentsController {
// Por método individual / Per individual method
@UseInterceptors(new ImmutableLogInterceptor(
{ apiKey: process.env.IMTBL_API_KEY! },
new Reflector(),
))
@Get('checkout')
checkout() {
return { status: 'ok' };
}
}How it works
The interceptor uses the RxJS Observable pattern. `next.handle()` returns an Observable that emits the response. The `tap` pipe captures success and `catchError` captures exceptions. In both cases, `_emit()` is called with event details before passing the result to the client.
intercept()
Captures timestamp, requestId and eventName from metadata
tap()
Emits success event when the Observable completes
catchError()
Captures errors, emits event and re-throws the exception
Custom event name
Use NestJS metadata via `SetMetadata` to define the event name on specific routes, without accessing the request object directly.
import { SetMetadata, Controller, Post } from '@nestjs/common';
import { IMTBL_EVENT_NAME } from '../immutablelog/immutablelog.interceptor';
// Helper decorator para nomear o evento / Helper decorator to name the event
export const AuditEvent = (name: string) => SetMetadata(IMTBL_EVENT_NAME, name);
@Controller('payments')
export class PaymentsController {
@AuditEvent('payment.checkout.initiated')
@Post('checkout')
checkout() {
return { status: 'ok' };
}
@AuditEvent('payment.refund.requested')
@Post('refund')
refund() {
return { status: 'ok' };
}
}The interceptor reads metadata via `Reflector` — first on the method, then on the controller. If not found, uses the default http.METHOD.path.
Path exclusion
Pass `skipPaths` in the module options. Health check and metrics routes are automatically ignored.
ImmutableLogModule.forRoot({
apiKey: process.env.IMTBL_API_KEY!,
skipPaths: ['/health', '/healthz', '/metrics', '/readyz'],
})This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.
