English · 简体中文
Server Logs Viewer
The dashboard includes a live Server Logs viewer (accessible from the
Analytics nav menu) that surfaces the same diagnostic lines the server prints to
stdout — routing decisions, provider health checks, key cooldowns, quota
exhaustion, compression fidelity gates, and any warn/error level events —
without requiring SSH or container log access.
Implementation internals: the ingest path, structured
providerLogmetadata, boot preload, theserver_logs/requests/request_attemptsschema, and the desktop file logger (freeapi.log) are covered in the Observability deep-dive. This guide covers the operator-facing dashboard, polling API, and client implementation.
Two-tier store
The log store uses one id space across two tiers:
| Tier | Capacity | Levels | Persistence | Purpose |
|---|---|---|---|---|
| Ring buffer | 1,000 entries (newest) | trace, debug, info, warn, error |
In-memory only | Live tail for the polling dashboard; survives filter changes, not restarts. |
server_logs table |
Configurable (SERVER_LOGS_MAX_ROWS, default 50,000) |
warn, error only |
SQLite (survives restart) | Durable history for the warnings/errors that matter most. |
- Ids are assigned by the store, not by SQLite. The counter is seeded from
MAX(id)at initialization so ids keep increasing across restarts. A dashboard tab holding asinceIdcursor never sees it go backwards. - At startup the store preloads up to 200 recent persisted rows (newest first) into the ring so the dashboard shows the warnings that preceded the restart instead of an empty panel.
- Capture happens inside the redaction wrapper (
lib/log-redaction.ts). There is exactly one console patch in the process; it redacts first, then the store only ever sees the redacted form. Secrets never reach the ring or the database. - Lines matching
GET|HEAD /api/(logs|ping)are filtered out at ingest to prevent a self-feeding buffer (the polling endpoint would otherwise be the loudest thing in the log).
API contract
All endpoints are mounted under /api/logs behind the dashboard session gate
(the unified /v1 key opens the inference surface, never this one — these
lines name providers, models, key ids, and failure reasons).
GET /api/logs — Polling endpoint
Cursor-based pagination built for a dashboard that polls every few seconds.
| Query param | Type | Description |
|---|---|---|
sinceId |
integer (optional) |
Return entries newer than this id. Omit for the newest limit entries (default 200). The cursor returned as nextId is the store's highest id, not the highest id returned — so a poll whose matches were all filtered still advances the cursor instead of re-scanning the same tail forever. |
levels |
csv (optional) |
Comma-separated level filter: trace,debug,info,warn,error. Unknown levels return 400. |
q |
string (optional) |
Case-insensitive text search across message, provider, source, event. |
provider |
string (optional) |
Exact provider name filter (e.g., openai, anthropic). |
limit |
integer (optional) |
Clamped to [1, 500], default 200. |
Response:
{
"entries": [
{
"id": 12345,
"ts": "2026-08-23T14:32:11.123Z",
"level": "warn",
"source": "CooldownProbe",
"provider": "openai",
"model": "gpt-4o",
"event": "cooldown_triggered",
"requestId": "req_abc123",
"message": "[CooldownProbe] openai:gpt-4o key #3 entered 45s cooldown (rate limit)"
}
],
"nextId": 12345,
"counts": { "debug": 12, "info": 145, "warn": 8, "error": 2 }
}
traceis folded intodebugin the counts (the dashboard shows four badges).- The
entriesarray is oldest → newest within the returned page. - An already-caught-up caller (
sinceId >= lastId) getsentries: []for the cost of one integer comparison.
GET /api/logs/counts — Level counts
Returns the same LogLevelCounts object included in every poll response, so a
page that only wants the badge numbers can fetch them once without the full
entry payload.
{ "debug": 12, "info": 145, "warn": 8, "error": 2 }
POST /api/logs/clear — Clear both tiers
Empties the ring buffer and truncates the server_logs table. The id
counter is not reset — a dashboard tab holding a cursor would otherwise be
handed ids it has already seen.
{ "ok": true }
Environment variables
| Variable | Default | Description |
|---|---|---|
SERVER_LOGS_RETENTION_DAYS |
7 |
Rows older than this many days are deleted by a daily maintenance job (see server/src/jobs/prune-logs.ts). |
SERVER_LOGS_MAX_ROWS |
50000 |
Hard cap on the server_logs table. When exceeded, oldest rows are deleted on the next insert. |
Both are read at server boot; changing them requires a restart.
Redaction integration
The store is a tap inside lib/log-redaction.ts, not a second console
wrapper. The flow:
- Application code calls
console.log/providerLog()/ etc. - The redaction wrapper intercepts, scrubs secrets (API keys, Bearer tokens, URL tokens, etc.), and emits the clean line to the original stdout.
- The wrapper then calls
recordConsoleLine()/ the store receives the already-redacted text. providerLog()(used for structured operational events) redacts its own message before recording, then mirrors to the wrapped console — idempotent under a second pass.
Result: secrets never enter the ring buffer or the database, and operators see exactly the same lines in the dashboard that they would see in the terminal.
Client implementation notes (client/src/pages/LogsPage.tsx)
- Polling interval: 3 seconds (
LOG_POLL_MS), paused when the tab is backgrounded (refetchIntervalInBackground: false). - Filter changes (level checkboxes, provider dropdown, search box) reset the stream: buffer cleared, cursor nulled, new query key → fresh newest-200 page.
- Search debounce: 300 ms (
SEARCH_DEBOUNCE_MS). - Tail-follow: The view auto-scrolls only while the user is parked at the bottom (within 40 px). Scrolling up detaches; a "Jump to latest" pill appears when new lines arrive while detached.
- Entry buffer: Capped at 500 entries (
LOG_BUFFER_LIMIT) in component state; oldest evicted as new pages arrive. - Expandable rows: Long messages (> 300 chars) are clamped with a "Show more/less" toggle.
- Level badges: Color-coded per severity (error=red, warn=amber, info=blue,
debug=muted) with live counts from the
countspayload. - Provider dropdown: Populated dynamically from the
providerfield of entries seen in the current stream. - Clear button: Confirmed action calling
POST /api/logs/clear, then invalidates the query cache and resets local state.
Navigation
The Logs page is linked from the Analytics nav menu
(client/src/pages/AnalyticsPage.tsx → sidebar). It sits alongside the
analytics charts as a diagnostic companion: the charts show what happened
(request volumes, latencies, errors); the logs show why.