@ahrowe/audit-log
v0.2.0
Published
Framework-agnostic audit-log recorder: hooks a DB event bus, records who/when/what (field-level diff + redacted snapshots) with AsyncLocalStorage request context.
Downloads
578
Maintainers
Readme
@ahrowe/audit-log
Framework-agnostic audit trail for debugging: hook a database event bus and get one immutable row per mutation — who changed what, when, and in which request — with a field-level diff on updates and redacted snapshots on create/delete.
It carries no database dependency. You inject any service that satisfies a
tiny structural interface (on / insert / createIndex), so it works with any
createService(...)-style db wrapper that emits create/update/remove events.
How it works
The db wrapper you already use emits created / updated / removed events.
registerAudit listens, diffs the docs, and writes one idempotent audit row.
Context (actor, tenantId, requestId, …) is captured at mutation time and carried
on the event payload, so an outbox relay can deliver it safely after the request ends.
request → context middleware (requestId, ip, …)
→ your auth sets actor → your code mutates a doc
→ db emits 'updated' { prevDoc, doc, context, eventId }
→ recorder diffs → writes one audit row (idempotent via eventId)Install
pnpm add @ahrowe/audit-log
# koa is an OPTIONAL peer — only needed for the `/koa` request-context middleware
pnpm add koaUsage
1. Bind the recorder to your audit collection
import { createAuditLog, AUDIT_LOG_COLLECTION } from '@ahrowe/audit-log';
import db from 'your-db-layer';
// You create the service (the db binding); the package owns the indexes.
// `db.createService` here is any wrapper that returns an `AuditDbService`-compatible
// object (emits created/updated/removed, has insert + createIndex).
const store = db.createService(AUDIT_LOG_COLLECTION, null, { addUpdatedOnField: false });
export const { registerAudit, recordEvent } = createAuditLog(store, { ttlDays: 30 });Audit rows are built internally and are correct by construction, so no write schema is shipped. If your db layer supports a validator and you want belt-and- suspenders write validation, pass your own in place of
null.
2. Register the collections you want audited
registerAudit(invoiceService, 'invoices');
registerAudit(userService, 'users', { redact: ['resetToken'] }); // + per-service secretsregisterAudit requires the service to emit created / updated / removed
as AuditDbEvent<T> (see AuditDbService). The context and eventId fields
are optional — services that don't emit them still work. passwordHash,
passwordSalt and updatedOn are always redacted; add more per service via redact.
3. Open a request context so rows get an actor
import { requestContext } from '@ahrowe/audit-log/koa';
import { auditContext, AuditActorType } from '@ahrowe/audit-log';
app.use(requestContext); // FIRST middleware
// after you authenticate the user:
auditContext.setActor({ type: AuditActorType.User, _id: user._id, email: user.email });
auditContext.setTenantId(company._id);No request context (cron/webhook) → rows are recorded with actor: { type: 'system' }.
Not on Koa? Skip /koa and open the store yourself with auditContext.run(store, next).
4. Record custom (non-CRUD) events
For domain events that aren't a raw mutation — and carry intent a diff can't:
await recordEvent({
action: 'invoice.finalized', // any string; CRUD verbs autocomplete
entityType: 'invoices',
entityId: invoice._id, // optional — omit for events with no single subject
metaData: { number: invoice.invoiceNumber, total: invoice.total },
});
// actor, requestId, tenantId, method/route/ip are filled from ALS context.recordEvent returns the write promise — await it for delivery confirmation, or ignore it for fire-and-forget. Recording only: there's no query/replay layer (that's an event store, a different tool).
What gets stored
| Source | action | changes | snapshot | metaData |
|---|---|---|---|---|
| CRUD created | created | — | redacted full doc | — |
| CRUD updated | updated | field-level before/after diff | — | — |
| CRUD removed | removed | — | redacted full doc | — |
| recordEvent | any string | — | — | your payload |
Rows are immutable and expire automatically ttlDays (default 30) after createdOn.
API
createAuditLog(store, { ttlDays?, logger?, tenantField? }) → { registerAudit, recordEvent, recordAudit }registerAudit(service, entityType, { redact? })— auto-capture CRUD off the db busrecordEvent({ action, entityType, entityId?, metaData?, tenantId? })— custom eventsauditContext—run/get/setActor/setTenantIdrequestContext(from@ahrowe/audit-log/koa)AUDIT_LOG_COLLECTIONdiffDocs,redactDoc,REDACT— pure helpers- types:
AuditLog,AuditAction,AuditActor,AuditActorType,AuditChange,AuditDbService,AuditDbEvent
