Naalya Handbook
Auditing Actions

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.

apps/server/src/app/audit-log/audit-log.service.ts
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.

apps/audit/src/processors/audit-log.processor.ts
@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).

terminal
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_LOG

If 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

On this page