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';

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

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.

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'],
})

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.