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';
// Margem de seguranca: o limite real do servidor e 16KB (retorna 413 acima disso).
const MAX_PAYLOAD_BYTES = 12_000;
const SENSITIVE_KEYS = new Set([
'password', 'pwd', 'passwd', 'pass', 'senha',
'token', 'access_token', 'refresh_token', 'id_token', 'api_key', 'apikey',
'secret', 'client_secret', 'authorization', 'auth',
'credit_card', 'card_number', 'card', 'cvv', 'cvc',
'pin', 'ssn', 'cpf', 'rg', 'otp', 'code',
]);
const REDACTED = '***REDACTED***';
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) =>
SENSITIVE_KEYS.has(k.toLowerCase()) ? [k, REDACTED] : [k, redact(v)],
),
);
}
return value;
}
// Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':'); o servidor responde 400 se violado.
function sanitizeTrail(value?: string | null): string | undefined {
if (typeof value !== 'string') return undefined;
let v = value.trim();
if (!v) return undefined;
if (v.includes(':')) v = v.replace(/:/g, '-');
if (v.length > 256) v = v.slice(0, 256);
return v;
}
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[];
// Timezone do cliente — usado pelo dashboard para exibir os timestamps
clientTz?: string;
clientOffsetMinutes?: number;
}
export const IMTBL_EVENT_NAME = 'imtbl:eventName';
export const IMTBL_TRAIL = 'imtbl:trail';
@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}`;
// Trilha definida via @AuditTrail() no metodo ou no controller
const trailMeta =
this.reflector.get<string>(IMTBL_TRAIL, context.getHandler()) ??
this.reflector.get<string>(IMTBL_TRAIL, context.getClass());
return next.handle().pipe(
tap(() => {
this._emit(req, res.statusCode, null, startedAt, requestId, eventName, trailMeta);
}),
catchError((err: Error) => {
const status = (err as unknown as { status?: number }).status ?? 500;
this._emit(req, status, err, startedAt, requestId, eventName, trailMeta);
return throwError(() => err);
}),
);
}
private _emit(
req: unknown,
statusCode: number,
err: Error | null,
startedAt: number,
requestId: string,
eventName: string,
trailMeta?: 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 query = (r['query'] as Record<string, unknown>) ?? {};
const user = r['user'] as { id?: string | number; email?: string; username?: string } | undefined;
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',
...(user && { userId: user.id, email: user.email, username: user.username }),
},
request: {
requestId,
method,
path: url,
// query params passam por redact() / query params go through redact()
queryParams: Object.keys(query).length ? redact(query) : null,
},
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' };
}
// Trilha imutavel: @AuditTrail() -> header X-Imtbl-Trail -> user-<username>
const trail =
sanitizeTrail(trailMeta) ??
sanitizeTrail(headers['x-imtbl-trail']) ??
sanitizeTrail(user?.username ? `user-${user.username}` : undefined);
const event = {
payload: clampPayload(payload),
// Todos os valores de meta precisam ser strings / Every meta value must be a string
meta: {
type: kind,
event_name: eventName,
service: this.opts.serviceName ?? 'nestjs-service',
request_id: requestId,
env: this.opts.env ?? 'production',
...(trail && { immutable_trail: trail }),
},
};
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 e OBRIGATORIO (sem ele a API responde 400)
'Idempotency-Key': `${eventName}-${requestId}`,
'Request-Id': requestId,
'X-Client-TZ': this.opts.clientTz ?? 'America/Sao_Paulo',
'X-Client-Offset-Minutes': String(this.opts.clientOffsetMinutes ?? -180),
...(trail && { 'X-Imtbl-Trail': trail }),
},
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.
Immutable trail (immutable_trail)
A trail groups related events into a single auditable timeline. The interceptor resolves the trail by priority: `@AuditTrail()` metadata (via `Reflector`) → `X-Imtbl-Trail` header → `user-<username>` fallback. The value goes through `sanitizeTrail` before sending.
import { SetMetadata, Controller, Post } from '@nestjs/common';
import { IMTBL_TRAIL } from '../immutablelog/immutablelog.interceptor';
// Helper decorator para definir a trilha / Helper decorator to set the trail
export const AuditTrail = (trail: string) => SetMetadata(IMTBL_TRAIL, trail);
@Controller('flows')
export class FlowsController {
@AuditTrail('flow-onboarding')
@Post('run')
run() {
return { status: 'ok' };
}
}
// Ou propague entre serviços via header: X-Imtbl-Trail: flow-123The trail cannot be empty, exceed 256 characters, or contain `:` — violations return `400 invalid_immutable_trail`. `sanitizeTrail` fixes the value before sending.
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'],
})Response and status codes
The ingestion API is asynchronous. Success returns `202 Accepted` (event queued) or `200 OK` when the Idempotency-Key already exists (`duplicate: true`). The body contains `{ ok, tx_id, payload_hash, status, duplicate, request_id }`.
| Status | Meaning |
|---|---|
| 202 | Event accepted and queued (new) |
| 200 | Idempotency-Key already exists — duplicate: true |
| 400 | Missing/empty Idempotency-Key or invalid_immutable_trail |
| 403 | Inactive/expired subscription, scope or retention |
| 413 | payload_too_large (server limit: 16KB) |
| 429 | monthly_limit_exceeded — do not retry |
| 503 | mempool_full — transient, retryable |
meta fields
Beyond the payload, the meta object carries the fields the SIEM uses to normalize (ECS) and enrich the event. All meta values are strings.
| Field in meta | What it does in the SIEM |
|---|---|
event_name | The event label (e.g. http.GET.auth-me-user, Payment error) — the identifier shown in the listing. Becomes event.action and derives event.category (e.g. a name with login → authentication). |
client_ip | Becomes source.ip with maximum precedence and provenance client_asserted. See the section below. |
immutable_trail | Becomes imtbl.immutable_trail — groups related events for investigation (e.g. one order, one user). |
type | Event severity: error / warning / info / success. Colors the badge in the listing. (event_type is accepted as a synonym.) |
service, env, request_id | Metadata: indexed and queryable in the dashboard (search/filters). |
client_ip — end-user IP
The problem it solves
Between the browser and the core there are proxies/ALB. The IP the core sees on the connection is the previous hop (the client's backend or an AWS proxy), not the user's. Without action, source.ip would be the proxy IP — useless for geo, threat and IP-based detection. (Real example: XFF arrived as 44.192.13.3 (AWS) while the user was 179.110.4.205.)
The solution
The client's backend is the only one that sees the browser's real IP — so it forwards that IP in meta.client_ip when calling POST /v1/events.
How the SIEM handles it
- Validates it is an IP (v4/v6). A non-IP value is ignored — it does not drop the event.
- Writes to
source.ipwith maximum precedence (see order below). - Stamps
imtbl.source_ip_origin = "client_asserted"— the UI shows the “User IP” badge. - Feeds GeoIP, threat intel and IP-based detection rules.
client_ipX-Forwarded-For (1º hop)X-Real-IPconnection IPProvenance and trust
client_ip is asserted by the tenant — the client's backend claims the IP, and it is spoofable by whoever controls that backend. However it is self-contained to the tenant (it never crosses another tenant's boundary). A threat/geo hit on a client_asserted value is a claim, not an edge observation.
How to obtain the IP in your backend
Take the first IP from X-Forwarded-For (closest to the user); as a fallback, use the connection IP.
function getClientIp(req: Request): string | undefined {
const xff = req.headers["x-forwarded-for"];
if (typeof xff === "string" && xff.length) return xff.split(",")[0].trim(); // primeiro hop
return req.socket.remoteAddress ?? undefined;
}
const meta = { event_name: "user.login", client_ip: getClientIp(req) };Payload example
{
"payload": "{\"user\":\"bob\",\"action\":\"login\"}",
"meta": {
"event_name": "user.login",
"client_ip": "179.110.4.205",
"immutable_trail": "user-bob"
}
}PII note: client_ip is PII and gets sealed into the immutable block (permanent). Decide consciously between forensic evidence and “right to be forgotten” before sending it.
This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.
