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

Integração com Express

Integre o ImmutableLog em qualquer aplicação Express com um middleware que captura automaticamente todas as requisições HTTP. Usa a API nativa `fetch` do Node.js 18+ para envio não-bloqueante.

Instalação

Nenhuma dependência extra além do Express. A integração usa o `fetch` nativo do Node.js 18+. Para versões anteriores, instale `node-fetch`.

bash
npm install express
# Node.js < 18 only:
npm install node-fetch

Código do Middleware

Salve o código abaixo em `src/middleware/immutablelog.ts`. O middleware usa `res.on('finish')` para capturar o status final da resposta após o handler completar, e `res.on('error')` para capturar erros de stream.

typescript
// src/middleware/immutablelog.ts
import crypto from 'crypto';
import { Request, Response, NextFunction } from 'express';

// Margem de seguranca: o limite real do servidor e 16KB (retorna 413 acima disso).
// Safety margin: the server hard limit is 16KB (returns 413 above that).
const MAX_PAYLOAD_BYTES = 12_000;

// Chaves cujos valores sao mascarados antes do envio (PII/segredos)
// Keys whose values are masked before sending (PII/secrets)
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 conforme as regras do servidor:
// trim, nao-vazio, max 256 chars, sem ':' (o servidor responde 400 se violado).
// Normalizes immutable_trail per server rules: trim, non-empty, max 256, no ':'.
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;
  delete p.requestBody;
  s = JSON.stringify(p);
  if (Buffer.byteLength(s, 'utf8') <= MAX_PAYLOAD_BYTES) return s;
  return JSON.stringify({
    id: (payload as Record<string, unknown>).id,
    kind: (payload as Record<string, unknown>).kind,
    message: String((payload as Record<string, unknown>).message ?? 'event').slice(0, 500),
    timestamp: (payload as Record<string, unknown>).timestamp,
  });
}

function hashBody(body: string | Buffer): string {
  const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
  return crypto.createHash('sha256').update(buf).digest('hex');
}

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

// Usuario autenticado, se o seu app popular req.user (ex: passport, jwt middleware)
// Authenticated user, if your app populates req.user (e.g. passport, jwt middleware)
interface AuthUser {
  id?: string | number;
  email?: string;
  username?: string;
}

export interface ImmutableLogOptions {
  apiKey: string;
  apiUrl?: string;
  serviceName?: string;
  env?: string;
  skipPaths?: string[];
  // Timezone do cliente — usado pelo dashboard para exibir os timestamps
  // Client timezone — used by the dashboard to render timestamps
  clientTz?: string;
  clientOffsetMinutes?: number;
}

