Skip to main content
ImmutableLog logo
BackNode.js / TypeScript

Node.js

Complete integration guide for ImmutableLog in Node.js and TypeScript. Choose the approach that best fits your project: automatic middleware/plugin for web frameworks, direct integration with `fetch`, or wrappers to instrument specific functions.

Official SDKBetaRecommended

The fastest way to integrate: the official Node.js SDK

@immutablelog/sdk

Official package published to npm with a framework-agnostic core and ready-made adapters for Express, Fastify, Koa, NestJS, AdonisJS and plain Node. Fail-open (an outage never breaks your app), non-blocking delivery, automatic redaction of sensitive data, SHA-256 body hashing and TypeScript types included. Zero runtime dependencies.

Works with:
ExpressFastifyKoaNestJSAdonisJSNode.js

Installation

bash
npm install @immutablelog/sdk

Quick start

typescript
import { imtbl } from '@immutablelog/sdk';

// Lê IMTBL_API_KEY / IMTBL_URL do ambiente
await imtbl.sendEvent({
  eventName: 'payment.approved',
  payload: { paymentId: 'pay_abc123', amount: 299.9 },
  kind: 'success',
  immutableTrail: 'order-7782',
});

Express example

typescript
import express from 'express';
import { immutableLog } from '@immutablelog/sdk/express';

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

// Audita toda requisição automaticamente
app.use(immutableLog({ service: 'orders-api' }));

// ...suas rotas...

// Captura exceções (depois das rotas)
app.use(immutableLog.errorHandler({ service: 'orders-api' }));

Beta release (0.1.x). The public API may change before 1.0 — pin the version in your package.json.

Integration with fetch

Use Node.js 18+ native `fetch` API to send events directly to ImmutableLog without depending on a web framework. Ideal for workers, scripts, jobs, and any Node.js code that needs to record events in the ledger.

Helper function sendEvent

Create a reusable utility function to encapsulate the sending logic. Fully typed in TypeScript — serializes the payload, generates required headers, and POSTs to the ingestion endpoint.

typescript
// src/lib/immutablelog.ts
import crypto from 'crypto';

const IMTBL_API_KEY = process.env.IMTBL_API_KEY ?? '';
const IMTBL_URL = process.env.IMTBL_URL ?? 'https://api.immutablelog.com';

// Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
// O servidor responde 400 invalid_immutable_trail se a regra for violada.
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;
}

export interface SendEventOptions {
  eventName: string;
  payload: Record<string, unknown>;
  kind?: 'success' | 'error' | 'info' | 'warning';
  service?: string;
  env?: string;
  // Agrupa eventos relacionados em uma trilha auditavel (ex: `flow-123`)
  immutableTrail?: string;
  // Timezone do cliente — usado pelo dashboard para exibir os timestamps
  clientTz?: string;
  clientOffsetMinutes?: number;
}

// Resposta da API de ingestao (assincrona)
export interface SendEventResult {
  ok: boolean;
  tx_id: string;
  payload_hash: string;
  status: string;       // "accepted" | "committed"
  duplicate: boolean;   // true quando a Idempotency-Key ja existia (HTTP 200)
  request_id?: string;
}

