diff --git a/.env.example b/.env.example index abed863585..c2a416d2ae 100644 --- a/.env.example +++ b/.env.example @@ -1449,6 +1449,11 @@ APP_LOG_TO_FILE=true # CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128) # 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) +# CHAT_LOG_MAX_BODY_KB=1024 # Whole request/response body size before it's replaced by a bare + # {_truncated, messageCount, ...} summary instead of the full clone + # (default: 1024 KB / 1MB). Raise this if the dashboard's "Full + # Conversation" transcript panel shows a placeholder instead of the + # actual messages for long agentic conversations. # Maximum rows in the proxy_logs SQLite table. # Default: 100000 @@ -2626,10 +2631,6 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # Used by: src/app/api/jobs/[id]/run-now/route.ts. Default: 30000 (30 seconds) # OMNIROUTE_RUNNOW_TIMEOUT_MS=30000 -# Maximum request/response body size before chat-log summarization, in KiB. -# Used by: src/lib/chatLogTruncation.ts. Default: 1024 -# CHAT_LOG_MAX_BODY_KB=1024 - # Adobe Firefly browser renewal and durable session cache (enabled by default). # Used by: open-sse/services/adobeFireflySession.ts. # ADOBE_FIREFLY_BROWSER_REFRESH=1 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 9f02980f0f..67ebff1917 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -753,6 +753,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | `CHAT_LOG_ARRAY_TAIL_ITEMS` | `128` | Number of array items retained from the tail when truncating chat log payloads. | | `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. | | `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). | +| `CHAT_LOG_MAX_BODY_KB` | `1024` | Whole request/response body size (KB) before it's replaced by a bare summary instead of the full clone. Raise this if long agentic conversations show a placeholder instead of the real messages in the dashboard. | | `CHAT_DEBUG_FILE` | `false` | When true, `serializeArtifactForStorage` skips size-based truncation. Debug only. | --- @@ -1446,7 +1447,6 @@ These settings were introduced after the previous environment-contract snapshot. | `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. | | `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). | | `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. | -| `CHAT_LOG_MAX_BODY_KB` | `1024` | `src/lib/logEnv.ts` | Maximum request or response body size before log summarization, in KiB. | | `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. | | `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. | | `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing between Adobe Firefly generate submissions. | diff --git a/open-sse/handlers/chatCore/logTruncation.ts b/open-sse/handlers/chatCore/logTruncation.ts index 03a854ae57..feae7f4ccc 100644 --- a/open-sse/handlers/chatCore/logTruncation.ts +++ b/open-sse/handlers/chatCore/logTruncation.ts @@ -60,9 +60,9 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown { /** * Truncate a large object for logging. If its JSON representation exceeds - * the configured max body size (getChatLogMaxBodyBytes()), return a - * lightweight summary instead of the full clone. This prevents - * persistAttemptLogs from holding multi-MB references to translatedBody + * getChatLogMaxBodyBytes() (default 1MB; CHAT_LOG_MAX_BODY_KB env override), + * return a lightweight summary instead of the full clone. This prevents + * persistAttemptLogs from holding unbounded references to translatedBody * across 17 call sites per request. * * When the summarized object carries a `tools` definition, re-attach it @@ -77,6 +77,9 @@ export function truncateForLog(value: unknown): Record | null | if (value === null || value === undefined) return value as null | undefined; if (typeof value !== "object") return value as unknown as Record; const maxBodyBytes = getChatLogMaxBodyBytes(); + // Pass maxBodyBytes as the early-exit point — otherwise estimateSizeFast's + // own default 256KB early-exit caps what it can ever report, silently + // making any configured threshold above 256KB unreachable (#trunc-limit-config). const estimatedSize = estimateSizeFast(value, maxBodyBytes); if (estimatedSize <= maxBodyBytes) return value as Record; // Object is too large — return a summary instead of a deep clone @@ -88,6 +91,11 @@ export function truncateForLog(value: unknown): Record | null | if (typeof obj.model === "string") summary.model = obj.model; if (typeof obj.provider === "string") summary.provider = obj.provider; if (Array.isArray(obj.messages)) summary.messageCount = obj.messages.length; + // Responses API bodies use `input[]`, not `messages[]` (OpenAI-chat/Gemini-only + // field name) — without this, a large /v1/responses request got summarized + // with no count at all, leaving the dashboard's "Full Conversation" panel + // nothing to base its "N messages not shown" placeholder on. + else if (Array.isArray(obj.input)) summary.messageCount = obj.input.length; if (Array.isArray(obj.contents)) summary.contentCount = obj.contents.length; if (typeof obj.stream === "boolean") summary.stream = obj.stream; if (Array.isArray(obj.tools)) summary.tools = cloneBoundedChatLogPayload(obj.tools); diff --git a/src/lib/logEnv.ts b/src/lib/logEnv.ts index c99cf1f209..4dc84ade79 100644 --- a/src/lib/logEnv.ts +++ b/src/lib/logEnv.ts @@ -179,10 +179,17 @@ export function getChatLogMaxObjectKeys(): number { } /** - * Was a hardcoded/default 8KB — trivially exceeded by any real multi-turn - * agentic conversation, meaning the dashboard's "Full Conversation" panel - * could only ever show a placeholder instead of the actual messages for - * nearly every logged row of any conversation with real substance. + * Cap for a single logged request/response body before it gets replaced by a + * bare {_truncated, _originalBytes, messageCount, ...} summary instead of the + * full clone (open-sse/handlers/chatCore/logTruncation.ts::truncateForLog()). + * Was a hardcoded 8KB — trivially exceeded by any real multi-turn agentic + * conversation, which meant the dashboard's "Full Conversation" transcript + * panel could only ever show a placeholder instead of the actual messages + * for nearly every logged row. Bumped 128x (to 1MB) by default and exposed + * as an operator override for anyone who needs it even larger (or smaller, + * on a memory-constrained box) — see the same "protect memory across many + * call sites per request" reasoning truncateForLog()'s own doc comment + * explains for why some cap must still exist. */ export function getChatLogMaxBodyBytes(): number { return parsePositiveInt(process.env.CHAT_LOG_MAX_BODY_KB, 1024) * 1024; diff --git a/tests/unit/chatcore-log-truncation.test.ts b/tests/unit/chatcore-log-truncation.test.ts index ef4b79fa0c..dc257cc562 100644 --- a/tests/unit/chatcore-log-truncation.test.ts +++ b/tests/unit/chatcore-log-truncation.test.ts @@ -134,7 +134,7 @@ test("truncateForLog summarizes oversized payloads instead of cloning", () => { provider: "openai", stream: true, // distinct object references so estimateSizeFast (WeakSet-dedup) counts each one - messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })), + messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(500) })), contents: [{ a: 1 }], }; const summary = truncateForLog(huge) as Record; @@ -150,6 +150,22 @@ test("truncateForLog summarizes oversized payloads instead of cloning", () => { assert.notEqual(summary, huge); }); +test("truncateForLog captures a message count for Responses API bodies too (input[], not messages[])", () => { + // Live bug: a large /v1/responses request got summarized with NO count at + // all (messages/contents are OpenAI-chat/Gemini-only field names), so the + // "Full Conversation" dashboard panel had nothing to base its "N messages + // not shown" placeholder on for any Responses-API conversation, even + // though the exact same 8KB summarization applies to it. + const huge = { + model: "gpt-5", + stream: true, + input: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(500) })), + }; + const summary = truncateForLog(huge) as Record; + assert.equal(summary._truncated, true); + assert.equal(summary.messageCount, 50000); +}); + test("truncateForLog keeps a bounded `tools` field alive when the request is summarized", () => { // A request whose message history alone blows well past the 8KB summary // threshold, but which also carries `tools` — a field that used to be @@ -184,7 +200,7 @@ test("truncateForLog keeps a bounded `tools` field alive when the request is sum model: "gpt-4o", provider: "openai", stream: true, - messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })), + messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(500) })), tools, }; @@ -216,7 +232,7 @@ test("truncateForLog bounds an oversized `tools` array to the configured tail-it })); const huge = { model: "gpt-4o", - messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })), + messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(500) })), tools: manyTools, };