Pular para o conteúdo principal
ImmutableLog logo
Voltar
NestJSNode.jsTypeScript

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`.

bash
npm install @nestjs/common @nestjs/core rxjs

Có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

typescript
// 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

typescript
// 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.

typescript
// 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.

typescript
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.

typescript
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.

typescript
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-123

A 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.

typescript
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 }`.

StatusSignificado
202Evento aceito e enfileirado (novo)
200Idempotency-Key já existente — duplicate: true
400Idempotency-Key ausente/vazia ou invalid_immutable_trail
403Assinatura inativa/expirada, escopo ou retenção
413payload_too_large (limite do servidor: 16KB)
429monthly_limit_exceeded — não fazer retry
503mempool_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.