The Audit App
How the server enqueues an audit job fire-and-forget, and how a separate audit microservice pulls it off Redis and does the only database write.
Once the interceptor has assembled a payload, the server's job is over — it hands the work to a queue and returns. The actual database insert happens later, in a separate audit microservice. This page follows the job from enqueue to insert.
Fire-and-forget enqueue
AuditLogService.log() stamps the tenant, prepares the snapshots, and adds the job. The crucial detail: it returns void, and the .add() promise's rejection is caught and logged — never thrown. The user's request can't fail on an audit hiccup.
log(entry: AuditLogJobPayload): void {
const payload = {
...entry,
schoolId: getTenantContext()?.schoolId ?? entry.schoolId ?? null,
beforeState: this.prepareSnapshot(entry.beforeState),
afterState: this.prepareSnapshot(entry.afterState),
};
this.auditQueue.add('write', payload).catch((err) =>
this.logger.error('Failed to enqueue audit log job', err),
);
}logWithMeta() is a thin wrapper for controller call sites that merges HTTP context (ip, user-agent, request id, method, path) onto the entry before calling log(). Use it when you log by hand from a controller; use log() from a service or processor that has no request.
Never await a log call
log() and logWithMeta() return void and swallow-then-log their own errors on purpose. Awaiting them gets you nothing and couples your request's success to the audit pipeline — exactly what the design avoids.
The processor
The job lands in the audit microservice (apps/audit), where AuditLogProcessor — a BullMQ WorkerHost bound to the audit-log queue — does the insert. It uses a raw query builder, not the ORM entity, for a lean write path that skips entity hooks and validation.
@Processor(AUDIT_LOG_QUEUE)
export class AuditLogProcessor extends WorkerHost {
async process(job: Job<AuditLogJobPayload>): Promise<void> {
const payload = job.data;
const actorName = payload.actorName ?? (await this.resolveActorName(payload.userId));
await this.dataSource.createQueryBuilder().insert().into('audit_log')
.values({ school_id: payload.schoolId ?? null, type: payload.type, action: payload.action,
actor_name: actorName, before_state: payload.beforeState ?? null,
after_state: payload.afterState ?? null, /* ...all columns */ })
.execute();
}
}Late actorName lookup
One nicety: if the payload omits actorName, the processor fills it in by querying all four profile tables (staff_profile, student_profile, guardian_profile, guest_profile) for a display name. That lookup runs here — off the request hot path — and is cached per userId. If the insert fails, BullMQ's attempts: 3 retries it.
actorName is resolved at insert time, not event time
Because the name is filled in when the row is written, a long-delayed job records the actor's name as it is at insert time, not when the event occurred. Invisible for nearly everything; it only matters if a name changed between the event and the write.
Step 1: Verify the entry landed
Boot the server and the audit app together, trigger your endpoint, and confirm the row. The fastest check is the read API; you can also watch the job flow through Bull Board at /queues (the audit-log queue is registered there).
pnpm dev:all # server + worker + audit together
# trigger your endpoint, then:
curl -s "$API/api/v1/audit-logs?resourceType=api_key&action=REVOKE" \
-H "Authorization: Bearer $TOKEN" # needs LIST on AUDIT_LOGIf the request succeeded but no row appeared, the job either failed to enqueue (check the server logs for "Failed to enqueue audit log job") or failed to insert (check the audit app and the /queues dashboard for a failed job). Remember the whole pipeline is fire-and-forget, so a missing row never surfaces as a request error — you have to go look.
Where to go next
Reading the Log
The read API, read-time diffing, redaction, manual logging, and the gotchas.
The @Audit Decorator
What runs on the server before the job is enqueued: the decorator and interceptor.
Queues & Messaging
The BullMQ layer the audit queue rides on, and how producers and consumers agree on a payload.
Add a Background Job
The contract / producer / processor pattern the audit pipeline follows, applied to a new queue.
The @Audit Decorator
How a one-line decorator plus a global interceptor capture the actor, action, and redacted before/after snapshots — and how to audit a mutation.
Reading the Log
The read-only audit API and read-time diffing, how redaction masks secrets, when to log manually, and the traps that bite people.