Files
skills/docs/api-stack/api/api-audit-file/src/sink/sink.md
2026-05-29 08:20:56 +01:00

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 to emit().
  • Schema validation at the boundary: invalid records throw AUDIT_FILE.INVALID_RECORD and increment dropped_total.
  • strict-sync (default) and append modes for the durability / throughput trade-off.
  • Boot-time mkdir -p of the parent directory + W_OK probe; unwriteable targets throw AUDIT_FILE.PATH_NOT_WRITEABLE from the factory itself so misconfiguration is visible at host startup.
  • Size + time rotation; maxFiles retention; per-eviction OTel counter.
  • Idempotent dispose(); observable queueDepth getter 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 CreateFileAuditSinkOptions
    • path: 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 1024
    • fs?: FsAdapter — default nodeFsAdapter
    • logger?: LoggerLike — default no-op
    • instruments?: AuditFileInstruments — default global meter
  • interface FileAuditSink
    • emit(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.