Files
OmniRoute/open-sse/handlers/chatCore/logTruncation.ts
Markus Hartung d925f6bf73 fix(logging): document CHAT_LOG_MAX_BODY_KB, capture messageCount for Responses API bodies (#10038)
* fix(logging): document CHAT_LOG_MAX_BODY_KB, capture messageCount for Responses API bodies

Extracted from PR #9439 (agentic conversation tracking). Most of the
original scope this commit was cherry-picked from (CHAT_LOG_MAX_BODY_KB
env var support, the estimateSizeFast() earlyExitAt parameterization)
turned out to already be present on the current upstream/release/v3.8.50
tip -- confirmed via diff and by running check-env-doc-sync.test.ts /
tests/unit/chatcore-log-truncation.test.ts against pristine upstream
before making any changes here. Only two genuine gaps remained:

1. CHAT_LOG_MAX_BODY_KB was read by getChatLogMaxBodyBytes() but
   undocumented in .env.example and docs/reference/ENVIRONMENT.md --
   tests/unit/check-env-doc-sync.test.ts flags any env var read in code
   but missing from both doc files. Documented it (both required --
   the same test enforces the pairing).

2. truncateForLog()'s summary only computed messageCount from
   obj.messages (OpenAI-chat/Gemini field name) -- a large /v1/responses
   request (which uses input[], not messages[]) got summarized with no
   count at all, leaving the dashboard's "Full Conversation" panel
   nothing to base its "N messages not shown" placeholder on for any
   Responses-API conversation, even though the same summarization logic
   applies to it.

Test plan:
- TDD: tests/unit/chatcore-log-truncation.test.ts's new regression test
  ("captures a message count for Responses API bodies too") confirmed
  failing against the pre-fix code, passing after.
- tests/unit/check-env-doc-sync.test.ts confirms CHAT_LOG_MAX_BODY_KB no
  longer appears in codeMissingEnv (remaining drift in that test is
  pre-existing/unrelated -- ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS,
  COMMANDCODE_API_URL, OMNIROUTE_STRICT_SYSTEM_PROVIDERS,
  TLS_FINGERPRINT_PROVIDERS -- confirmed identical on a pristine
  upstream/release/v3.8.50 checkout, base-red inherited: #9985).
- tests/unit/chatcore-log-truncation.test.ts -- 19/19 passing.
- npx tsc --noEmit / npm run lint -- clean.

⚠️ base-red inherited: #9985

* docs(logging): consolidate CHAT_LOG_MAX_BODY_KB into a single entry per file

The variable was already documented (with a stale src/lib/chatLogTruncation.ts
reference in .env.example); keep the new richer entries next to the CHAT_LOG_*
family and drop the old duplicates.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 04:02:42 -03:00

104 lines
4.6 KiB
TypeScript

import {
getChatLogTextLimit,
getChatLogMaxDepth,
getChatLogArrayTailItems,
getChatLogMaxObjectKeys,
getChatLogMaxBodyBytes,
} from "@/lib/logEnv";
import { estimateSizeFast } from "../../utils/estimateSize.ts";
export const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024;
export function capMemoryExtractionText(value: string): string {
if (value.length <= MEMORY_EXTRACTION_TEXT_LIMIT) return value;
return value.slice(-MEMORY_EXTRACTION_TEXT_LIMIT);
}
export function truncateChatLogText(value: string): string {
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}`;
}
export 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 >= getChatLogMaxDepth()) return "[MaxDepth]";
const maxTailItems = getChatLogArrayTailItems();
if (Array.isArray(value)) {
const retained = value.length > maxTailItems ? value.slice(-maxTailItems) : value;
const cloned = retained.map((item) => cloneBoundedChatLogPayload(item, depth + 1));
if (value.length > maxTailItems) {
return [
{
_omniroute_truncated_array: true,
originalLength: value.length,
retainedTailItems: maxTailItems,
},
...cloned,
];
}
return cloned;
}
const result: Record<string, unknown> = {};
const entries = Object.entries(value as Record<string, unknown>);
const maxKeys = getChatLogMaxObjectKeys();
for (const [key, item] of maxKeys > 0 ? entries.slice(0, maxKeys) : entries) {
result[key] = cloneBoundedChatLogPayload(item, depth + 1);
}
if (maxKeys > 0 && entries.length > maxKeys) {
result._omniroute_truncated_keys = entries.length - maxKeys;
}
return result;
}
/**
* Truncate a large object for logging. If its JSON representation exceeds
* 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
* (bounded via `cloneBoundedChatLogPayload`) so the request-details view can
* still show which tools were available even though the rest of the payload
* — including the message history that triggered this summary — is dropped.
* The reused helper already caps array length, object keys, nesting depth,
* and string length, so this stays a small, bounded addition rather than a
* separately-budgeted multi-KB/MB re-clone.
*/
export function truncateForLog(value: unknown): Record<string, unknown> | null | undefined {
if (value === null || value === undefined) return value as null | undefined;
if (typeof value !== "object") return value as unknown as Record<string, unknown>;
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<string, unknown>;
// Object is too large — return a summary instead of a deep clone
const obj = value as Record<string, unknown>;
const summary: Record<string, unknown> = {
_truncated: true,
_originalBytes: estimatedSize,
};
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);
return summary;
}