4.7 KiB
sink
(no submodules)
Purpose
The public entry point of @suaveplan/api-audit-file. The
createFileAuditSink factory composes the schema, hmac, rotation,
retention, instruments, and fs-helpers modules into a single object
that implements the RequestAuditSink contract from
@suaveplan/api-host/request-pipeline. The sink writes each accepted
record as one JSON line with an HMAC-SHA-256 signature, rolls the file
when size OR age limits are reached, and keeps at most maxFiles
rotated siblings.
The sink is queue-buffered: callers await emit() and resolve as soon
as the record has been appended to disk. A bounded queue protects the
host from a stalled filesystem: records arriving while the queue is at
maxQueueSize are dropped with a typed AUDIT_FILE.QUEUE_FULL and an
api.audit.dropped_total{sink="file"} increment. dispose() flushes
the queue and is idempotent.
Features
createFileAuditSink({ path, hmacKey, ... })— async factory. Returns a sink ready toemit().- Schema validation at the boundary: invalid records throw
AUDIT_FILE.INVALID_RECORDand incrementdropped_total. strict-sync(default) andappendmodes for the durability / throughput trade-off.- Boot-time
mkdir -pof the parent directory +W_OKprobe; unwriteable targets throwAUDIT_FILE.PATH_NOT_WRITEABLEfrom the factory itself so misconfiguration is visible at host startup. - Size + time rotation;
maxFilesretention; per-eviction OTel counter. - Idempotent
dispose(); observablequeueDepthgetter for probes.
Usage: Basic
import { createFileAuditSink } from "@suaveplan/api-audit-file/sink";
const sink = await createFileAuditSink({
path: "/var/log/suaveplan/audit.log",
hmacKey: new TextEncoder().encode(process.env.AUDIT_HMAC_KEY ?? ""),
});
await sink.emit({
at: new Date().toISOString(),
requestId: "r-1",
traceId: null,
route: "/admin/users",
plugin: "core-admin",
method: "POST",
principal: { id: "user-1", scopes: ["admin"] },
tenantId: "tenant-a",
status: 200,
outcome: "success",
});
await sink.dispose();
Usage: Advanced
Run with a custom rotation policy, append mode, real MeterProvider,
and an injected FakeClock for deterministic tests:
import { FakeClock } from "@suaveplan/testing";
import {
MeterProvider,
InMemoryMetricExporter,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { createFileAuditSink } from "@suaveplan/api-audit-file/sink";
import { createAuditFileInstruments } from "@suaveplan/api-audit-file/instruments";
const clock = new FakeClock(0);
const exporter = new InMemoryMetricExporter(0);
const reader = new PeriodicExportingMetricReader({ exporter });
const meterProvider = new MeterProvider({ readers: [reader] });
const sink = await createFileAuditSink({
path: "/tmp/audit.log",
hmacKey: new TextEncoder().encode("secret"),
clock: clock.read,
rotation: { maxSizeBytes: 1024, maxAgeMs: 60_000, maxFiles: 5 },
appendMode: "append",
maxQueueSize: 256,
instruments: createAuditFileInstruments({ meterProvider }),
});
API Reference
interface CreateFileAuditSinkOptionspath: string(required)hmacKey: Uint8Array(required, non-empty)clock?: Clock— default() => Date.now()rotation?: Partial<RotationPolicy>appendMode?: "strict-sync" | "append"— default"strict-sync"maxQueueSize?: number— default 1024fs?: FsAdapter— defaultnodeFsAdapterlogger?: LoggerLike— default no-opinstruments?: AuditFileInstruments— default global meter
interface FileAuditSinkemit(record): Promise<void>dispose(): Promise<void>readonly queueDepth: number
createFileAuditSink(options): Promise<FileAuditSink>
Implementation Notes
The drain loop is mutex-style: a single in-flight drain promise covers
every concurrent emit() call so writes are serialised against the
file handle even when callers fan out. Backpressure is signalled
synchronously — emit() throws QUEUE_FULL before any await when the
bounded queue is at capacity, so the host can apply load shedding
without observing a write-latency spike. Rotation runs in the same
drain frame as the triggering write so the rename always happens
before the segment grows past the cap by one extra line. Retention
runs after rotation; a retention failure is logged but does not
propagate — the sink stays available even if unlink is transiently
denied. The on-disk format is intentionally trivial ({ record, hmac }
per line) so an operator with jq and openssl dgst -sha256 -hmac can
verify integrity without any Suaveplan tooling on hand.