Integração com NestJS
Integre o ImmutableLog em qualquer aplicação NestJS com um Interceptor que usa RxJS para capturar o ciclo de vida completo de cada requisição. Suporta registro global, por módulo ou por controller, seguindo os padrões de injeção de dependência do NestJS.
Instalação
A integração usa apenas dependências já presentes em qualquer projeto NestJS padrão: `@nestjs/common`, `@nestjs/core` e `rxjs`.
npm install @nestjs/common @nestjs/core rxjsCódigo do Interceptor
Salve os arquivos abaixo no seu projeto. O `ImmutableLogInterceptor` implementa `NestInterceptor` e usa o pipe RxJS `tap`/`catchError` para capturar resposta e erros. O `ImmutableLogModule` encapsula a configuração para facilitar o registro global.
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: [],
};
}
}Registro Global
Registre o interceptor globalmente no `main.ts` ou no `AppModule`. O registro global garante que todas as rotas sejam auditadas automaticamente, sem precisar decorar cada 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 o `ConfigModule` do NestJS ou variáveis de ambiente para carregar o `IMTBL_API_KEY` de forma segura. Nunca hardcode o token.
Registro por Controller
Para auditar apenas controllers específicos, use o decorator `@UseInterceptors()` diretamente no controller ou em métodos individuais.
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' };
}
}Como funciona
O interceptor usa o padrão Observable do RxJS. `next.handle()` retorna um Observable que emite a resposta. O pipe `tap` captura o sucesso e `catchError` captura exceções. Em ambos os casos, `_emit()` é chamado com os detalhes do evento antes de repassar o resultado ao cliente.
intercept()
Captura timestamp, requestId e eventName dos metadados
tap()
Emite evento de sucesso quando o Observable completa
catchError()
Captura erros, emite evento e re-lanca a excecao
Nome de evento customizado
Use metadados do NestJS via `SetMetadata` para definir o nome do evento em rotas específicas, sem acessar o objeto request diretamente.
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' };
}
}O interceptor lê os metadados via `Reflector` — primeiro no método, depois no controller. Se não encontrar, usa o padrão http.METHOD.path.
Trilha imutável (immutable_trail)
A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. O interceptor resolve a trilha por prioridade: metadado `@AuditTrail()` (via `Reflector`) → header `X-Imtbl-Trail` → fallback `user-<username>`. O valor passa por `sanitizeTrail` antes do envio.
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-123A trilha não pode ser vazia, exceder 256 caracteres ou conter `:` — violações retornam `400 invalid_immutable_trail`. O `sanitizeTrail` corrige o valor antes do envio.
Exclusão de rotas
Passe `skipPaths` nas opções do módulo. Rotas de health check e métricas são ignoradas automaticamente.
ImmutableLogModule.forRoot({
apiKey: process.env.IMTBL_API_KEY!,
skipPaths: ['/health', '/healthz', '/metrics', '/readyz'],
})Resposta e códigos de status
A API de ingestão é assíncrona. Sucesso retorna `202 Accepted` (evento enfileirado) ou `200 OK` quando a Idempotency-Key já existe (`duplicate: true`). O corpo traz `{ ok, tx_id, payload_hash, status, duplicate, request_id }`.
| Status | Significado |
|---|---|
| 202 | Evento aceito e enfileirado (novo) |
| 200 | Idempotency-Key já existente — duplicate: true |
| 400 | Idempotency-Key ausente/vazia ou invalid_immutable_trail |
| 403 | Assinatura inativa/expirada, escopo ou retenção |
| 413 | payload_too_large (limite do servidor: 16KB) |
| 429 | monthly_limit_exceeded — não fazer retry |
| 503 | mempool_full — transitório, pode dar retry |
Esta documentação reflete o comportamento atual da integração. Para dúvidas ou integrações avançadas, entre em contato com o time de suporte.
