Files
OmniRoute/src/lib/usage/callLogs.ts
Diego Rodrigues de Sa e Souza 3d75fb3fae Release v3.8.0 (#2073)
Integrated into release/v3.8.0
2026-05-10 00:55:06 -03:00

951 lines
30 KiB
TypeScript

/**
* Structured call log management.
*
* SQLite stores only summary metadata. Detailed request/response payloads live in
* filesystem artifacts and are loaded only for explicit detail/export flows.
*/
import fs from "fs";
import path from "path";
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
import { getDbInstance } from "../db/core";
import { getRequestDetailLogByCallLogId } from "../db/detailedLogs";
import { shouldPersistToDisk } from "./migrations";
import {
getLoggedInputTokens,
getLoggedOutputTokens,
getPromptCacheReadTokensOrNull,
getPromptCacheCreationTokensOrNull,
getReasoningTokensOrNull,
} from "./tokenAccounting";
import { isNoLog } from "../compliance";
import { sanitizePII } from "../piiSanitizer";
import { protectPayloadForLog, parseStoredPayload } from "../logPayloads";
import { getCallLogMaxEntries, getCallLogRetentionDays, getCallLogsTableMaxRows } from "../logEnv";
import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail";
import {
CALL_LOGS_DIR,
cleanupEmptyCallLogDirs,
deleteCallArtifact,
listCallLogArtifactFiles,
readCallArtifact,
writeCallArtifact,
type CallLogArtifact,
type CallLogDetailState,
} from "./callLogArtifacts";
type JsonRecord = Record<string, unknown>;
const CALL_LOG_ROTATE_THROTTLE_MS = 60_000;
let lastCallLogRotationScheduledAt = 0;
let callLogRotateInFlight = false;
let callLogRotateScheduled = false;
type CallLogSummaryRow = {
id: string;
timestamp: string | null;
method: string | null;
path: string | null;
status: number | null;
model: string | null;
requested_model: string | null;
provider: string | null;
account: string | null;
connection_id: string | null;
duration: number | null;
tokens_in: number | null;
tokens_out: number | null;
tokens_cache_read: number | null;
tokens_cache_creation: number | null;
tokens_reasoning: number | null;
tokens_compressed: number | null;
cache_source: string | null;
request_type: string | null;
source_format: string | null;
target_format: string | null;
api_key_id: string | null;
api_key_name: string | null;
combo_name: string | null;
combo_step_id: string | null;
combo_execution_key: string | null;
error_summary: string | null;
detail_state: string | null;
artifact_relpath: string | null;
artifact_size_bytes: number | null;
artifact_sha256: string | null;
has_request_body: number | null;
has_response_body: number | null;
has_pipeline_details: number | null;
request_summary: string | null;
provider_node_prefix?: string | null;
};
type LegacyInlineRow = {
request_body: string | null;
response_body: string | null;
error: string | null;
};
type DeleteResult = {
deletedRows: number;
deletedArtifacts: number;
};
let logIdCounter = 0;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function truncateText(value: string, maxLength: number) {
return value.length > maxLength ? value.slice(0, maxLength) : value;
}
function parseInlineError(value: unknown): unknown {
if (typeof value !== "string" || value.trim().length === 0) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function normalizeDetailState(value: unknown): CallLogDetailState {
if (
value === "ready" ||
value === "missing" ||
value === "corrupt" ||
value === "legacy-inline"
) {
return value;
}
return "none";
}
function sanitizeErrorForLog(error: unknown): unknown {
if (error === null || error === undefined) return null;
if (typeof error === "string") return sanitizePII(error).text;
if (error instanceof Error) {
return {
message: sanitizePII(error.message).text,
stack: sanitizePII(error.stack || "").text || undefined,
name: error.name,
};
}
return protectPayloadForLog(error);
}
function toStoredErrorSummary(error: unknown): string | null {
const sanitized = sanitizeErrorForLog(error);
if (sanitized === null || sanitized === undefined) return null;
if (typeof sanitized === "string") {
return truncateText(sanitized, 4000);
}
try {
return truncateText(JSON.stringify(sanitized), 4000);
} catch {
return truncateText(String(sanitized), 4000);
}
}
function protectPipelinePayloads(payloads: unknown): RequestPipelinePayloads | null {
if (!payloads || typeof payloads !== "object") return null;
const protectedPayloads: RequestPipelinePayloads = {};
for (const [key, value] of Object.entries(payloads as JsonRecord)) {
if (value === null || value === undefined) continue;
if (key === "streamChunks" && value && typeof value === "object") {
const chunks = value as Record<string, unknown>;
const compacted = Object.fromEntries(
Object.entries(chunks).filter(
([, chunkValue]) => Array.isArray(chunkValue) && chunkValue.length > 0
)
);
if (Object.keys(compacted).length > 0) {
protectedPayloads.streamChunks = protectPayloadForLog(
compacted
) as RequestPipelinePayloads["streamChunks"];
}
continue;
}
protectedPayloads[key as keyof RequestPipelinePayloads] = protectPayloadForLog(value) as never;
}
return Object.keys(protectedPayloads).length > 0 ? protectedPayloads : null;
}
function buildRequestSummary(requestType: string | null, requestBody: unknown): string | null {
if (requestType !== "search") return null;
const body = asRecord(requestBody);
if (Object.keys(body).length === 0) return null;
const summary: JsonRecord = {};
if (typeof body.query === "string" && body.query.trim().length > 0) {
summary.query = sanitizePII(body.query).text;
}
const filters = Object.fromEntries(
Object.entries(body).filter(([key]) => key !== "query" && key !== "provider")
);
if (Object.keys(filters).length > 0) {
summary.filters = filters;
}
if (Object.keys(summary).length === 0) return null;
return JSON.stringify(summary);
}
function generateLogId() {
logIdCounter++;
return `${Date.now()}-${logIdCounter}`;
}
async function resolveAccountName(connectionId: string | null | undefined) {
let account = connectionId ? connectionId.slice(0, 8) : "-";
if (!connectionId) {
return account;
}
try {
const { getProviderConnections } = await import("@/lib/localDb");
const connections = await getProviderConnections();
const conn = connections.find((item) => item.id === connectionId);
if (conn) {
account = pickMaskedDisplayValue(
[toStringOrNull(conn.name), toStringOrNull(conn.email)],
account
);
}
} catch {
// Best-effort lookup only.
}
return account;
}
async function resolveProviderPrefix(providerId: string): Promise<string | null> {
if (!providerId) return null;
try {
const { getProviderNodeById } = await import("@/lib/localDb");
const node = await getProviderNodeById(providerId);
if (node && typeof node.prefix === "string" && node.prefix.trim().length > 0) {
return node.prefix.trim();
}
} catch {
// Best-effort lookup only.
}
return null;
}
function isCompatibleProviderId(providerId: string | null): boolean {
if (!providerId) return false;
return (
providerId.startsWith("openai-compatible-") || providerId.startsWith("anthropic-compatible-")
);
}
function applyNodePrefix(
requestedModel: string | null,
provider: string | null,
nodePrefix: string | null
): string | null {
if (!requestedModel || !provider || !nodePrefix) return requestedModel;
if (requestedModel.startsWith(provider + "/")) {
return nodePrefix + "/" + requestedModel.slice(provider.length + 1);
}
return requestedModel;
}
function buildArtifact(
logEntry: {
id: string;
timestamp: string;
method: string;
path: string;
status: number;
model: string;
requestedModel: string | null;
provider: string;
account: string;
connectionId: string | null;
duration: number;
tokensIn: number;
tokensOut: number;
tokensCacheRead: number | null;
tokensCacheCreation: number | null;
tokensReasoning: number | null;
tokensCompressed: number | null;
requestType: string | null;
sourceFormat: string | null;
targetFormat: string | null;
apiKeyId: string | null;
apiKeyName: string | null;
comboName: string | null;
comboStepId: string | null;
comboExecutionKey: string | null;
},
requestBody: unknown,
responseBody: unknown,
error: unknown,
pipelinePayloads: RequestPipelinePayloads | null
): CallLogArtifact {
return {
schemaVersion: 5,
summary: {
id: logEntry.id,
timestamp: logEntry.timestamp,
method: logEntry.method,
path: logEntry.path,
status: logEntry.status,
model: logEntry.model,
requestedModel: logEntry.requestedModel,
provider: logEntry.provider,
account: logEntry.account,
connectionId: logEntry.connectionId,
duration: logEntry.duration,
tokens: {
in: logEntry.tokensIn,
out: logEntry.tokensOut,
cacheRead: logEntry.tokensCacheRead,
cacheWrite: logEntry.tokensCacheCreation,
reasoning: logEntry.tokensReasoning,
compressed: logEntry.tokensCompressed,
},
requestType: logEntry.requestType,
sourceFormat: logEntry.sourceFormat,
targetFormat: logEntry.targetFormat,
apiKeyId: logEntry.apiKeyId,
apiKeyName: logEntry.apiKeyName,
comboName: logEntry.comboName,
comboStepId: logEntry.comboStepId,
comboExecutionKey: logEntry.comboExecutionKey,
},
requestBody: requestBody ?? null,
responseBody: responseBody ?? null,
error: error ?? null,
...(pipelinePayloads ? { pipeline: pipelinePayloads } : {}),
};
}
function hasTable(tableName: string): boolean {
const db = getDbInstance();
return Boolean(
db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName)
);
}
function readLegacyLogFromDisk(entry: {
timestamp: string | null;
model: string | null;
status: number;
}) {
if (!CALL_LOGS_DIR || !entry.timestamp) return null;
try {
const date = new Date(entry.timestamp);
if (Number.isNaN(date.getTime())) return null;
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((file) => file.startsWith(time) && file.endsWith(`_${entry.status}.json`));
if (files.length > 0) {
return JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8"));
}
} catch (error) {
console.error("[callLogs] Failed to read legacy disk log:", (error as Error).message);
}
return null;
}
function clearArtifactReference(relativePath: string, nextState: CallLogDetailState) {
const db = getDbInstance();
db.prepare(
`
UPDATE call_logs
SET detail_state = ?,
artifact_relpath = NULL,
artifact_size_bytes = NULL,
artifact_sha256 = NULL
WHERE artifact_relpath = ?
`
).run(nextState, relativePath);
}
function listReferencedArtifacts() {
const db = getDbInstance();
const rows = db
.prepare("SELECT artifact_relpath FROM call_logs WHERE artifact_relpath IS NOT NULL")
.all() as Array<{ artifact_relpath: string | null }>;
return new Set(
rows.map((row) => row.artifact_relpath).filter((value): value is string => Boolean(value))
);
}
function deleteCallLogRowsByIds(ids: string[]): DeleteResult {
if (ids.length === 0) {
return { deletedRows: 0, deletedArtifacts: 0 };
}
const db = getDbInstance();
const placeholders = ids.map(() => "?").join(", ");
const rows = db
.prepare(`SELECT artifact_relpath FROM call_logs WHERE id IN (${placeholders})`)
.all(...ids) as Array<{ artifact_relpath: string | null }>;
const result = db.prepare(`DELETE FROM call_logs WHERE id IN (${placeholders})`).run(...ids);
let deletedArtifacts = 0;
for (const row of rows) {
if (deleteCallArtifact(row.artifact_relpath)) {
deletedArtifacts++;
}
}
cleanupEmptyCallLogDirs();
return {
deletedRows: result.changes,
deletedArtifacts,
};
}
export function cleanupOrphanCallLogFiles(baseDir = CALL_LOGS_DIR) {
if (!baseDir || !fs.existsSync(baseDir)) return 0;
try {
const referenced = listReferencedArtifacts();
let deleted = 0;
for (const file of listCallLogArtifactFiles(baseDir)) {
if (referenced.has(file.relativePath)) continue;
if (deleteCallArtifact(file.relativePath)) {
deleted++;
}
}
cleanupEmptyCallLogDirs(baseDir);
return deleted;
} catch (error) {
console.error("[callLogs] Failed to prune orphan request artifacts:", (error as Error).message);
return 0;
}
}
export function cleanupOverflowCallLogFiles(baseDir = CALL_LOGS_DIR, maxEntries?: number) {
if (!baseDir || !fs.existsSync(baseDir)) return 0;
const limit = maxEntries ?? getCallLogMaxEntries();
if (!Number.isInteger(limit) || limit < 1) return 0;
try {
let deleted = 0;
const files = listCallLogArtifactFiles(baseDir);
for (const file of files.slice(limit)) {
if (deleteCallArtifact(file.relativePath)) {
clearArtifactReference(file.relativePath, "missing");
deleted++;
}
}
cleanupEmptyCallLogDirs(baseDir);
return deleted;
} catch (error) {
console.error(
"[callLogs] Failed to prune overflow request artifacts:",
(error as Error).message
);
return 0;
}
}
export function deleteCallLogsBefore(cutoff: string): DeleteResult {
const db = getDbInstance();
const ids = db
.prepare("SELECT id FROM call_logs WHERE timestamp < ? ORDER BY timestamp ASC")
.all(cutoff)
.map((row) => String((row as { id: string }).id));
return deleteCallLogRowsByIds(ids);
}
export function trimCallLogsToMaxRows(maxRows = getCallLogsTableMaxRows()) {
if (!Number.isInteger(maxRows) || maxRows < 1) {
return { deletedRows: 0, deletedArtifacts: 0 };
}
const db = getDbInstance();
let deletedRows = 0;
let deletedArtifacts = 0;
const batchSize = 5000;
while (true) {
const currentCount = db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as {
cnt: number;
};
if (currentCount.cnt <= maxRows) break;
const toDelete = Math.min(currentCount.cnt - maxRows, batchSize);
const ids = db
.prepare("SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ?")
.all(toDelete)
.map((row) => String((row as { id: string }).id));
const result = deleteCallLogRowsByIds(ids);
deletedRows += result.deletedRows;
deletedArtifacts += result.deletedArtifacts;
if (result.deletedRows === 0) break;
}
return { deletedRows, deletedArtifacts };
}
function mapSummaryRow(row: CallLogSummaryRow) {
const detailState = normalizeDetailState(row.detail_state);
const provider = row.provider;
const nodePrefix = row.provider_node_prefix ?? null;
return {
id: row.id,
timestamp: row.timestamp,
method: row.method,
path: row.path,
status: toNumber(row.status),
model: row.model,
requestedModel: applyNodePrefix(row.requested_model, provider, nodePrefix),
provider,
account: row.account,
connectionId: row.connection_id,
duration: toNumber(row.duration),
tokens: {
in: toNumber(row.tokens_in),
out: toNumber(row.tokens_out),
cacheRead: row.tokens_cache_read != null ? toNumber(row.tokens_cache_read) : null,
cacheWrite: row.tokens_cache_creation != null ? toNumber(row.tokens_cache_creation) : null,
reasoning: row.tokens_reasoning != null ? toNumber(row.tokens_reasoning) : null,
compressed: row.tokens_compressed != null ? toNumber(row.tokens_compressed) : null,
},
cacheSource: row.cache_source || "upstream",
requestType: row.request_type,
sourceFormat: row.source_format,
targetFormat: row.target_format,
apiKeyId: row.api_key_id,
apiKeyName: row.api_key_name,
comboName: row.combo_name,
comboStepId: row.combo_step_id,
comboExecutionKey: row.combo_execution_key,
error: row.error_summary,
detailState,
artifactRelPath: row.artifact_relpath,
artifactSizeBytes: row.artifact_size_bytes,
artifactSha256: row.artifact_sha256,
requestSummary: row.request_summary ? parseStoredPayload(row.request_summary) : null,
hasRequestBody: toNumber(row.has_request_body) === 1,
hasResponseBody: toNumber(row.has_response_body) === 1,
hasPipelineDetails: toNumber(row.has_pipeline_details) === 1,
};
}
function buildLegacyPipelinePayloads(id: string) {
const detailed = getRequestDetailLogByCallLogId(id);
if (!detailed) return null;
return {
clientRequest: detailed.client_request ?? null,
providerRequest: detailed.translated_request ?? null,
providerResponse: detailed.provider_response ?? null,
clientResponse: detailed.client_response ?? null,
};
}
function getLegacyInlineDetail(id: string) {
if (!hasTable("call_logs_v1_legacy")) return null;
const db = getDbInstance();
const row = db
.prepare("SELECT request_body, response_body, error FROM call_logs_v1_legacy WHERE id = ?")
.get(id) as LegacyInlineRow | undefined;
if (!row) return null;
return {
requestBody: parseStoredPayload(row.request_body),
responseBody: parseStoredPayload(row.response_body),
error: parseInlineError(row.error),
};
}
export async function saveCallLog(entry: any) {
if (!shouldPersistToDisk) return;
try {
const apiKeyId = entry.apiKeyId || null;
const noLogEnabled = Boolean(entry.noLog) || (apiKeyId ? isNoLog(apiKeyId) : false);
const protectedRequestBody = noLogEnabled ? null : protectPayloadForLog(entry.requestBody);
const protectedResponseBody = noLogEnabled ? null : protectPayloadForLog(entry.responseBody);
const protectedPipelinePayloads = noLogEnabled
? null
: protectPipelinePayloads(entry.pipelinePayloads ?? entry.pipeline ?? null);
const protectedError = sanitizeErrorForLog(entry.error);
const account = await resolveAccountName(entry.connectionId || null);
const rawProvider: string = entry.provider || "-";
const rawRequestedModel: string | null = entry.requestedModel || null;
let resolvedRequestedModel = rawRequestedModel;
if (rawRequestedModel && isCompatibleProviderId(rawProvider)) {
const nodePrefix = await resolveProviderPrefix(rawProvider);
resolvedRequestedModel = applyNodePrefix(rawRequestedModel, rawProvider, nodePrefix);
}
const logEntry = {
id: typeof entry.id === "string" && entry.id.length > 0 ? entry.id : generateLogId(),
timestamp: typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString(),
method: entry.method || "POST",
path: entry.path || "/v1/chat/completions",
status: entry.status || 0,
model: entry.model || "-",
requestedModel: resolvedRequestedModel,
provider: rawProvider,
account,
connectionId: entry.connectionId || null,
duration: entry.duration || 0,
tokensIn: toNumber(getLoggedInputTokens(entry.tokens)),
tokensOut: toNumber(getLoggedOutputTokens(entry.tokens)),
tokensCacheRead: getPromptCacheReadTokensOrNull(entry.tokens),
tokensCacheCreation: getPromptCacheCreationTokensOrNull(entry.tokens),
tokensReasoning: getReasoningTokensOrNull(entry.tokens),
tokensCompressed: entry.tokensCompressed != null ? toNumber(entry.tokensCompressed) : null,
cacheSource: entry.cacheSource === "semantic" ? "semantic" : "upstream",
requestType: entry.requestType || null,
sourceFormat: entry.sourceFormat || null,
targetFormat: entry.targetFormat || null,
apiKeyId,
apiKeyName: entry.apiKeyName || null,
comboName: entry.comboName || null,
comboStepId: toStringOrNull(entry.comboStepId),
comboExecutionKey:
toStringOrNull(entry.comboExecutionKey) || toStringOrNull(entry.comboStepId),
};
const requestSummary = noLogEnabled
? null
: buildRequestSummary(logEntry.requestType, protectedRequestBody);
const detailExpected =
!noLogEnabled &&
(protectedRequestBody !== null ||
protectedResponseBody !== null ||
protectedError !== null ||
protectedPipelinePayloads !== null);
let detailState: CallLogDetailState = "none";
let artifactRelPath: string | null = null;
let artifactSizeBytes: number | null = null;
let artifactSha256: string | null = null;
if (detailExpected) {
const artifact = buildArtifact(
logEntry,
protectedRequestBody,
protectedResponseBody,
protectedError,
protectedPipelinePayloads
);
const artifactResult = writeCallArtifact(artifact);
if (artifactResult) {
detailState = "ready";
artifactRelPath = artifactResult.relPath;
artifactSizeBytes = artifactResult.sizeBytes;
artifactSha256 = artifactResult.sha256;
} else {
detailState = "missing";
}
}
const db = getDbInstance();
db.prepare(
`
INSERT INTO call_logs (
id, timestamp, method, path, status, model, requested_model, provider,
account, connection_id, duration, tokens_in, tokens_out,
tokens_cache_read, tokens_cache_creation, tokens_reasoning, tokens_compressed,
cache_source, request_type, source_format, target_format, api_key_id, api_key_name,
combo_name, combo_step_id, combo_execution_key, error_summary, detail_state,
artifact_relpath, artifact_size_bytes, artifact_sha256,
has_request_body, has_response_body, has_pipeline_details, request_summary
)
VALUES (
@id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider,
@account, @connectionId, @duration, @tokensIn, @tokensOut,
@tokensCacheRead, @tokensCacheCreation, @tokensReasoning, @tokensCompressed,
@cacheSource, @requestType, @sourceFormat, @targetFormat, @apiKeyId, @apiKeyName,
@comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState,
@artifactRelPath, @artifactSizeBytes, @artifactSha256,
@hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary
)
`
).run({
...logEntry,
errorSummary: toStoredErrorSummary(protectedError),
detailState,
artifactRelPath,
artifactSizeBytes,
artifactSha256,
hasRequestBody: protectedRequestBody !== null ? 1 : 0,
hasResponseBody: protectedResponseBody !== null ? 1 : 0,
hasPipelineDetails: protectedPipelinePayloads ? 1 : 0,
requestSummary,
});
scheduleCallLogRotation();
} catch (error) {
console.error("[callLogs] Failed to save call log:", (error as Error).message);
}
}
export function rotateCallLogs() {
try {
if (!CALL_LOGS_DIR || !fs.existsSync(CALL_LOGS_DIR)) return;
const retentionMs = getCallLogRetentionDays() * 24 * 60 * 60 * 1000;
const cutoff = new Date(Date.now() - retentionMs).toISOString();
deleteCallLogsBefore(cutoff);
trimCallLogsToMaxRows(getCallLogsTableMaxRows());
cleanupOverflowCallLogFiles(CALL_LOGS_DIR, getCallLogMaxEntries());
cleanupOrphanCallLogFiles(CALL_LOGS_DIR);
} catch (error) {
console.error("[callLogs] Failed to rotate request artifacts:", (error as Error).message);
}
}
function runScheduledCallLogRotation() {
if (callLogRotateInFlight) return;
callLogRotateInFlight = true;
setImmediate(() => {
try {
rotateCallLogs();
} catch (error) {
console.error("[callLogs] Failed to rotate request artifacts:", (error as Error).message);
} finally {
callLogRotateInFlight = false;
}
});
}
export function scheduleCallLogRotation() {
if (!CALL_LOGS_DIR) return;
const elapsed = Date.now() - lastCallLogRotationScheduledAt;
if (elapsed >= CALL_LOG_ROTATE_THROTTLE_MS) {
lastCallLogRotationScheduledAt = Date.now();
runScheduledCallLogRotation();
return;
}
if (callLogRotateScheduled) return;
callLogRotateScheduled = true;
lastCallLogRotationScheduledAt = Date.now();
const timer = setTimeout(() => {
callLogRotateScheduled = false;
runScheduledCallLogRotation();
}, CALL_LOG_ROTATE_THROTTLE_MS - elapsed);
timer.unref?.();
}
if (shouldPersistToDisk && process.env.NODE_ENV !== "test") {
scheduleCallLogRotation();
}
export async function getCallLogs(filter: any = {}) {
const db = getDbInstance();
let sql = `
SELECT cl.*,
pn.prefix AS provider_node_prefix
FROM call_logs cl
LEFT JOIN provider_nodes pn ON pn.id = cl.provider
`;
const conditions: string[] = [];
const params: Record<string, unknown> = {};
if (filter.status) {
if (filter.status === "error") {
conditions.push("(cl.status >= 400 OR cl.error_summary IS NOT NULL)");
} else if (filter.status === "ok") {
conditions.push("cl.status >= 200 AND cl.status < 300");
} else {
const statusCode = parseInt(filter.status, 10);
if (!Number.isNaN(statusCode)) {
conditions.push("cl.status = @statusCode");
params.statusCode = statusCode;
}
}
}
if (filter.model) {
conditions.push("(cl.model LIKE @modelQ OR cl.requested_model LIKE @modelQ)");
params.modelQ = `%${filter.model}%`;
}
if (filter.provider) {
conditions.push("cl.provider LIKE @providerQ");
params.providerQ = `%${filter.provider}%`;
}
if (filter.account) {
conditions.push("cl.account LIKE @accountQ");
params.accountQ = `%${filter.account}%`;
}
if (filter.apiKey) {
conditions.push("(cl.api_key_name LIKE @apiKeyQ OR cl.api_key_id LIKE @apiKeyQ)");
params.apiKeyQ = `%${filter.apiKey}%`;
}
if (filter.combo) {
conditions.push("cl.combo_name IS NOT NULL");
}
if (filter.search) {
conditions.push(`(
cl.model LIKE @searchQ OR cl.path LIKE @searchQ OR cl.account LIKE @searchQ OR
cl.requested_model LIKE @searchQ OR cl.provider LIKE @searchQ OR
cl.api_key_name LIKE @searchQ OR cl.api_key_id LIKE @searchQ OR
cl.combo_name LIKE @searchQ OR CAST(cl.status AS TEXT) LIKE @searchQ
OR cl.combo_step_id LIKE @searchQ OR cl.combo_execution_key LIKE @searchQ
OR cl.error_summary LIKE @searchQ
)`);
params.searchQ = `%${filter.search}%`;
}
if (conditions.length > 0) {
sql += " WHERE " + conditions.join(" AND ");
}
const limit = filter.limit || 200;
sql += ` ORDER BY cl.timestamp DESC LIMIT ${limit}`;
const rows = db.prepare(sql).all(params) as CallLogSummaryRow[];
return rows.map(mapSummaryRow);
}
export async function getCallLogById(id: string) {
const db = getDbInstance();
const row = db
.prepare(
`SELECT cl.*,
pn.prefix AS provider_node_prefix
FROM call_logs cl
LEFT JOIN provider_nodes pn ON pn.id = cl.provider
WHERE cl.id = ?`
)
.get(id) as CallLogSummaryRow | undefined;
if (!row) return null;
const entry = mapSummaryRow(row);
let detailState = entry.detailState;
let artifactRelPath = entry.artifactRelPath;
if (artifactRelPath) {
const artifactResult = readCallArtifact(artifactRelPath);
if (artifactResult.state === "ready" && artifactResult.artifact) {
return {
...entry,
detailState: "ready" as const,
requestBody: artifactResult.artifact.requestBody ?? null,
responseBody: artifactResult.artifact.responseBody ?? null,
error: artifactResult.artifact.error ?? entry.error,
pipelinePayloads: artifactResult.artifact.pipeline ?? buildLegacyPipelinePayloads(id),
hasPipelineDetails: Boolean(artifactResult.artifact.pipeline) || entry.hasPipelineDetails,
};
}
detailState = artifactResult.state;
if (artifactResult.state === "missing") {
clearArtifactReference(artifactRelPath, "missing");
artifactRelPath = null;
} else {
db.prepare("UPDATE call_logs SET detail_state = ? WHERE id = ?").run("corrupt", id);
}
}
if (detailState === "legacy-inline") {
const legacyInline = getLegacyInlineDetail(id);
if (legacyInline) {
const legacyPipeline = buildLegacyPipelinePayloads(id);
return {
...entry,
detailState,
artifactRelPath,
...legacyInline,
pipelinePayloads: legacyPipeline,
hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails,
};
}
}
const legacyDisk = readLegacyLogFromDisk(entry);
if (legacyDisk) {
const legacyPipeline = buildLegacyPipelinePayloads(id);
return {
...entry,
detailState,
artifactRelPath,
requestBody: legacyDisk.requestBody ?? null,
responseBody: legacyDisk.responseBody ?? null,
error: legacyDisk.error ?? entry.error,
pipelinePayloads: legacyPipeline,
hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails,
};
}
const legacyPipeline = buildLegacyPipelinePayloads(id);
return {
...entry,
detailState,
artifactRelPath,
requestBody: null,
responseBody: null,
error: entry.error,
pipelinePayloads: legacyPipeline,
hasPipelineDetails: Boolean(legacyPipeline) || entry.hasPipelineDetails,
};
}
export async function exportCallLogsSince(since: string) {
const db = getDbInstance();
const ids = db
.prepare("SELECT id FROM call_logs WHERE timestamp >= ? ORDER BY timestamp DESC")
.all(since)
.map((row) => String((row as { id: string }).id));
const logs: unknown[] = [];
for (const id of ids) {
const log = await getCallLogById(id);
if (log) logs.push(log);
}
return logs;
}