Audit Module — Admin
Unified audit log capturing every domain event (attributed to the triggering actor via request-context propagation) plus eventless HTTP mutations, enriched (geo + device) and stored in ClickHouse via a dedicated event bus and a separately-deployable worker. Filterable, paginated admin read API.
Unified audit log capturing every domain event (attributed to the triggering actor via request-context propagation) plus eventless HTTP mutations, enriched (geo + device) and stored in ClickHouse via a dedicated event bus and a separately-deployable worker. Filterable, paginated admin read API.
Source:
api-modules/audit/src/query/admin-audit.controller.ts.Optional plugin. Removing
AuditModule.forRoot()fromapp.module.tsdisables it entirely — the global capture interceptor and theonAnydomain-event collector stop, the worker processor + ClickHouse migrations don't register, and/admin/auditleaves the OpenAPI surface. Cross-module consumers depend on nothing here.
Architecture
API role (capture) Worker role (process — separately deployable)
────────────────── ────────────────────────────────────────────
AuditInterceptor (HTTP mutations) ─┐
AuditEventCollector (onAny events) ─┴─► audit_outbox ──► AuditProcessor (drain loop)
(Postgres, ├ geo-IP + device enrich
durable) └ insert ─► ClickHouse audit_events- Capture is cheap and on the request path only for one Postgres insert. The HTTP interceptor records every
POST/PUT/PATCH/DELETE(reads are skipped); the collector mirrors every domain event viaEventEmitter2.onAny(no per-event listener). - Every event is durable. Both producers write to the Postgres
audit_outbox; the worker drains it (loop-until-caught-up, every ~2s) into ClickHouse. So nothing is lost if the worker / audit Redis blips — store-side events (cart, wishlist, …) are as reliably logged as orders. ClickHouse is a rebuildable mirror, not the system of record; the in-transactionorder_event/user_admin_auditledgers remain authoritative for state changes. - The worker does all heavy lifting — geo-IP (MaxMind, optional) + device/browser/OS (Bowser) enrichment, redaction, and the ClickHouse insert. It is gated behind
workersEnabled(), so it runs only in theworker/allrole and can be deployed separately from the API by running the same image withAPP_ROLE=worker(no code change). - Pluggable transport. The
EVENT_BUSport (BullMQ on a dedicated Redis) is still mounted for the worker's drain scheduling and remains the seam for a future Redpanda/Kafka analytics stream.
Capture semantics
Actor attribution (request-context propagation) — a per-request context (AsyncLocalStorage) carries the actor + ip + ua + a requestId across the whole call stack, so a domain event emitted deep inside a service (discount.updated) is attributed to the admin who triggered it, not system. Events emitted outside a request (cron / worker) fall back to system (or an actor id found in the payload).
De-duplication — when an action produces a domain event, that event is the audit record (correctly attributed), and the generic http.* row is suppressed. So updating a discount yields a single discount.updated row by admin — not a discount.updated (system) + http.patch (admin) pair. The http.* fallback is recorded only for mutating requests that emit no domain event (e.g. an eventless settings write, or a 4xx/5xx). Every event from one request shares a requestId.
HTTP (fallback) — actorType (admin / vendor / customer / anonymous, by route prefix + session), actorId / actorEmail, method, matched route + path, statusCode (success and error), client ip (first x-forwarded-for hop), userAgent, and the redacted request body. GET/HEAD/OPTIONS and a small ignore-list (/admin/audit, health, docs, session polling) are never recorded.
Domain events — one event per emitted domain event (order.placed, cart.item.added, vendor.application.approved, …), with the redacted payload, the inherited actor + request context, the origin source channel, and resource / resourceId. search.performed is ignored (analytics signal, not an audited action).
source is the origin channel (admin-portal / store / vendor-portal / api / webhook / system) — consistent with order_event.source / user_admin_audit.source — not the capture mechanism. The actual event is always in event_type.
Redaction — keys matching password, token, secret, card, cvv, otp, authorization, cookie, pin, accountNumber, … (case/underscore-insensitive) are replaced with [REDACTED]; oversized payloads (> 8 KB) are stored as a truncation marker.
Enrichment — geo_country/region/city come from a MaxMind GeoLite2-City DB only when GEOIP_DB_PATH is set (the DB is licence-restricted and never bundled; unset = no geo, everything else works). device_type / browser / os are parsed from the user-agent.
Retention — ClickHouse native TTL of 24 months on occurred_at.
Endpoints
All require a Better-Auth admin session and a role granting audit: view.
GET /admin/audit
Paginated, filterable list (newest first). Standard offset/limit pagination (limit, offset) plus filters:
| Query param | Description |
|---|---|
eventType | Exact event type (e.g. http.post, order.placed) |
actorId | Filter by acting principal id |
actorType | admin | vendor | customer | system | anonymous |
source | Origin channel: admin-portal | store | vendor-portal | api | webhook | system |
resource | Resource family (e.g. order, things) |
vendorId | Filter by vendor scope |
requestId | All events produced during one HTTP request |
from / to | ISO-8601 bounds on occurred_at (inclusive) |
Response: { data: AuditEvent[], metadata: { total, limit, offset, hasMore } } (the canonical ApiWrappedPaginatedResponse shape).
GET /admin/audit/:id
Fetch a single audit event by id. 404 if not found.
AuditEvent shape
id, eventType, occurredAt (ISO), actorType, actorId, actorEmail, source, requestId, method, route, path, statusCode, ip, userAgent, resource, resourceId, subject, vendorId, geo { country, region, city }, device { type, browser, browserVersion, os, osVersion }, changes, payload, metadata (the last three parsed back from stored JSON).
Self-describing rows. Beyond the full redacted event payload, each row carries:
changes— a{ from, to, reason, changedFields, delta }before→after picture. Update events across the system emit field-levelfrom → to(e.g. a discount edit showsvalue: 1000 → 1500); the collector also derives it frompreviousX/newX,previousStatus,changedFields[], andquantityDelta.subject— the entity's human identifier (name / number / code / slug / email), derived from the event payload, so a reviewer sees which entity (e.g. the discount name) without a lookup.
Configuration
| Env var | Default | Purpose |
|---|---|---|
CLICKHOUSE_URL | http://localhost:8123 | ClickHouse HTTP endpoint |
CLICKHOUSE_DB | supercommerce | ClickHouse database |
CLICKHOUSE_USER / CLICKHOUSE_PASSWORD | default / clickhouse | ClickHouse credentials |
CLICKHOUSE_ENABLED | true | false runs the app without ClickHouse (capture still enqueues; the worker skips the insert) |
AUDIT_REDIS_HOST / AUDIT_REDIS_PORT / AUDIT_REDIS_PASSWORD | localhost / 6381 | Dedicated audit-bus Redis (isolated from the main queue + cache) |
AUDIT_BUS_ENABLED | true | false wires a no-op bus (events dropped) |
AUDIT_WORKER_CONCURRENCY | 20 | Worker queue concurrency |
GEOIP_DB_PATH | (unset) | Path to a MaxMind GeoLite2-City .mmdb to enable geo enrichment |
Durable in-transaction audit
A service that needs an audit record written atomically with its action injects AuditOutboxRepository and calls append(entry, tx) inside its own transaction; the worker's relay drains it into the same ClickHouse sink.
Analytics Module — Admin surface
Admin-facing HTTP surface for precomputed platform-wide dashboard snapshots (GMV, orders, commission & payouts, vendor leaderboard, customers, inventory KPIs for today / 7d / 30d / all-time) and a live SSE platform paid-order feed.
Back in Stock Module — Admin
Admin surface for back-in-stock ("notify me") requests — who is waiting on which sold-out variant, and withdrawing a request on a shopper's behalf. The demand signal behind a sold-out…