diff --git a/.env.example b/.env.example index 00d38538..b92b5e7d 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,8 @@ STORAGE_DRIVER=sqlite # Generate with: openssl rand -hex 32 STORAGE_ENCRYPTION_KEY= STORAGE_ENCRYPTION_KEY_VERSION=v1 -LOG_RETENTION_DAYS=90 +APP_LOG_RETENTION_DAYS=90 +CALL_LOG_RETENTION_DAYS=90 SQLITE_MAX_SIZE_MB=2048 SQLITE_CLEAN_LEGACY_FILES=true DISABLE_SQLITE_AUTO_BACKUP=false @@ -38,7 +39,6 @@ INSTANCE_NAME=omniroute # Recommended security and ops variables MACHINE_ID_SALT=endpoint-proxy-salt -ENABLE_REQUEST_LOGS=false AUTH_COOKIE_SECURE=false REQUIRE_API_KEY=false ALLOW_API_KEY_REVEAL=false @@ -197,12 +197,15 @@ GEMINI_CLI_USER_AGENT=google-api-nodejs-client/9.15.1 # CORS_ORIGINS=* # Logging -# LOG_LEVEL=info -# LOG_FORMAT=text -LOG_TO_FILE=true -# LOG_FILE_PATH=logs/application/app.log -# LOG_MAX_FILE_SIZE=50M -# LOG_RETENTION_DAYS=7 +# APP_LOG_LEVEL=info +# APP_LOG_FORMAT=text +APP_LOG_TO_FILE=true +# APP_LOG_FILE_PATH=logs/application/app.log +# APP_LOG_MAX_FILE_SIZE=50M +# APP_LOG_RETENTION_DAYS=7 +# APP_LOG_MAX_FILES=20 +# CALL_LOG_RETENTION_DAYS=7 +# CALL_LOG_MAX_ENTRIES=10000 # ───────────────────────────────────────────────────────────────────────────── # Memory Optimization (Low-RAM configurations) @@ -221,6 +224,4 @@ LOG_TO_FILE=true # SEMANTIC_CACHE_TTL_MS=1800000 # In-memory log buffers -# PROXY_LOG_MAX_ENTRIES=200 -# CALL_LOGS_MAX=200 # STREAM_HISTORY_MAX=50 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d1a3544..38ecea1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,10 +41,11 @@ - **Legacy Request Log Upgrade Backup:** Upgrades now archive old `data/logs/`, legacy `data/call_logs/`, and `data/log.txt` layouts into `DATA_DIR/log_archives/*.zip` before removing the deprecated structure. - **Streaming Usage Persistence:** Streaming requests now write a single `usage_history` row on completion instead of emitting a duplicate in-progress usage row with empty status metadata. +- **Logging Follow-up Cleanup:** Pipeline logs no longer capture `SOURCE REQUEST`, request artifact entries now honor `CALL_LOG_MAX_ENTRIES`, and application log archives now honor `APP_LOG_MAX_FILES`. --- -## [3.3.11] - 2026-03-31 +## [3.4.0] - 2026-03-31 ### 🚀 Features diff --git a/README.md b/README.md index 0aba453e..dbbc474e 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,11 @@ _Your universal API proxy — one endpoint, 67+ providers, zero downtime. Now wi > - `APP_LOG_FILE_PATH` > - `APP_LOG_MAX_FILE_SIZE` > - `APP_LOG_RETENTION_DAYS` +> - `APP_LOG_MAX_FILES` > - `APP_LOG_LEVEL` > - `APP_LOG_FORMAT` > - `CALL_LOG_RETENTION_DAYS` +> - `CALL_LOG_MAX_ENTRIES` > > For release details and upgrade notes, see the [CHANGELOG](CHANGELOG.md). @@ -415,7 +417,7 @@ When a call fails, the dev doesn't know if it was a rate limit, expired token, w - **SQLite Proxy Logs** — Persistent logs that survive server restarts - **Translator Playground** — 4 debugging modes: Playground (format translation), Chat Tester (round-trip), Test Bench (batch), Live Monitor (real-time) - **Request Telemetry** — p50/p95/p99 latency + X-Request-Id tracing -- **File-Based Logging with Rotation** — Console interceptor captures everything to JSON log with size-based rotation +- **File-Based Logging with Rotation** — App logs rotate by size, retention days, and archive count; call log artifacts rotate by retention days and file count - **System Info Report** — `npm run system-info` generates `system-info.txt` with your full environment (Node version, OmniRoute version, OS, CLI tools, Docker/PM2 status). Attach it when reporting issues for instant triage. @@ -1945,6 +1947,7 @@ opencode - Request artifacts are written to `DATA_DIR/call_logs/` as one JSON file per request - Enable pipeline capture from Dashboard → Logs → Request Logs if you need detailed per-stage payloads - Set `APP_LOG_TO_FILE=true` if you also want application console logs in `logs/application/app.log` +- Adjust `APP_LOG_MAX_FILE_SIZE`, `APP_LOG_RETENTION_DAYS`, `APP_LOG_MAX_FILES`, and `CALL_LOG_MAX_ENTRIES` as needed **Connection test shows "Invalid" for OpenAI-compatible providers** diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d8a46133..2b0926bd 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -646,9 +646,6 @@ export async function handleChatCore({ ); } - // 1. Log raw request from client - reqLogger.logRawRequest(body); - log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`); // ── Common input sanitization (runs for ALL paths including passthrough) ── diff --git a/open-sse/utils/requestLogger.ts b/open-sse/utils/requestLogger.ts index c99549f5..861c0aad 100644 --- a/open-sse/utils/requestLogger.ts +++ b/open-sse/utils/requestLogger.ts @@ -9,7 +9,6 @@ type HeaderInput = export type RequestPipelinePayloads = { clientRawRequest?: JsonRecord; - sourceRequest?: JsonRecord; openaiRequest?: JsonRecord; providerRequest?: JsonRecord; providerResponse?: JsonRecord; @@ -25,7 +24,6 @@ export type RequestPipelinePayloads = { type RequestLogger = { sessionPath: null; logClientRawRequest: (endpoint: unknown, body: unknown, headers?: HeaderInput) => void; - logRawRequest: (body: unknown, headers?: HeaderInput) => void; logOpenAIRequest: (body: unknown) => void; logTargetRequest: (url: unknown, headers: HeaderInput, body: unknown) => void; logProviderResponse: ( @@ -115,7 +113,6 @@ function createNoOpLogger(): RequestLogger { return { sessionPath: null, logClientRawRequest() {}, - logRawRequest() {}, logOpenAIRequest() {}, logTargetRequest() {}, logProviderResponse() {}, @@ -152,14 +149,6 @@ export async function createRequestLogger( }; }, - logRawRequest(body, headers = {}) { - payloads.sourceRequest = { - timestamp: new Date().toISOString(), - headers: maskSensitiveHeaders(headers), - body, - }; - }, - logOpenAIRequest(body) { payloads.openaiRequest = { timestamp: new Date().toISOString(), diff --git a/src/app/api/translator/load/route.ts b/src/app/api/translator/load/route.ts index e5ccb645..a65e1e41 100644 --- a/src/app/api/translator/load/route.ts +++ b/src/app/api/translator/load/route.ts @@ -17,7 +17,6 @@ export async function GET(request) { // Security: only allow specific filenames const allowedFiles = [ "1_req_client.json", - "2_req_source.json", "3_req_openai.json", "4_req_target.json", "5_res_provider.txt", diff --git a/src/app/api/translator/save/route.ts b/src/app/api/translator/save/route.ts index dd96df46..c5ab45af 100644 --- a/src/app/api/translator/save/route.ts +++ b/src/app/api/translator/save/route.ts @@ -31,7 +31,6 @@ export async function POST(request) { // Security: only allow specific filenames const allowedFiles = [ "1_req_client.json", - "2_req_source.json", "3_req_openai.json", "4_req_target.json", "5_res_provider.txt", diff --git a/src/lib/logEnv.ts b/src/lib/logEnv.ts index 381a3294..c1524cb9 100644 --- a/src/lib/logEnv.ts +++ b/src/lib/logEnv.ts @@ -3,6 +3,8 @@ import path from "path"; const DEFAULT_APP_LOG_RETENTION_DAYS = 7; const DEFAULT_CALL_LOG_RETENTION_DAYS = 7; const DEFAULT_APP_LOG_MAX_SIZE = 50 * 1024 * 1024; +const DEFAULT_APP_LOG_MAX_FILES = 20; +const DEFAULT_CALL_LOG_MAX_ENTRIES = 10000; const DEFAULT_APP_LOG_PATH = path.join(process.cwd(), "logs", "application", "app.log"); function parsePositiveInt(value: string | undefined, fallback: number): number { @@ -52,6 +54,14 @@ export function getCallLogRetentionDays(): number { return parsePositiveInt(process.env.CALL_LOG_RETENTION_DAYS, DEFAULT_CALL_LOG_RETENTION_DAYS); } +export function getAppLogMaxFiles(): number { + return parsePositiveInt(process.env.APP_LOG_MAX_FILES, DEFAULT_APP_LOG_MAX_FILES); +} + +export function getCallLogMaxEntries(): number { + return parsePositiveInt(process.env.CALL_LOG_MAX_ENTRIES, DEFAULT_CALL_LOG_MAX_ENTRIES); +} + export function getAppLogLevel(defaultLevel: string): string { return process.env.APP_LOG_LEVEL || defaultLevel; } diff --git a/src/lib/logRotation.ts b/src/lib/logRotation.ts index c4644135..45c3297c 100644 --- a/src/lib/logRotation.ts +++ b/src/lib/logRotation.ts @@ -4,6 +4,7 @@ * Handles: * - Rotating log files when they exceed max size * - Cleaning up old log files past retention period + * - Capping the number of rotated log files kept on disk * - Creating the log directory on startup * * Configuration via env vars: @@ -11,12 +12,14 @@ * - APP_LOG_FILE_PATH: path to log file (default: logs/application/app.log) * - APP_LOG_MAX_FILE_SIZE: max file size before rotation (default: 50MB) * - APP_LOG_RETENTION_DAYS: days to keep old logs (default: 7) + * - APP_LOG_MAX_FILES: max number of rotated log files to keep (default: 20) */ import { existsSync, mkdirSync, statSync, renameSync, readdirSync, unlinkSync } from "fs"; import { dirname, join, basename, extname } from "path"; import { getAppLogFilePath, + getAppLogMaxFiles, getAppLogMaxFileSize, getAppLogRetentionDays, getAppLogToFile, @@ -27,8 +30,9 @@ export function getLogConfig() { const logFilePath = getAppLogFilePath() || join(process.cwd(), "logs/application/app.log"); const maxFileSize = getAppLogMaxFileSize(); const retentionDays = getAppLogRetentionDays(); + const maxFiles = getAppLogMaxFiles(); - return { logToFile, logFilePath, maxFileSize, retentionDays }; + return { logToFile, logFilePath, maxFileSize, retentionDays, maxFiles }; } /** @@ -100,6 +104,44 @@ export function cleanupOldLogs(logFilePath: string, retentionDays: number): void } } +/** + * Keep only the newest rotated files up to the configured count limit. + */ +export function cleanupOverflowLogs(logFilePath: string, maxFiles: number): void { + try { + const dir = dirname(logFilePath); + if (!existsSync(dir) || maxFiles < 1) return; + + const ext = extname(logFilePath); + const base = basename(logFilePath, ext); + const rotatedFiles = readdirSync(dir) + .filter( + (file) => + file !== basename(logFilePath) && file.startsWith(base + ".") && file.endsWith(ext) + ) + .map((file) => { + const filePath = join(dir, file); + try { + return { filePath, mtimeMs: statSync(filePath).mtimeMs }; + } catch { + return null; + } + }) + .filter((entry): entry is { filePath: string; mtimeMs: number } => !!entry) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + for (const entry of rotatedFiles.slice(maxFiles)) { + try { + unlinkSync(entry.filePath); + } catch { + // Best effort only. + } + } + } catch { + // Cleanup is best-effort + } +} + /** * Initialize log rotation — call once at application startup. * Creates directories, rotates if needed, and cleans up old files. @@ -111,4 +153,5 @@ export function initLogRotation(): void { ensureLogDir(config.logFilePath); rotateIfNeeded(config.logFilePath, config.maxFileSize); cleanupOldLogs(config.logFilePath, config.retentionDays); + cleanupOverflowLogs(config.logFilePath, config.maxFiles); } diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index cca04ee9..abb7344d 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -21,7 +21,7 @@ import { parseStoredPayload, serializePayloadForStorage, } from "../logPayloads"; -import { getCallLogRetentionDays } from "../logEnv"; +import { getCallLogMaxEntries, getCallLogRetentionDays } from "../logEnv"; type JsonRecord = Record; @@ -230,6 +230,7 @@ function writeCallArtifact(artifact: CallLogArtifact): string | null { try { fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(absPath, JSON.stringify(artifact, null, 2)); + rotateCallLogs(); return relPath; } catch (error) { console.error("[callLogs] Failed to write request artifact:", (error as Error).message); @@ -293,6 +294,69 @@ function readLegacyLogFromDisk(entry: { return null; } +function cleanupEmptyCallLogDirs() { + if (!CALL_LOGS_DIR || !fs.existsSync(CALL_LOGS_DIR)) return; + + try { + for (const entry of fs.readdirSync(CALL_LOGS_DIR)) { + const entryPath = path.join(CALL_LOGS_DIR, 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 cleanupOverflowCallLogFiles(baseDir = CALL_LOGS_DIR, maxEntries?: number) { + if (!baseDir || !fs.existsSync(baseDir)) return; + + const limit = maxEntries ?? getCallLogMaxEntries(); + if (!Number.isInteger(limit) || limit < 1) return; + + try { + const files = 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 filePath = path.join(entryPath, file); + const fileStat = fs.statSync(filePath); + return { filePath, mtimeMs: fileStat.mtimeMs }; + }); + } catch { + return []; + } + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + for (const file of files.slice(limit)) { + try { + fs.rmSync(file.filePath, { force: true }); + } catch { + // Best effort only. + } + } + + cleanupEmptyCallLogDirs(); + } catch (error) { + console.error( + "[callLogs] Failed to prune overflow request artifacts:", + (error as Error).message + ); + } +} + export async function saveCallLog(entry: any) { if (!shouldPersistToDisk) return; @@ -392,6 +456,7 @@ export function rotateCallLogs() { fs.rmSync(entryPath, { recursive: true, force: true }); } } + cleanupOverflowCallLogFiles(CALL_LOGS_DIR, getCallLogMaxEntries()); } catch (error) { console.error("[callLogs] Failed to rotate request artifacts:", (error as Error).message); } diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index 19a45caa..8b231465 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -94,7 +94,6 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC ? [ ["clientRawRequest", "Client Raw Request"], ["clientRequest", "Client Request"], - ["sourceRequest", "Source Request"], ["openaiRequest", "OpenAI Request"], ["providerRequest", "Provider Request"], ["providerResponse", "Provider Response"], diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index 5b5fba9f..1593a72d 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -95,7 +95,6 @@ export default function RequestLoggerV2() { const [detailData, setDetailData] = useState(null); const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); const [detailLoggingLoading, setDetailLoggingLoading] = useState(false); - const [detailLoggingReady, setDetailLoggingReady] = useState(false); const intervalRef = useRef(null); const hasLoadedRef = useRef(false); const [providerNodes, setProviderNodes] = useState([]); @@ -173,7 +172,6 @@ export default function RequestLoggerV2() { .then((data) => { if (!data) return; setDetailLoggingEnabled(data.enabled === true); - setDetailLoggingReady(true); }) .catch(() => {}); }, []); @@ -258,11 +256,10 @@ export default function RequestLoggerV2() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: nextEnabled }), }); - if (!res.ok) throw new Error("Failed to update detailed logging"); + if (!res.ok) throw new Error("Failed to update pipeline logging"); setDetailLoggingEnabled(nextEnabled); - setDetailLoggingReady(true); } catch (error) { - console.error("Failed to toggle detailed logging:", error); + console.error("Failed to toggle pipeline logging:", error); } finally { setDetailLoggingLoading(false); } @@ -315,25 +312,18 @@ export default function RequestLoggerV2() { ? "bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-300" : "bg-bg-subtle border-border text-text-muted" }`} - title="Capture per-request pipeline payloads inside the unified call log artifact" + title="Capture pipeline payloads for new requests" > {detailLoggingLoading - ? "Updating detailed logs..." + ? "Updating pipeline logs..." : detailLoggingEnabled - ? "Detailed Logs On" - : "Detailed Logs Off"} + ? "Pipeline Logs On" + : "Pipeline Logs Off"} - {detailLoggingReady && ( - - New requests will {detailLoggingEnabled ? "" : "not "}capture client/provider pipeline - payloads. - - )} - {/* Search */}
@@ -769,8 +759,8 @@ export default function RequestLoggerV2() {
- Each request is also saved as a single JSON artifact in{" "} - {`{DATA_DIR}/call_logs/`}. + Call logs are also saved as JSON files to {`{DATA_DIR}/call_logs/`} and rotated + by CALL_LOG_RETENTION_DAYS and CALL_LOG_MAX_ENTRIES.
{/* Detail Modal */} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index fbab5451..7a2c0380 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -767,7 +767,6 @@ const nonEmptyJsonRecordSchema = jsonRecordSchema.refine( const translatorLogFileSchema = z.enum([ "1_req_client.json", - "2_req_source.json", "3_req_openai.json", "4_req_target.json", "5_res_provider.txt", diff --git a/tests/unit/call-log-cap.test.mjs b/tests/unit/call-log-cap.test.mjs index a2fe775d..d9b50b77 100644 --- a/tests/unit/call-log-cap.test.mjs +++ b/tests/unit/call-log-cap.test.mjs @@ -70,6 +70,7 @@ test("call logs store a single per-request artifact with pipeline details", asyn assert.equal(artifact.summary.id, logId); assert.equal(artifact.summary.requestedModel, "openai/gpt-5"); assert.equal(artifact.pipeline.clientRawRequest.body.raw, true); + assert.equal("sourceRequest" in artifact.pipeline, false); }); test("call log artifact rotation removes directories older than CALL_LOG_RETENTION_DAYS", async () => { diff --git a/tests/unit/call-log-file-rotation.test.mjs b/tests/unit/call-log-file-rotation.test.mjs new file mode 100644 index 00000000..22e457b0 --- /dev/null +++ b/tests/unit/call-log-file-rotation.test.mjs @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-call-log-files-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_RETENTION_DAYS = process.env.CALL_LOG_RETENTION_DAYS; +const ORIGINAL_MAX_ENTRIES = process.env.CALL_LOG_MAX_ENTRIES; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.CALL_LOG_RETENTION_DAYS = "7"; +process.env.CALL_LOG_MAX_ENTRIES = "2"; + +const { rotateCallLogs } = await import("../../src/lib/usage/callLogs.ts"); +const { CALL_LOGS_DIR } = await import("../../src/lib/usage/migrations.ts"); + +test.after(() => { + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } + + if (ORIGINAL_RETENTION_DAYS === undefined) { + delete process.env.CALL_LOG_RETENTION_DAYS; + } else { + process.env.CALL_LOG_RETENTION_DAYS = ORIGINAL_RETENTION_DAYS; + } + + if (ORIGINAL_MAX_ENTRIES === undefined) { + delete process.env.CALL_LOG_MAX_ENTRIES; + } else { + process.env.CALL_LOG_MAX_ENTRIES = ORIGINAL_MAX_ENTRIES; + } + + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("call log file rotation honors both retention days and file count", () => { + assert.ok(CALL_LOGS_DIR, "CALL_LOGS_DIR should resolve for test data dir"); + fs.rmSync(CALL_LOGS_DIR, { recursive: true, force: true }); + fs.mkdirSync(CALL_LOGS_DIR, { recursive: true }); + + const oldDir = path.join(CALL_LOGS_DIR, "2026-03-01"); + const activeDir = path.join(CALL_LOGS_DIR, "2026-03-31"); + fs.mkdirSync(oldDir, { recursive: true }); + fs.mkdirSync(activeDir, { recursive: true }); + + const oldFile = path.join(oldDir, "080000_old_200.json"); + const keepA = path.join(activeDir, "090000_keep-a_200.json"); + const keepB = path.join(activeDir, "091000_keep-b_200.json"); + const keepC = path.join(activeDir, "092000_keep-c_200.json"); + + for (const file of [oldFile, keepA, keepB, keepC]) { + fs.writeFileSync(file, JSON.stringify({ file }), "utf8"); + } + + const now = Date.now(); + const oneDay = 24 * 60 * 60 * 1000; + fs.utimesSync(oldFile, new Date(now - 10 * oneDay), new Date(now - 10 * oneDay)); + fs.utimesSync(oldDir, new Date(now - 10 * oneDay), new Date(now - 10 * oneDay)); + fs.utimesSync(keepA, new Date(now - 3 * oneDay), new Date(now - 3 * oneDay)); + fs.utimesSync(keepB, new Date(now - 2 * oneDay), new Date(now - 2 * oneDay)); + fs.utimesSync(keepC, new Date(now - oneDay), new Date(now - oneDay)); + + rotateCallLogs(); + + assert.equal(fs.existsSync(oldDir), false); + assert.equal(fs.existsSync(keepA), false); + assert.equal(fs.existsSync(keepB), true); + assert.equal(fs.existsSync(keepC), true); +}); diff --git a/tests/unit/log-rotation.test.mjs b/tests/unit/log-rotation.test.mjs new file mode 100644 index 00000000..559c5655 --- /dev/null +++ b/tests/unit/log-rotation.test.mjs @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const { cleanupOldLogs, cleanupOverflowLogs, getLogConfig } = + await import("../../src/lib/logRotation.ts"); + +test("getLogConfig reads APP_LOG_* values", () => { + const originalEnv = { + APP_LOG_TO_FILE: process.env.APP_LOG_TO_FILE, + APP_LOG_FILE_PATH: process.env.APP_LOG_FILE_PATH, + APP_LOG_MAX_FILE_SIZE: process.env.APP_LOG_MAX_FILE_SIZE, + APP_LOG_RETENTION_DAYS: process.env.APP_LOG_RETENTION_DAYS, + APP_LOG_MAX_FILES: process.env.APP_LOG_MAX_FILES, + }; + + process.env.APP_LOG_TO_FILE = "false"; + process.env.APP_LOG_FILE_PATH = "/tmp/omniroute-test-app.log"; + process.env.APP_LOG_MAX_FILE_SIZE = "64M"; + process.env.APP_LOG_RETENTION_DAYS = "14"; + process.env.APP_LOG_MAX_FILES = "12"; + + try { + const config = getLogConfig(); + + assert.equal(config.logToFile, false); + assert.equal(config.logFilePath, "/tmp/omniroute-test-app.log"); + assert.equal(config.maxFileSize, 64 * 1024 * 1024); + assert.equal(config.retentionDays, 14); + assert.equal(config.maxFiles, 12); + } finally { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +}); + +test("app log cleanup honors both retention days and file count", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-log-rotation-")); + const logFilePath = path.join(tmpDir, "app.log"); + + fs.writeFileSync(logFilePath, "", "utf8"); + + const oldFile = path.join(tmpDir, "app.2026-03-01_010101.log"); + const keepA = path.join(tmpDir, "app.2026-03-02_010101.log"); + const keepB = path.join(tmpDir, "app.2026-03-03_010101.log"); + const dropByCount = path.join(tmpDir, "app.2026-03-04_010101.log"); + + for (const file of [oldFile, keepA, keepB, dropByCount]) { + fs.writeFileSync(file, file, "utf8"); + } + + const now = Date.now(); + const oneDay = 24 * 60 * 60 * 1000; + fs.utimesSync(oldFile, new Date(now - 10 * oneDay), new Date(now - 10 * oneDay)); + fs.utimesSync(keepA, new Date(now - 3 * oneDay), new Date(now - 3 * oneDay)); + fs.utimesSync(keepB, new Date(now - 2 * oneDay), new Date(now - 2 * oneDay)); + fs.utimesSync(dropByCount, new Date(now - oneDay), new Date(now - oneDay)); + + cleanupOldLogs(logFilePath, 7); + cleanupOverflowLogs(logFilePath, 2); + + assert.equal(fs.existsSync(oldFile), false); + assert.equal(fs.existsSync(keepA), false); + assert.equal(fs.existsSync(keepB), true); + assert.equal(fs.existsSync(dropByCount), true); + + fs.rmSync(tmpDir, { recursive: true, force: true }); +});