DEV Community

Cover image for Add an Audit Trail to Your API in Minutes with HazelJS
Muhammad Arslan
Muhammad Arslan

Posted on

Add an Audit Trail to Your API in Minutes with HazelJS

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.

If you like it don't forget to Star HazelJS repository


Why an audit trail?

  • 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.

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).


What’s in the starter?

The hazeljs-audit-starter is a minimal HazelJS app that demonstrates:

  • 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.

So: every request is audited automatically, and every important business action is audited explicitly, in one place.


Quick start

Clone or place the starter next to the HazelJS monorepo, then:

cd hazeljs-audit-starter
npm install
cp .env.example .env
npm run dev
Enter fullscreen mode Exit fullscreen mode

Server runs at http://localhost:3000. Try:

# 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

Each request produces an audit event. You’ll see one JSON line on stdout and one line appended to logs/audit.jsonl.


What an audit event looks like

Every event is a single JSON object with a consistent shape. Example from the file after a few POST /orders calls:

{
  "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
  • 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.
  • resultsuccess, failure, or denied.
  • timestamp — ISO string (set automatically if you omit it).
  • metadata — extra data; sensitive keys (password, token, etc.) are redacted by default.

So you get a clear, queryable trail: who did what, when, and with what outcome.


How it’s wired in code

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):

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

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:

@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

3. Service: custom events

For business-level events, the service injects AuditService and calls log() with action, actor, resource, and metadata:

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

So you get both: automatic HTTP audit and explicit domain events, in one pipeline.


Where events go: console, file, Kafka

  • 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.

One event is sent to all configured transports, so you can have dev (console + file) and prod (file + Kafka) without changing your business code.


Production checklist

  • 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.

Summary

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.

Top comments (0)