fix: Added in debug mode, support for storing raw data in json (#2156)

Integrated into release/v3.8.0 — configurable chat log truncation, CHAT_DEBUG_FILE mode, cloudflared state file lock
This commit is contained in:
ipanghu
2026-05-11 21:19:09 +08:00
committed by GitHub
parent 6fc66eaba7
commit d3e0353203
8 changed files with 191 additions and 22 deletions

View File

@@ -621,6 +621,14 @@ APP_LOG_TO_FILE=true
# Default: 512
# CALL_LOG_PIPELINE_MAX_SIZE_KB=512
# Call log payload truncation limits — controls how much of request/response
# bodies is retained in the database.
# Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload()
# CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB)
# CHAT_LOG_ARRAY_TAIL_ITEMS=24 # Number of array items retained from tail (default: 24)
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
# Maximum rows in the proxy_logs SQLite table.
# Default: 100000
# PROXY_LOGS_TABLE_MAX_ROWS=100000
@@ -777,6 +785,12 @@ APP_LOG_TO_FILE=true
# Log Responses API SSE-to-JSON translation details.
# DEBUG_RESPONSES_SSE_TO_JSON=true
# Write raw (untruncated) request/response JSON in call log artifacts.
# When enabled, serializeArtifactForStorage skips size-based truncation.
# Also enabled automatically when APP_LOG_LEVEL=debug.
# WARNING: produces large files — use only for temporary debugging.
# CHAT_DEBUG_FILE=true
# Enable E2E test mode — relaxes auth and enables test harness hooks.
# NEXT_PUBLIC_OMNIROUTE_E2E_MODE=true

View File

@@ -0,0 +1,44 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

50
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'

View File

@@ -45,7 +45,13 @@ import {
} from "../services/errorClassifier.ts";
import { updateProviderConnection } from "@/lib/db/providers";
import { isDetailedLoggingEnabled } from "@/lib/db/detailedLogs";
import { getCallLogPipelineCaptureStreamChunks } from "@/lib/logEnv";
import {
getCallLogPipelineCaptureStreamChunks,
getChatLogTextLimit,
getChatLogArrayTailItems,
getChatLogMaxDepth,
getChatLogMaxObjectKeys,
} from "@/lib/logEnv";
import { logAuditEvent } from "@/lib/compliance";
import { extractProviderWarnings } from "@/lib/compliance/providerAudit";
import { adaptBodyForCompression } from "../services/compression/bodyAdapter.ts";
@@ -181,10 +187,6 @@ import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits";
import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage";
const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024;
const CHAT_LOG_TEXT_LIMIT = 64 * 1024;
const CHAT_LOG_ARRAY_TAIL_ITEMS = 24;
const CHAT_LOG_MAX_DEPTH = 6;
const CHAT_LOG_MAX_OBJECT_KEYS = 80;
function capMemoryExtractionText(value: string): string {
if (value.length <= MEMORY_EXTRACTION_TEXT_LIMIT) return value;
@@ -192,28 +194,30 @@ function capMemoryExtractionText(value: string): string {
}
function truncateChatLogText(value: string): string {
if (value.length <= CHAT_LOG_TEXT_LIMIT) return value;
const head = value.slice(0, Math.floor(CHAT_LOG_TEXT_LIMIT / 2));
const tail = value.slice(-Math.ceil(CHAT_LOG_TEXT_LIMIT / 2));
return `${head}\n[...truncated ${value.length - CHAT_LOG_TEXT_LIMIT} chars...]\n${tail}`;
const limit = getChatLogTextLimit();
if (value.length <= limit) return value;
const head = value.slice(0, Math.floor(limit / 2));
const tail = value.slice(-Math.ceil(limit / 2));
return `${head}\n[...truncated ${value.length - limit} chars...]\n${tail}`;
}
function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {
if (value === null || value === undefined) return value;
if (typeof value === "string") return truncateChatLogText(value);
if (typeof value !== "object") return value;
if (depth >= CHAT_LOG_MAX_DEPTH) return "[MaxDepth]";
if (depth >= getChatLogMaxDepth()) return "[MaxDepth]";
const maxTailItems = getChatLogArrayTailItems();
if (Array.isArray(value)) {
const retained =
value.length > CHAT_LOG_ARRAY_TAIL_ITEMS ? value.slice(-CHAT_LOG_ARRAY_TAIL_ITEMS) : value;
const retained = value.length > maxTailItems ? value.slice(-maxTailItems) : value;
const cloned = retained.map((item) => cloneBoundedChatLogPayload(item, depth + 1));
if (value.length > CHAT_LOG_ARRAY_TAIL_ITEMS) {
if (value.length > maxTailItems) {
return [
{
_omniroute_truncated_array: true,
originalLength: value.length,
retainedTailItems: CHAT_LOG_ARRAY_TAIL_ITEMS,
retainedTailItems: maxTailItems,
},
...cloned,
];
@@ -223,11 +227,12 @@ function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {
const result: Record<string, unknown> = {};
const entries = Object.entries(value as Record<string, unknown>);
for (const [key, item] of entries.slice(0, CHAT_LOG_MAX_OBJECT_KEYS)) {
const maxKeys = getChatLogMaxObjectKeys();
for (const [key, item] of maxKeys > 0 ? entries.slice(0, maxKeys) : entries) {
result[key] = cloneBoundedChatLogPayload(item, depth + 1);
}
if (entries.length > CHAT_LOG_MAX_OBJECT_KEYS) {
result._omniroute_truncated_keys = entries.length - CHAT_LOG_MAX_OBJECT_KEYS;
if (maxKeys > 0 && entries.length > maxKeys) {
result._omniroute_truncated_keys = entries.length - maxKeys;
}
return result;
}

View File

@@ -123,6 +123,7 @@
"express": "^5.2.1",
"fetch-socks": "^1.3.3",
"fuse.js": "^7.3.0",
"gray-matter": "^4.0.3",
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^9.0.0",
"ioredis": "^5.10.1",
@@ -180,7 +181,6 @@
"eslint": "^9.39.4",
"eslint-config-next": "16.2.6",
"glob": "^13.0.6",
"gray-matter": "^4.0.3",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^16.4.0",

View File

@@ -134,6 +134,7 @@ let tunnelProcess: ReturnType<typeof spawn> | null = null;
let tunnelPid: number | null = null;
let installPromise: Promise<string> | null = null;
let startPromise: Promise<CloudflaredTunnelStatus> | null = null;
let stateFileQueue: Promise<void> = Promise.resolve();
const NON_ACTIONABLE_CLOUDFLARED_WARNING_PATTERNS = [
/failed to sufficiently increase receive buffer size/i,
] as const;
@@ -205,14 +206,35 @@ async function readStateFile(): Promise<PersistedTunnelState> {
}
}
async function writeStateFile(state: PersistedTunnelState) {
async function writeStateFileNow(state: PersistedTunnelState) {
await ensureTunnelDir();
await fs.writeFile(getStateFilePath(), JSON.stringify(state, null, 2) + "\n", "utf8");
}
async function withStateFileLock<T>(operation: () => Promise<T>): Promise<T> {
const previous = stateFileQueue;
let release!: () => void;
stateFileQueue = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await operation();
} finally {
release();
}
}
async function writeStateFile(state: PersistedTunnelState) {
await withStateFileLock(() => writeStateFileNow(state));
}
async function updateStateFile(patch: PersistedTunnelState) {
const current = await readStateFile();
await writeStateFile({ ...current, ...patch });
await withStateFileLock(async () => {
const current = await readStateFile();
await writeStateFileNow({ ...current, ...patch });
});
}
async function clearPidFile() {

View File

@@ -16,6 +16,12 @@ function parsePositiveInt(value: string | undefined, fallback: number): number {
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function parseNonNegativeInt(value: string | undefined, fallback: number): number {
if (value === undefined || value === "") return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (!value) return fallback;
const normalized = value.trim().toLowerCase();
@@ -101,3 +107,26 @@ export function getAppLogLevel(defaultLevel: string): string {
export function getAppLogFormat(defaultFormat: string): string {
return process.env.APP_LOG_FORMAT || defaultFormat;
}
// ─── Chat log truncation limits ─────────────────────────────────────────────
export function getChatLogTextLimit(): number {
return parsePositiveInt(process.env.CHAT_LOG_TEXT_LIMIT, 64 * 1024);
}
export function getChatLogArrayTailItems(): number {
return parsePositiveInt(process.env.CHAT_LOG_ARRAY_TAIL_ITEMS, 24);
}
export function getChatLogMaxDepth(): number {
return parsePositiveInt(process.env.CHAT_LOG_MAX_DEPTH, 6);
}
export function getChatLogMaxObjectKeys(): number {
return parseNonNegativeInt(process.env.CHAT_LOG_MAX_OBJECT_KEYS, 80);
}
export function isChatDebugFileEnabled(): boolean {
if (parseBoolean(process.env.CHAT_DEBUG_FILE, false)) return true;
return process.env.APP_LOG_LEVEL?.trim().toLowerCase() === "debug";
}

View File

@@ -2,7 +2,7 @@ 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 } from "../logEnv";
import { getCallLogPipelineMaxSizeBytes, isChatDebugFileEnabled } from "../logEnv";
const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null;
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
@@ -151,6 +151,11 @@ function serializeFinalSizeLimitFallback(artifact: CallLogArtifact, maxBytes: nu
}
function serializeArtifactForStorage(artifact: CallLogArtifact): string {
// Debug mode: write full untruncated payload
if (isChatDebugFileEnabled()) {
return JSON.stringify(artifact, null, 2);
}
const maxBytes = getArtifactMaxBytes(artifact);
const serialized = JSON.stringify(artifact, null, 2);
if (Buffer.byteLength(serialized) <= maxBytes) {