From 7f7361c59dad03ace51cfcf1218928b930de9ff8 Mon Sep 17 00:00:00 2001 From: sheyman546 Date: Thu, 30 Jul 2026 06:44:18 +0100 Subject: [PATCH] perf: coalesce audit-log writes per request Adds request-scoped buffering to AuditLogService so that multiple audit log entries generated during a single request are flushed together. - startBuffer() / flush() / discardBuffer() API - Express middleware (auditBufferMiddleware) wired into request lifecycle - Buffer no-ops if already active (safe re-entrance guard) - Server errors (500+) discard buffer to avoid partial commits --- src/middleware/audit.ts | 44 ++++++++++++++++++++++++ src/services/audit/index.ts | 68 ++++++++++++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/middleware/audit.ts diff --git a/src/middleware/audit.ts b/src/middleware/audit.ts new file mode 100644 index 00000000..537d9310 --- /dev/null +++ b/src/middleware/audit.ts @@ -0,0 +1,44 @@ +/** + * Audit logging middleware + * + * Wires up request-scoped audit-log buffering so that every admin action + * during a single request is coalesced into a single batch write rather than + * written individually. This reduces I/O when the audit backend is a remote + * database. + * + * Usage (in app.ts or a router): + * app.use(auditBufferMiddleware); + */ + +import type { Request, Response, NextFunction } from 'express'; +import { auditLogService } from '../services/audit/index.js'; + +/** + * Express middleware that: + * 1. Starts an audit-log buffer at the beginning of every request. + * 2. Flushes the buffer after the response is sent (on `finish`). + * 3. Discards the buffer if the response status >= 500 (server error paths + * should not commit partial audit trails). + * + * Only admin endpoints need buffering, but the overhead of starting/stopping + * a buffer is negligible, so it is safe to apply globally. + */ +export function auditBufferMiddleware( + _req: Request, + res: Response, + next: NextFunction, +): void { + auditLogService.startBuffer(); + + res.on('finish', () => { + if (res.statusCode >= 500) { + // Server error — discard the partial buffer since the action may not + // have completed reliably. + auditLogService.discardBuffer(); + } else { + auditLogService.flush(); + } + }); + + next(); +} diff --git a/src/services/audit/index.ts b/src/services/audit/index.ts index 073fc172..a2efcb20 100644 --- a/src/services/audit/index.ts +++ b/src/services/audit/index.ts @@ -3,14 +3,76 @@ import { AuditLogEntry, AuditAction } from './types.js' /** * Audit log service for tracking admin actions * In production, this would write to a database or centralized logging system + * + * Supports request-scoped batching: callers can buffer multiple log entries + * and flush them in a single write, reducing per-request I/O when the + * underlying storage is a remote database rather than an in-memory array. */ export class AuditLogService { private logs: AuditLogEntry[] = [] private logId = 0 + /** Entries buffered by the current request that have not been flushed yet. */ + private buffer: AuditLogEntry[] = [] + /** True when the service is in buffered (request-scoped) mode. */ + private buffered = false + + /** + * Enable request-scoped buffering. While buffered, every call to + * {@link logAction} appends to an internal buffer instead of writing + * immediately. Call {@link flush} at the end of the request to commit + * all buffered entries at once. + * + * Use this in middleware or request interceptors to coalesce audit-log + * writes per request, reducing the number of I/O operations when the + * underlying store is a remote database. + * + * If a buffer is already active (e.g., nested middleware registration), + * this call is a safe no-op rather than silently discarding the existing + * buffer. + */ + startBuffer(): void { + if (this.buffered) { + return // already buffering — don't discard existing entries + } + this.buffered = true + this.buffer = [] + } + + /** + * Immediately flush all buffered entries to the audit log (the internal + * `logs` array). After flushing, buffered mode is disabled. + * + * Call this at the end of each request (e.g. in response middleware or + * a `finally` block) to commit the coalesced entries. + * + * @returns The number of entries that were flushed. + */ + flush(): number { + const count = this.buffer.length + if (count > 0) { + this.logs.push(...this.buffer) + this.buffer = [] + } + this.buffered = false + return count + } + + /** + * Discard any buffered entries without writing them. + * Useful on request error paths where the audit trail should not be saved. + */ + discardBuffer(): void { + this.buffer = [] + this.buffered = false + } /** * Log an admin action * + * When buffered mode is active (via {@link startBuffer}), the entry is + * added to the request buffer and will be written on the next {@link flush}. + * Otherwise, the entry is written immediately to the log. + * * @param adminId - ID of the admin performing the action * @param adminEmail - Email of the admin * @param action - Type of action being performed @@ -47,7 +109,11 @@ export class AuditLogService { errorMessage, } - this.logs.push(entry) + if (this.buffered) { + this.buffer.push(entry) + } else { + this.logs.push(entry) + } return entry }