Files
OmniRoute/src/lib/usage/callLogArtifacts.ts
Diego Rodrigues de Sa e Souza bfaf459f3c Release v3.8.32 (#4418)
Release v3.8.32 — see CHANGELOG.md [3.8.32] for the full list. Merged via --admin over documented non-blocking checks: CodeQL alerts ratchet (#665 fixed by #4457/#4462, auto-closes on main rescan), Integration Tests (env-flaky batch-upstream), SonarCloud/SonarQube (advisory new-code).
2026-06-21 08:56:51 -03:00

338 lines
10 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
import { resolveDataDir } from "../dataPaths";
import { getCallLogPipelineMaxSizeBytes, isChatDebugFileEnabled } from "../logEnv";
const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null;
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
const DATA_DIR = resolveDataDir({ isCloud });
export const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs");
export const MAX_CALL_LOG_ARTIFACT_BYTES = 512 * 1024;
const SIZE_LIMIT_EXCEEDED_REASON = "call_log_artifact_size_limit_exceeded";
const OMITTED_FOR_SIZE_LIMIT = "[omitted: call log artifact size limit exceeded]";
const STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT =
"[stream chunks omitted: call log artifact size limit exceeded]";
export type CallLogDetailState = "none" | "ready" | "missing" | "corrupt" | "legacy-inline";
export type CallLogArtifact = {
schemaVersion: 5;
summary: {
id: string;
timestamp: string;
method: string;
path: string;
status: number;
model: string;
requestedModel: string | null;
provider: string;
account: string;
connectionId: string | null;
duration: number;
tokens: {
in: number;
out: number;
cacheRead: number | null;
cacheWrite: number | null;
reasoning: number | null;
compressed: 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;
pipeline?: RequestPipelinePayloads;
};
export type CallLogArtifactWriteResult = {
relPath: string;
sizeBytes: number;
sha256: string;
};
export type PurgeCallLogArtifactDirectoryResult = {
deletedArtifacts: number;
errors: number;
};
export function buildArtifactRelativePath(timestamp: string, id: string) {
const parsed = new Date(timestamp);
const safeTimestamp = (
Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString()
).replace(/[:]/g, "-");
const dateFolder = safeTimestamp.slice(0, 10);
return path.posix.join(dateFolder, `${safeTimestamp}_${id}.json`);
}
function computeArtifactChecksum(serialized: string): string {
const bytes = Buffer.from(serialized);
let hash = 0x811c9dc5;
for (const byte of bytes) {
hash ^= byte;
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
function truncateArtifactForStorage(artifact: CallLogArtifact): CallLogArtifact {
const pipeline = artifact.pipeline;
if (!pipeline?.streamChunks) return artifact;
return {
...artifact,
pipeline: {
...pipeline,
streamChunks: {
provider: pipeline.streamChunks.provider?.length
? [STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT]
: undefined,
openai: pipeline.streamChunks.openai?.length
? [STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT]
: undefined,
client: pipeline.streamChunks.client?.length
? [STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT]
: undefined,
},
},
};
}
function omitOversizedPipeline(artifact: CallLogArtifact): CallLogArtifact {
if (!artifact.pipeline) return artifact;
return {
...artifact,
pipeline: {
error: {
_omniroute_truncated: true,
reason: SIZE_LIMIT_EXCEEDED_REASON,
},
},
};
}
function getArtifactMaxBytes(artifact: CallLogArtifact): number {
return artifact.pipeline ? getCallLogPipelineMaxSizeBytes() : MAX_CALL_LOG_ARTIFACT_BYTES;
}
function buildMinimalArtifactForSizeLimit(artifact: CallLogArtifact) {
return {
schemaVersion: artifact.schemaVersion,
summary: artifact.summary,
requestBody: OMITTED_FOR_SIZE_LIMIT,
responseBody: OMITTED_FOR_SIZE_LIMIT,
error: artifact.error ? OMITTED_FOR_SIZE_LIMIT : null,
pipeline: {
error: {
_omniroute_truncated: true,
reason: SIZE_LIMIT_EXCEEDED_REASON,
},
},
};
}
function serializeFinalSizeLimitFallback(artifact: CallLogArtifact, maxBytes: number): string {
const withSummary = JSON.stringify(buildMinimalArtifactForSizeLimit(artifact));
if (Buffer.byteLength(withSummary) <= maxBytes) {
return withSummary;
}
return JSON.stringify({
schemaVersion: artifact.schemaVersion,
_omniroute_truncated: true,
reason: SIZE_LIMIT_EXCEEDED_REASON,
});
}
function serializeArtifactForStorage(artifact: CallLogArtifact): string {
// Debug mode: write full untruncated payload
if (isChatDebugFileEnabled()) {
return JSON.stringify(artifact, null, 2);
}
const maxBytes = getArtifactMaxBytes(artifact);
// Single-pass, non-pretty serialization on the hot path. Artifacts are machine-read via
// JSON.parse (readCallArtifact), so pretty-printing only doubled the bytes and CPU of
// serializing large request/response bodies on every request — a contributor to the
// CPU-runaway. The debug path above keeps pretty output for human inspection.
const serialized = JSON.stringify(artifact);
if (Buffer.byteLength(serialized) <= maxBytes) {
return serialized;
}
const truncated = JSON.stringify(truncateArtifactForStorage(artifact));
if (Buffer.byteLength(truncated) <= maxBytes) {
return truncated;
}
const withoutPipeline = JSON.stringify(omitOversizedPipeline(artifact));
if (Buffer.byteLength(withoutPipeline) <= maxBytes) {
return withoutPipeline;
}
const minimal = JSON.stringify({
...omitOversizedPipeline(artifact),
requestBody: OMITTED_FOR_SIZE_LIMIT,
responseBody: OMITTED_FOR_SIZE_LIMIT,
error: artifact.error ? OMITTED_FOR_SIZE_LIMIT : null,
});
if (Buffer.byteLength(minimal) <= maxBytes) {
return minimal;
}
return serializeFinalSizeLimitFallback(artifact, maxBytes);
}
export function writeCallArtifact(
artifact: CallLogArtifact,
relativePath = buildArtifactRelativePath(artifact.summary.timestamp, artifact.summary.id)
): CallLogArtifactWriteResult | null {
if (!CALL_LOGS_DIR || isBuildPhase) return null;
const absPath = path.join(CALL_LOGS_DIR, relativePath);
const tmpPath = `${absPath}.${process.pid}.${Date.now()}.tmp`;
try {
const serialized = serializeArtifactForStorage(artifact);
const sizeBytes = Buffer.byteLength(serialized);
// Keep the legacy field name for storage compatibility, but use a non-cryptographic checksum
// so artifact bookkeeping is not treated as password hashing by static analysis.
const fileChecksum = computeArtifactChecksum(serialized);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(tmpPath, serialized);
fs.renameSync(tmpPath, absPath);
return {
relPath: relativePath,
sizeBytes,
sha256: fileChecksum,
};
} catch (error) {
try {
fs.rmSync(tmpPath, { force: true });
} catch {
// Best effort cleanup only.
}
console.error("[callLogs] Failed to write request artifact:", (error as Error).message);
return null;
}
}
export function readCallArtifact(relativePath: string | null): {
artifact: CallLogArtifact | null;
state: "ready" | "missing" | "corrupt";
} {
if (!CALL_LOGS_DIR || !relativePath) {
return { artifact: null, state: "missing" };
}
try {
const absPath = path.join(CALL_LOGS_DIR, relativePath);
if (!fs.existsSync(absPath)) {
return { artifact: null, state: "missing" };
}
return {
artifact: JSON.parse(fs.readFileSync(absPath, "utf8")) as CallLogArtifact,
state: "ready",
};
} catch (error) {
console.error("[callLogs] Failed to read request artifact:", (error as Error).message);
return { artifact: null, state: "corrupt" };
}
}
export function deleteCallArtifact(relativePath: string | null): boolean {
if (!CALL_LOGS_DIR || !relativePath) return false;
try {
const absPath = path.join(CALL_LOGS_DIR, relativePath);
if (!fs.existsSync(absPath)) return false;
fs.rmSync(absPath, { force: true });
return true;
} catch {
return false;
}
}
export function cleanupEmptyCallLogDirs(baseDir = CALL_LOGS_DIR) {
if (!baseDir || !fs.existsSync(baseDir)) return;
try {
for (const entry of fs.readdirSync(baseDir)) {
const entryPath = path.join(baseDir, entry);
const stat = fs.statSync(entryPath);
if (!stat.isDirectory()) continue;
if (fs.readdirSync(entryPath).length === 0) {
fs.rmSync(entryPath, { recursive: true, force: true });
}
}
} catch {
// Best effort only.
}
}
export function listCallLogArtifactFiles(baseDir = CALL_LOGS_DIR) {
if (!baseDir || !fs.existsSync(baseDir)) return [];
return fs
.readdirSync(baseDir)
.flatMap((entry) => {
const entryPath = path.join(baseDir, entry);
try {
const stat = fs.statSync(entryPath);
if (!stat.isDirectory()) return [];
return fs
.readdirSync(entryPath)
.filter((file) => file.endsWith(".json"))
.map((file) => {
const absPath = path.join(entryPath, file);
const fileStat = fs.statSync(absPath);
return {
relativePath: path.posix.join(entry, file),
absPath,
mtimeMs: fileStat.mtimeMs,
};
});
} catch {
return [];
}
})
.sort((a, b) => b.mtimeMs - a.mtimeMs);
}
export function purgeCallLogArtifactDirectory(
baseDir = CALL_LOGS_DIR
): PurgeCallLogArtifactDirectoryResult {
const result = { deletedArtifacts: 0, errors: 0 };
if (!baseDir || !fs.existsSync(baseDir)) return result;
try {
result.deletedArtifacts = listCallLogArtifactFiles(baseDir).length;
} catch {
result.deletedArtifacts = 0;
}
try {
fs.rmSync(baseDir, { recursive: true, force: true });
} catch (error) {
console.error("[callLogArtifacts] Failed to purge call log artifacts:", error);
result.deletedArtifacts = 0;
result.errors++;
}
return result;
}