export async function sendEvent(opts: SendEventOptions): Promise<SendEventResult> {
  const {
    eventName, payload, kind = 'info', service = 'node-service', env = 'production',
    clientTz = 'America/Sao_Paulo', clientOffsetMinutes = -180,
  } = opts;
  const requestId = crypto.randomUUID();
  const trail = sanitizeTrail(opts.immutableTrail);
  const payloadStr = JSON.stringify({
    ...payload,
    timestamp: new Date().toISOString(),
  });

  const event = {
    payload: payloadStr,
    // IMPORTANTE: todos os valores de meta precisam ser strings.
    meta: {
      type: kind,
      event_name: eventName,
      service,
      request_id: requestId,
      env,
      ...(trail && { immutable_trail: trail }),
    },
  };

  const res = await fetch(`${IMTBL_URL}/v1/events`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${IMTBL_API_KEY}`,
      '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),
  });

  // Sucesso: 202 (novo) ou 200 (duplicata idempotente). Erros lancam com o status.
  if (!res.ok) {
    const err = new Error(`ImmutableLog error: ${res.status}`) as Error & { status?: number };
    err.status = res.status;
    throw err;
  }
  return res.json() as Promise<SendEventResult>;
}

// Uso / Usage
await sendEvent({
  eventName: 'payment.approved',
  payload: { paymentId: 'pay_abc123', amount: 299.90, currency: 'BRL' },
  kind: 'success',
  service: 'payments-service',
  immutableTrail: 'order-7782', // agrupa todos os eventos deste pedido
});

Immutable trail (immutable_trail)

A trail groups related events into a single auditable timeline — ideal for tracking a business flow (`flow-123`), an order (`order-7782`), or a user (`user-maria`). Pass `immutableTrail` in any `sendEvent` call; the value is normalized by `sanitizeTrail` and sent both in `meta.immutable_trail` and the `X-Imtbl-Trail` header.

typescript
// Todos os eventos abaixo ficam na mesma trilha "order-7782"
// All events below share the same "order-7782" trail
await sendEvent({ eventName: 'order.created',   payload: { orderId: 'ord_7782' }, immutableTrail: 'order-7782', kind: 'success' });
await sendEvent({ eventName: 'payment.approved', payload: { amount: 299.9 },      immutableTrail: 'order-7782', kind: 'success' });
await sendEvent({ eventName: 'order.shipped',    payload: { carrier: 'ABC' },     immutableTrail: 'order-7782', kind: 'success' });

// sanitizeTrail aplica as regras do servidor antes do envio:
// sanitizeTrail('flow:42  ') -> 'flow-42'  (remove espacos, troca ':' por '-')

Server rules: the trail cannot be empty, exceed 256 characters, or contain `:`. Violations return `400 invalid_immutable_trail` — that is why `sanitizeTrail` normalizes the value before sending.

Response and status codes

Ingestion 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 }`. The `Idempotency-Key` is required — without it the API returns `400`.

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

Retry with exponential backoff

For high-availability production, add automatic retry with exponential backoff. ImmutableLog returns `202` on success and `429` when the monthly limit is reached — never retry on 429.

typescript
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function sendEventWithRetry(
  opts: SendEventOptions,
  maxRetries = 3,
  baseDelay = 500,
): Promise<{ tx_id: string } | null> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await sendEvent(opts);
    } catch (err) {
      const status = (err as { status?: number }).status;

      // 429 = limite mensal — nao fazer retry / monthly limit — do not retry
      if (status === 429) {
        console.warn('[ImmutableLog] Monthly limit reached, skipping retry.');
        return null;
      }

      if (attempt === maxRetries - 1) {
        console.error('[ImmutableLog] Max retries reached:', err);
        return null;
      }

      const delay = baseDelay * 2 ** attempt; // 500ms, 1s, 2s
      console.warn(`[ImmutableLog] Retry ${attempt + 1}/${maxRetries} in ${delay}ms`);
      await sleep(delay);
    }
  }
  return null;
}

Batch sending

In high-frequency workers, accumulate events and send in parallel using `Promise.allSettled`. Each event is still sent individually — use `Promise.allSettled` to parallelize without failing the batch.

typescript
async function sendEventsBatch(events: SendEventOptions[]): Promise<void> {
  const results = await Promise.allSettled(events.map((ev) => sendEvent(ev)));

  results.forEach((result, i) => {
    if (result.status === 'rejected') {
      console.warn(`[ImmutableLog] Batch item ${i} failed:`, result.reason);
    }
  });
}

// Uso / Usage
await sendEventsBatch([
  { eventName: 'order.created',  payload: { orderId: 'ord_1' }, kind: 'success' },
  { eventName: 'order.created',  payload: { orderId: 'ord_2' }, kind: 'success' },
  { eventName: 'payment.failed', payload: { orderId: 'ord_3' }, kind: 'error' },
]);

