Files
EpicNext-Cms/src/lib/error-monitor.ts
T
Simo c389c3893d
CI / check (push) Successful in 52s
CI / deploy (push) Successful in 2m10s
CI / publish-container (push) Failing after 1m18s
feat(cms): improve catalog, editorial recovery and operations
2026-09-09 19:36:15 +02:00

283 lines
8.3 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import {
appendFile,
mkdir,
readdir,
readFile,
rename,
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;
assignment?: { userId: number; at: string } | null;
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|assignment)(?:\.[a-f0-9-]+\.tmp)?)$/.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 assign(fingerprint: string, userId: number | null) {
if (!/^[a-f0-9]{16}$/.test(fingerprint))
throw new Error("Invalid fingerprint");
if (userId !== null && (!Number.isSafeInteger(userId) || userId <= 0))
throw new Error("Invalid assignee");
if (ErrorStore.pending >= 100) throw new Error("Error storage queue full");
ErrorStore.pending++;
const write = ErrorStore.queue.then(async () => {
const { records } = await this.read();
if (!records.some((record) => record.fingerprint === fingerprint))
throw new Error("Error group no longer available");
const at = new Date().toISOString();
const file = path.join(
this.directory,
`${at.slice(0, 10)}.${fingerprint}.assignment`,
);
const metadata = (await readdir(this.directory)).filter(
(name) =>
name.startsWith(at.slice(0, 10)) && name.endsWith(".assignment"),
);
if (metadata.length >= 1000 && !metadata.includes(path.basename(file)))
throw new Error("Daily assignment storage limit reached");
const temp = `${file}.${randomUUID()}.tmp`;
try {
await writeFile(temp, JSON.stringify({ userId, at }), { mode: 0o600 });
await rename(temp, file);
} finally {
await unlink(temp).catch(() => {});
}
});
ErrorStore.queue = write.catch(() => {});
try {
await write;
} finally {
ErrorStore.pending--;
}
}
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 cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
.toISOString()
.slice(0, 10);
const assignments = new Map<string, CmsErrorRecord["assignment"]>();
for (const name of files
.filter(
(f) =>
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.assignment$/.test(f) &&
f.slice(0, 10) >= cutoff,
)
.sort()) {
const file = path.join(this.directory, name);
if ((await stat(file)).size > 512) continue;
try {
const value = JSON.parse(await readFile(file, "utf8"));
if (
typeof value.at !== "string" ||
!Number.isFinite(Date.parse(value.at))
)
continue;
if (value.userId === null) assignments.set(name.split(".")[1], null);
else if (Number.isSafeInteger(value.userId) && value.userId > 0)
assignments.set(name.split(".")[1], {
userId: value.userId,
at: value.at,
});
} catch {
/* Ignore interrupted or malformed metadata. */
}
}
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) &&
f.slice(0, 10) >= cutoff,
)
.sort()) {
const at = await readFile(path.join(this.directory, name), "utf8");
resolved.set(name.split(".")[1], at);
}
const records: CmsErrorRecord[] = [];
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,
assignment: assignments.get(r.fingerprint) ?? null,
});
} 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;
}