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

Integração com Fastify

Integre o ImmutableLog em qualquer aplicação Fastify com um plugin que usa os hooks nativos do framework. Captura automaticamente todas as requisições com latência mínima — projetado para aplicações de alta performance.

Instalação

O plugin depende do `fastify-plugin` para garantir que os hooks sejam globais. O `fetch` nativo do Node.js 18+ é usado para envio não-bloqueante.

bash
npm install fastify fastify-plugin

Código do Plugin

Salve o código abaixo em `src/plugins/immutablelog.ts`. O plugin usa `fastify-plugin` para desencapsular os hooks e garantir que funcionem globalmente em toda a aplicação, incluindo rotas em sub-plugins.

typescript
// src/plugins/immutablelog.ts
import crypto from 'crypto';
import fp from 'fastify-plugin';
import {
  FastifyInstance,
  FastifyPluginOptions,
  FastifyRequest,
  FastifyReply,
} from 'fastify';

// Margem de seguranca: o limite real do servidor e 16KB (retorna 413 acima disso).
const MAX_PAYLOAD_BYTES = 12_000;

// Chaves cujos valores sao mascarados antes do envio (PII/segredos)
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';
}

interface AuthUser { id?: string | number; email?: string; username?: string; }

export interface ImmutableLogPluginOptions extends FastifyPluginOptions {
  apiKey: string;
  apiUrl?: string;
  serviceName?: string;
  env?: string;
  skipPaths?: string[];
  // Timezone do cliente — usado pelo dashboard para exibir os timestamps
  clientTz?: string;
  clientOffsetMinutes?: number;
}

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

  fastify.addHook('onRequest', async (request: FastifyRequest) => {
    if (skip.has(request.url)) return;
    (request as any).startedAt = Date.now();
    (request as any).requestId =
      (request.headers['x-request-id'] as string) || crypto.randomUUID();
  });

  fastify.addHook('onResponse', async (request: FastifyRequest, reply: FastifyReply) => {
    if (skip.has(request.url) || !(request as any).startedAt) return;
    emit(request, reply.statusCode, null);
  });

  fastify.addHook('onError', async (request: FastifyRequest, reply: FastifyReply, error: Error) => {
    if (!(request as any).startedAt) return;
    emit(request, reply.statusCode || 500, error);
  });

  function emit(request: FastifyRequest, statusCode: number, err: Error | null) {
    const startedAt: number = (request as any).startedAt;
    const requestId: string = (request as any).requestId || crypto.randomUUID();
    const latencyMs = Date.now() - startedAt;
    const kind = severityFrom(statusCode, err);
    const eventName =
      (request as any).imtblEventName ??
      `http.${request.method}.${(request as any).routeOptions?.url ?? request.url}`;

    const user = (request as any).user as AuthUser | undefined;

    const payload: Record<string, unknown> = {
      id: crypto.randomUUID(),
      kind,
      message: err
        ? `${request.method} ${request.url} failed: ${err.constructor.name}`
        : kind === 'success'
        ? `${request.method} ${request.url} completed successfully`
        : `${request.method} ${request.url} processed`,
      timestamp: new Date().toISOString(),
      context: {
        ip: request.ip ?? 'unknown',
        userAgent: request.headers['user-agent'] ?? 'unknown',
        ...(user && { userId: user.id, email: user.email, username: user.username }),
      },
      request: {
        requestId,
        method: request.method,
        path: request.url,
        // query params passam por redact() / query params go through redact()
        queryParams: Object.keys(request.query as object).length ? redact(request.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' };
    } else {
      payload.info = { statusCode, action: request.method.toLowerCase() };
    }

    // Trilha imutavel: request.imtblTrail -> header X-Imtbl-Trail -> user-<username>
    const trail =
      sanitizeTrail((request as any).imtblTrail) ??
      sanitizeTrail(request.headers['x-imtbl-trail'] as string | undefined) ??
      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: serviceName,
        request_id: requestId,
        env,
        ...(trail && { immutable_trail: trail }),
      },
    };

    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': `${eventName}-${requestId}`,
        'Request-Id': requestId,
        '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) => fastify.log.warn(`[ImmutableLog] emit failed: ${e.message}`));
  }
}

// fp() desencapsula o plugin para que os hooks sejam globais
// fp() unwraps the plugin so hooks are global
export default fp(immutableLogPlugin, { name: 'immutablelog-audit', fastify: '>=4.0.0' });

Registro e Configuração

Registre o plugin com `fastify.register()` antes das suas rotas. As opções são tipadas e passadas diretamente no registro. Use variáveis de ambiente para as credenciais.

typescript
// src/server.ts
import Fastify from 'fastify';
import immutableLogPlugin from './plugins/immutablelog';

const fastify = Fastify({ logger: true });

await fastify.register(immutableLogPlugin, {
  apiKey: process.env.IMTBL_API_KEY!,
  apiUrl: process.env.IMTBL_URL ?? 'https://api.immutablelog.com',
  serviceName: process.env.IMTBL_SERVICE_NAME ?? 'fastify-service',
  env: process.env.IMTBL_ENV ?? 'production',
  skipPaths: ['/health', '/healthz', '/metrics'],
});

fastify.get('/users', async () => ({ users: [] }));
await fastify.listen({ port: 3000 });

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

Como funciona

O plugin registra três hooks do Fastify. `onRequest` captura o timestamp de início e gera o `requestId`. `onResponse` calcula a latência e emite o evento de sucesso/info. `onError` captura exceções não tratadas e emite o evento de erro com detalhes da exceção.

onRequest

Registra startedAt e gera requestId unico

onResponse

Calcula latencia, emite evento via fetch fire-and-forget

onError

Captura excecoes, emite evento de erro com detalhes

O uso de `fastify-plugin` é essencial: sem ele, os hooks ficam encapsulados no escopo do plugin e não capturam rotas registradas fora dele.

Nome de evento customizado

Atribua `request.imtblEventName` dentro de qualquer handler ou hook `preHandler` para sobrescrever o nome do evento gerado automaticamente pelo plugin.

typescript
fastify.post('/checkout', async (request, reply) => {
  (request as any).imtblEventName = 'payment.checkout.initiated';
  return { status: 'ok' };
});

Exclusão de rotas

Passe `skipPaths` nas opções do plugin. As rotas ignoradas não geram eventos no ledger, evitando que health checks e métricas poluam a auditoria.

typescript
await fastify.register(immutableLogPlugin, {
  apiKey: process.env.IMTBL_API_KEY!,
  skipPaths: ['/health', '/healthz', '/metrics', '/readyz'],
});

Trilha imutável (immutable_trail)

A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. O plugin resolve a trilha por prioridade: `request.imtblTrail` → header `X-Imtbl-Trail` → fallback `user-<username>`. O valor passa por `sanitizeTrail` antes do envio.

typescript
fastify.post('/flows/:id/run', async (request, reply) => {
  (request as any).imtblTrail = `flow-${(request.params as any).id}`;
  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.

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

Augmentation de tipos

Para evitar o uso de `any` ao acessar `request.imtblEventName` e `request.startedAt`, adicione a declaração de módulo abaixo ao seu projeto TypeScript.

typescript
// src/types/fastify.d.ts
import 'fastify';

declare module 'fastify' {
  interface FastifyRequest {
    startedAt?: number;
    requestId?: string;
    imtblEventName?: string;
    imtblTrail?: string;
    user?: { id?: string | number; email?: string; username?: string };
  }
}

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.