@immutablelog/sdk
v0.1.0
Published
Official ImmutableLog SDK for Node.js — audit logging middleware for Express, Fastify, Koa, NestJS, AdonisJS and plain Node.
Downloads
89
Maintainers
Readme
@immutablelog/sdk
SDK oficial do ImmutableLog para Node.js Official ImmutableLog SDK for Node.js
Envie eventos de auditoria para o ledger imutável a partir de Express, Fastify, Koa, NestJS, AdonisJS ou Node.js puro. Ship audit events to the immutable ledger from Express, Fastify, Koa, NestJS, AdonisJS or plain Node.js.
✨ Highlights / Destaques
| | |
| --- | ------------------------------------------------------------------------------------- |
| ✅ | Fail-open — uma indisponibilidade do ImmutableLog nunca quebra sua aplicação. |
| ⚡ | Não-bloqueante — eventos são entregues em background por padrão. |
| 🔒 | Redação de segredos — senhas, tokens, cartões etc. removidos no cliente. |
| 🧾 | Grau de auditoria — hash SHA-256 do corpo sem armazenar o corpo. |
| 🧩 | Adapters de framework + um client de baixo nível para qualquer caso. |
| 🟦 | JS e TS — ESM + CommonJS + tipos .d.ts no mesmo pacote. |
| 0️⃣ | Zero dependências de runtime (usa fetch nativo, Node ≥ 18). |
JavaScript e TypeScript: o pacote publica
import(ESM),require(CommonJS) e definições de tipos. Funciona sem configuração em qualquer um dos dois. / The package ships ESM, CommonJS and type definitions — works out of the box in both.
🇧🇷 Português
Índice
- Instalação
- Configuração
- Início rápido (eventos manuais)
- Adapters de framework
- Express · Fastify · Koa · NestJS · AdonisJS v6 · Node.js puro
- Controles por requisição
- Formato do evento enviado
- Regras do
immutable_trail - Códigos de status da API
- Amostragem (sampling)
- Redação de dados sensíveis
- Encerramento gracioso (flush)
- Referência de configuração
- Referência da API pública
- TypeScript
- Solução de problemas
Instalação
npm install @immutablelog/sdk
# ou
pnpm add @immutablelog/sdk
yarn add @immutablelog/sdkRequisito: Node.js ≥ 18 (usa o fetch global). Em versões antigas, injete um
fetch via configuração (veja Referência de configuração).
Configuração
O client lê estas variáveis de ambiente por padrão:
IMTBL_API_KEY=iml_live_xxxxxxxxxxxx # obrigatória para enviar
IMTBL_URL=https://api.immutablelog.com # opcional (este é o padrão)
IMTBL_SERVICE_NAME=orders-api # opcional
IMTBL_ENV=production # opcionalQualquer valor passado no código sobrescreve o ambiente. Sem IMTBL_API_KEY/IMTBL_URL,
o client vira no-op (fail-open): não envia nada e nunca lança erro.
Início rápido (eventos manuais)
TypeScript / ESM
import { imtbl } from '@immutablelog/sdk';
await imtbl.sendEvent({
eventName: 'payment.approved',
payload: { paymentId: 'pay_abc123', amount: 299.9 },
kind: 'success',
service: 'payments-service',
immutableTrail: 'order-7782',
});JavaScript / CommonJS
const { imtbl } = require('@immutablelog/sdk');
imtbl.sendEvent({
eventName: 'user.deleted',
payload: { userId: 42 },
kind: 'audit',
});O payload é sanitizado (redação de segredos), limitado em tamanho e convertido para
string JSON automaticamente. Todos os valores de meta são convertidos para string
(exigência da API) e o immutable_trail é sanitizado no cliente (: → -, trim, máx. 256).
Para uma instância isolada (ex.: múltiplos serviços/keys):
import { createClient } from '@immutablelog/sdk';
const auditClient = createClient({ apiKey: process.env.AUDIT_KEY, service: 'billing' });
await auditClient.sendEvent({ eventName: 'invoice.issued', payload: { id: 1 } });Adapters de framework
Cada adapter captura automaticamente: método, rota, status, latência, IP (com
X-Forwarded-For), user-agent, usuário autenticado (req.user), erros lançados e
corpo da requisição (com hash + redação, apenas em erros). Também propaga/gera
X-Request-Id.
Express
import express from 'express';
import { immutableLog } from '@immutablelog/sdk/express';
const app = express();
app.use(express.json());
// audita todas as requisições
app.use(immutableLog({ service: 'orders-api' }));
app.get('/orders/:id', (req, res) => res.json({ id: req.params.id }));
// DEPOIS das rotas: captura exceções com nome/mensagem da exceção
app.use(immutableLog.errorHandler({ service: 'orders-api' }));Fastify
import Fastify from 'fastify';
import { immutableLog } from '@immutablelog/sdk/fastify';
const app = Fastify();
await app.register(immutableLog, { service: 'orders-api' });O plugin é registrado sem encapsulamento (
skip-override), então vale para toda a aplicação.
Koa
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import { immutableLog } from '@immutablelog/sdk/koa';
const app = new Koa();
app.use(bodyParser());
app.use(immutableLog({ service: 'orders-api' }));NestJS
Funciona tanto na plataforma Express quanto Fastify.
// main.ts
import { ImmutableLogInterceptor } from '@immutablelog/sdk/nestjs';
app.useGlobalInterceptors(new ImmutableLogInterceptor({ service: 'orders-api' }));Ou via injeção de dependência (global):
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ImmutableLogInterceptor } from '@immutablelog/sdk/nestjs';
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useValue: new ImmutableLogInterceptor({ service: 'orders-api' }) },
],
})
export class AppModule {}O nome do evento é derivado de
http.{MÉTODO}.{Controller}.{handler}automaticamente.
AdonisJS v6
// app/middleware/imtbl_middleware.ts
import { immutableLog } from '@immutablelog/sdk/adonis';
export default class ImmutableLogMiddleware {
handle = immutableLog({ service: 'orders-api' });
}Registre como middleware global em start/kernel.ts:
import server from '@adonisjs/core/services/server';
server.use([() => import('#middleware/imtbl_middleware')]);Node.js puro
import http from 'node:http';
import { withImmutableLog } from '@immutablelog/sdk/node';
const server = http.createServer(
withImmutableLog(async (req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
}, { service: 'edge-gateway' }),
);
server.listen(3000);Para frameworks baseados em http não listados, use o helper de baixo nível:
import { instrument } from '@immutablelog/sdk/node';
function handler(req, res) {
const done = instrument(req, res, { service: 'custom' });
try {
// ...sua lógica...
} catch (err) {
done(err); // registra o erro (o evento sai sozinho ao finalizar a resposta)
throw err;
}
}Controles por requisição
Os adapters leem estas propriedades convencionais quando presentes:
| Propriedade / Header | Efeito |
| ---------------------- | ------------------------------------------------------------------ |
| req.imtblTrail | Define o immutable_trail desta requisição |
| req.imtblEventName | Sobrescreve o nome do evento gerado |
| req.user | Adiciona { id, email, username } ao contexto do evento |
| header X-Request-Id | Reutilizado para correlação (senão, um UUID é gerado) |
| header X-Imtbl-Trail | Trail propagada por um chamador upstream |
Em Koa/Adonis use
ctx.state.imtblTrail/ctx.imtblTrail. A ordem de resolução da trail é:imtblTrail→ headerX-Imtbl-Trail→ fallbackuser-<username>.
Formato do evento enviado
O SDK faz POST {IMTBL_URL}/v1/events com este corpo:
{
// payload é SEMPRE uma string JSON (limitada a ~16KB; o SDK reduz se passar)
"payload": "{\"id\":\"...\",\"kind\":\"success\",\"message\":\"...\",\"timestamp\":\"...\",\"context\":{...},\"request\":{...},\"metrics\":{\"latency_ms\":12,\"status_code\":200},\"severity\":\"low\"}",
// todos os valores de meta são strings
"meta": {
"type": "success", // error | warning | info | success | audit | debug
"event_name": "http.GET /orders",
"service": "orders-api",
"request_id": "uuid",
"env": "production",
"immutable_trail": "order-7782" // opcional
}
}Headers enviados:
| Header | Valor |
| -------------------------- | -------------------------------------- |
| Authorization | Bearer <IMTBL_API_KEY> |
| Content-Type | application/json |
| Idempotency-Key | {eventName}-{requestId} |
| Request-Id | UUID de correlação |
| X-Client-TZ | America/Sao_Paulo (configurável) |
| X-Client-Offset-Minutes | -180 (configurável) |
Resposta (202 Accepted):
{ "ok": true, "tx_id": "uuid", "payload_hash": "sha256-hex", "status": "accepted", "duplicate": false, "request_id": "uuid" }sendEvent() / emitFromHttp() resolvem para um SendResult:
{ ok, status, txId, payloadHash, duplicate, requestId, error }.
Regras do immutable_trail
A trail agrupa eventos relacionados em uma única linha do tempo auditável.
- Não pode ser vazia após
trim. - Máximo de 256 caracteres (o SDK trunca e emite um aviso).
- Não pode conter
:— o SDK substitui por-. - Use
-,_,.ou/como separadores. - Case-sensitive (
Order-1≠order-1).
Códigos de status da API
| Status | Significado | Ação do SDK |
| ------ | -------------------------------------- | --------------------------------- |
| 202 | Evento enfileirado (novo) | sucesso |
| 200 | Duplicado (mesma Idempotency-Key) | sucesso, duplicate: true |
| 400 | Trail inválida / sem Idempotency-Key | logado como warning |
| 403 | Assinatura inativa/expirada | logado como warning |
| 413 | Payload acima de 16KB | mitigado pelo clamp de payload |
| 429 | Limite mensal excedido | não faz retry |
| 503 | Mempool cheia | faz retry (configurável) |
Amostragem (sampling)
Eventos error, warning, audit e debug são sempre enviados. Para reduzir
ruído/custo em volume alto, amostre success/info:
createClient({ sampleSuccess: 0.1, sampleInfo: 0 }); // 10% dos success, 0% dos infoRedação de dados sensíveis
Chaves comuns (password, token, secret, authorization, credit_card, cpf, cvv, ...)
têm o valor substituído por ***REDACTED*** recursivamente, em query, body e payload.
Adicione chaves extras:
createClient({ redactKeys: ['national_id', 'session'] });Em erros, o SDK inclui o hash SHA-256 do corpo (prova sem guardar o conteúdo) e um
trecho sanitizado (desligável com includeBodyOnError: false).
Encerramento gracioso (flush)
Como a entrega é em background, aguarde os envios pendentes antes de sair:
import { imtbl } from '@immutablelog/sdk';
process.on('SIGTERM', async () => {
await imtbl.flush();
process.exit(0);
});Referência de configuração
| Opção | Env | Padrão | Descrição |
| -------------------- | -------------------- | ------------------------------- | ----------------------------------------------- |
| apiKey | IMTBL_API_KEY | — | Chave secreta (necessária para enviar) |
| url | IMTBL_URL | https://api.immutablelog.com | URL base da API |
| service | IMTBL_SERVICE_NAME | node-service | Nome lógico do serviço |
| env | IMTBL_ENV | production | Ambiente de deploy |
| headers | — | {} | Headers estáticos extras |
| timeoutMs | — | 400 | Timeout por requisição |
| retries | — | 1 | Retries em erros transitórios/transporte |
| sampleSuccess | — | 1 | Taxa de amostragem de success (0..1) |
| sampleInfo | — | 1 | Taxa de amostragem de info (0..1) |
| ignorePaths | — | [] | Prefixos de path a ignorar (health, metrics) |
| maxPayloadBytes | — | 12000 | Limite para reduzir o payload |
| includeBodyOnError | — | true | Anexa corpo sanitizado + hash em erros |
| redactKeys | — | conjunto interno | Chaves sensíveis extras |
| clientTz | — | America/Sao_Paulo | Header X-Client-TZ |
| clientOffsetMinutes| — | -180 | Header X-Client-Offset-Minutes |
| awaitDelivery | — | false | Aguarda a entrega em vez de fire-and-forget |
| logger | — | silencioso | Passe console para depurar |
| fetch | — | fetch global | Implementação de fetch customizada |
| skip | — | — | ({ method, path }) => boolean para pular |
| client | — | — | Reutiliza um client já criado (nos adapters) |
Referência da API pública
import {
createClient, // factory: createClient(config) => ImmutableLogClient
ImmutableLogClient, // classe
imtbl, // client padrão dirigido por variáveis de ambiente
// utilitários (também usados internamente)
redact, safeBody, sanitizeTrail, clampPayload, hashBody, severityFrom, severityLevel,
} from '@immutablelog/sdk';Métodos do client:
sendEvent(input): Promise<SendResult>— evento manual de alto nível.emitFromHttp(input): Promise<SendResult>— a partir de uma troca HTTP normalizada (usado pelos adapters).flush(): Promise<void>— aguarda entregas em background.get enabled(): boolean— se está configurado para enviar.
TypeScript
Os tipos acompanham o pacote. Os principais:
import type {
EventKind, SeverityLevel, Logger, FetchLike,
ImmutableLogConfig, SendEventInput, HttpEventInput, SendResult, AdapterOptions,
} from '@immutablelog/sdk';Solução de problemas
- Nada é enviado → verifique
IMTBL_API_KEY/IMTBL_URL. Sem eles o client é no-op. Uselogger: consolepara confirmar. require is not defined/ fetch ausente → Node < 18. Atualize ou passefetchna config.400 invalid_immutable_trail→ a trail tem caractere inválido ou está vazia; revise as regras.- Eventos somem ao encerrar o processo → chame
await imtbl.flush()antes de sair.
🇺🇸 English
Table of contents
- Installation
- Configuration
- Quick start (manual events)
- Framework adapters
- Express · Fastify · Koa · NestJS · AdonisJS v6 · Plain Node.js
- Per-request controls
- Event payload format
immutable_trailrules- API status codes
- Sampling
- Secret redaction
- Graceful shutdown
- Configuration reference
- Public API reference
- TypeScript
- Troubleshooting
Installation
npm install @immutablelog/sdk
# or
pnpm add @immutablelog/sdk
yarn add @immutablelog/sdkRequires Node.js ≥ 18 (uses the global fetch). On older runtimes, inject a fetch
via config (see Configuration reference).
Configuration
The client reads these environment variables by default:
IMTBL_API_KEY=iml_live_xxxxxxxxxxxx # required to send
IMTBL_URL=https://api.immutablelog.com # optional (this is the default)
IMTBL_SERVICE_NAME=orders-api # optional
IMTBL_ENV=production # optionalAnything passed in code overrides the environment. Without IMTBL_API_KEY/IMTBL_URL
the client becomes a no-op (fail-open): it sends nothing and never throws.
Quick start (manual events)
TypeScript / ESM
import { imtbl } from '@immutablelog/sdk';
await imtbl.sendEvent({
eventName: 'payment.approved',
payload: { paymentId: 'pay_abc123', amount: 299.9 },
kind: 'success',
service: 'payments-service',
immutableTrail: 'order-7782',
});JavaScript / CommonJS
const { imtbl } = require('@immutablelog/sdk');
imtbl.sendEvent({
eventName: 'user.deleted',
payload: { userId: 42 },
kind: 'audit',
});payload is sanitized (secret redaction), size-clamped and JSON-stringified for you.
All meta values are coerced to strings (an API requirement) and immutable_trail is
sanitized client-side (: → -, trimmed, max 256).
For an isolated instance (e.g. multiple services/keys):
import { createClient } from '@immutablelog/sdk';
const auditClient = createClient({ apiKey: process.env.AUDIT_KEY, service: 'billing' });
await auditClient.sendEvent({ eventName: 'invoice.issued', payload: { id: 1 } });Framework adapters
Each adapter captures automatically: method, route, status, latency, IP (honouring
X-Forwarded-For), user-agent, authenticated user (req.user), thrown errors and the
request body (hashed + redacted, on errors only). It also propagates/generates X-Request-Id.
Express
import express from 'express';
import { immutableLog } from '@immutablelog/sdk/express';
const app = express();
app.use(express.json());
// audit every request
app.use(immutableLog({ service: 'orders-api' }));
app.get('/orders/:id', (req, res) => res.json({ id: req.params.id }));
// AFTER your routes: capture thrown errors with exception name/message
app.use(immutableLog.errorHandler({ service: 'orders-api' }));Fastify
import Fastify from 'fastify';
import { immutableLog } from '@immutablelog/sdk/fastify';
const app = Fastify();
await app.register(immutableLog, { service: 'orders-api' });The plugin registers without encapsulation (
skip-override) so it applies app-wide.
Koa
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import { immutableLog } from '@immutablelog/sdk/koa';
const app = new Koa();
app.use(bodyParser());
app.use(immutableLog({ service: 'orders-api' }));NestJS
Works on both the Express and Fastify platforms.
// main.ts
import { ImmutableLogInterceptor } from '@immutablelog/sdk/nestjs';
app.useGlobalInterceptors(new ImmutableLogInterceptor({ service: 'orders-api' }));Or via dependency injection (global):
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ImmutableLogInterceptor } from '@immutablelog/sdk/nestjs';
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useValue: new ImmutableLogInterceptor({ service: 'orders-api' }) },
],
})
export class AppModule {}The event name is derived as
http.{METHOD}.{Controller}.{handler}automatically.
AdonisJS v6
// app/middleware/imtbl_middleware.ts
import { immutableLog } from '@immutablelog/sdk/adonis';
export default class ImmutableLogMiddleware {
handle = immutableLog({ service: 'orders-api' });
}Register it as a global middleware in start/kernel.ts:
import server from '@adonisjs/core/services/server';
server.use([() => import('#middleware/imtbl_middleware')]);Plain Node.js
import http from 'node:http';
import { withImmutableLog } from '@immutablelog/sdk/node';
const server = http.createServer(
withImmutableLog(async (req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
}, { service: 'edge-gateway' }),
);
server.listen(3000);For http-based frameworks not listed, use the low-level helper:
import { instrument } from '@immutablelog/sdk/node';
function handler(req, res) {
const done = instrument(req, res, { service: 'custom' });
try {
// ...your logic...
} catch (err) {
done(err); // record the error (the event is emitted on response finish)
throw err;
}
}Per-request controls
Adapters read these conventional properties when present:
| Property / Header | Effect |
| ---------------------- | ------------------------------------------------------------ |
| req.imtblTrail | Sets immutable_trail for this request |
| req.imtblEventName | Overrides the generated event name |
| req.user | Adds { id, email, username } to the event context |
| header X-Request-Id | Reused for correlation (otherwise a UUID is generated) |
| header X-Imtbl-Trail | Trail propagated from an upstream caller |
On Koa/Adonis use
ctx.state.imtblTrail/ctx.imtblTrail. Trail resolution order is:imtblTrail→X-Imtbl-Trailheader →user-<username>fallback.
Event payload format
The SDK does POST {IMTBL_URL}/v1/events with this body:
{
// payload is ALWAYS a JSON string (capped at ~16KB; the SDK shrinks it if needed)
"payload": "{\"id\":\"...\",\"kind\":\"success\",\"message\":\"...\",\"timestamp\":\"...\",\"context\":{...},\"request\":{...},\"metrics\":{\"latency_ms\":12,\"status_code\":200},\"severity\":\"low\"}",
// all meta values are strings
"meta": {
"type": "success", // error | warning | info | success | audit | debug
"event_name": "http.GET /orders",
"service": "orders-api",
"request_id": "uuid",
"env": "production",
"immutable_trail": "order-7782" // optional
}
}Headers sent:
| Header | Value |
| -------------------------- | -------------------------------------- |
| Authorization | Bearer <IMTBL_API_KEY> |
| Content-Type | application/json |
| Idempotency-Key | {eventName}-{requestId} |
| Request-Id | correlation UUID |
| X-Client-TZ | America/Sao_Paulo (configurable) |
| X-Client-Offset-Minutes | -180 (configurable) |
Response (202 Accepted):
{ "ok": true, "tx_id": "uuid", "payload_hash": "sha256-hex", "status": "accepted", "duplicate": false, "request_id": "uuid" }sendEvent() / emitFromHttp() resolve to a SendResult:
{ ok, status, txId, payloadHash, duplicate, requestId, error }.
immutable_trail rules
The trail groups related events into a single auditable timeline.
- Cannot be empty after
trim. - Maximum 256 characters (the SDK truncates and warns).
- Cannot contain
:— the SDK replaces it with-. - Use
-,_,.or/as separators. - Case-sensitive (
Order-1≠order-1).
API status codes
| Status | Meaning | SDK behaviour |
| ------ | -------------------------------------- | --------------------------------- |
| 202 | Event queued (new) | success |
| 200 | Duplicate (same Idempotency-Key) | success, duplicate: true |
| 400 | Invalid trail / missing idempotency | logged as warning |
| 403 | Inactive/expired subscription | logged as warning |
| 413 | Payload over 16KB | mitigated by payload clamping |
| 429 | Monthly limit exceeded | not retried |
| 503 | Mempool full | retried (configurable) |
Sampling
error, warning, audit and debug events are always sent. To cut noise/cost at
high volume, sample success/info:
createClient({ sampleSuccess: 0.1, sampleInfo: 0 }); // 10% of success, 0% of infoSecret redaction
Common keys (password, token, secret, authorization, credit_card, cpf, cvv, ...)
are recursively replaced with ***REDACTED*** in query, body and payload. Add extras:
createClient({ redactKeys: ['national_id', 'session'] });On errors, the SDK includes the body's SHA-256 hash (proof without storing content)
plus a sanitized snippet (disable with includeBodyOnError: false).
Graceful shutdown
Because delivery is fire-and-forget, flush pending events before exit:
import { imtbl } from '@immutablelog/sdk';
process.on('SIGTERM', async () => {
await imtbl.flush();
process.exit(0);
});Configuration reference
| Option | Env | Default | Description |
| -------------------- | -------------------- | ------------------------------- | -------------------------------------------- |
| apiKey | IMTBL_API_KEY | — | Secret API key (required to send) |
| url | IMTBL_URL | https://api.immutablelog.com | API base URL |
| service | IMTBL_SERVICE_NAME | node-service | Logical service name |
| env | IMTBL_ENV | production | Deployment environment |
| headers | — | {} | Extra static headers |
| timeoutMs | — | 400 | Per-request timeout |
| retries | — | 1 | Retries on transient/transport errors |
| sampleSuccess | — | 1 | Sampling rate for success (0..1) |
| sampleInfo | — | 1 | Sampling rate for info (0..1) |
| ignorePaths | — | [] | Path prefixes to skip (health, metrics) |
| maxPayloadBytes | — | 12000 | Clamp threshold for the payload |
| includeBodyOnError | — | true | Attach sanitized body + hash on errors |
| redactKeys | — | built-in set | Extra sensitive keys |
| clientTz | — | America/Sao_Paulo | X-Client-TZ header |
| clientOffsetMinutes| — | -180 | X-Client-Offset-Minutes header |
| awaitDelivery | — | false | Await delivery instead of fire-and-forget |
| logger | — | silent | Pass console to debug |
| fetch | — | global fetch | Custom fetch implementation |
| skip | — | — | ({ method, path }) => boolean to skip |
| client | — | — | Reuse an existing client (in adapters) |
Public API reference
import {
createClient, // factory: createClient(config) => ImmutableLogClient
ImmutableLogClient, // class
imtbl, // env-driven default client
// utilities (also used internally)
redact, safeBody, sanitizeTrail, clampPayload, hashBody, severityFrom, severityLevel,
} from '@immutablelog/sdk';Client methods:
sendEvent(input): Promise<SendResult>— high-level manual event.emitFromHttp(input): Promise<SendResult>— from a normalized HTTP exchange (used by adapters).flush(): Promise<void>— await background deliveries.get enabled(): boolean— whether it is configured to send.
TypeScript
Types ship with the package. The main ones:
import type {
EventKind, SeverityLevel, Logger, FetchLike,
ImmutableLogConfig, SendEventInput, HttpEventInput, SendResult, AdapterOptions,
} from '@immutablelog/sdk';Troubleshooting
- Nothing is sent → check
IMTBL_API_KEY/IMTBL_URL. Without them the client is a no-op. Uselogger: consoleto confirm. require is not defined/ missing fetch → Node < 18. Upgrade or passfetchin the config.400 invalid_immutable_trail→ the trail has an invalid character or is empty; review the rules.- Events lost on process exit → call
await imtbl.flush()before exiting.
Made with care for ImmutableLog · MIT License
