mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 07:02:16 +03:00
T-19 — Domain Layer: - modelAvailability.js: Model availability tracking with TTL cooldowns - costRules.js: Per-API-key budget management with daily/monthly limits - fallbackPolicy.js: Declarative fallback chain routing T-22 — Error Codes Catalog: - errorCodes.js: 24 codes in 6 categories + createErrorResponse helper T-23 — Correlation ID: - requestId.js: AsyncLocalStorage-based x-request-id propagation T-25 — Fetch Timeout: - fetchTimeout.js: AbortController wrapper with FETCH_TIMEOUT_MS env var T-27 — JSDoc + @ts-check: - Added @ts-check to 8 critical files TASKS.md updated: 37/46 tasks Concluído, 9 remaining Tests: 119/119 pass (88 existing + 31 new)
341 lines
10 KiB
JavaScript
341 lines
10 KiB
JavaScript
// @ts-check
|
|
/**
|
|
* Call Logs — extracted from usageDb.js (T-15)
|
|
*
|
|
* Structured call log management: save, query, rotate, and
|
|
* full-payload disk storage for the Logger UI.
|
|
*
|
|
* @module lib/usage/callLogs
|
|
*/
|
|
|
|
import path from "path";
|
|
import fs from "fs";
|
|
import { getDbInstance } from "../db/core.js";
|
|
import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations.js";
|
|
|
|
const CALL_LOGS_MAX = 500;
|
|
|
|
let logIdCounter = 0;
|
|
function generateLogId() {
|
|
logIdCounter++;
|
|
return `${Date.now()}-${logIdCounter}`;
|
|
}
|
|
|
|
/**
|
|
* Save a structured call log entry.
|
|
*/
|
|
export async function saveCallLog(entry) {
|
|
if (!shouldPersistToDisk) return;
|
|
|
|
try {
|
|
// Resolve account name
|
|
let account = entry.connectionId ? entry.connectionId.slice(0, 8) : "-";
|
|
try {
|
|
const { getProviderConnections } = await import("@/lib/localDb.js");
|
|
const connections = await getProviderConnections();
|
|
const conn = connections.find((c) => c.id === entry.connectionId);
|
|
if (conn) account = conn.name || conn.email || account;
|
|
} catch {}
|
|
|
|
// Truncate large payloads for DB storage (keep under 8KB each)
|
|
const truncatePayload = (obj) => {
|
|
if (!obj) return null;
|
|
const str = JSON.stringify(obj);
|
|
if (str.length <= 8192) return str;
|
|
try {
|
|
return JSON.stringify({
|
|
_truncated: true,
|
|
_originalSize: str.length,
|
|
_preview: str.slice(0, 8192) + "...",
|
|
});
|
|
} catch {
|
|
return JSON.stringify({ _truncated: true });
|
|
}
|
|
};
|
|
|
|
const logEntry = {
|
|
id: generateLogId(),
|
|
timestamp: new Date().toISOString(),
|
|
method: entry.method || "POST",
|
|
path: entry.path || "/v1/chat/completions",
|
|
status: entry.status || 0,
|
|
model: entry.model || "-",
|
|
provider: entry.provider || "-",
|
|
account,
|
|
connectionId: entry.connectionId || null,
|
|
duration: entry.duration || 0,
|
|
tokensIn: entry.tokens?.prompt_tokens || 0,
|
|
tokensOut: entry.tokens?.completion_tokens || 0,
|
|
sourceFormat: entry.sourceFormat || null,
|
|
targetFormat: entry.targetFormat || null,
|
|
apiKeyId: entry.apiKeyId || null,
|
|
apiKeyName: entry.apiKeyName || null,
|
|
comboName: entry.comboName || null,
|
|
requestBody: truncatePayload(entry.requestBody),
|
|
responseBody: truncatePayload(entry.responseBody),
|
|
error: entry.error || null,
|
|
};
|
|
|
|
// 1. Insert into SQLite
|
|
const db = getDbInstance();
|
|
db.prepare(
|
|
`
|
|
INSERT INTO call_logs (id, timestamp, method, path, status, model, provider,
|
|
account, connection_id, duration, tokens_in, tokens_out, source_format, target_format,
|
|
api_key_id, api_key_name, combo_name, request_body, response_body, error)
|
|
VALUES (@id, @timestamp, @method, @path, @status, @model, @provider,
|
|
@account, @connectionId, @duration, @tokensIn, @tokensOut, @sourceFormat, @targetFormat,
|
|
@apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error)
|
|
`
|
|
).run(logEntry);
|
|
|
|
// 2. Trim old entries beyond CALL_LOGS_MAX
|
|
const count = db.prepare("SELECT COUNT(*) as cnt FROM call_logs").get()?.cnt || 0;
|
|
if (count > CALL_LOGS_MAX) {
|
|
db.prepare(
|
|
`
|
|
DELETE FROM call_logs WHERE id IN (
|
|
SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ?
|
|
)
|
|
`
|
|
).run(count - CALL_LOGS_MAX);
|
|
}
|
|
|
|
// 3. Write full payload to disk file (untruncated)
|
|
writeCallLogToDisk(
|
|
{ ...logEntry, tokens: { in: logEntry.tokensIn, out: logEntry.tokensOut } },
|
|
entry.requestBody,
|
|
entry.responseBody
|
|
);
|
|
} catch (error) {
|
|
console.error("[callLogs] Failed to save call log:", error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write call log as JSON file to disk (full payloads, not truncated).
|
|
*/
|
|
function writeCallLogToDisk(logEntry, requestBody, responseBody) {
|
|
if (!CALL_LOGS_DIR) return;
|
|
|
|
try {
|
|
const now = new Date();
|
|
const dateFolder = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
const dir = path.join(CALL_LOGS_DIR, dateFolder);
|
|
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
|
|
const safeModel = (logEntry.model || "unknown").replace(/[/:]/g, "-");
|
|
const time = `${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
|
|
const filename = `${time}_${safeModel}_${logEntry.status}.json`;
|
|
|
|
const fullEntry = {
|
|
...logEntry,
|
|
requestBody: requestBody || null,
|
|
responseBody: responseBody || null,
|
|
};
|
|
|
|
fs.writeFileSync(path.join(dir, filename), JSON.stringify(fullEntry, null, 2));
|
|
} catch (err) {
|
|
console.error("[callLogs] Failed to write disk log:", err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rotate old call log directories (keep last 7 days).
|
|
*/
|
|
export function rotateCallLogs() {
|
|
if (!CALL_LOGS_DIR || !fs.existsSync(CALL_LOGS_DIR)) return;
|
|
|
|
try {
|
|
const entries = fs.readdirSync(CALL_LOGS_DIR);
|
|
const now = Date.now();
|
|
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(CALL_LOGS_DIR, entry);
|
|
const stat = fs.statSync(entryPath);
|
|
if (stat.isDirectory() && now - stat.mtimeMs > sevenDays) {
|
|
fs.rmSync(entryPath, { recursive: true, force: true });
|
|
console.log(`[callLogs] Rotated old logs: ${entry}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("[callLogs] Failed to rotate logs:", err.message);
|
|
}
|
|
}
|
|
|
|
// Run rotation on startup
|
|
if (shouldPersistToDisk) {
|
|
try {
|
|
rotateCallLogs();
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Get call logs with optional filtering.
|
|
*/
|
|
export async function getCallLogs(filter = {}) {
|
|
const db = getDbInstance();
|
|
let sql = "SELECT * FROM call_logs";
|
|
const conditions = [];
|
|
const params = {};
|
|
|
|
if (filter.status) {
|
|
if (filter.status === "error") {
|
|
conditions.push("(status >= 400 OR error IS NOT NULL)");
|
|
} else if (filter.status === "ok") {
|
|
conditions.push("status >= 200 AND status < 300");
|
|
} else {
|
|
const statusCode = parseInt(filter.status);
|
|
if (!isNaN(statusCode)) {
|
|
conditions.push("status = @statusCode");
|
|
params.statusCode = statusCode;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (filter.model) {
|
|
conditions.push("model LIKE @modelQ");
|
|
params.modelQ = `%${filter.model}%`;
|
|
}
|
|
if (filter.provider) {
|
|
conditions.push("provider LIKE @providerQ");
|
|
params.providerQ = `%${filter.provider}%`;
|
|
}
|
|
if (filter.account) {
|
|
conditions.push("account LIKE @accountQ");
|
|
params.accountQ = `%${filter.account}%`;
|
|
}
|
|
if (filter.apiKey) {
|
|
conditions.push("(api_key_name LIKE @apiKeyQ OR api_key_id LIKE @apiKeyQ)");
|
|
params.apiKeyQ = `%${filter.apiKey}%`;
|
|
}
|
|
if (filter.combo) {
|
|
conditions.push("combo_name IS NOT NULL");
|
|
}
|
|
if (filter.search) {
|
|
conditions.push(`(
|
|
model LIKE @searchQ OR path LIKE @searchQ OR account LIKE @searchQ OR
|
|
provider LIKE @searchQ OR api_key_name LIKE @searchQ OR api_key_id LIKE @searchQ OR
|
|
combo_name LIKE @searchQ OR CAST(status AS TEXT) LIKE @searchQ
|
|
)`);
|
|
params.searchQ = `%${filter.search}%`;
|
|
}
|
|
|
|
if (conditions.length > 0) {
|
|
sql += " WHERE " + conditions.join(" AND ");
|
|
}
|
|
|
|
const limit = filter.limit || 200;
|
|
sql += ` ORDER BY timestamp DESC LIMIT ${limit}`;
|
|
|
|
const rows = db.prepare(sql).all(params);
|
|
|
|
return rows.map((l) => ({
|
|
id: l.id,
|
|
timestamp: l.timestamp,
|
|
method: l.method,
|
|
path: l.path,
|
|
status: l.status,
|
|
model: l.model,
|
|
provider: l.provider,
|
|
account: l.account,
|
|
duration: l.duration,
|
|
tokens: { in: l.tokens_in, out: l.tokens_out },
|
|
sourceFormat: l.source_format,
|
|
targetFormat: l.target_format,
|
|
error: l.error,
|
|
comboName: l.combo_name || null,
|
|
apiKeyId: l.api_key_id || null,
|
|
apiKeyName: l.api_key_name || null,
|
|
hasRequestBody: !!l.request_body,
|
|
hasResponseBody: !!l.response_body,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get a single call log by ID (with full payloads from disk when available).
|
|
*/
|
|
export async function getCallLogById(id) {
|
|
const db = getDbInstance();
|
|
const row = db.prepare("SELECT * FROM call_logs WHERE id = ?").get(id);
|
|
if (!row) return null;
|
|
|
|
const entry = {
|
|
id: row.id,
|
|
timestamp: row.timestamp,
|
|
method: row.method,
|
|
path: row.path,
|
|
status: row.status,
|
|
model: row.model,
|
|
provider: row.provider,
|
|
account: row.account,
|
|
connectionId: row.connection_id,
|
|
duration: row.duration,
|
|
tokens: { in: row.tokens_in, out: row.tokens_out },
|
|
sourceFormat: row.source_format,
|
|
targetFormat: row.target_format,
|
|
apiKeyId: row.api_key_id,
|
|
apiKeyName: row.api_key_name,
|
|
comboName: row.combo_name,
|
|
requestBody: row.request_body ? JSON.parse(row.request_body) : null,
|
|
responseBody: row.response_body ? JSON.parse(row.response_body) : null,
|
|
error: row.error,
|
|
};
|
|
|
|
// If payloads were truncated, try to read full version from disk
|
|
const needsDisk = entry.requestBody?._truncated || entry.responseBody?._truncated;
|
|
if (needsDisk && CALL_LOGS_DIR) {
|
|
try {
|
|
const diskEntry = readFullLogFromDisk(entry);
|
|
if (diskEntry) {
|
|
return {
|
|
...entry,
|
|
requestBody: diskEntry.requestBody ?? entry.requestBody,
|
|
responseBody: diskEntry.responseBody ?? entry.responseBody,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
console.error("[callLogs] Failed to read full log from disk:", err.message);
|
|
}
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* Read the full (untruncated) log entry from disk.
|
|
*/
|
|
function readFullLogFromDisk(entry) {
|
|
if (!CALL_LOGS_DIR || !entry.timestamp) return null;
|
|
|
|
try {
|
|
const date = new Date(entry.timestamp);
|
|
const dateFolder = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
const dir = path.join(CALL_LOGS_DIR, dateFolder);
|
|
|
|
if (!fs.existsSync(dir)) return null;
|
|
|
|
const time = `${String(date.getHours()).padStart(2, "0")}${String(date.getMinutes()).padStart(2, "0")}${String(date.getSeconds()).padStart(2, "0")}`;
|
|
const safeModel = (entry.model || "unknown").replace(/[/:]/g, "-");
|
|
const expectedName = `${time}_${safeModel}_${entry.status}.json`;
|
|
|
|
const exactPath = path.join(dir, expectedName);
|
|
if (fs.existsSync(exactPath)) {
|
|
return JSON.parse(fs.readFileSync(exactPath, "utf8"));
|
|
}
|
|
|
|
const files = fs
|
|
.readdirSync(dir)
|
|
.filter((f) => f.startsWith(time) && f.endsWith(`_${entry.status}.json`));
|
|
if (files.length > 0) {
|
|
return JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8"));
|
|
}
|
|
} catch (err) {
|
|
console.error("[callLogs] Disk log read error:", err.message);
|
|
}
|
|
|
|
return null;
|
|
}
|