Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section documents the new rotation behavior, but other repo docs still describe the old request-logger pipeline/file layout (including 2_req_source.json and ENABLE_REQUEST_LOGS=true), e.g. docs/ARCHITECTURE.md and docs/CODEBASE_DOCUMENTATION.md. That drift can confuse operators upgrading to the unified logging model; please update or remove those older references (and any localized copies if applicable) to match the new pipeline stages and env var model.

Suggested change
- **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
- **Unified File-Based Logging with Rotation** — Part of a single unified logging pipeline; app logs rotate by size, retention days, and archive count, and call log artifacts rotate by retention days and file count.
- **Modern Logging Env Vars** — Configurable via the unified logging env-var model; older request-logger files like `2_req_source.json` and the `ENABLE_REQUEST_LOGS` flag are no longer used and are superseded by this pipeline.

Copilot uses AI. Check for mistakes.
- **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>
Expand Down Expand Up @@ -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**

Expand Down
3 changes: 0 additions & 3 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──
Expand Down
11 changes: 0 additions & 11 deletions open-sse/utils/requestLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ type HeaderInput =

export type RequestPipelinePayloads = {
clientRawRequest?: JsonRecord;
sourceRequest?: JsonRecord;
openaiRequest?: JsonRecord;
providerRequest?: JsonRecord;
providerResponse?: JsonRecord;
Expand All @@ -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: (
Expand Down Expand Up @@ -115,7 +113,6 @@ function createNoOpLogger(): RequestLogger {
return {
sessionPath: null,
logClientRawRequest() {},
logRawRequest() {},
logOpenAIRequest() {},
logTargetRequest() {},
logProviderResponse() {},
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 0 additions & 1 deletion src/app/api/translator/load/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion src/app/api/translator/save/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/lib/logEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
45 changes: 44 additions & 1 deletion src/lib/logRotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@
* 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:
* - APP_LOG_TO_FILE: enable file logging (default: true)
* - 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,
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -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.
Expand All @@ -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);
}
67 changes: 66 additions & 1 deletion src/lib/usage/callLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
parseStoredPayload,
serializePayloadForStorage,
} from "../logPayloads";
import { getCallLogRetentionDays } from "../logEnv";
import { getCallLogMaxEntries, getCallLogRetentionDays } from "../logEnv";

type JsonRecord = Record<string, unknown>;

Expand Down Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Performance concern - rotation and overflow cleanup runs synchronously after every artifact write. This includes retention-based directory cleanup and file count pruning, which could cause latency spikes under high request throughput.

Consider deferring the cleanup to a scheduled job or batching it (e.g., run cleanup every N writes or every X seconds).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Executing rotateCallLogs() synchronously on every request that persists to disk introduces a significant performance bottleneck. This function performs a full recursive scan of the call_logs directory (potentially thousands of files across multiple subdirectories) to enforce retention caps. In high-traffic scenarios, this will lead to substantial I/O overhead and increased request latency.

Consider moving this maintenance task to a background interval or a periodic schedule (e.g., once an hour) instead of running it on the hot path of every request.

return relPath;
Comment on lines 230 to 234

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeCallArtifact() now calls rotateCallLogs() after every artifact write. Since rotateCallLogs() + cleanupOverflowCallLogFiles() do synchronous directory scans + statSync over potentially many entries, this adds per-request filesystem work on the hot path and can increase request latency under load. Consider throttling/debouncing rotation (e.g., run at most once per N seconds / N writes), or moving pruning to a background interval/job so request handling isn’t blocked by cleanup.

Copilot uses AI. Check for mistakes.
} catch (error) {
console.error("[callLogs] Failed to write request artifact:", (error as Error).message);
Expand Down Expand Up @@ -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);
Comment on lines +321 to +341

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The implementation of cleanupOverflowCallLogFiles performs a full recursive scan of the disk (readdirSync + statSync on every file) to determine which files to prune. Since call logs are already indexed in the SQLite call_logs table, it would be much more efficient to query the database for the oldest entries exceeding the limit and delete those specific files. This would avoid expensive disk-crawling operations, especially as the number of logs approaches the 10,000 entry limit.


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;

Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 0 additions & 1 deletion src/shared/components/RequestLoggerDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading