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.
npm install fastify fastify-pluginPlugin 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.
// 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.
// 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.
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.
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.
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-123The 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 }`.
| 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 |
Type augmentation
To avoid using `any` when accessing `request.imtblEventName` and `request.startedAt`, add the module declaration below to your TypeScript project.
// 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 };
}
}This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.
