Skip to main content
ImmutableLog logo
Back
FastifyNode.jsTypeScript

Fastify Integration

Integrate ImmutableLog into any Fastify application with a plugin that uses the framework's native hooks. Automatically captures all requests with minimal latency — designed for high-performance applications.

Installation

The plugin depends on `fastify-plugin` to ensure hooks are global. Node.js 18+ native `fetch` is used for non-blocking sending.

bash
npm install fastify fastify-plugin

Plugin Code

Save the code below in `src/plugins/immutablelog.ts`. The plugin uses `fastify-plugin` to unwrap the hooks and ensures they work globally across the entire application, including routes in 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' });

Registration and Configuration

Register the plugin with `fastify.register()` before your routes. Options are typed and passed directly at registration. Use environment variables for credentials.

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

Never pass the token directly in code. Use environment variables and ensure `.env` is in `.gitignore`.

How it works

The plugin registers three Fastify hooks. `onRequest` captures the start timestamp and generates the `requestId`. `onResponse` calculates latency and emits the success/info event. `onError` captures unhandled exceptions and emits the error event with exception details.

onRequest

Records startedAt and generates unique requestId

onResponse

Calculates latency, emits event via fire-and-forget fetch

onError

Captures exceptions, emits error event with details

Using `fastify-plugin` is essential: without it, hooks are encapsulated in the plugin scope and do not capture routes registered outside of it.

Custom event name

Assign `request.imtblEventName` inside any handler or `preHandler` hook to override the event name automatically generated by the plugin.

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

Path exclusion

Pass `skipPaths` in the plugin options. Ignored routes do not generate events in the ledger, preventing health checks and metrics from polluting the audit.

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

Immutable trail (immutable_trail)

A trail groups related events into a single auditable timeline. The plugin resolves the trail by priority: `request.imtblTrail` → `X-Imtbl-Trail` header → `user-<username>` fallback. The value goes through `sanitizeTrail` before sending.

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

The trail cannot be empty, exceed 256 characters, or contain `:` — violations return `400 invalid_immutable_trail`. `sanitizeTrail` fixes the value before sending.

Response and status codes

The ingestion API is asynchronous. Success returns `202 Accepted` (event queued) or `200 OK` when the Idempotency-Key already exists (`duplicate: true`). The body contains `{ ok, tx_id, payload_hash, status, duplicate, request_id }`.

StatusMeaning
202Event accepted and queued (new)
200Idempotency-Key already exists — duplicate: true
400Missing/empty Idempotency-Key or invalid_immutable_trail
403Inactive/expired subscription, scope or retention
413payload_too_large (server limit: 16KB)
429monthly_limit_exceeded — do not retry
503mempool_full — transient, retryable

Type augmentation

To avoid using `any` when accessing `request.imtblEventName` and `request.startedAt`, add the module declaration below to your TypeScript project.

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

meta fields

Beyond the payload, the meta object carries the fields the SIEM uses to normalize (ECS) and enrich the event. All meta values are strings.

Field in metaWhat it does in the SIEM
event_nameThe event label (e.g. http.GET.auth-me-user, Payment error) — the identifier shown in the listing. Becomes event.action and derives event.category (e.g. a name with loginauthentication).
client_ipBecomes source.ip with maximum precedence and provenance client_asserted. See the section below.
immutable_trailBecomes imtbl.immutable_trail — groups related events for investigation (e.g. one order, one user).
typeEvent severity: error / warning / info / success. Colors the badge in the listing. (event_type is accepted as a synonym.)
service, env, request_idMetadata: indexed and queryable in the dashboard (search/filters).

client_ip — end-user IP

The problem it solves

Between the browser and the core there are proxies/ALB. The IP the core sees on the connection is the previous hop (the client's backend or an AWS proxy), not the user's. Without action, source.ip would be the proxy IP — useless for geo, threat and IP-based detection. (Real example: XFF arrived as 44.192.13.3 (AWS) while the user was 179.110.4.205.)

The solution

The client's backend is the only one that sees the browser's real IP — so it forwards that IP in meta.client_ip when calling POST /v1/events.

How the SIEM handles it

  • Validates it is an IP (v4/v6). A non-IP value is ignored — it does not drop the event.
  • Writes to source.ip with maximum precedence (see order below).
  • Stamps imtbl.source_ip_origin = "client_asserted" — the UI shows the “User IP” badge.
  • Feeds GeoIP, threat intel and IP-based detection rules.
source.ip precedence:client_ipX-Forwarded-For (1º hop)X-Real-IPconnection IP

Provenance and trust

client_ip is asserted by the tenant — the client's backend claims the IP, and it is spoofable by whoever controls that backend. However it is self-contained to the tenant (it never crosses another tenant's boundary). A threat/geo hit on a client_asserted value is a claim, not an edge observation.

How to obtain the IP in your backend

Take the first IP from X-Forwarded-For (closest to the user); as a fallback, use the connection IP.

typescript
function getClientIp(req: Request): string | undefined {
  const xff = req.headers["x-forwarded-for"];
  if (typeof xff === "string" && xff.length) return xff.split(",")[0].trim(); // primeiro hop
  return req.socket.remoteAddress ?? undefined;
}

const meta = { event_name: "user.login", client_ip: getClientIp(req) };

Payload example

json
{
  "payload": "{\"user\":\"bob\",\"action\":\"login\"}",
  "meta": {
    "event_name": "user.login",
    "client_ip": "179.110.4.205",
    "immutable_trail": "user-bob"
  }
}

PII note: client_ip is PII and gets sealed into the immutable block (permanent). Decide consciously between forensic evidence and “right to be forgotten” before sending it.

This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.