Initial Commit
This commit is contained in:
108
packages/api-stack/api/api-audit-stdout/src/sink/sink.md
Normal file
108
packages/api-stack/api/api-audit-stdout/src/sink/sink.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `sink` — `createStdoutAuditSink`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module wires the schema, HMAC signer, instruments, and options
|
||||
into a `RequestAuditSink` implementation that writes JSON-per-line
|
||||
audit records to a `LineWriter` (default `process.stdout`). It is the
|
||||
default audit sink shipped with `@suaveplan/api-host`: container
|
||||
runtimes already redirect stdout into a log pipeline, so operators
|
||||
inherit the records without touching host configuration.
|
||||
|
||||
The sink is single-flight: at most one write is on the wire at any
|
||||
time. Subsequent records wait for the slot, but only up to
|
||||
`maxQueueMs` (default 100ms) before being dropped. Drops are NEVER
|
||||
silent — every drop increments `api.audit.dropped_total{sink,reason}`
|
||||
and (subject to the once-per-`warnThrottleMs` throttle) emits a
|
||||
single warning log. Validation, HMAC signing, and serialisation
|
||||
happen on the calling side of the queue so a malformed record is
|
||||
rejected synchronously without consuming the slot.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `RequestAuditSink` (`emit(envelope)`) from
|
||||
`@suaveplan/api-host/request-pipeline` — host accepts the instance
|
||||
directly.
|
||||
- `write(record: AuditRecord)` overload for admin paths that already
|
||||
hold a canonical record.
|
||||
- Boundary Zod validation — `AUDIT_STDOUT.INVALID_RECORD` on a bad
|
||||
record.
|
||||
- Bounded queue — `AUDIT_STDOUT.QUEUE_FULL` on timeout, drop counter
|
||||
+ throttled warning.
|
||||
- HMAC-SHA-256 over canonical bytes appended as `_hmac` field.
|
||||
- `dispose()` drains the in-flight write and rejects subsequent
|
||||
writes as `closed` drops.
|
||||
- Pluggable `clock`, `timers`, `logger`, `writer`, `instruments`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStdoutAuditSink } from "@suaveplan/api-audit-stdout/sink";
|
||||
|
||||
const sink = createStdoutAuditSink({
|
||||
hmacKey: new TextEncoder().encode(process.env["AUDIT_HMAC_KEY"]!),
|
||||
});
|
||||
await sink.write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "admin.plugin.disabled",
|
||||
principal: { id: "user_1", scopes: ["admin"] },
|
||||
tenant: "tenant_a",
|
||||
request_id: "req_1",
|
||||
trace_id: null,
|
||||
subject: "plugin.exports",
|
||||
outcome: "success",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createStdoutAuditSink } from "@suaveplan/api-audit-stdout/sink";
|
||||
import { createStdoutAuditInstruments } from "@suaveplan/api-audit-stdout/instruments";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const meterProvider = new MeterProvider({ readers: [...] });
|
||||
const sink = createStdoutAuditSink({
|
||||
hmacKey,
|
||||
writer: {
|
||||
async write(line) {
|
||||
await myStream.write(line);
|
||||
},
|
||||
},
|
||||
maxQueueMs: 250,
|
||||
warnThrottleMs: 30_000,
|
||||
instruments: createStdoutAuditInstruments({ meterProvider }),
|
||||
logger: myLogger,
|
||||
});
|
||||
// Hand into createHost({ audit: sink }) per host wiring.
|
||||
await sink.emit(envelope);
|
||||
await sink.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createStdoutAuditSink(options)` → `StdoutAuditSink`.
|
||||
- `StdoutAuditSinkOptions` — `{ hmacKey, writer?, clock?, timers?,
|
||||
logger?, instruments?, maxQueueMs?, warnThrottleMs?, sinkLabel? }`.
|
||||
- `StdoutAuditSink` — extends `RequestAuditSink`; adds `write(record)`
|
||||
and `dispose()`.
|
||||
- `LineWriter` — `{ write(line: string): void | Promise<void> }`.
|
||||
- `adaptEnvelope(envelope)` — exposed for tests and bespoke
|
||||
re-emission flows.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The slot reservation uses an in-flight `Promise<void>` rather than a
|
||||
queue + worker so cancellation flows trivially through GC — there's
|
||||
no worker loop to shut down. `acquireSlot` races the in-flight write
|
||||
against the `timers.setTimer` callback; whichever wins clears the
|
||||
loser. The `closed` flag is checked before validation so a disposed
|
||||
sink rejects fast without spending CPU on schema work.
|
||||
`adaptEnvelope` is the only place the host's camelCase envelope
|
||||
shape meets the canonical snake_case record — keeping that mapping
|
||||
isolated lets the design-doc wire shape evolve without rippling
|
||||
through unrelated callers.
|
||||
Reference in New Issue
Block a user