Function wrappers

Wrappers allow you to instrument specific functions without modifying their internal logic. Ideal for marking critical business operations (e.g., process payment, create user) where you want to ensure traceability regardless of the framework used.

Sync / async wrapper

A single wrapper automatically detects if the function is async via `fn.constructor.name`. The event is sent in the `finally` block — ensuring recording even when the function throws an exception.

typescript
function withAudit<T extends (...args: unknown[]) => unknown>(
  fn: T,
  eventName?: string,
  kind: 'success' | 'info' = 'success',
  service = 'node-service',
): T {
  const isAsync = fn.constructor.name === 'AsyncFunction';
  const name = eventName ?? `fn.${fn.name}`;

  const handle = (startedAt: number, err: Error | null) => {
    const latencyMs = Date.now() - startedAt;
    const actualKind = err ? 'error' : kind;
    const payload: Record<string, unknown> = {
      id: crypto.randomUUID(),
      kind: actualKind,
      message: err ? `${name} failed: ${err.constructor.name}` : `${name} executed`,
      metrics: { latencyMs },
      ...(err && { error: { exception: err.constructor.name, exceptionMessage: err.message } }),
    };
    // fire-and-forget
    sendEvent({ eventName: name, payload, kind: actualKind, service }).catch(() => {});
  };

  if (isAsync) {
    return (async (...args: unknown[]) => {
      const startedAt = Date.now();
      try { return await (fn as (...a: unknown[]) => Promise<unknown>)(...args); }
      catch (err) { handle(startedAt, err as Error); throw err; }
      finally { handle(startedAt, null); }
    }) as unknown as T;
  }

  return ((...args: unknown[]) => {
    const startedAt = Date.now();
    try { return fn(...args); }
    catch (err) { handle(startedAt, err as Error); throw err; }
    finally { handle(startedAt, null); }
  }) as unknown as T;
}

// Uso / Usage
const processPayment = withAudit(
  async (paymentId: string) => {
    // ... logica de negocio / business logic ...
    return { status: 'approved' };
  },
  'payment.process',
  'success',
  'payments-service',
);

await processPayment('pay_abc123');

AuditClient class (configurable)

For larger projects, use a class to centralize configuration. Instantiate once at startup and use `audit.wrap()` on any sync or async function.

typescript
import crypto from 'crypto';

interface AuditClientOptions {
  apiKey?: string;
  apiUrl?: string;
  service?: string;
  env?: string;
  clientTz?: string;
  clientOffsetMinutes?: number;
}

class AuditClient {
  private readonly apiKey: string;
  private readonly apiUrl: string;
  private readonly service: string;
  private readonly env: string;
  private readonly clientTz: string;
  private readonly clientOffsetMinutes: number;

  constructor(opts: AuditClientOptions = {}) {
    this.apiKey = opts.apiKey ?? process.env.IMTBL_API_KEY ?? '';
    this.apiUrl = opts.apiUrl ?? process.env.IMTBL_URL ?? 'https://api.immutablelog.com';
    this.service = opts.service ?? 'node-service';
    this.env = opts.env ?? 'production';
    this.clientTz = opts.clientTz ?? 'America/Sao_Paulo';
    this.clientOffsetMinutes = opts.clientOffsetMinutes ?? -180;
  }

  wrap<T extends (...args: unknown[]) => unknown>(
    fn: T,
    eventName?: string,
    kind: 'success' | 'info' = 'success',
  ): T {
    const name = eventName ?? `fn.${fn.name}`;
    const isAsync = fn.constructor.name === 'AsyncFunction';
    const emit = (startedAt: number, err: Error | null) => {
      const payload = {
        id: crypto.randomUUID(),
        kind: err ? 'error' : kind,
        message: err ? `${name} failed: ${err.constructor.name}` : `${name} executed`,
        metrics: { latencyMs: Date.now() - startedAt },
        ...(err && { error: { exception: err.constructor.name, exceptionMessage: err.message } }),
      };
      this._send(name, payload, err ? 'error' : kind);
    };

    if (isAsync) {
      return (async (...args: unknown[]) => {
        const t = Date.now();
        try { return await (fn as (...a: unknown[]) => Promise<unknown>)(...args); }
        catch (e) { emit(t, e as Error); throw e; }
        finally { emit(t, null); }
      }) as unknown as T;
    }
    return ((...args: unknown[]) => {
      const t = Date.now();
      try { return fn(...args); }
      catch (e) { emit(t, e as Error); throw e; }
      finally { emit(t, null); }
    }) as unknown as T;
  }

