46 lines
1.5 KiB
TypeScript
46 lines
1.5 KiB
TypeScript
import type { AuditEntry } from "@repo/core-shared/audit";
|
|
import type { IAuditLog } from "./audit-log.interface";
|
|
|
|
/**
|
|
* Fan-out wrapper. Delivers each entry to every inner sink with settle-all
|
|
* semantics — one failing sink doesn't drop the audit entry from others.
|
|
*
|
|
* Failures emit a structured `audit-sink-error` JSON line to stderr.
|
|
* Stderr (not via OTel/Sentry) avoids recursion: if Sentry is one of the
|
|
* sinks failing and we routed the error back through Sentry's reporter,
|
|
* we'd loop. Stderr is consumed by the same log shipper as audit entries
|
|
* themselves, so the operator sees the failure in their aggregator.
|
|
*/
|
|
export class MultiSinkAuditLog implements IAuditLog {
|
|
constructor(private readonly sinks: IAuditLog[]) {}
|
|
|
|
async record(entry: AuditEntry): Promise<void> {
|
|
const results = await Promise.allSettled(this.sinks.map((s) => s.record(entry)));
|
|
for (const r of results) {
|
|
if (r.status === "rejected") {
|
|
this.reportSinkError(r.reason);
|
|
}
|
|
}
|
|
}
|
|
|
|
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
|
const results = await Promise.allSettled(
|
|
this.sinks.map((s) => s.eraseSubject(actorId, mode)),
|
|
);
|
|
for (const r of results) {
|
|
if (r.status === "rejected") {
|
|
this.reportSinkError(r.reason);
|
|
}
|
|
}
|
|
}
|
|
|
|
private reportSinkError(reason: unknown): void {
|
|
const line = JSON.stringify({
|
|
_type: "audit-sink-error",
|
|
error: String(reason),
|
|
at: new Date().toISOString(),
|
|
});
|
|
process.stderr.write(line + "\n");
|
|
}
|
|
}
|