/** * Heuristic "likely culprit" classifier for the admin error inspector. * * Given an `error_events` row, returns a short human-readable label + * a longer hint pointing at the probable root cause. This is best-effort * — the goal is to save the admin five minutes of stack reading on the * common cases (FK violations, schema drift, external service outages, * timeouts) without giving false confidence on the unusual ones. * * The classifier reads from data already on the row (no DB lookups), * so it can run inside a server-component render with no extra cost. */ import type { ErrorEvent } from '@/lib/db/schema/system'; export interface LikelyCulprit { /** Short label for a badge / column. */ label: string; /** Longer hint shown in the detail view, with a "next step" suggestion. */ hint: string; /** Subsystem tag for filtering: 'db' | 'storage' | 'email' | … */ subsystem: string; } /** * Postgres SQLSTATE codes commonly thrown by `postgres` driver wrappers. * Drizzle bubbles these up unchanged on the `code` field. We translate * the most-common ones into plain-English admin hints. */ const PG_CODE_HINTS: Record = { '23502': { label: 'NOT NULL violation', hint: 'A required column was missing on insert. Check the validator vs the schema — a recently added .notNull() column may not have a default.', subsystem: 'db', }, '23503': { label: 'FK violation', hint: 'A referenced row no longer exists (or never did). Check whether the parent was archived/deleted, or the FK was created without ON DELETE handling.', subsystem: 'db', }, '23505': { label: 'Unique violation', hint: 'A duplicate value hit a UNIQUE index. Common causes: duplicate name within the same port, retried writes after a transient error, or a partial unique index that should fire.', subsystem: 'db', }, '23514': { label: 'CHECK violation', hint: 'A value failed a CHECK constraint (e.g. polymorphic discriminator outside its allowed set). Verify the input matches the schema enum.', subsystem: 'db', }, '42703': { label: 'Schema drift', hint: 'A column referenced by the query does not exist in the database. The most recent migration probably has not been applied — run pnpm db:push or apply the SQL file.', subsystem: 'db', }, '42P01': { label: 'Missing table', hint: 'The query referenced a table that does not exist. Either a migration is unapplied or the table was renamed.', subsystem: 'db', }, '40001': { label: 'Serialization failure', hint: 'Two transactions raced. Retrying the operation usually resolves it; if it persists, look for hot-row contention.', subsystem: 'db', }, '57014': { label: 'Query cancelled', hint: 'The query exceeded the configured timeout or the client disconnected mid-flight.', subsystem: 'db', }, '53300': { label: 'Connection limit', hint: 'The database connection pool is exhausted. Look for leaked connections or scale the pool.', subsystem: 'db', }, }; /** Classify by error name (stable across providers). */ const ERROR_NAME_HINTS: Record = { AbortError: { label: 'Request aborted', hint: 'The client disconnected (closed the tab, navigated away) before the response finished. Usually benign.', subsystem: 'request', }, TimeoutError: { label: 'Timeout', hint: 'An upstream call exceeded its time budget. Check the external service health (Documenso, MinIO, OpenAI, SMTP).', subsystem: 'request', }, FetchError: { label: 'External service unreachable', hint: 'A fetch() call failed. Likely the Documenso/MinIO/OpenAI/SMTP host is down or blocked by firewall.', subsystem: 'integration', }, ZodError: { label: 'Validation', hint: 'A zod schema rejected the input. The details array on the response shows which fields failed.', subsystem: 'validation', }, }; /** Classify by stack-path heuristics. The first match wins. */ const STACK_PATH_HINTS: Array<{ pattern: RegExp; culprit: LikelyCulprit }> = [ { pattern: /\/lib\/storage\//, culprit: { label: 'Storage backend', hint: 'Failure inside MinIO/S3 or the filesystem proxy. Check storage availability and the backend config in admin > storage.', subsystem: 'storage', }, }, { pattern: /\/lib\/email\//, culprit: { label: 'Email subsystem', hint: 'SMTP/IMAP error. Check the configured account credentials in admin > email and the SMTP provider status.', subsystem: 'email', }, }, { pattern: /documenso/i, culprit: { label: 'Documenso integration', hint: 'Failure talking to Documenso. Check the API host + key in admin > integrations and verify Documenso uptime.', subsystem: 'integration', }, }, { pattern: /openai|claude/i, culprit: { label: 'AI provider', hint: 'OpenAI/Claude call failed. Likely a provider outage, expired key, or rate-limit ceiling. Falls back to template draft when available.', subsystem: 'integration', }, }, { pattern: /\/queue\/workers\//, culprit: { label: 'Background worker', hint: 'A BullMQ job threw. Check Redis health and the worker logs for the failed job id.', subsystem: 'queue', }, }, ]; /** Classify by free-text scan of the error message — last-resort. */ const MESSAGE_HINTS: Array<{ pattern: RegExp; culprit: LikelyCulprit }> = [ { pattern: /econnrefused|enotfound|getaddrinfo/i, culprit: { label: 'Network connection refused', hint: 'A network call could not reach its host. Check that the dependency (DB, Redis, MinIO, SMTP) is running and reachable.', subsystem: 'integration', }, }, { pattern: /rate.?limit/i, culprit: { label: 'Rate limited', hint: 'An upstream provider rate-limited us. Common with SMTP, OpenAI, and Documenso. Back off or raise the per-port cap.', subsystem: 'integration', }, }, { pattern: /unauthorized|invalid.?(api.?)?key|401/i, culprit: { label: 'Auth failure', hint: 'A credential was rejected by an upstream service. Check the encrypted secrets in admin > integrations.', subsystem: 'integration', }, }, { pattern: /timeout/i, culprit: { label: 'Timeout', hint: 'An operation exceeded its time budget. May be a slow upstream call or a heavy DB query.', subsystem: 'request', }, }, ]; /** * Best-effort culprit classification. Returns null when nothing * matches — the inspector will display "Uncategorized". */ export function classifyError(row: ErrorEvent): LikelyCulprit | null { // 1. Postgres SQLSTATE on the metadata bag. const meta = row.metadata as { code?: unknown; pgCode?: unknown } | null; const pgCode = (typeof meta?.code === 'string' && meta.code) || (typeof meta?.pgCode === 'string' && meta.pgCode) || null; if (pgCode && PG_CODE_HINTS[pgCode]) return PG_CODE_HINTS[pgCode]; // 2. Error class name. if (row.errorName) { const named = ERROR_NAME_HINTS[row.errorName]; if (named) return named; } // 3. Stack path heuristics. if (row.errorStack) { for (const { pattern, culprit } of STACK_PATH_HINTS) { if (pattern.test(row.errorStack)) return culprit; } } // 4. Message free-text. if (row.errorMessage) { for (const { pattern, culprit } of MESSAGE_HINTS) { if (pattern.test(row.errorMessage)) return culprit; } } return null; }