feat(admin): add production error center and refresh CMS dependencies
This commit is contained in:
1 parent
c6b919c01d
commit
816e3875c2
47 files changed
+1545
-547
No files matched your search
@@ -0,0 +1,209 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user