export function immutableLogMiddleware(opts: ImmutableLogOptions) {
  const {
    apiKey,
    apiUrl = 'https://api.immutablelog.com',
    serviceName = 'express-service',
    env = 'production',
    skipPaths = ['/health', '/healthz'],
    clientTz = 'America/Sao_Paulo',
    clientOffsetMinutes = -180,
  } = opts;
  const skip = new Set(skipPaths);

  return (req: Request, res: Response, next: NextFunction): void => {
    if (skip.has(req.path)) return next();

    const startedAt = Date.now();
    const requestId =
      (req.headers['x-request-id'] as string) || crypto.randomUUID();
    (req as Request & { requestId: string }).requestId = requestId;

    const rawBody: string =
      (req as Request & { rawBody?: string }).rawBody ?? '';

    const emit = (err?: Error): void => {
      const latencyMs = Date.now() - startedAt;
      const statusCode = err ? 500 : res.statusCode;
      const kind = severityFrom(statusCode, err ?? null);
      const eventName =
        (req as Request & { imtblEventName?: string }).imtblEventName ??
        `http.${req.method}.${(req.route?.path as string | undefined) ?? req.path}`;

      const user = (req as Request & { user?: AuthUser }).user;

      const payload: Record<string, unknown> = {
        id: crypto.randomUUID(),
        kind,
        message: err
          ? `${req.method} ${req.path} failed: ${err.constructor.name}`
          : kind === 'success'
          ? `${req.method} ${req.path} completed successfully`
          : `${req.method} ${req.path} processed`,
        timestamp: new Date().toISOString(),
        context: {
          ip: req.ip ?? req.socket?.remoteAddress ?? 'unknown',
          userAgent: req.headers['user-agent'] ?? 'unknown',
          // Enriquece com o usuario autenticado quando disponivel
          // Enrich with authenticated user when available
          ...(user && {
            userId: user.id,
            email: user.email,
            username: user.username,
          }),
        },
        request: {
          requestId,
          method: req.method,
          path: req.path,
          // query params passam por redact() — nunca enviar segredos em claro
          // query params go through redact() — never send secrets in the clear
          queryParams: Object.keys(req.query).length ? redact(req.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,
          }),
          ...(rawBody && { requestBodyHash: hashBody(rawBody) }),
        };
      } else if (kind === 'success') {
        payload.success = {
          statusCode,
          result: statusCode === 200 ? 'ok' : 'processed',
        };
      } else {
        payload.info = { statusCode, action: req.method.toLowerCase() };
      }

      // Trilha imutavel (immutable_trail). Prioridade / priority:
      //   1. req.imtblTrail (definido por uma rota, ex: `flow-${flow.id}`)
      //   2. header X-Imtbl-Trail propagado pelo cliente
      //   3. fallback por usuario autenticado: user-<username>
      // Omitido se nenhum se aplicar — nao invente um trail generico.
      const trail =
        sanitizeTrail((req as Request & { imtblTrail?: string }).imtblTrail) ??
        sanitizeTrail(req.headers['x-imtbl-trail'] as string | undefined) ??
        sanitizeTrail(user?.username ? `user-${user.username}` : undefined);

      const event = {
        payload: clampPayload(payload),
        // IMPORTANTE: todos os valores de meta precisam ser strings.
        // IMPORTANT: every meta value must be a string.
        meta: {
          type: kind,
          event_name: eventName,
          service: serviceName,
          request_id: requestId,
          env,
          ...(trail && { immutable_trail: trail }),
        },
      };

      // fire-and-forget — nunca bloqueia a resposta / never blocks the response
      fetch(`${apiUrl}/v1/events`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${apiKey}`,
          'Content-Type': 'application/json',
          // Idempotency-Key e OBRIGATORIO (sem ele a API responde 400)
          // Idempotency-Key is REQUIRED (without it the API returns 400)
          'Idempotency-Key': `${eventName}-${requestId}`,
          'Request-Id': requestId,
          // Timezone do cliente — alimenta meta.client_tz / client_offset_minutes
          'X-Client-TZ': clientTz,
          'X-Client-Offset-Minutes': String(clientOffsetMinutes),
          ...(trail && { 'X-Imtbl-Trail': trail }),
        },
        body: JSON.stringify(event),
        signal: AbortSignal.timeout(5000),
      }).catch((e: Error) =>
        console.warn('[ImmutableLog] emit failed:', e.message),
      );

      res.setHeader('x-request-id', requestId);
    };

    res.on('finish', () => emit());
    res.on('error', (err: Error) => emit(err));

    next();
  };
}

Registro e Configuração

Registre o middleware globalmente com `app.use()` antes das suas rotas. Configure via variáveis de ambiente para manter as credenciais fora do código.

typescript
// src/app.ts
import express from 'express';
import { immutableLogMiddleware } from './middleware/immutablelog';

const app = express();
app.use(express.json());

// Registrar antes das rotas / Register before routes
app.use(
  immutableLogMiddleware({
    apiKey: process.env.IMTBL_API_KEY!,
    apiUrl: process.env.IMTBL_URL ?? 'https://api.immutablelog.com',
    serviceName: process.env.IMTBL_SERVICE_NAME ?? 'express-service',
    env: process.env.IMTBL_ENV ?? 'production',
    skipPaths: ['/health', '/healthz', '/metrics'],
  }),
);

app.get('/users', (req, res) => res.json({ users: [] }));
app.listen(3000);

Nunca passe o token diretamente no código. Use variáveis de ambiente (.env) e garanta que o arquivo `.env` esteja no `.gitignore`.

Como funciona

O middleware registra o timestamp de início na entrada, escuta `res.on('finish')` para capturar o status final e calcula a latência. O envio ao ImmutableLog é feito via `fetch` fire-and-forget — nunca bloqueia a resposta para o cliente.

entry

Timestamp registrado, request_id gerado ou herdado do header

res.on('finish')

Latencia calculada, evento emitido via fetch fire-and-forget

res.on('error')

Erros de stream capturados e emitidos como evento de erro

O header x-request-id é injetado na resposta para rastreabilidade end-to-end entre sistemas.

Nome de evento customizado

Atribua `req.imtblEventName` em qualquer handler ou middleware anterior para sobrescrever o nome do evento gerado automaticamente.

typescript
app.post('/checkout', (req, res) => {
  // Sobrescreve o nome do evento / Override the event name
  (req as any).imtblEventName = 'payment.checkout.initiated';

  // ... logica de negocio / business logic ...
  res.json({ status: 'ok' });
});

Padrao automatico: http.METHOD.route_path (ex: http.POST./checkout).

Exclusão de rotas

Passe `skipPaths` nas opções para ignorar rotas específicas como health checks e métricas.

typescript
app.use(
  immutableLogMiddleware({
    apiKey: process.env.IMTBL_API_KEY!,
    apiUrl: 'https://api.immutablelog.com',
    skipPaths: ['/health', '/healthz', '/metrics', '/readyz'],
  }),
);

Enriquecimento em erros

Em eventos de erro (4xx/5xx), o middleware inclui o hash SHA-256 do body da requisição, flag `retryable` e, quando disponíveis, os detalhes da exceção.

typescript
// Error handler do Express — capturado automaticamente pelo middleware
// Express error handler — automatically captured by the middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  res.status(500).json({ error: err.message });
});

error.requestBodyHash

SHA-256 do body (auditoria sem expor dados)

error.retryable

true para: 408, 429, 500, 502, 503, 504

error.exception

Nome da classe do erro (se disponivel)

error.exceptionMessage

Mensagem do erro (se disponivel)

Trilha imutável (immutable_trail)

A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável (ex: `flow-123`, `user-maria`). O middleware resolve a trilha por prioridade: `req.imtblTrail` definido na rota → header `X-Imtbl-Trail` propagado pelo cliente → fallback `user-<username>` do usuário autenticado. O valor passa por `sanitizeTrail` antes do envio.

typescript
// 1. Definir a trilha em uma rota especifica / Set the trail on a specific route
app.post('/flows/:id/run', (req, res) => {
  (req as any).imtblTrail = `flow-${req.params.id}`;
  res.json({ status: 'ok' });
});

// 2. Ou propagar entre servicos via header / Or propagate across services via header
//    X-Imtbl-Trail: flow-123
// 3. Sem nada disso, cai no fallback user-<username> (se houver req.user)

Regras do servidor: a trilha não pode ser vazia, ter mais de 256 caracteres ou conter `:`. Violações retornam `400 invalid_immutable_trail`. Por isso o `sanitizeTrail` corrige o valor (substitui `:` por `-` e trunca em 256) antes do envio.

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 }`. Como o middleware é fire-and-forget, esses códigos importam principalmente se você enviar eventos manualmente.

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.