Tools
Tools: Add an Audit Trail to Your API in Minutes with HazelJS
2026-03-02
0 views
admin
Why an audit trail? ## What’s in the starter? ## Quick start ## What an audit event looks like ## How it’s wired in code ## Where events go: console, file, Kafka ## Production checklist ## Summary Who did what, when, and with what outcome? If your API needs to answer that for compliance, security, or debugging, you need a structured audit trail. This post shows how to get one quickly using the HazelJS Audit Starter: a small Orders API that logs every HTTP request and every business event to console, file, and optionally Kafka — with actors, resources, and redaction built in. Doing this from scratch means instrumenting every endpoint, normalizing event shape, and piping events to logs or a SIEM. The HazelJS Audit Starter and @hazeljs/audit give you a ready-made pattern: one interceptor for HTTP, one service for custom events, and pluggable transports (console, file, Kafka). The hazeljs-audit-starter is a minimal HazelJS app that demonstrates: So: every request is audited automatically, and every important business action is audited explicitly, in one place. Clone or place the starter next to the HazelJS monorepo, then: Server runs at http://localhost:3000. Try: Each request produces an audit event. You’ll see one JSON line on stdout and one line appended to logs/audit.jsonl. Every event is a single JSON object with a consistent shape. Example from the file after a few POST /orders calls: So you get a clear, queryable trail: who did what, when, and with what outcome. 1. Module and transports In app.module.ts, the app registers the audit module with console and file transports (and optional Kafka when env is set): 2. Controller: interceptor + @Audit The Orders controller uses the global AuditInterceptor and the @Audit decorator so every HTTP call is logged and each method has a clear action/resource: 3. Service: custom events For business-level events, the service injects AuditService and calls log() with action, actor, resource, and metadata: So you get both: automatic HTTP audit and explicit domain events, in one pipeline. One event is sent to all configured transports, so you can have dev (console + file) and prod (file + Kafka) without changing your business code. The HazelJS Audit Starter shows how to add a full audit trail to a small API with minimal code: register AuditModule with the transports you want, put AuditInterceptor and @Audit on your controller, and call AuditService.log() for domain events. Events are structured, include actor and resource, and can go to console, file, and Kafka (including Avro). Clone the starter, run it, and then adapt it to your stack and policies. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
cd hazeljs-audit-starter
npm install
cp .env.example .env
npm run dev Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
cd hazeljs-audit-starter
npm install
cp .env.example .env
npm run dev CODE_BLOCK:
cd hazeljs-audit-starter
npm install
cp .env.example .env
npm run dev COMMAND_BLOCK:
# Health (no auth)
curl http://localhost:3000/health # Create an order (send user headers so the event has an actor)
curl -X POST http://localhost:3000/orders \ -H "Content-Type: application/json" \ -H "X-User-Id: u1" \ -H "X-User-Name: alice" \ -H "X-User-Role: admin" \ -d '{"customerId":"c1","amount":99.99}' Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# Health (no auth)
curl http://localhost:3000/health # Create an order (send user headers so the event has an actor)
curl -X POST http://localhost:3000/orders \ -H "Content-Type: application/json" \ -H "X-User-Id: u1" \ -H "X-User-Name: alice" \ -H "X-User-Role: admin" \ -d '{"customerId":"c1","amount":99.99}' COMMAND_BLOCK:
# Health (no auth)
curl http://localhost:3000/health # Create an order (send user headers so the event has an actor)
curl -X POST http://localhost:3000/orders \ -H "Content-Type: application/json" \ -H "X-User-Id: u1" \ -H "X-User-Name: alice" \ -H "X-User-Role: admin" \ -d '{"customerId":"c1","amount":99.99}' CODE_BLOCK:
{ "action": "order.create", "actor": { "id": "u1", "username": "alice", "role": "admin" }, "resource": "Order", "resourceId": "ord-1", "result": "success", "metadata": { "amount": 99.99, "customerId": "c1" }, "timestamp": "2026-03-02T12:25:30.806Z", "_type": "audit"
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
{ "action": "order.create", "actor": { "id": "u1", "username": "alice", "role": "admin" }, "resource": "Order", "resourceId": "ord-1", "result": "success", "metadata": { "amount": 99.99, "customerId": "c1" }, "timestamp": "2026-03-02T12:25:30.806Z", "_type": "audit"
} CODE_BLOCK:
{ "action": "order.create", "actor": { "id": "u1", "username": "alice", "role": "admin" }, "resource": "Order", "resourceId": "ord-1", "result": "success", "metadata": { "amount": 99.99, "customerId": "c1" }, "timestamp": "2026-03-02T12:25:30.806Z", "_type": "audit"
} CODE_BLOCK:
AuditModule.forRoot({ transports: [ new ConsoleAuditTransport(), new FileAuditTransport({ filePath: path.join(process.cwd(), process.env.AUDIT_LOG_FILE || 'logs/audit.jsonl'), ensureDir: true, // maxSizeBytes, rollDaily for rotation }), ], redactKeys: ['password', 'token', 'authorization', 'secret'],
}); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
AuditModule.forRoot({ transports: [ new ConsoleAuditTransport(), new FileAuditTransport({ filePath: path.join(process.cwd(), process.env.AUDIT_LOG_FILE || 'logs/audit.jsonl'), ensureDir: true, // maxSizeBytes, rollDaily for rotation }), ], redactKeys: ['password', 'token', 'authorization', 'secret'],
}); CODE_BLOCK:
AuditModule.forRoot({ transports: [ new ConsoleAuditTransport(), new FileAuditTransport({ filePath: path.join(process.cwd(), process.env.AUDIT_LOG_FILE || 'logs/audit.jsonl'), ensureDir: true, // maxSizeBytes, rollDaily for rotation }), ], redactKeys: ['password', 'token', 'authorization', 'secret'],
}); CODE_BLOCK:
@Controller('/orders')
@UseGuards(DemoUserGuard)
@UseInterceptors(AuditInterceptor)
export class OrdersController { @Post() @Audit({ action: 'order.create', resource: 'Order' }) create(@Body() body: CreateOrderDto, @Req() req: RequestWithUser) { // ... return this.ordersService.create(body, ctx); }
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
@Controller('/orders')
@UseGuards(DemoUserGuard)
@UseInterceptors(AuditInterceptor)
export class OrdersController { @Post() @Audit({ action: 'order.create', resource: 'Order' }) create(@Body() body: CreateOrderDto, @Req() req: RequestWithUser) { // ... return this.ordersService.create(body, ctx); }
} CODE_BLOCK:
@Controller('/orders')
@UseGuards(DemoUserGuard)
@UseInterceptors(AuditInterceptor)
export class OrdersController { @Post() @Audit({ action: 'order.create', resource: 'Order' }) create(@Body() body: CreateOrderDto, @Req() req: RequestWithUser) { // ... return this.ordersService.create(body, ctx); }
} CODE_BLOCK:
this.audit.log({ action: 'order.create', actor: this.audit.actorFromContext(context), resource: 'Order', resourceId: order.id, result: 'success', metadata: { amount: order.total },
}); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
this.audit.log({ action: 'order.create', actor: this.audit.actorFromContext(context), resource: 'Order', resourceId: order.id, result: 'success', metadata: { amount: order.total },
}); CODE_BLOCK:
this.audit.log({ action: 'order.create', actor: this.audit.actorFromContext(context), resource: 'Order', resourceId: order.id, result: 'success', metadata: { amount: order.total },
}); - Compliance — Regulators and policies often require a record of who accessed or changed what.
- Security — After an incident, you need a timeline of actions and actors.
- Debugging — “Why did this order change?” is easier when every create/update/delete is logged. - AuditModule with console (stdout) and file (logs/audit.jsonl) transports.
- AuditInterceptor on the Orders controller so every GET / POST / PUT / DELETE is logged (success or failure).
- AuditService in the orders service for explicit events: order.create, order.update, order.delete with resource id and metadata.
- @Audit decorator on controller methods for clear action/resource semantics.
- Demo user context via headers (X-User-Id, X-User-Name, X-User-Role) so each event has an actor (swap for JWT in production).
- Optional Kafka — set KAFKA_BROKERS and events go to a topic as JSON or Avro. - action — e.g. order.create, http.get, order.delete.
- actor — who did it (from request context or headers in the starter).
- resource / resourceId — what was affected.
- result — success, failure, or denied.
- timestamp — ISO string (set automatically if you omit it).
- metadata — extra data; sensitive keys (password, token, etc.) are redacted by default. - Console — One JSON line per event on stdout (handy for local dev and container logs).
- File — Same JSON lines in logs/audit.jsonl (or AUDIT_LOG_FILE). You can enable rotation by size (AUDIT_LOG_MAX_SIZE_MB) or by day (AUDIT_LOG_ROLL_DAILY=true).
- Kafka (optional) — Set KAFKA_BROKERS and optionally KAFKA_AUDIT_TOPIC and KAFKA_AUDIT_AVRO=true; the starter adds KafkaAuditTransport at startup and events are published as JSON or Avro. See src/audit-kafka.ts for the Avro schema and createKafkaAuditTransport. - Replace DemoUserGuard with @hazeljs/auth (JWT) so context.user comes from a verified token.
- Keep redactKeys in forRoot so sensitive fields in metadata are never logged in full.
- For scale or central logging, add KafkaAuditTransport (or your own AuditTransport) and keep file/console as needed.
- Use file rotation (AUDIT_LOG_MAX_SIZE_MB or AUDIT_LOG_ROLL_DAILY) so logs/audit.jsonl doesn’t grow unbounded. - Starter: hazeljs-audit-starter
- Package: @hazeljs/audit
- Docs: HazelJS documentation
how-totutorialguidedev.toaiserver