From d3e0353203a016c693b21021c050bb5369a950c6 Mon Sep 17 00:00:00 2001 From: ipanghu Date: Mon, 11 May 2026 21:19:09 +0800 Subject: [PATCH] fix: Added in debug mode, support for storing raw data in json (#2156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — configurable chat log truncation, CHAT_DEBUG_FILE mode, cloudflared state file lock --- .env.example | 14 +++++++ .github/workflows/claude-code-review.yml | 44 +++++++++++++++++++++ .github/workflows/claude.yml | 50 ++++++++++++++++++++++++ open-sse/handlers/chatCore.ts | 39 ++++++++++-------- package.json | 2 +- src/lib/cloudflaredTunnel.ts | 28 +++++++++++-- src/lib/logEnv.ts | 29 ++++++++++++++ src/lib/usage/callLogArtifacts.ts | 7 +++- 8 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml diff --git a/.env.example b/.env.example index 0bd7ebd43e..9e5fe57ea3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000000..b5e8cfd4dc --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -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 + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000000..6b15fac7af --- /dev/null +++ b/.github/workflows/claude.yml @@ -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 *)' + diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index ec187039c5..3d903eb939 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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 = {}; const entries = Object.entries(value as Record); - 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; } diff --git a/package.json b/package.json index 96ddbfcc20..bbac1556ec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/cloudflaredTunnel.ts b/src/lib/cloudflaredTunnel.ts index 8106a585d2..bb8b6044b7 100644 --- a/src/lib/cloudflaredTunnel.ts +++ b/src/lib/cloudflaredTunnel.ts @@ -134,6 +134,7 @@ let tunnelProcess: ReturnType | null = null; let tunnelPid: number | null = null; let installPromise: Promise | null = null; let startPromise: Promise | null = null; +let stateFileQueue: Promise = 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 { } } -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(operation: () => Promise): Promise { + const previous = stateFileQueue; + let release!: () => void; + stateFileQueue = new Promise((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() { diff --git a/src/lib/logEnv.ts b/src/lib/logEnv.ts index f10d318034..f210cd0b11 100644 --- a/src/lib/logEnv.ts +++ b/src/lib/logEnv.ts @@ -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"; +} diff --git a/src/lib/usage/callLogArtifacts.ts b/src/lib/usage/callLogArtifacts.ts index 41d03e1648..96f8dd4542 100644 --- a/src/lib/usage/callLogArtifacts.ts +++ b/src/lib/usage/callLogArtifacts.ts @@ -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) {