From e6c278198bb117443f93ec19958052d51177225c Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 25 Oct 2026 05:11:31 +0100 Subject: [PATCH] Quiet the logs and capture the errors that were missing - Activity page: "Delete all" empties the open tab (api/admin/logs/clear-logs, super admin only, logged under system) - Stop logging every WebSocket connect - Log unexpected API errors (safeErrorMessage -> logUnexpectedError), every failed bot job attempt and every job the queue lost - Drop the cron jobs' console output; a failed model sync goes to the logs Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/bots-queue/process-bot-job.ts | 32 ++++++++++ src/bots-queue/report-lost-bot-jobs.ts | 14 ++++- src/cron/prune-bots-queue/index.ts | 5 -- src/cron/sync-harness-models/index.ts | 22 +++---- .../backend/db/cron-jobs/fire-cron-job.ts | 2 +- src/functions/backend/log-unexpected-error.ts | 22 +++++++ .../admin/logs/(functions)/clear-logs.ts | 36 +++++++++++ .../admin/logs/(hooks)/use-logs-list-init.tsx | 15 +++++ .../logs/(partials)/clear-logs-button.tsx | 61 +++++++++++++++++++ .../admin/logs/(partials)/logs-toolbar.tsx | 37 ++++++----- src/pages/api/auth/signup.tsx | 2 +- src/pages/api/contact.tsx | 2 +- src/schemas/log-schema.ts | 17 ++++++ src/utils/safe-error-message.ts | 12 +++- src/websocket/index.ts | 11 ---- 16 files changed, 245 insertions(+), 47 deletions(-) create mode 100644 src/functions/backend/log-unexpected-error.ts create mode 100644 src/pages/admin/logs/(functions)/clear-logs.ts create mode 100644 src/pages/admin/logs/(partials)/clear-logs-button.tsx create mode 100644 src/schemas/log-schema.ts diff --git a/AGENTS.md b/AGENTS.md index 3dc7ecf..78fc6a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Auth layout:** `/auth/*` (except `/auth/logout`) renders in `src/layouts/auth/` with no site header/footer. - **User types** (`UserTypes` in `src/dict/user-types-dict.ts`, the only place they're defined): `super_admin` (the first account, runs the install), `admin` (manages members; only the super admin grants it), `member` (a regular user: new users start with it), `read_only`, `revoked` (can't sign in). A user needs at least one to sign in. Admins grant and remove `member`, `read_only` and `revoked` on anyone but admins and the super admin. - **RBAC:** `src/utils/check-user-access.ts` (includes/excludes primitive, `super_admin` bypass) plus the declarative engine in `src/utils/rbac/` - `userTypeAssignment` interprets `UserTypeAssignmentMatrix`/`UserTypeDeletionMatrix` in `src/dict/user-types-dict.ts`. `read_only` users are blocked from non-GET CRUD in the router. -- **Audit trail:** `src/functions/backend/logger.ts` - `logEvent`/`logError` write to the `logs` table. Signup, login, Google login, logout, cron runs, and WebSocket connects are all logged. +- **Audit trail:** `src/functions/backend/logger.ts` - `logEvent`/`logError` write to the `logs` table. Signup, login, Google login, logout and cron runs are logged (WebSocket connects are not: one per tab and reconnect drowned the page). Errors land under `error`: every unexpected one an API route catches (`safeErrorMessage` calls `logUnexpectedError` for anything but an `AppError`, skipped under tests; pass `log: false` where the caller already logs it), every failed bot job attempt with its bot, kind and try (`processBotJob`), and every job the queue lost (`reportLostBotJobs`). The Activity page's "Delete all" empties the open tab (`api/admin/logs/clear-logs`, super admin only, logged under `system`). ### WebSocket diff --git a/src/bots-queue/process-bot-job.ts b/src/bots-queue/process-bot-job.ts index ee39231..a680263 100644 --- a/src/bots-queue/process-bot-job.ts +++ b/src/bots-queue/process-bot-job.ts @@ -290,6 +290,38 @@ export default async function processBotJob({ return null; }); + /** + * Every failed try is on the Activity page, so a model or provider + * that keeps failing shows there and not only in one chat. + */ + if (failed) { + const final = failed.status == "failed"; + + await logError({ + title: final + ? `Bot job #${job_id} failed` + : `Bot job #${job_id} failed, retrying (try ${tries})`, + error: timed_out ? new Error(timeout_message) : error, + user_id: user_id || undefined, + details: [ + { label: "Bot", value: bot_id }, + { label: "Kind", value: job.kind }, + { label: "Title", value: job.title || "" }, + { label: "Try", value: tries }, + ], + metadata: { + job_id, + bot_id, + kind: job.kind, + session_id: job.session_id, + workspace_id: job.workspace_id, + schedule_id, + fatal: error instanceof BotJobFatalError, + harness_code: error instanceof HarnessError ? error.code : undefined, + }, + }).catch(() => {}); + } + if (failed?.notice) { await pushBotNotice({ bot_id, ...failed.notice }); } diff --git a/src/bots-queue/report-lost-bot-jobs.ts b/src/bots-queue/report-lost-bot-jobs.ts index 0c47431..d199d8d 100644 --- a/src/bots-queue/report-lost-bot-jobs.ts +++ b/src/bots-queue/report-lost-bot-jobs.ts @@ -1,10 +1,11 @@ import grabUnreportedFailedBotJobs from "@/src/functions/backend/db/bots-queue/grab-unreported-failed-bot-jobs"; import notifyBotJobFailed from "@/src/functions/backend/db/bots-queue/notify-bot-job-failed"; +import { logError } from "@/src/functions/backend/logger"; import pushBotNotice from "./push-bot-notice"; /** * Tells the user about jobs that ran out of attempts by going silent - * (bun-sqlite fails those itself, without a message). + * (bun-sqlite fails those itself, without a message), and logs each. */ export default async function reportLostBotJobs() { const jobs = await grabUnreportedFailedBotJobs(); @@ -18,5 +19,16 @@ export default async function reportLostBotJobs() { }); await pushBotNotice({ bot_id: job.bot_id, ...notice }); + + await logError({ + title: `Bot job #${job.id} lost`, + error: job.last_error || "The job's lock timed out.", + details: [ + { label: "Bot", value: job.bot_id }, + { label: "Kind", value: job.kind }, + { label: "Title", value: job.title || "" }, + ], + metadata: { job_id: job.id, bot_id: job.bot_id, kind: job.kind }, + }).catch(() => {}); } } diff --git a/src/cron/prune-bots-queue/index.ts b/src/cron/prune-bots-queue/index.ts index 9aa97c6..ab7ab45 100644 --- a/src/cron/prune-bots-queue/index.ts +++ b/src/cron/prune-bots-queue/index.ts @@ -1,4 +1,3 @@ -import bunext from "@moduletrace/bunext"; import BunSQLite from "@moduletrace/bun-sqlite"; import { AppData } from "@/src/data/app-data"; @@ -15,9 +14,5 @@ export default async function pruneBotsQueue() { values: [cutoff], }); - bunext.bunextLog.info( - `Cron: pruned ${res.payload?.length || 0} finished bot job(s)`, - ); - return res; } diff --git a/src/cron/sync-harness-models/index.ts b/src/cron/sync-harness-models/index.ts index 5af3990..e5a7d9d 100644 --- a/src/cron/sync-harness-models/index.ts +++ b/src/cron/sync-harness-models/index.ts @@ -1,23 +1,23 @@ -import bunext from "@moduletrace/bunext"; import syncAllHarnessModels from "@/src/functions/backend/db/harness/sync-all-harness-models"; +import { logError } from "@/src/functions/backend/logger"; /** * Refreshes every enabled provider's model list. Free models on OpenRouter - * come and go, and automatic picks follow them from this list. + * come and go, and automatic picks follow them from this list. A provider + * that fails doesn't fail the run, so its error goes to the logs here. */ export default async function cronSyncHarnessModels() { const results = await syncAllHarnessModels(); for (const result of results) { - if (result.error) { - bunext.bunextLog.error( - `Cron: model sync for provider #${result.provider_id} failed: ${result.error}`, - ); - } else { - bunext.bunextLog.info( - `Cron: provider #${result.provider_id} lists ${result.count} model(s)`, - ); - } + if (!result.error) continue; + + await logError({ + title: `Cron: model sync for provider #${result.provider_id} failed`, + category: "cron", + error: result.error, + metadata: { provider_id: result.provider_id }, + }).catch(() => {}); } return results; diff --git a/src/functions/backend/db/cron-jobs/fire-cron-job.ts b/src/functions/backend/db/cron-jobs/fire-cron-job.ts index 8bb1a05..9ab6c12 100644 --- a/src/functions/backend/db/cron-jobs/fire-cron-job.ts +++ b/src/functions/backend/db/cron-jobs/fire-cron-job.ts @@ -87,7 +87,7 @@ export default async function fireCronJob({ cron_job, now = Date.now() }: Params metadata: { cron_job_id: id, job_id, bot_id: cron_job.bot_id }, }); } catch (error) { - await saveRun({ id, last_error: safeErrorMessage({ error, fallback: `Couldn't queue the run.` }) }); + await saveRun({ id, last_error: safeErrorMessage({ error, fallback: `Couldn't queue the run.`, log: false }) }); await logError({ title: `Schedule #${id} couldn't run`, diff --git a/src/functions/backend/log-unexpected-error.ts b/src/functions/backend/log-unexpected-error.ts new file mode 100644 index 0000000..4c505c6 --- /dev/null +++ b/src/functions/backend/log-unexpected-error.ts @@ -0,0 +1,22 @@ +import { logError } from "./logger"; + +type Params = { + error: unknown; +}; + +/** + * Writes an error nobody meant to show (anything but an `AppError`) to the + * logs, so a failure the client only sees as "Something went wrong" is + * still on the Activity page with its stack. Never during tests, which + * throw plain errors on purpose, and never throws. + */ +export default function logUnexpectedError({ error }: Params) { + if (process.env.NODE_ENV == "test") return; + + const name = error instanceof Error ? error.name : typeof error; + + void logError({ + title: `Unexpected server error (${name})`, + error, + }).catch(() => {}); +} diff --git a/src/pages/admin/logs/(functions)/clear-logs.ts b/src/pages/admin/logs/(functions)/clear-logs.ts new file mode 100644 index 0000000..a6053d9 --- /dev/null +++ b/src/pages/admin/logs/(functions)/clear-logs.ts @@ -0,0 +1,36 @@ +import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import type { ClearLogsRequest } from "@/src/schemas/log-schema"; + +type Params = { + category?: ClearLogsRequest["category"]; + /** + * The tab's name, for the confirmation. + */ + title: string; + count?: number; +}; + +/** + * Deletes every log in the open tab, after asking. + */ +export default async function clearLogs({ category, title, count }: Params) { + const amount = typeof count == "number" ? `${count} ` : ""; + const confirm_msg = category + ? `Delete all ${amount}${title} logs? This cannot be undone.` + : `Delete all ${amount}logs? This cannot be undone.`; + + if (!window.confirm(confirm_msg)) { + return { success: false, cancelled: true }; + } + + const res = await fetchApi>( + `/api/admin/logs/clear-logs`, + { method: "POST", body: { category } }, + ); + + return { + success: Boolean(res?.success), + msg: res?.success ? undefined : res?.msg || `Couldn't delete logs`, + }; +} diff --git a/src/pages/admin/logs/(hooks)/use-logs-list-init.tsx b/src/pages/admin/logs/(hooks)/use-logs-list-init.tsx index 4c9e021..05b8f1f 100644 --- a/src/pages/admin/logs/(hooks)/use-logs-list-init.tsx +++ b/src/pages/admin/logs/(hooks)/use-logs-list-init.tsx @@ -78,6 +78,20 @@ export default function useLogsListInit(_props?: Props) { setRes(undefined); }; + /** + * The tab was emptied: back to its first page, reloaded. + */ + const afterClear = () => { + setSelectedIds([]); + + if (page > 1) { + setPage(1); + return; + } + + setRes(undefined); + }; + const all_selected = Boolean(logs?.[0]) && selected_ids.length == logs?.length; @@ -100,6 +114,7 @@ export default function useLogsListInit(_props?: Props) { toggleSelectAll, all_selected, afterDelete, + afterClear, active_log, openLog, log_modal_open, diff --git a/src/pages/admin/logs/(partials)/clear-logs-button.tsx b/src/pages/admin/logs/(partials)/clear-logs-button.tsx new file mode 100644 index 0000000..b2e03b1 --- /dev/null +++ b/src/pages/admin/logs/(partials)/clear-logs-button.tsx @@ -0,0 +1,61 @@ +import { Trash2 } from "lucide-react"; +import Button from "@moduletrace/twui/layout/Button"; +import Tag from "@moduletrace/twui/elements/Tag"; +import Row from "@moduletrace/twui/layout/Row"; +import Span from "@moduletrace/twui/layout/Span"; +import useStatus from "@moduletrace/twui/hooks/useStatus"; +import { LogCategoryFilters } from "../(data)/log-category-filters"; +import type { LogCategoryValue } from "../(data)/log-category-styles"; +import clearLogs from "../(functions)/clear-logs"; + +type Props = { + category?: LogCategoryValue; + count?: number; + afterClear: () => void; +}; + +/** + * Deletes every log in the open tab ("All" deletes every log). + */ +export default function ClearLogsButton({ category, count, afterClear }: Props) { + const { loading, setLoading, status, setStatus } = useStatus(); + + const title = LogCategoryFilters.find((filter) => filter.value == category)?.title || "All"; + + const clear_logs = async () => { + setStatus(undefined); + setLoading(true); + + const res = await clearLogs({ category, title, count }); + + setLoading(false); + + if (res.success) { + afterClear(); + } else if (res.msg) { + setStatus({ error: true, msg: res.msg }); + } + }; + + return ( + + {status?.error && {status.msg}} + + + ); +} diff --git a/src/pages/admin/logs/(partials)/logs-toolbar.tsx b/src/pages/admin/logs/(partials)/logs-toolbar.tsx index b7b47c9..12d9dd9 100644 --- a/src/pages/admin/logs/(partials)/logs-toolbar.tsx +++ b/src/pages/admin/logs/(partials)/logs-toolbar.tsx @@ -4,6 +4,7 @@ import Row from "@moduletrace/twui/layout/Row"; import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; import type useLogsListInit from "../(hooks)/use-logs-list-init"; +import ClearLogsButton from "./clear-logs-button"; import DeleteLogsButton from "./delete-logs-button"; import LogCategoryFilter from "./log-category-filter"; @@ -18,6 +19,7 @@ export default function LogsToolbar({ init }: Props) { selectCategory, selected_ids, afterDelete, + afterClear, } = init; return ( @@ -33,20 +35,27 @@ export default function LogsToolbar({ init }: Props) { ? `${count} log${count == 1 ? "" : "s"}` : "--"} - {selected_ids[0] ? ( - - - - Delete selected ({selected_ids.length}) - - - ) : null} + + {selected_ids[0] ? ( + + + + Delete selected ({selected_ids.length}) + + + ) : null} + + diff --git a/src/pages/api/auth/signup.tsx b/src/pages/api/auth/signup.tsx index 603aced..387bf7d 100644 --- a/src/pages/api/auth/signup.tsx +++ b/src/pages/api/auth/signup.tsx @@ -114,7 +114,7 @@ export const handler: BunextAPIRouteHandler = async ({ return { success: false, - msg: safeErrorMessage({ error }), + msg: safeErrorMessage({ error, log: false }), }; } }; diff --git a/src/pages/api/contact.tsx b/src/pages/api/contact.tsx index 6523fb9..e92273f 100644 --- a/src/pages/api/contact.tsx +++ b/src/pages/api/contact.tsx @@ -89,7 +89,7 @@ export const handler: BunextAPIRouteHandler = async ({ return { success: false, - msg: safeErrorMessage({ error }), + msg: safeErrorMessage({ error, log: false }), }; } }; diff --git a/src/schemas/log-schema.ts b/src/schemas/log-schema.ts new file mode 100644 index 0000000..049419d --- /dev/null +++ b/src/schemas/log-schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { LogCategories } from "@/src/dict/log-categories"; + +const LogCategoryValues = LogCategories.map((lc) => lc.value) as [ + (typeof LogCategories)[number]["value"], + ...(typeof LogCategories)[number]["value"][], +]; + +/** + * `POST api/admin/logs/clear-logs`: deletes every log in one category, or + * every log when it is left out (the "All" tab). + */ +export const ClearLogsRequestSchema = z.object({ + category: z.enum(LogCategoryValues).optional(), +}); + +export type ClearLogsRequest = z.infer; diff --git a/src/utils/safe-error-message.ts b/src/utils/safe-error-message.ts index a3cadf0..50712e1 100644 --- a/src/utils/safe-error-message.ts +++ b/src/utils/safe-error-message.ts @@ -1,23 +1,33 @@ import AppError from "./app-error"; +import logUnexpectedError from "@/src/functions/backend/log-unexpected-error"; type Params = { error: unknown; fallback?: string; + /** + * Write an unexpected error to the logs. Off where the caller already + * logs it. + */ + log?: boolean; }; /** * # Safe Error Message * Returns the message of an `AppError` (deliberate, user-facing) and a * generic fallback for everything else, so internal details (SQL errors, - * file paths, stack messages) never reach the client. + * file paths, stack messages) never reach the client. The unexpected ones + * are logged (`logUnexpectedError`), since the client never sees why. */ export default function safeErrorMessage({ error, fallback = "Something went wrong. Please try again.", + log = true, }: Params): string { if (error instanceof AppError) { return error.message; } + if (log) logUnexpectedError({ error }); + return fallback; } diff --git a/src/websocket/index.ts b/src/websocket/index.ts index 1f73cdb..238078e 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -9,7 +9,6 @@ import { getAllClients, getAllRooms } from "./(data)/rooms"; import type { WebSocketDataType } from "@/src/types"; import { wsSendData } from "./(functions)/ws-send-data"; import checkSessionAccess from "./(functions)/check-session-access"; -import logEvent from "@/src/functions/backend/logger"; import { computerProxySocket } from "@/src/functions/server/computers/computer-proxy-socket"; import touchUserPresence from "@/src/functions/backend/db/push/touch-user-presence"; @@ -46,16 +45,6 @@ export const websocket = { message: "WebSocket connected", }, }); - - logEvent({ - title: "WebSocket connected", - category: "websocket", - user_id: user.id, - plain_text: `User #${user.id} [${user.first_name} ${user.last_name}] connected via WebSocket.`, - metadata: { - user_id: user.id, - }, - }); }, async message(