| 1 | import type { Env } from "../env.js"; |
| 2 | |
| 3 | /** |
| 4 | * Unexpected server errors (5xx answers), kept in D1 so site administrators can read them |
| 5 | * without Cloudflare log access. A 5xx answer carries its request id, which finds the record. |
| 6 | * Records hold the method, route, path, status, message and stack, never request bodies, |
| 7 | * query strings or headers; token-like strings in a message or stack are masked. |
| 8 | */ |
| 9 | export const KEEP_ERRORS = 500; |
| 10 | |
| 11 | export interface ServerError { |
| 12 | id: string; |
| 13 | at: string; |
| 14 | method: string; |
| 15 | route: string | null; |
| 16 | path: string; |
| 17 | status: number; |
| 18 | message: string; |
| 19 | stack: string | null; |
| 20 | } |
| 21 | |
| 22 | /** The id a 5xx answer reports: Cloudflare's ray id when there is one, so it also finds the request in Cloudflare's logs. */ |
| 23 | export function requestId(req: Request): string { |
| 24 | const ray = req.headers.get("cf-ray"); |
| 25 | return ray && /^[\w-]{8,64}$/.test(ray) ? ray : crypto.randomUUID(); |
| 26 | } |
| 27 | |
| 28 | /** Masks credentials an error message might quote: Diffinery and GitHub tokens, bearer and basic auth, and URL user info. */ |
| 29 | export function redact(s: string): string { |
| 30 | return s |
| 31 | .replace(/\b(dfy|ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{8,}/g, "$1_…") |
| 32 | .replace(/\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 …") |
| 33 | .replace(/(\w+:\/\/)[^/\s@]+@/g, "$1…@"); |
| 34 | } |
| 35 | |
| 36 | /** A request path to keep: a route that carries a secret in the path (`/signin/:token`) keeps its pattern instead. */ |
| 37 | export function pathToKeep(path: string, route: string | null): string { |
| 38 | return route && /:(token|code|secret|key)\b/i.test(route) ? route : path.slice(0, 500); |
| 39 | } |
| 40 | |
| 41 | export function errorRecord(e: unknown, r: { id: string; method: string; route: string | null; path: string; status: number }): ServerError { |
| 42 | const err = e instanceof Error ? e : null; |
| 43 | return { |
| 44 | id: r.id, |
| 45 | at: new Date().toISOString(), |
| 46 | method: r.method, |
| 47 | route: r.route, |
| 48 | path: pathToKeep(r.path, r.route), |
| 49 | status: r.status, |
| 50 | message: redact(err ? `${err.name}: ${err.message}` : String(e)).slice(0, 2000), |
| 51 | stack: err?.stack ? redact(err.stack).slice(0, 8000) : null, |
| 52 | }; |
| 53 | } |
| 54 | |
| 55 | /** Saves an error and drops all but the newest KEEP_ERRORS. Never throws: the database may be what failed. */ |
| 56 | export async function recordServerError(env: Env, rec: ServerError): Promise<void> { |
| 57 | try { |
| 58 | await env.DB.prepare("INSERT OR REPLACE INTO server_errors (id, at, method, route, path, status, message, stack) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") |
| 59 | .bind(rec.id, rec.at, rec.method, rec.route, rec.path, rec.status, rec.message, rec.stack) |
| 60 | .run(); |
| 61 | await env.DB.prepare("DELETE FROM server_errors WHERE at < (SELECT at FROM server_errors ORDER BY at DESC LIMIT 1 OFFSET ?)").bind(KEEP_ERRORS - 1).run(); |
| 62 | } catch (x) { |
| 63 | console.error("could not record a server error", x); |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /** The newest errors first, or the one with request id `id`. */ |
| 68 | export async function serverErrors(env: Env, o: { limit?: number; id?: string } = {}): Promise<ServerError[]> { |
| 69 | if (o.id) return (await env.DB.prepare("SELECT * FROM server_errors WHERE id = ?").bind(o.id).all<ServerError>()).results; |
| 70 | const limit = Math.min(Math.max(o.limit ?? 100, 1), KEEP_ERRORS); |
| 71 | return (await env.DB.prepare("SELECT * FROM server_errors ORDER BY at DESC LIMIT ?").bind(limit).all<ServerError>()).results; |
| 72 | } |