@kolayik/audit-client
v0.1.1
Published
Kolay audit client — write audit events to the local audit-sidecar over a Unix Domain Socket. Fire-and-forget, ambient request context, one shared connection.
Readme
@kolayik/audit-client (Node/TypeScript)
Write audit events to the local audit-sidecar over a Unix Domain Socket. Fire-and-forget, one shared connection, and ambient request context so you set who/where once in a middleware and never repeat it at call sites.
The heavy lifting (durability, batching, retry, UUID/timestamp/hash) is the
sidecar's job — this client just serializes the business fields and writes a
line. See ../wire/README.md for the wire contract.
Install
npm install @kolayik/audit-clientConfigure once (startup)
import { configure } from '@kolayik/audit-client'
configure({
source: 'shift-v2', // -> the sidecar's `source` if unset
// socketPath defaults to AUDIT_SOCKET_PATH or /var/run/audit/audit.sock
// enabled defaults to AUDIT_ENABLED !== 'false'
defaultLegalBasis: 'contract',
defaultRetention: 'kvkk_10y',
onDrop: (reason) => log.debug('audit drop', { reason }), // optional
onMetric: (m) => { /* map to your Prometheus counters — see Observability */ },
})Set context once (middleware)
Wrap each request so everything inside sees the same actor/tenant/ip/request_id.
logAudit() deeper in the request fills these automatically.
Hono
import { runWithContext } from '@kolayik/audit-client'
app.use('*', async (c, next) => {
await runWithContext(
{
actor_id: c.get('user')?.id,
tenant_id: c.get('authenticatedTenantId'),
actor_role_snapshot: c.get('accessType'),
ip: c.req.header('x-forwarded-for') ?? '',
user_agent: c.req.header('user-agent') ?? '',
request_id: c.get('requestId'),
endpoint: `${c.req.method} ${c.req.path}`,
http_method: c.req.method,
request_origin: 'web',
},
() => next()
)
})Express
import { runWithContext } from '@kolayik/audit-client'
app.use((req, res, next) => {
runWithContext(
{
actor_id: req.user?.id,
tenant_id: req.tenantId,
ip: req.ip,
user_agent: req.get('user-agent') ?? '',
request_id: req.headers['x-request-id'] as string,
endpoint: `${req.method} ${req.path}`,
http_method: req.method,
},
() => next()
)
})If some fields are known later (e.g. after auth), call setContext({...})
inside the request to merge them in.
Log an event (anywhere in the request)
Call sites say only WHAT happened — WHO/WHERE come from context:
import { logAudit, auditDiff } from '@kolayik/audit-client'
logAudit({ action: 'CREATE', resource_type: 'shift', resource_id: shift.id })
// UPDATE with a compact before/after diff:
logAudit({
action: 'UPDATE',
resource_type: 'shift',
resource_id: id,
...auditDiff(before, after), // { before, after, changed_fields }
})Outside a request (cron, webhook) there's no ambient context, so pass the required WHO fields explicitly:
logAudit({ actor_id: 'cron', tenant_id, action: 'EXPORT', resource_type: 'report' })Type-safe actions & diffs
action is a closed union — a typo is a compile error, not a silently
accepted custom action:
logAudit({ action: 'UPDATE', ... }) // ok
logAudit({ action: 'UPDAET', ... }) // ✗ compile error — did you mean 'UPDATE'?The event type is discriminated on action, so each action group requires
the diff fields it semantically needs (see ../RULESET.md for the full table):
| Action group | Required |
|---|---|
| UPDATE, CONFIG_CHANGE | before + after + changed_fields |
| CREATE, RESTORE, IMPORT | after |
| DELETE, SOFT_DELETE | before |
| LOGIN_FAILED | failure_reason |
| everything else | — (diff optional) |
logAudit({ action: 'UPDATE', resource_type: 'shift' }) // ✗ missing before/after/changed_fields
logAudit({ action: 'UPDATE', resource_type: 'shift', ...auditDiff(before, after) }) // okObservability (metrics)
The client is fire-and-forget, so a dropped event would otherwise be invisible
— it never reaches the sidecar. To make producer-side loss visible, the SDK
emits typed AuditMetric events through the onMetric callback. It ships
no metrics library of its own (no prom-client, no registry, no HTTP server)
— your app owns the metrics and the /metrics endpoint. This keeps the SDK
dependency-free and avoids duplicate-prom-client-copy problems under
symlinked/monorepo installs.
The events
type AuditMetric =
| { type: 'sent'; action: string }
| { type: 'dropped'; reason: DropReason; action?: string }
| { type: 'disabled' }
| { type: 'write_bytes'; bytes: number }
| { type: 'context_missing'; field: 'actor_id' | 'tenant_id' }DropReason (exported as DROP_REASONS for pre-seeding at 0):
missing_mandatory_fields, socket_not_ready, write_failed, backpressure,
connect_error, serialize_failed, oversize, disabled.
serialize_failed and oversize are also guards — a line that can't be
serialized or exceeds the sidecar's 1 MiB limit is dropped locally (and emitted)
instead of being lost silently downstream.
Wiring it to Prometheus (host owns the counters)
Define counters in your prom-client registry, map the events, and serve
that registry on /metrics. Metric names/labels are the cross-service contract
(see ../RULESET.md §6) so one dashboard spans every producer.
import { Counter, Histogram, register } from 'prom-client'
import { configure, DROP_REASONS } from '@kolayik/audit-client'
register.setDefaultLabels({ service: 'shift-v2', pod: process.env.POD_NAME ?? 'unknown' })
const sent = new Counter({ name: 'audit_client_events_total', labelNames: ['action'], registers: [register] })
const dropped = new Counter({ name: 'audit_client_events_dropped_total', labelNames: ['reason'], registers: [register] })
const ctxMiss = new Counter({ name: 'audit_client_context_missing_total',labelNames: ['field'], registers: [register] })
const bytes = new Histogram({ name: 'audit_client_write_bytes', buckets: [256,1024,4096,16384,65536,262144,524288,1048576,2097152], registers: [register] })
for (const r of DROP_REASONS) dropped.labels(r).inc(0) // pre-seed → no "no data"
configure({
source: 'shift-v2',
onMetric: (m) => {
if (m.type === 'sent') sent.labels(m.action).inc()
else if (m.type === 'dropped') dropped.labels(m.reason).inc()
else if (m.type === 'disabled') dropped.labels('disabled').inc()
else if (m.type === 'write_bytes')bytes.observe(m.bytes)
else if (m.type === 'context_missing') ctxMiss.labels(m.field).inc()
},
})
// public, auth-free, one endpoint per process
app.get('/metrics', async (c) =>
c.body(await register.metrics(), 200, { 'Content-Type': register.contentType }))Then add the service as a Prometheus scrape target (metrics_path: /metrics).
The SDK is not "done" until the consuming service shows UP in Prometheus with
audit_client_* queryable. onMetric is best-effort — a throwing callback is
swallowed and never breaks the request path.
Guarantees
- Never throws / never blocks the request — a sidecar outage drops the event
(the sidecar owns durability, not the client). A drop is no longer silent: it
emits an
AuditMetric(dropped) so your/metricscan surface it. - Do not send
id/occurred_at— the sidecar assigns a UUID + UTC time + hash before persisting. - One long-lived UDS connection, transparently reconnected on drop.
- Zero runtime dependencies — the SDK bundles no metrics/HTTP library.
API
| Export | Purpose |
|--------|---------|
| configure(opts) | one-time setup (socket path, source, defaults, onDrop, onMetric) |
| runWithContext(ctx, fn) | bind ambient context for a request (middleware) |
| setContext(patch) | merge more fields into the current context |
| logAudit(event) | log an event; WHO/WHERE from context, caller fields win |
| audit(event) | log a fully-formed event (no context merge) |
| auditDiff(before, after, fields?) | compact {before, after, changed_fields} |
| close() | close the connection (tests / graceful shutdown) |
| DROP_REASONS | exhaustive drop-reason list (pre-seed metrics at 0) |
Types
| Type | Purpose |
|------|---------|
| AuditAction | closed union of all audit actions |
| AuditEvent / ContextualAuditEvent | discriminated event; per-action required diff fields |
| AuditMetric | observability event passed to onMetric |
| MetricSink | (m: AuditMetric) => void — the onMetric signature |
| DropReason | union of drop-reason strings |
