Download server/src/lib/request-log.ts from automindy/fokemal: direct link, hf CLI and curl.
- Browser
- Download file 9.21 kB
-
https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/lib/request-log.ts
- Command line
-
hf download hf://spaces/automindy/fokemal/server/src/lib/request-log.ts
-
curl -L -o request-log.ts https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/lib/request-log.ts
9.21 kB
| import { getDb } from '../db/index.js'; | |
| import { pruneRequestAnalytics } from '../services/request-retention.js'; | |
| import { getClientContext } from './client-context.js'; | |
| import { noteRequestRowId, type RequestTrace } from './attempt-trace.js'; | |
| type LogTx = ReturnType<typeof getDb>; | |
| // SQLite stores created_at as 'YYYY-MM-DD HH:MM:SS' (UTC). Truncate to hour | |
| // for the aggregate upsert. Duplicated from the migration helper so this | |
| // module has no import dependency on db/migrations/. | |
| function hourKey(createdAt: string): string { | |
| return createdAt.slice(0, 13) + ':00:00'; | |
| } | |
| function incrementSetting(db: LogTx, key: string, delta: number): void { | |
| // Read-then-write inside the same transaction; safe because better-sqlite3 | |
| // is synchronous and serialized at the connection level. ON CONFLICT keeps | |
| // the first ever insert without a prior SELECT. | |
| db.prepare(` | |
| INSERT INTO settings (key, value) VALUES (?, ?) | |
| ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT) | |
| `).run(key, String(delta), delta); | |
| } | |
| function setSettingIfMissing(db: LogTx, key: string, value: string): void { | |
| db.prepare(` | |
| INSERT INTO settings (key, value) VALUES (?, ?) | |
| ON CONFLICT(key) DO NOTHING | |
| `).run(key, value); | |
| } | |
| // Resolve the concrete models.id for a request (#1187). Catalog requests | |
| // resolve by (platform, model_id); custom requests resolve through the | |
| // request's key (its normalized base_url is the row's endpoint_scope). | |
| // Returns NULL when the request is unattributable β a custom request whose | |
| // key never reached routing, or whose key no longer resolves to a matching | |
| // model row. An unattributable request must not land on the wrong relay. | |
| function resolveModelDbId( | |
| db: LogTx, | |
| platform: string, | |
| modelId: string, | |
| keyId: number | null, | |
| ): number | null { | |
| if (platform !== 'custom') { | |
| const row = db.prepare('SELECT id FROM models WHERE platform = ? AND model_id = ? LIMIT 1') | |
| .get(platform, modelId) as { id: number } | undefined; | |
| return row?.id ?? null; | |
| } | |
| if (keyId == null) return null; | |
| const key = db.prepare("SELECT base_url FROM api_keys WHERE id = ? AND platform = 'custom'") | |
| .get(keyId) as { base_url: string | null } | undefined; | |
| if (!key?.base_url) return null; | |
| const scope = key.base_url.trim().replace(/\/+$/, ''); | |
| const row = db.prepare( | |
| 'SELECT id FROM models WHERE platform = ? AND model_id = ? AND endpoint_scope = ? LIMIT 1', | |
| ).get('custom', modelId, scope) as { id: number } | undefined; | |
| return row?.id ?? null; | |
| } | |
| // Append a row to the request analytics table. Shared by the chat proxy, the | |
| // responses path, and the fusion panel so every served (or failed) sub-request | |
| // is logged identically. Lives in a neutral lib module to avoid an import cycle | |
| // between the fusion service and the proxy route that both call it. | |
| // | |
| // Status is 'success', 'error', or 'canceled' (#752 β the client hung up | |
| // mid-attempt). A canceled request counts toward request totals β it happened β | |
| // but toward NEITHER success nor error: rates and scoring must read | |
| // success/(success+error), never success/total. | |
| // | |
| // In addition to the raw row, we update two durable aggregates so analytics | |
| // totals survive the raw-row prune (REQUEST_ANALYTICS_MAX_ROWS): | |
| // - request_hourly: per-hour bucket counts and tokens (max window = 30d). | |
| // - settings: lifetime totals (total_requests, total_input_tokens, total_output_tokens) | |
| // plus first_request_at (set on the first ever logged request). | |
| // All upserts run in the same transaction so the aggregates never disagree | |
| // with the raw row count. | |
| export function logRequest( | |
| platform: string, | |
| modelId: string, | |
| // NULL for rejections that never reached routing (no key was involved), | |
| // e.g. an over-limit request body turned away at the parser. | |
| keyId: number | null, | |
| status: string, | |
| inputTokens: number, | |
| outputTokens: number, | |
| latencyMs: number, | |
| error: string | null, | |
| ttfbMs: number | null = null, | |
| // The model id the client pinned; null for auto-routed requests. Lets | |
| // analytics split pinned vs auto traffic and detect failover overrides | |
| // (requested_model set but != model_id). | |
| requestedModel: string | null = null, | |
| // The model the UPSTREAM claims it served, ONLY when it genuinely differs | |
| // from the routed model_id after cosmetic normalization (#534 β see | |
| // lib/served-model.ts). NULL when it matches or the provider reported | |
| // nothing usable, so the column stays empty in the healthy case. | |
| servedModel: string | null = null, | |
| // Which gateway pathway produced this request. Today every inference | |
| // surface writes 'http': the OpenAI-compatible proxy (/v1/chat/completions, | |
| // /v1/completions), /v1/responses, /v1/messages, the Ollama + Gemini wires | |
| // (lib/inbound-chat.ts), and fusion's panel/judge sub-calls. The /mcp | |
| // JSON-RPC surface is introspection-only (list models, health, usage) and | |
| // runs no inference, so it logs nothing; the dashboard playground calls the | |
| // same HTTP endpoints as any other client and is indistinguishable from | |
| // them here. The column is free-form so a future surface can add a value | |
| // without a migration. NULL for call sites that pass no caller β notably | |
| // the shared fallback loop's 'canceled' row, which is surface-agnostic. | |
| caller: string | null = null, | |
| ) { | |
| try { | |
| const db = getDb(); | |
| // Caller identity from the request-scoped context (set by the express | |
| // middleware); null when logging happens outside an HTTP request. | |
| const client = getClientContext(); | |
| const tx = db.transaction(() => { | |
| const insert = db.prepare(` | |
| INSERT INTO requests (platform, model_id, key_id, status, input_tokens, output_tokens, latency_ms, error, ttfb_ms, requested_model, served_model, client_ip, client_user_agent, client_agent, caller, model_db_id) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| `).run(platform, modelId, keyId, status, inputTokens, outputTokens, latencyMs, error, ttfbMs, requestedModel, servedModel, client.ip, client.userAgent, client.agent, caller, resolveModelDbId(db, platform, modelId, keyId)); | |
| // Report the row id back to the fallback loop's attempt trace (if one is | |
| // active): the LAST id noted during a loop run is the terminal row the | |
| // per-attempt batch is keyed to. No-op outside a fallback-loop run. | |
| if (insert.lastInsertRowid != null) noteRequestRowId(insert.lastInsertRowid); | |
| const createdAt = db.prepare(`SELECT created_at FROM requests WHERE id = ?`).get(insert.lastInsertRowid) as { created_at: string } | undefined; | |
| const hour = hourKey(createdAt?.created_at ?? new Date().toISOString().slice(0, 19).replace('T', ' ')); | |
| const isSuccess = status === 'success' ? 1 : 0; | |
| const isError = status === 'error' ? 1 : 0; | |
| db.prepare(` | |
| INSERT INTO request_hourly (hour, total_requests, success_count, error_count, input_tokens, output_tokens) | |
| VALUES (?, 1, ?, ?, ?, ?) | |
| ON CONFLICT(hour) DO UPDATE SET | |
| total_requests = total_requests + 1, | |
| success_count = success_count + ?, | |
| error_count = error_count + ?, | |
| input_tokens = input_tokens + ?, | |
| output_tokens = output_tokens + ? | |
| `).run(hour, isSuccess, isError, inputTokens, outputTokens, isSuccess, isError, inputTokens, outputTokens); | |
| incrementSetting(db, 'total_requests', 1); | |
| incrementSetting(db, 'total_input_tokens', inputTokens); | |
| incrementSetting(db, 'total_output_tokens', outputTokens); | |
| if (createdAt?.created_at) { | |
| setSettingIfMissing(db, 'first_request_at', createdAt.created_at); | |
| } | |
| }); | |
| tx(); | |
| pruneRequestAnalytics({ db }); | |
| } catch (e) { | |
| console.error('Failed to log request:', e); | |
| } | |
| } | |
| // Persist a finished attempt trace as one small insert batch keyed to the | |
| // terminal `requests` row of the failover ladder (the success row, a committed | |
| // mid-stream error row, or the last per-attempt failure row). Called once per | |
| // request by the fallback loop AFTER the response is finished, so the write is | |
| // off the client's latency path. Zero-failure single-attempt successes write | |
| // exactly one 'ok' row. A trace with no parent row writes nothing β since the | |
| // fallback loop logs a 'canceled' row for pure client aborts (#752), that is | |
| // now only the loop-top stop paths, whose failed attempts each wrote their own | |
| // row already. | |
| export function persistRequestAttempts(trace: RequestTrace): void { | |
| if (trace.records.length === 0 || trace.lastRequestRowId == null) return; | |
| try { | |
| const db = getDb(); | |
| const insert = db.prepare(` | |
| INSERT INTO request_attempts (request_id, ordinal, platform, model_id, key_ordinal, key_label, outcome, start_offset_ms, duration_ms, error_summary) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| `); | |
| const tx = db.transaction(() => { | |
| for (const r of trace.records) { | |
| insert.run(trace.lastRequestRowId, r.ordinal, r.platform, r.modelId, r.keyOrdinal, r.keyLabel, r.outcome, r.startOffsetMs, r.durationMs, r.errorSummary); | |
| } | |
| }); | |
| tx(); | |
| } catch (e) { | |
| console.error('Failed to persist request attempts:', e); | |
| } | |
| } | |