Files
Epicnabbo-Catalogus-Updated…/src/lib/error-monitor.ts
T
openhands cbffb565ec perf: cut biome lint from 25s to 1s and fix pre-existing lint failures
Exclude generated drizzle-kit snapshot artifacts from formatting checks (drizzle/drafts/meta), which made biome scan a 360KB generated JSON for 23s. Fix the pre-existing lint errors in error-monitor, article-form and the admin-search-permissions mock so pnpm biome:lint is green in CI.
2026-09-08 18:22:49 +02:00

210 lines
5.9 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import {
appendFile,
mkdir,
readdir,
readFile,
stat,
unlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
export function redactErrorText(value: string): string {
return value
.slice(0, 12000)
.replace(
/(https?:\/\/|mysql:\/\/|redis:\/\/)[^\s/@]+:[^\s/@]+@/gi,
"$1[REDACTED]@",
)
.replace(/([?&])[^\s#)]+/g, "$1[REDACTED]")
.replace(
/((?:password|secret|token|authorization|cookie|api[_-]?key)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
"$1[REDACTED]",
)
.replace(/Bearer\s+[^\s,;]+/gi, "Bearer [REDACTED]");
}
export interface CmsErrorRecord {
id: string;
resolved?: boolean;
at: string;
source: "server" | "browser";
event: string;
message: string;
stack: string;
release: string;
fingerprint: string;
context: Record<string, string | number | boolean | null>;
}
export function createErrorRecord(
source: CmsErrorRecord["source"],
event: string,
error: unknown,
context: Record<string, unknown> = {},
): CmsErrorRecord {
const message = redactErrorText(
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error",
);
const stack =
error instanceof Error ? redactErrorText(error.stack ?? "") : "";
const safeContext = Object.fromEntries(
Object.entries(context)
.slice(0, 24)
.map(([key, value]) => [
key,
/password|secret|token|cookie|authorization|body|query|email|api[_-]?key/i.test(
key,
)
? "[REDACTED]"
: typeof value === "string"
? redactErrorText(value).slice(0, 1000)
: typeof value === "number" ||
typeof value === "boolean" ||
value === null
? value
: "[NON_SCALAR]",
]),
);
return {
id: randomUUID(),
at: new Date().toISOString(),
source,
event: redactErrorText(event).slice(0, 160),
message,
stack,
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
fingerprint: createHash("sha256")
.update(`${source}:${event}:${message}:${stack.split("\n")[1] ?? ""}`)
.digest("hex")
.slice(0, 16),
context: safeContext,
};
}
const DAY_LIMIT = 10 * 1024 * 1024;
const RETENTION_DAYS = 7;
export class ErrorStore {
private static queue: Promise<void> = Promise.resolve();
private static pending = 0;
constructor(
private directory = path.join(process.cwd(), "storage", "cms-errors"),
) {}
async append(record: CmsErrorRecord) {
if (ErrorStore.pending >= 100) throw new Error("Error storage queue full");
ErrorStore.pending++;
const write = ErrorStore.queue.then(() => this.writeRecord(record));
ErrorStore.queue = write.catch(() => {});
try {
await write;
} finally {
ErrorStore.pending--;
}
}
private async writeRecord(record: CmsErrorRecord) {
await mkdir(this.directory, { recursive: true });
const files = await readdir(this.directory);
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
.toISOString()
.slice(0, 10);
for (const name of files)
if (
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.resolved)$/.test(name) &&
name.slice(0, 10) < cutoff
)
await unlink(path.join(this.directory, name)).catch(() => {});
const file = path.join(this.directory, `${record.at.slice(0, 10)}.jsonl`);
const size = await stat(file)
.then((s) => s.size)
.catch(() => 0);
if (size >= DAY_LIMIT) throw new Error("Daily error storage limit reached");
await appendFile(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
}
async resolve(fingerprint: string) {
if (!/^[a-f0-9]{16}$/.test(fingerprint))
throw new Error("Invalid fingerprint");
const { records } = await this.read();
if (!records.some((r) => r.fingerprint === fingerprint))
throw new Error("Error group no longer available");
const now = new Date().toISOString();
await writeFile(
path.join(this.directory, `${now.slice(0, 10)}.${fingerprint}.resolved`),
now,
{ mode: 0o600 },
);
}
async read(): Promise<{ records: CmsErrorRecord[]; truncated: boolean }> {
const files = await readdir(this.directory).catch(
(e: NodeJS.ErrnoException) => {
if (e.code === "ENOENT") return [];
throw e;
},
);
const resolved = new Map<string, string>();
for (const name of files
.filter((f) => /^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f))
.sort()) {
const at = await readFile(path.join(this.directory, name), "utf8");
resolved.set(name.split(".")[1], at);
}
const records: CmsErrorRecord[] = [];
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
.toISOString()
.slice(0, 10);
for (const name of files
.filter(
(f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) >= cutoff,
)
.sort()
.reverse()) {
const file = path.join(this.directory, name);
if ((await stat(file)).size > DAY_LIMIT + 100000) continue;
const lines = (await readFile(file, "utf8")).trim().split("\n").reverse();
for (const line of lines) {
try {
const r = JSON.parse(line);
if (r.id && r.fingerprint)
records.push({
...r,
resolved: (resolved.get(r.fingerprint) ?? "") >= r.at,
});
} catch {
/* Ignore an interrupted final write. */
}
if (records.length >= 1000) return { records, truncated: true };
}
}
return { records, truncated: false };
}
}
const store = new ErrorStore();
let pending = 0;
let lastFailure = 0;
export function captureCmsError(
source: CmsErrorRecord["source"],
event: string,
error: unknown,
context: Record<string, unknown> = {},
) {
const record = createErrorRecord(source, event, error, context);
if (process.env.NODE_ENV === "test" || process.env.VITEST || pending >= 100)
return record.id;
pending++;
void store
.append(record)
.catch(() => {
if (Date.now() - lastFailure > 60000) {
lastFailure = Date.now();
process.stderr.write(
"CMS error monitor could not persist an event; check storage permissions or daily quota.\n",
);
}
})
.finally(() => {
pending--;
});
return record.id;
}