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.

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

export interface SendEventOptions {
  eventName: string;
  payload: Record<string, unknown>;
  kind?: 'success' | 'error' | 'info' | 'warning';
  service?: string;
  env?: string;
}

export async function sendEvent(opts: SendEventOptions): Promise<{ tx_id: string; payload_hash: string }> {
  const { eventName, payload, kind = 'info', service = 'node-service', env = 'production' } = opts;
  const requestId = crypto.randomUUID();
  const payloadStr = JSON.stringify({
    ...payload,
    timestamp: new Date().toISOString(),
  });

  const event = {
    payload: payloadStr,
    meta: {
      type: kind,
      event_name: eventName,
      service,
      request_id: requestId,
      env,
    },
  };

  const res = await fetch(`${IMTBL_URL}/v1/events`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${IMTBL_API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': `${eventName}-${requestId}`,
      'Request-Id': requestId,
    },
    body: JSON.stringify(event),
    signal: AbortSignal.timeout(5000),
  });

  if (!res.ok) throw new Error(`ImmutableLog error: ${res.status}`);
  return res.json() as Promise<{ tx_id: string; payload_hash: string }>;
}

// Uso / Usage
await sendEvent({
  eventName: 'payment.approved',
  payload: { paymentId: 'pay_abc123', amount: 299.90, currency: 'BRL' },
  kind: 'success',
  service: 'payments-service',
});

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

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

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

  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,
      },
      body: JSON.stringify({
        payload: JSON.stringify(payload),
        meta: { type: kind, event_name: eventName, service: this.service, 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.