mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
* feat(logging): make the chat-log truncation limit configurable, bumped default 128x The 8KB cap on logged request/response bodies (open-sse/handlers/chatCore/logTruncation.ts::truncateForLog()) was hardcoded — 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. - Added CHAT_LOG_MAX_BODY_KB env var (src/lib/logEnv.ts:: getChatLogMaxBodyBytes()), default 1024 KB (1MB) — a 128x bump from the old hardcoded 8KB — following the same configurable-limit pattern as the sibling CHAT_LOG_TEXT_LIMIT/CHAT_LOG_ARRAY_TAIL_ITEMS/etc. vars. - Documented in .env.example and docs/reference/ENVIRONMENT.md. estimateSizeFast() (open-sse/utils/estimateSize.ts) has been substantially rewritten upstream since this bug was first found (now an iterative Frame-based walker with a separate node-visit budget, not the simple stack loop originally patched) — re-implemented the fix against the current algorithm rather than porting the old diff: the byte early-exit was unconditionally the module-level ESTIMATE_SIZE_BYTE_LIMIT (256 KiB) with no way for a caller to raise it, so any caller comparing against a bigger configured threshold could never see a size above ~256 KiB — every payload between 256 KiB and the caller's real limit looked "under threshold" and truncation never fired, the opposite of intended. Added an optional byteLimit parameter (default unchanged at ESTIMATE_SIZE_BYTE_LIMIT, so isSmallEnoughForSemanticCache's existing behavior is untouched) threaded through both the byte-check early-exit and the node-budget-exhaustion fail-closed fallback, with truncateForLog() now passing its own configured getChatLogMaxBodyBytes() value through. * feat(dashboard): show conversation session tag in request detail metadata Adds a "Conversation" field to the request detail panel's metadata grid (after "Combo"), showing the request's conversation id (sessionTag) for quick reference/copy. --------- Co-authored-by: Markus Hartung <mail@hartmark.se>
126 lines
4.2 KiB
TypeScript
126 lines
4.2 KiB
TypeScript
/**
|
|
* Fast object-tree size estimator — walks without JSON.stringify / toJSON / clone.
|
|
* Safe for circular references (WeakSet). Iterative frames only (no recursive call stack).
|
|
*
|
|
* Budgets:
|
|
* - byteLimit param (default ESTIMATE_SIZE_BYTE_LIMIT, 256 KiB): early-exit
|
|
* once counted bytes exceed the limit — pass the caller's own threshold
|
|
* explicitly rather than relying on the default, since a caller comparing
|
|
* against a bigger configured limit would otherwise never see a size
|
|
* above 256 KiB.
|
|
* - ESTIMATE_SIZE_NODE_BUDGET: max value visits (containers + primitives/elements)
|
|
*
|
|
* Arrays are walked by index frame (never pre-push/copy every element reference).
|
|
* Plain objects yield own enumerable values incrementally (no Object.keys materialization).
|
|
* Node-budget exhaustion returns a value strictly above the effective byteLimit
|
|
* so callers fail closed.
|
|
*/
|
|
|
|
/** Default byte early-exit threshold (256 KiB) when a caller doesn't pass its own. */
|
|
export const ESTIMATE_SIZE_BYTE_LIMIT = 262_144;
|
|
|
|
/**
|
|
* Max value/element visits before fail-closed.
|
|
* Conservative cap keeps auxiliary stack/WeakSet growth bounded under adversarial input.
|
|
*/
|
|
export const ESTIMATE_SIZE_NODE_BUDGET = 16_384;
|
|
|
|
type Frame =
|
|
| { t: "v"; v: unknown }
|
|
| { t: "a"; a: unknown[]; i: number }
|
|
| { t: "o"; o: object; it: Iterator<string> };
|
|
|
|
function ownEnumerableKeyIterator(obj: object): Iterator<string> {
|
|
return (function* ownEnumerableKeys() {
|
|
for (const key in obj) {
|
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
yield key;
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
|
|
/** @returns next byte total, or a value > limit when the limit is exceeded. */
|
|
function addPrimitiveBytes(bytes: number, v: string | number | boolean): number {
|
|
if (typeof v === "string") return bytes + v.length;
|
|
if (typeof v === "number") return bytes + 8;
|
|
return bytes + 4;
|
|
}
|
|
|
|
function enqueueContainer(stack: Frame[], obj: object, seen: WeakSet<object>): void {
|
|
if (seen.has(obj)) return;
|
|
seen.add(obj);
|
|
if (Array.isArray(obj)) {
|
|
if (obj.length > 0) stack.push({ t: "a", a: obj, i: 0 });
|
|
return;
|
|
}
|
|
stack.push({ t: "o", o: obj, it: ownEnumerableKeyIterator(obj) });
|
|
}
|
|
|
|
type ValueFrame = Extract<Frame, { t: "v" }>;
|
|
|
|
function isValueFrame(frame: Frame): frame is ValueFrame {
|
|
return frame.t === "v";
|
|
}
|
|
|
|
/** Expand a container frame into the next child value. */
|
|
function expandContainerFrame(stack: Frame[], frame: Exclude<Frame, ValueFrame>): void {
|
|
if (frame.t === "a") {
|
|
if (frame.i >= frame.a.length) return;
|
|
if (frame.i + 1 < frame.a.length) {
|
|
stack.push({ t: "a", a: frame.a, i: frame.i + 1 });
|
|
}
|
|
stack.push({ t: "v", v: frame.a[frame.i] });
|
|
return;
|
|
}
|
|
const next = frame.it.next();
|
|
if (next.done) return;
|
|
stack.push(frame);
|
|
stack.push({ t: "v", v: (frame.o as Record<string, unknown>)[next.value] });
|
|
}
|
|
|
|
/**
|
|
* @param byteLimit - early-exit threshold (default ESTIMATE_SIZE_BYTE_LIMIT,
|
|
* 256 KiB). Pass the actual threshold you're comparing against (see
|
|
* chatCore/logTruncation.ts::truncateForLog) so raising that threshold
|
|
* doesn't silently cap what this function is even capable of reporting —
|
|
* the byte check and the node-budget fail-closed fallback both key off this
|
|
* value, not the fixed module constant, when a caller supplies one.
|
|
*/
|
|
export function estimateSizeFast(value: unknown, byteLimit = ESTIMATE_SIZE_BYTE_LIMIT): number {
|
|
let bytes = 0;
|
|
let visitsLeft = ESTIMATE_SIZE_NODE_BUDGET;
|
|
const seen = new WeakSet<object>();
|
|
const stack: Frame[] = [{ t: "v", v: value }];
|
|
|
|
while (stack.length > 0) {
|
|
if (visitsLeft <= 0) return byteLimit + 1;
|
|
|
|
const frame = stack.pop()!;
|
|
if (!isValueFrame(frame)) {
|
|
expandContainerFrame(stack, frame);
|
|
continue;
|
|
}
|
|
|
|
visitsLeft -= 1;
|
|
const v = frame.v;
|
|
if (v === null || v === undefined) continue;
|
|
|
|
const ty = typeof v;
|
|
if (ty === "string" || ty === "number" || ty === "boolean") {
|
|
bytes = addPrimitiveBytes(bytes, v as string | number | boolean);
|
|
if (bytes > byteLimit) return bytes;
|
|
continue;
|
|
}
|
|
if (ty === "object") {
|
|
enqueueContainer(stack, v as object, seen);
|
|
}
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
export function isSmallEnoughForSemanticCache(value: unknown): boolean {
|
|
return estimateSizeFast(value) <= 256 * 1024;
|
|
}
|