Express Integration
Integrate ImmutableLog into any Express application with a middleware that automatically captures all HTTP requests. Uses Node.js 18+ native `fetch` API for non-blocking sending.
Installation
No extra dependencies beyond Express. The integration uses Node.js 18+ native `fetch`. For older versions, install `node-fetch`.
npm install express
# Node.js < 18 only:
npm install node-fetchMiddleware Code
Save the code below in `src/middleware/immutablelog.ts`. The middleware uses `res.on('finish')` to capture the final response status after the handler completes, and `res.on('error')` to capture stream errors.
// src/middleware/immutablelog.ts
import crypto from 'crypto';
import { Request, Response, NextFunction } from 'express';
// Margem de seguranca: o limite real do servidor e 16KB (retorna 413 acima disso).
// Safety margin: the server hard limit is 16KB (returns 413 above that).
const MAX_PAYLOAD_BYTES = 12_000;
// Chaves cujos valores sao mascarados antes do envio (PII/segredos)
// Keys whose values are masked before sending (PII/secrets)
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 conforme as regras do servidor:
// trim, nao-vazio, max 256 chars, sem ':' (o servidor responde 400 se violado).
// Normalizes immutable_trail per server rules: trim, non-empty, max 256, no ':'.
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;
delete p.requestBody;
s = JSON.stringify(p);
if (Buffer.byteLength(s, 'utf8') <= MAX_PAYLOAD_BYTES) return s;
return JSON.stringify({
id: (payload as Record<string, unknown>).id,
kind: (payload as Record<string, unknown>).kind,
message: String((payload as Record<string, unknown>).message ?? 'event').slice(0, 500),
timestamp: (payload as Record<string, unknown>).timestamp,
});
}
function hashBody(body: string | Buffer): string {
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
return crypto.createHash('sha256').update(buf).digest('hex');
}
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';
}
// Usuario autenticado, se o seu app popular req.user (ex: passport, jwt middleware)
// Authenticated user, if your app populates req.user (e.g. passport, jwt middleware)
interface AuthUser {
id?: string | number;
email?: string;
username?: string;
}
export interface ImmutableLogOptions {
apiKey: string;
apiUrl?: string;
serviceName?: string;
env?: string;
skipPaths?: string[];
// Timezone do cliente — usado pelo dashboard para exibir os timestamps
// Client timezone — used by the dashboard to render timestamps
clientTz?: string;
clientOffsetMinutes?: number;
}
export function immutableLogMiddleware(opts: ImmutableLogOptions) {
const {
apiKey,
apiUrl = 'https://api.immutablelog.com',
serviceName = 'express-service',
env = 'production',
skipPaths = ['/health', '/healthz'],
clientTz = 'America/Sao_Paulo',
clientOffsetMinutes = -180,
} = opts;
const skip = new Set(skipPaths);
return (req: Request, res: Response, next: NextFunction): void => {
if (skip.has(req.path)) return next();
const startedAt = Date.now();
const requestId =
(req.headers['x-request-id'] as string) || crypto.randomUUID();
(req as Request & { requestId: string }).requestId = requestId;
const rawBody: string =
(req as Request & { rawBody?: string }).rawBody ?? '';
const emit = (err?: Error): void => {
const latencyMs = Date.now() - startedAt;
const statusCode = err ? 500 : res.statusCode;
const kind = severityFrom(statusCode, err ?? null);
const eventName =
(req as Request & { imtblEventName?: string }).imtblEventName ??
`http.${req.method}.${(req.route?.path as string | undefined) ?? req.path}`;
const user = (req as Request & { user?: AuthUser }).user;
const payload: Record<string, unknown> = {
id: crypto.randomUUID(),
kind,
message: err
? `${req.method} ${req.path} failed: ${err.constructor.name}`
: kind === 'success'
? `${req.method} ${req.path} completed successfully`
: `${req.method} ${req.path} processed`,
timestamp: new Date().toISOString(),
context: {
ip: req.ip ?? req.socket?.remoteAddress ?? 'unknown',
userAgent: req.headers['user-agent'] ?? 'unknown',
// Enriquece com o usuario autenticado quando disponivel
// Enrich with authenticated user when available
...(user && {
userId: user.id,
email: user.email,
username: user.username,
}),
},
request: {
requestId,
method: req.method,
path: req.path,
// query params passam por redact() — nunca enviar segredos em claro
// query params go through redact() — never send secrets in the clear
queryParams: Object.keys(req.query).length ? redact(req.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,
}),
...(rawBody && { requestBodyHash: hashBody(rawBody) }),
};
} else if (kind === 'success') {
payload.success = {
statusCode,
result: statusCode === 200 ? 'ok' : 'processed',
};
} else {
payload.info = { statusCode, action: req.method.toLowerCase() };
}
// Trilha imutavel (immutable_trail). Prioridade / priority:
// 1. req.imtblTrail (definido por uma rota, ex: `flow-${flow.id}`)
// 2. header X-Imtbl-Trail propagado pelo cliente
// 3. fallback por usuario autenticado: user-<username>
// Omitido se nenhum se aplicar — nao invente um trail generico.
const trail =
sanitizeTrail((req as Request & { imtblTrail?: string }).imtblTrail) ??
sanitizeTrail(req.headers['x-imtbl-trail'] as string | undefined) ??
sanitizeTrail(user?.username ? `user-${user.username}` : undefined);
const event = {
payload: clampPayload(payload),
// IMPORTANTE: todos os valores de meta precisam ser strings.
// IMPORTANT: every meta value must be a string.
meta: {
type: kind,
event_name: eventName,
service: serviceName,
request_id: requestId,
env,
...(trail && { immutable_trail: trail }),
},
};
// fire-and-forget — nunca bloqueia a resposta / never blocks the response
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 is REQUIRED (without it the API returns 400)
'Idempotency-Key': `${eventName}-${requestId}`,
'Request-Id': requestId,
// Timezone do cliente — alimenta meta.client_tz / client_offset_minutes
'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) =>
console.warn('[ImmutableLog] emit failed:', e.message),
);
res.setHeader('x-request-id', requestId);
};
res.on('finish', () => emit());
res.on('error', (err: Error) => emit(err));
next();
};
}Registration and Configuration
Register the middleware globally with `app.use()` before your routes. Configure via environment variables to keep credentials out of the code.
// src/app.ts
import express from 'express';
import { immutableLogMiddleware } from './middleware/immutablelog';
const app = express();
app.use(express.json());
// Registrar antes das rotas / Register before routes
app.use(
immutableLogMiddleware({
apiKey: process.env.IMTBL_API_KEY!,
apiUrl: process.env.IMTBL_URL ?? 'https://api.immutablelog.com',
serviceName: process.env.IMTBL_SERVICE_NAME ?? 'express-service',
env: process.env.IMTBL_ENV ?? 'production',
skipPaths: ['/health', '/healthz', '/metrics'],
}),
);
app.get('/users', (req, res) => res.json({ users: [] }));
app.listen(3000);Never pass the token directly in code. Use environment variables (.env) and ensure the `.env` file is in `.gitignore`.
How it works
The middleware records the start timestamp on entry, listens to `res.on('finish')` to capture the final status and calculate latency. Sending to ImmutableLog is done via fire-and-forget `fetch` — it never blocks the response to the client.
entry
Timestamp recorded, request_id generated or inherited from header
res.on('finish')
Latency calculated, event emitted via fire-and-forget fetch
res.on('error')
Stream errors captured and emitted as error event
The x-request-id is injected into the response for end-to-end traceability across systems.
Custom event name
Assign `req.imtblEventName` in any handler or preceding middleware to override the automatically generated event name.
app.post('/checkout', (req, res) => {
// Sobrescreve o nome do evento / Override the event name
(req as any).imtblEventName = 'payment.checkout.initiated';
// ... logica de negocio / business logic ...
res.json({ status: 'ok' });
});Auto default: http.METHOD.route_path (e.g., http.POST./checkout).
Path exclusion
Pass `skipPaths` in the options to ignore specific routes such as health checks and metrics.
app.use(
immutableLogMiddleware({
apiKey: process.env.IMTBL_API_KEY!,
apiUrl: 'https://api.immutablelog.com',
skipPaths: ['/health', '/healthz', '/metrics', '/readyz'],
}),
);Error enrichment
On error events (4xx/5xx), the middleware includes the SHA-256 hash of the request body, `retryable` flag, and when available, exception details.
// Error handler do Express — capturado automaticamente pelo middleware
// Express error handler — automatically captured by the middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({ error: err.message });
});error.requestBodyHash
SHA-256 of body (audit without exposing data)
error.retryable
true for: 408, 429, 500, 502, 503, 504
error.exception
Error class name (if available)
error.exceptionMessage
Error message (if available)
Immutable trail (immutable_trail)
A trail groups related events into a single auditable timeline (e.g., `flow-123`, `user-maria`). The middleware resolves the trail by priority: `req.imtblTrail` set on the route → `X-Imtbl-Trail` header propagated by the client → `user-<username>` fallback from the authenticated user. The value goes through `sanitizeTrail` before sending.
// 1. Definir a trilha em uma rota especifica / Set the trail on a specific route
app.post('/flows/:id/run', (req, res) => {
(req as any).imtblTrail = `flow-${req.params.id}`;
res.json({ status: 'ok' });
});
// 2. Ou propagar entre servicos via header / Or propagate across services via header
// X-Imtbl-Trail: flow-123
// 3. Sem nada disso, cai no fallback user-<username> (se houver req.user)Server rules: the trail cannot be empty, exceed 256 characters, or contain `:`. Violations return `400 invalid_immutable_trail`. That is why `sanitizeTrail` fixes the value (replaces `:` with `-` and truncates at 256) 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 }`. Since the middleware is fire-and-forget, these codes matter mostly if you send events manually.
| Status | Meaning |
|---|---|
| 202 | Event accepted and queued (new) |
| 200 | Idempotency-Key already exists — duplicate: true |
| 400 | Missing/empty Idempotency-Key or invalid_immutable_trail |
| 403 | Inactive/expired subscription, scope or retention |
| 413 | payload_too_large (server limit: 16KB) |
| 429 | monthly_limit_exceeded — do not retry |
| 503 | mempool_full — transient, retryable |
This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.