  private _send(eventName: string, payload: object, kind: string) {
    const requestId = crypto.randomUUID();
    fetch(`${this.apiUrl}/v1/events`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': `${eventName}-${requestId}`,
        'Request-Id': requestId,
        'X-Client-TZ': this.clientTz,
        'X-Client-Offset-Minutes': String(this.clientOffsetMinutes),
      },
      body: JSON.stringify({
        payload: JSON.stringify(payload),
        // Todos os valores de meta precisam ser strings / Every meta value must be a string
        meta: { type: kind, event_name: eventName, service: this.service, request_id: requestId, env: this.env },
      }),
      signal: AbortSignal.timeout(5000),
    }).catch(() => {});
  }
}

// Inicializar uma vez / Initialize once
const audit = new AuditClient({
  service: 'payments-service',
  env: 'production',
});

// Usar em qualquer funcao / Use on any function
const processPayment = audit.wrap(
  async (id: string) => ({ status: 'approved', id }),
  'payment.process',
);

const deleteUser = audit.wrap(
  async (userId: number) => { /* ... */ },
  'user.delete',
  'info',
);

TypeScript Decorator (experimental)

If your project uses TypeScript with `experimentalDecorators: true`, you can use the `@AuditLog()` decorator directly on class methods. Works with sync and async methods.

typescript
// tsconfig.json: "experimentalDecorators": true

function AuditLog(eventName?: string, kind: 'success' | 'info' = 'success') {
  return function (
    _target: object,
    propertyKey: string,
    descriptor: PropertyDescriptor,
  ): PropertyDescriptor {
    const original = descriptor.value as (...args: unknown[]) => unknown;
    const name = eventName ?? `method.${propertyKey}`;
    const isAsync = original.constructor.name === 'AsyncFunction';

    descriptor.value = isAsync
      ? async function (this: unknown, ...args: unknown[]) {
          const startedAt = Date.now();
          let err: Error | null = null;
          try {
            return await original.apply(this, args);
          } catch (e) {
            err = e as Error;
            throw e;
          } finally {
            emitAudit(name, kind, startedAt, err);
          }
        }
      : function (this: unknown, ...args: unknown[]) {
          const startedAt = Date.now();
          let err: Error | null = null;
          try {
            return original.apply(this, args);
          } catch (e) {
            err = e as Error;
            throw e;
          } finally {
            emitAudit(name, kind, startedAt, err);
          }
        };

    return descriptor;
  };
}

function emitAudit(name: string, kind: string, startedAt: number, err: Error | null) {
  const actualKind = err ? 'error' : kind;
  sendEvent({
    eventName: name,
    payload: {
      message: err ? `${name} failed: ${err.constructor.name}` : `${name} executed`,
      metrics: { latencyMs: Date.now() - startedAt },
      ...(err && { error: { exception: err.constructor.name, exceptionMessage: err.message } }),
    },
    kind: actualKind as 'success' | 'error' | 'info',
  }).catch(() => {});
}

// Uso em classe / Usage in class
class PaymentService {
  @AuditLog('payment.process', 'success')
  async processPayment(paymentId: string): Promise<{ status: string }> {
    // ... logica de negocio / business logic ...
    return { status: 'approved' };
  }

  @AuditLog('invoice.generate')
  async generateInvoice(orderId: string): Promise<void> {
    // ...
  }
}

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