Refine pipeline logging and add retention caps
This commit is contained in:
+11
-10
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
</details>
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+44
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
parseStoredPayload,
|
||||
serializePayloadForStorage,
|
||||
} from "../logPayloads";
|
||||
import { getCallLogRetentionDays } from "../logEnv";
|
||||
import { getCallLogMaxEntries, getCallLogRetentionDays } from "../logEnv";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${detailLoggingEnabled ? "bg-amber-500" : "bg-text-muted"}`}
|
||||
/>
|
||||
{detailLoggingLoading
|
||||
? "Updating detailed logs..."
|
||||
? "Updating pipeline logs..."
|
||||
: detailLoggingEnabled
|
||||
? "Detailed Logs On"
|
||||
: "Detailed Logs Off"}
|
||||
? "Pipeline Logs On"
|
||||
: "Pipeline Logs Off"}
|
||||
</button>
|
||||
|
||||
{detailLoggingReady && (
|
||||
<span className="text-[11px] text-text-muted">
|
||||
New requests will {detailLoggingEnabled ? "" : "not "}capture client/provider pipeline
|
||||
payloads.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-[18px]">
|
||||
@@ -769,8 +759,8 @@ export default function RequestLoggerV2() {
|
||||
</Card>
|
||||
|
||||
<div className="text-[10px] text-text-muted italic">
|
||||
Each request is also saved as a single JSON artifact in{" "}
|
||||
<code>{`{DATA_DIR}/call_logs/`}</code>.
|
||||
Call logs are also saved as JSON files to <code>{`{DATA_DIR}/call_logs/`}</code> and rotated
|
||||
by <code>CALL_LOG_RETENTION_DAYS</code> and <code>CALL_LOG_MAX_ENTRIES</code>.
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user