Repository navigation
fix(otel): bound recorded content safely - #114
Conversation
| for (let index = redacted.length - 1; index >= 0; index--) { | ||
| const candidate = [redacted[index], ...retained] | ||
| if (safeStringify(candidate).length > PROMPT_MAX_CHARS) break | ||
| retained.unshift(redacted[index]) | ||
| } |
There was a problem hiding this comment.
HIGH (confidence: high) — when the newest array element alone exceeds PROMPT_MAX_CHARS, this loop keeps nothing, and the attribute ends up as only the truncation marker. All content is lost, and the marker misreports the cause.
for (let index = redacted.length - 1; index >= 0; index--) {
const candidate = [redacted[index], ...retained]
if (safeStringify(candidate).length > PROMPT_MAX_CHARS) break
retained.unshift(redacted[index])
}Walk it for a single oversized element: index = last, candidate = [newest], serialized > 32 KiB → break on the first iteration → retained = [] → dropped = redacted.length → the marker is unshifted → retained.length === 1, so the while below does not run. Output:
[{"role":"system","parts":[{"type":"text","content":"[truncated 1 earlier messages]"}]}]This is not an edge case for gen_ai.output.messages. That call site (handlers.ts:765) always passes a single-element array:
promptAttr([{ role: "assistant", parts: [...output.values()], finish_reason: part.reason ?? "unknown" }])output is messageOutput, which accumulates every text/reasoning/tool part of the step (rememberOutput, line 688-695), and serializePart puts both arguments and result on each tool part. Leaf capping bounds each string at 8 000, not the message: four parallel read calls returning ~8 000 chars each already push one assistant message past 32 KiB. At that point the completion — and finish_reason with it — is replaced by a marker claiming "1 earlier messages", which for a one-element array is also false on its face.
Array-dropping is the wrong bound for an array that can have one element. Suggested shape: if retained is empty after the loop, keep the newest element and bound it in place — drop or capTo its parts until it fits, or fall back to boundedJson(redacted.at(-1), PROMPT_MAX_CHARS) — so the attribute always carries the thing it exists to record. Whatever the fix, the "1 earlier messages" wording should not appear for a single-element input.
There was a problem hiding this comment.
Fixed in e9cd2fd. The marker/array-dropping strategy was removed. Oversized single completion messages now keep their original role, parts, and finish_reason; boundedJson shortens string leaves while preserving the enclosing schema. The long completion test asserts the completion tail and finish_reason.
| const marker = { | ||
| role: "system", | ||
| parts: [{ type: "text", content: `[truncated ${dropped} earlier messages]` }], | ||
| } |
There was a problem hiding this comment.
MEDIUM (confidence: high) — the marker is message-shaped, but promptAttr is used for three differently-shaped arrays; on two of them this element does not match its neighbours.
const marker = {
role: "system",
parts: [{ type: "text", content: `[truncated ${dropped} earlier messages]` }],
}recordInputMessages(line 486) passes{ role, parts }— the marker fits, but it arrives withrole: "system"at the front of the conversation, indistinguishable from a real system message to anything rendering the trace.recordSystemInstructions(line 494) passessystem.filter(Boolean).map((content) => ({ type: "text", content }))— a flat array of parts. Injecting{ role, parts }into it makes the array heterogeneous, so a consumer readingitem.contentoff each entry getsundefinedfor the marker.- the step-finish call (line 765) passes assistant messages, where a
role: "system"element is at least odd.
Also worth a second look for the system-instructions case: dropping from the front removes the beginning of the system prompt and keeps the appended tail, which inverts the usual importance ordering there (the main instructions come first, env/context blocks last). The "newest is the useful part" reasoning in the docstring holds for a conversation, not for a system-instruction array.
Two options: give promptAttr a marker appropriate to the shape it was handed (a { type: "text", content } marker for the system path), or record the drop out-of-band as its own attribute (e.g. opencode.otel.input_messages.dropped = N) so no synthetic element is mixed into the recorded payload at all. The second also removes the need for the eviction loop below.
There was a problem hiding this comment.
Fixed in e9cd2fd. No synthetic marker is inserted into input, system, or output arrays, so each attribute keeps its native JSON shape. All three call sites now use the same schema-preserving bounded serializer.
| while (retained.length > 1 && safeStringify(retained).length > PROMPT_MAX_CHARS) { | ||
| retained.splice(1, 1) | ||
| } |
There was a problem hiding this comment.
MEDIUM (confidence: high) — this eviction loop removes more messages after dropped was already computed, so the marker undercounts.
while (retained.length > 1 && safeStringify(retained).length > PROMPT_MAX_CHARS) {
retained.splice(1, 1)
}const dropped = redacted.length - retained.length is evaluated at line 103, before the marker is unshifted. Making room for the marker then evicts entries at index 1 (the oldest retained real message), and each splice invalidates the count already baked into the marker's text. A run that evicts three more messages still reports the pre-eviction number, so the attribute states a history length that never existed.
Cheap fix: compute the count after the loop settles, e.g. retain first, then set the marker content once the array is final — or track evictions in the loop and rebuild the marker string at the end.
Second point on the same loop: its guard is retained.length > 1, so it will happily evict the newest message and stop with [marker] alone. That is the same data-loss path described on lines 98-102; if you fix that one by keeping and bounding the newest element, this guard needs to protect it too (retained.length > 2, or evict only indices above the last one).
There was a problem hiding this comment.
Fixed in e9cd2fd by removing the marker and eviction loop entirely. There is no dropped-count bookkeeping now; the existing JSON structure is retained and only string leaf lengths are reduced to meet the total budget.
| if (text.length <= limit) return text | ||
| const marker = `\n[truncated ${text.length - limit} chars]\n` | ||
| const available = Math.max(0, limit - marker.length) | ||
| const head = Math.ceil(available / 2) | ||
| const tail = available - head | ||
| return `${text.slice(0, head)}${marker}${tail ? text.slice(-tail) : ""}` |
There was a problem hiding this comment.
LOW (confidence: high) — the char count in the marker is short by marker.length, and the function can return a string longer than limit.
const marker = `\n[truncated ${text.length - limit} chars]\n`
const available = Math.max(0, limit - marker.length)Actual chars removed are text.length - available, i.e. text.length - limit + marker.length, but the marker reports text.length - limit. So capTo("y".repeat(100), 50) claims 50 dropped when 72 were dropped. Small, but this number is the only signal a reader has about how much is missing, and it is used as the basis for sizing decisions during triage. text.length - available is the same expression reordered — compute available first, then build the marker.
Separately, the Math.max(0, ...) clamp means that when limit < marker.length the return value is the marker, whose length exceeds limit — the one case where the function violates its own contract. Not reachable from today's call sites (leafLimit is always 8 000, and boundedJson's previewLimit floors at 96, both comfortably above the ~30-char marker), so this is a latent trap rather than a live bug. A guard like if (limit <= marker.length) return text.slice(0, limit) would make it hold unconditionally.
| depth = 0, | ||
| seen = new WeakSet<object>(), | ||
| ): unknown { | ||
| if (typeof value === "string") return capTo(redactText(value), leafLimit) |
There was a problem hiding this comment.
MEDIUM (confidence: medium-high) — the per-leaf cap is unconditional, so it truncates content the 32 KiB budget had room for.
if (typeof value === "string") return capTo(redactText(value), leafLimit)Every string leaf is cut to 8 000 chars before anything checks whether the whole payload was over budget. A single-message conversation with a 20 KB pasted SQL script or log excerpt — the normal case for this tool — loses 12 KB with a hole punched in the middle, even though the serialized array would have been ~20 KB against a 32 KiB limit. Relative to main after #111 that is a real loss of content on payloads that were never the problem this PR is fixing.
The two bounds also disagree on the same value depending on which span records it: a tool input goes through boundedJson(..., CONTENT_MAX_CHARS) on the execute_tool span (8 000 total), and through promptAttr inside output.messages on the chat span (8 000 per leaf, so potentially much larger). Triaging one call means reading two spans that were cut differently.
A budget-driven order would avoid both: serialize first, and only trim leaves (or drop messages) when the result exceeds the limit. That keeps the size guarantee this PR is restoring while leaving small-but-long payloads intact.
| for (let previewLimit = limit; previewLimit > 0; previewLimit -= 128) { | ||
| const candidate = safeStringify({ | ||
| truncated: true, | ||
| preview: capTo(serialized, previewLimit), | ||
| }) |
There was a problem hiding this comment.
Question — please confirm intent (MEDIUM, confidence: high): this changes the shape of gen_ai.tool.call.arguments, not just its size.
const candidate = safeStringify({
truncated: true,
preview: capTo(serialized, previewLimit),
})Before this PR the attribute was always the tool input object, so a consumer could read JSON.parse(args).file or .statement. For any input over 8 000 serialized chars it is now {"truncated":true,"preview":"<a broken JSON fragment as a string>"} — still valid JSON, as the PR description says, but a different schema, and the field a dashboard or query was reading is gone. The test at test/otel-span-build.test.ts:477 was updated to match, which confirms the change is deliberate at the code level; what I can't tell from the repo is whether anything downstream (Langfuse views, saved queries, alerting on tool arguments) parses this attribute. Worth a look before merge, since the switch is silent from the consumer's side.
If you want to keep the shape parseable, an alternative is to keep the object and bound it structurally — replace oversized leaves with a marker and add a sibling flag (e.g. "opencode.tool.arguments.truncated": true as its own attribute) — so .file still resolves and the size guarantee still holds.
Minor, same function: the previewLimit -= 128 walk re-serializes an ~8 KB string up to 62 times in the worst case (escape expansion is what drives it down). Bounded and cheap enough, but a single corrective pass — measure the escape overhead once, subtract it, retry — would do the same job in two iterations.
There was a problem hiding this comment.
Fixed in e9cd2fd. Tool argument JSON is no longer wrapped as {truncated, preview}; original field names remain parseable. The many-short-fields test verifies the serialized object is <=8 KiB and still exposes field_0.
| expect(input.length).toBeLessThanOrEqual(32 * 1024) | ||
| expect(system.length).toBeLessThanOrEqual(32 * 1024) | ||
| expect(output.length).toBeLessThanOrEqual(32 * 1024) | ||
| expect(JSON.parse(input).at(-1)).toEqual({ | ||
| role: "user", | ||
| parts: [{ type: "text", content: expect.stringContaining("PASSWORD=<redacted>") }], | ||
| }) |
There was a problem hiding this comment.
MEDIUM (confidence: high) — the new code's main branch has no test, and this rewrite removed the assertion that would have covered it.
expect(input.length).toBeLessThanOrEqual(32 * 1024)
...
expect(JSON.parse(input).at(-1)).toEqual({With history and text both capped to 8 000 chars per leaf, each of the two messages serializes to roughly 8-11 KB, so this fixture stays under the 32 KiB budget and dropped is 0. Nothing here reaches the marker path, the eviction while loop, or the total-loss case. rg "earlier messages" packages/cz-cli/test returns nothing, so no other test does either — the entire if (dropped > 0) block in promptAttr ships uncovered.
The previous assertion was an exact toEqual on both messages; replacing it with .at(-1) also means the test no longer notices if the history message is dropped, which is precisely the behavior under change. Two additions would close it:
- Keep a retention assertion here —
expect(JSON.parse(input)).toHaveLength(2)— so a regression that starts dropping history fails. - Add a case that actually exceeds the budget (e.g. six messages each carrying a full 8 000-char text part) and assert the marker is present, its count matches the number of elements actually missing, and the newest message is still there.
A case for gen_ai.output.messages with several large tool parts in one step would cover the single-element path I flagged in handlers.ts:98-102.
I can't run the suite here, so I'm not claiming anything about pass/fail — only that these paths have no assertions pointed at them.
There was a problem hiding this comment.
Updated in e9cd2fd. The marker/eviction branch no longer exists. Tests now exercise the actual schema-preserving budget path for long input/system/output, assert the latest user content, completion tail, finish_reason, and total 32 KiB bounds.
| function redactDeep( | ||
| value: unknown, | ||
| leafLimit = CONTENT_MAX_CHARS, | ||
| depth = 0, |
There was a problem hiding this comment.
LOW (confidence: high) — leafLimit is a parameter with exactly one value.
function redactDeep(
value: unknown,
leafLimit = CONTENT_MAX_CHARS,Both call sites pass it explicitly and both pass CONTENT_MAX_CHARS (lines 94 and 126), which is also the default. So it adds a positional parameter ahead of depth/seen without adding reachable behavior, and AGENTS.md asks for flexibility to be introduced when a second caller exists. Reading CONTENT_MAX_CHARS directly inside the string branch would leave the signature as it was and keep depth/seen in their original positions.
Not worth blocking on; flagging because the positional shift is the kind of thing a later redactDeep(value, 0) call gets wrong silently.
There was a problem hiding this comment.
Fixed in e9cd2fd. redactDeep keeps its original depth/seen signature; content bounding is handled separately by boundedJson only when the total serialized value exceeds its budget.
| function promptAttr(value: unknown): string { | ||
| return safeStringify(redactDeep(value)) | ||
| const redacted = redactDeep(value, CONTENT_MAX_CHARS) | ||
| if (!Array.isArray(redacted)) return safeStringify(redacted) |
There was a problem hiding this comment.
LOW (confidence: high) — this early return is an unbounded escape hatch from the function whose job is to bound.
if (!Array.isArray(redacted)) return safeStringify(redacted)A non-array value gets leaf capping only, so an object with many short fields serializes without any total limit — exactly the failure mode the PR description calls out for tool inputs ("many short fields cannot create a multi-megabyte span attribute"), left open on this path. All three current callers pass arrays (lines 486, 494, 765), so it is unreachable today; it becomes a size regression the moment a fourth caller passes an object. return boundedJson(redacted, PROMPT_MAX_CHARS) here would close it in one line and reuse the bound you already have.
Unrelated note on the loop below, since it is the same function: each iteration builds [redacted[index], ...retained] and re-serializes the whole accumulated array, so cost is quadratic in the retained count. A history of small messages can retain several hundred entries under the 32 KiB budget, which means a few hundred JSON.stringify calls over an average ~16 KB array on every recorded request. Bounded and probably a few ms, so not worth restructuring on its own — but serializing each element once and summing lengths (plus separators) gives the same answer in one pass if you end up touching this loop for the data-loss fix.
There was a problem hiding this comment.
Fixed in e9cd2fd. promptAttr always delegates to boundedJson, so non-array values also receive a total serialized-size bound.
Review summaryA. Upstream invasiveness — no issues found. Both changed files are B. Clean fix or hole drilled around it — mostly the clean fix, with one structural caveat. Bounding at the point of recording, after redaction, is the right layer and the right order; no new env flag, no try/catch hiding a failure, no copy-paste of an existing helper, no drive-by edits. The caveat is that bounding is now split across three mechanisms with different strategies — C. Regression risk — see inline findings. What already-working behavior this can change:
Lower-severity items are inline at handlers.ts:60-63, handlers.ts:80-85 and handlers.ts:95. I did not run the test suite, so nothing here is a claim about pass or fail — the coverage observations come from reading the fixtures and from |
b7580a2 to
e9cd2fd
Compare
|
已根据本轮 review 更新到 commit
本地验证: |
| if (serialized.length <= limit) return serialized | ||
|
|
||
| let low = 0 | ||
| let high = Math.min(CONTENT_MAX_CHARS, limit) |
There was a problem hiding this comment.
MEDIUM (confidence: high) — the per-string ceiling is clamped to CONTENT_MAX_CHARS, so a prompt can never use most of its 32 KiB budget.
let high = Math.min(CONTENT_MAX_CHARS, limit)For promptAttr, limit is PROMPT_MAX_CHARS (32768) but high becomes 8000. Once the serialized value exceeds 32 KiB, every string leaf is capped at 8000 chars regardless of how much of the 32 KiB budget is still free. A session whose history is one pasted 40 KB file gets shredded to 8000 chars while ~24 KB of the allowance goes unused.
This is visible in the new test in this PR: bounds long history while keeping the latest input and valid JSON has two string leaves, so the search settles at middle = 8000 and emits roughly 21 KB — the expect(input.length).toBeLessThanOrEqual(32 * 1024) assertion passes with ~11 KB to spare, not because the budget was well spent but because the clamp stopped it early.
CONTENT_MAX_CHARS is the tool-attribute budget; using it as the prompt search ceiling conflates two different limits. Suggest:
| let high = Math.min(CONTENT_MAX_CHARS, limit) | |
| let high = limit |
If an intentional hard per-message ceiling is wanted, a named constant distinct from the tool budget would say so, and capStrings could apply it unconditionally rather than only through the search's upper bound.
| high = middle - 1 | ||
| } | ||
| } | ||
| return best.length <= limit ? best : '{"truncated":true}' |
There was a problem hiding this comment.
MEDIUM (confidence: high on reachability) — this fallback replaces the array with an object, and it is reachable in an ordinary session rather than only in a pathological one.
return best.length <= limit ? best : '{"truncated":true}'capStrings(redacted, 0) is not a zero-length floor: capTo returns the marker itself when limit < marker.length (see the separate comment on capTo), so every string leaf costs ~23–26 chars in the serialized output no matter how far the search drives middle down. The floor is therefore structure + 25 × leafCount, and once that exceeds limit the search cannot help.
Counting against serializePart's output, a text-only message floors at roughly 116 chars ({"role":…,"parts":[{"type":…,"content":…}]} plus three markers), so ~280 messages. But an assistant message carrying three tool_call parts contributes a leaf for each of type/id/name/result and every string leaf inside arguments — call it 20 leaves, ~800 chars floored. That crosses 32 KiB at roughly 40 messages, which is a normal mid-length coding session.
At that point gen_ai.input.messages is the single token {"truncated":true}: every message, role and part is gone, and the value is an object where every consumer (Langfuse, any dashboard query, the assertions in this PR's own tests) expects an array. JSON.parse(input).at(-1) returns undefined rather than failing loudly.
Two things would help, and the first is what the PR description already claims happens:
- Drop whole messages from the front of the array until the remainder fits, keeping the newest. That degrades gracefully — a short valid array — instead of falling off a cliff, and it matches "drops older messages before serializing history".
- If a fallback is still needed, keep the container shape:
[]for the message arrays, so a consumer sees "no messages recorded" rather than a type error.
Worth noting this is strictly worse than the unbounded behavior #111 introduced for exactly the sessions where prompt content is most worth having.
| function capStrings(value: unknown, limit: number): unknown { | ||
| if (typeof value === "string") return capTo(value, limit) | ||
| if (Array.isArray(value)) return value.map((item) => capStrings(item, limit)) | ||
| if (!value || typeof value !== "object") return value | ||
| return Object.fromEntries( | ||
| Object.entries(value as Record<string, unknown>).map(([key, inner]) => [key, capStrings(inner, limit)]), | ||
| ) | ||
| } |
There was a problem hiding this comment.
MEDIUM (confidence: high) — capStrings caps every string leaf, including the discriminant and identifier fields, so the schema survives but its meaning does not.
function capStrings(value: unknown, limit: number): unknown {
if (typeof value === "string") return capTo(value, limit)The walk has no notion of which keys are content and which are structure. When the search drives middle low, role: "assistant" (9 chars) becomes role: "\n[truncated 9 chars]\n", type: "text" becomes a marker at middle < 4, and finish_reason, tool name and callID go the same way. The doc comment immediately below states:
This preserves the original JSON schema, including message roles, part types and finish reasons.
It preserves the keys, not the values. A consumer grouping spans by role or filtering on type === "tool_call" gets nothing usable, and there is an asymmetric window between middle 4 and 9 where role: "user" survives intact while role: "assistant" is mangled — so the recorded conversation looks like it has only user turns.
These fields are bounded by construction (role comes from a small enum, type from serializePart's switch, callID is provider-generated and short), so they cost nothing to exempt. Capping only the fields that actually carry free-form text — content, result, error, and the leaves under arguments — would both honor the comment and lower the per-message floor that drives the {"truncated":true} fallback.
| function capTo(text: string, limit: number): string { | ||
| if (text.length <= limit) return text | ||
| const marker = `\n[truncated ${text.length - limit} chars]\n` | ||
| const available = Math.max(0, limit - marker.length) | ||
| const head = Math.ceil(available / 2) | ||
| const tail = available - head | ||
| return `${text.slice(0, head)}${marker}${tail ? text.slice(-tail) : ""}` | ||
| } |
There was a problem hiding this comment.
MEDIUM (confidence: high) — capTo can return more characters than the limit it was given.
const marker = `\n[truncated ${text.length - limit} chars]\n`
const available = Math.max(0, limit - marker.length)marker.length is 20 + digits(N), so ~23–24 chars in practice. When limit is below that, available clamps to 0, head and tail are both 0, and the function returns the marker alone — longer than limit. Concretely, capTo("x".repeat(1000), 5) yields a 23-char string, and capTo(text, 0) yields ~24 chars rather than "".
Two consequences:
capStrings(redacted, 0)is not the zero floorboundedJsontreats it as, which is what makes the{"truncated":true}fallback reachable (separate comment on line 122).capTo's output length is non-monotone inlimitacross[0, ~24)— it stays flat at ~marker.lengthand even shrinks slightly asNloses a digit. Binary search over a non-monotone predicate is not guaranteed to find the true maximum. The finalbest.length <= limitguard keeps the output valid, so this is not a live overflow, but the search is not doing what the code assumes in that range.
Returning a short fixed sentinel when there is no room for the full marker fixes both:
| function capTo(text: string, limit: number): string { | |
| if (text.length <= limit) return text | |
| const marker = `\n[truncated ${text.length - limit} chars]\n` | |
| const available = Math.max(0, limit - marker.length) | |
| const head = Math.ceil(available / 2) | |
| const tail = available - head | |
| return `${text.slice(0, head)}${marker}${tail ? text.slice(-tail) : ""}` | |
| } | |
| function capTo(text: string, limit: number): string { | |
| if (text.length <= limit) return text | |
| const marker = `\n[truncated ${text.length - limit} chars]\n` | |
| if (limit < marker.length) return "…".slice(0, limit) | |
| const available = limit - marker.length | |
| const head = Math.ceil(available / 2) | |
| const tail = available - head | |
| return `${text.slice(0, head)}${marker}${tail ? text.slice(-tail) : ""}` | |
| } |
Separately, text.slice(0, head) and text.slice(-tail) cut on UTF-16 code units, so a non-BMP character (emoji, some CJK extension blocks) can be split into a lone surrogate. Inside boundedJson that is harmless — JSON.stringify emits \udXXX for it — but the gen_ai.tool.call.result path on line 866 sets the capped string directly as a span attribute with no stringify, so a lone surrogate reaches the OTLP encoder and lands as U+FFFD. Cosmetic, low confidence that anyone notices, but the test fixtures in this PR are already CJK so it is worth a thought.
| return safeStringify(redactDeep(value)) | ||
| function capTo(text: string, limit: number): string { | ||
| if (text.length <= limit) return text | ||
| const marker = `\n[truncated ${text.length - limit} chars]\n` |
There was a problem hiding this comment.
LOW (confidence: high) — the marker under-reports how much was dropped, by its own length.
const marker = `\n[truncated ${text.length - limit} chars]\n`The retained content is available = limit - marker.length, not limit, so the number actually dropped is text.length - limit + marker.length — about 24 more than the marker claims. For a 20,000-char value capped to 8,000 it reads [truncated 12000 chars] where 12,024 went away.
Small, but the marker exists so a reader can reason about what is missing, and the count is also self-referential (its digit count feeds back into marker.length). Computing available first and reporting text.length - available would be exact.
| /** | ||
| * Reduce string leaves until the serialized value fits. This preserves the original JSON | ||
| * schema, including message roles, part types and finish reasons. | ||
| */ | ||
| function boundedJson(value: unknown, limit: number): string { | ||
| const redacted = redactDeep(value) |
There was a problem hiding this comment.
Question on intent (MEDIUM if unintended, confidence: high on the code, none on the intent) — the PR description says the change "drops older messages before serializing history", but I cannot find that anywhere; please confirm which behavior you meant to ship.
function boundedJson(value: unknown, limit: number): string {
const redacted = redactDeep(value)boundedJson only shrinks string leaves; it never removes array elements. recordInputMessages (line 468) maps over every message and hands the whole array to promptAttr with no slice:
const serialized = messages.map((m) => ({
role: m.info?.role ?? "unknown",
parts: (m.parts ?? []).map(serializePart).filter(Boolean),
}))
sessionInput.set(sessionID, promptAttr(serialized))So the 32 KiB budget is divided uniformly across the whole history: the current user prompt gets exactly the same per-string allowance as a message from 200 turns ago. In a long session that allowance falls to tens of characters, and the newest message — the one #111's removed comment called out as the thing that must not be hidden ("Preserve complete, valid JSON so long histories cannot hide the latest user input") — ends up as a head/tail fragment around a truncation marker, or vanishes entirely via the {"truncated":true} fallback.
Dropping whole messages from the front until the remainder fits would spend the budget on the turns a reader actually wants, keep the array shape, and remove the pressure that makes the fallback reachable. If the uniform split is a deliberate choice, it would be worth saying so in the doc comment, since it reverses the priority the previous comment stated.
| entry.span.setStatus({ code: SpanStatusCode.ERROR, message }) | ||
| entry.span.setAttribute("error.message", message) | ||
| } else if (_recordContent && state.output) { | ||
| entry.span.setAttribute("gen_ai.tool.call.result", redactText(String(state.output))) | ||
| entry.span.setAttribute( | ||
| "gen_ai.tool.call.result", | ||
| capTo(redactText(String(state.output)), CONTENT_MAX_CHARS), | ||
| ) |
There was a problem hiding this comment.
MEDIUM (confidence: high) — the success branch of this span is now bounded while the error branch on the line above is left both unbounded and unredacted.
entry.span.setStatus({ code: SpanStatusCode.ERROR, message })
entry.span.setAttribute("error.message", message)
} else if (_recordContent && state.output) {
entry.span.setAttribute(
"gen_ai.tool.call.result",
capTo(redactText(String(state.output)), CONTENT_MAX_CHARS),
)message is built at line 853 as String(state.error || "unknown") — no capTo, no redactText, and no _recordContent gate. Two gaps follow:
- Size. A tool failure carries the same class of payload as a success: a SQL error echoing the whole failing statement, a stack trace, a subprocess's stderr. The megabyte-span problem this PR fixes is still open on the failure path, which is the path a user is most likely to be looking at. It also rides
setStatus, so it ships twice per failed call, and again into the log record at line 886. - Credentials. This is the case the review context calls out — plaintext credentials have reached
_positionalbefore. A failingbashtool echoes its own command line, so a--password …or a connection string lands inerror.messageverbatim while the identical text instate.outputtwo lines below goes throughredactText. The redaction order elsewhere in this PR is correct (redact, then cap, so capping can never un-redact); this is the one content path that skips the redactor entirely.
Both are pre-existing rather than introduced here, so this is not a regression — but it is the sibling attribute on the same span, and leaving it out means the bound this PR adds can be bypassed by any tool that fails. Suggest:
| entry.span.setStatus({ code: SpanStatusCode.ERROR, message }) | |
| entry.span.setAttribute("error.message", message) | |
| } else if (_recordContent && state.output) { | |
| entry.span.setAttribute("gen_ai.tool.call.result", redactText(String(state.output))) | |
| entry.span.setAttribute( | |
| "gen_ai.tool.call.result", | |
| capTo(redactText(String(state.output)), CONTENT_MAX_CHARS), | |
| ) | |
| entry.span.setStatus({ code: SpanStatusCode.ERROR, message }) | |
| entry.span.setAttribute("error.message", capTo(redactText(message), CONTENT_MAX_CHARS)) | |
| } else if (_recordContent && state.output) { | |
| entry.span.setAttribute( | |
| "gen_ai.tool.call.result", | |
| capTo(redactText(String(state.output)), CONTENT_MAX_CHARS), | |
| ) |
The same applies to turnFailed's failure string (line 419) and the session.error log's error.message (line 593), if you want to close the class rather than this instance.
| const CONTENT_MAX_CHARS = 8_000 | ||
| const PROMPT_MAX_CHARS = 32 * 1024 |
There was a problem hiding this comment.
LOW (confidence: high on the arithmetic, medium on whether it matters to you) — these budgets are UTF-16 code units, but the thing being bounded is a wire payload measured in bytes.
const CONTENT_MAX_CHARS = 8_000
const PROMPT_MAX_CHARS = 32 * 1024CJK text is one UTF-16 unit per character and three UTF-8 bytes, so a 32,768-char prompt attribute of Chinese content is ~96 KB on the wire and an 8,000-char tool result ~24 KB. That is not hypothetical for this product's users, and this PR's own fixture is CJK ("用户输入\n\"内容\" ".repeat(5_000) in the test). If the goal is bounding span size, Buffer.byteLength(s) would measure what the exporter actually sends; if the goal is bounding what a human reads in a trace viewer, chars are the right unit and a comment saying so would settle it.
Two related notes on this pair of constants:
- Neither is configurable. fix(otel): preserve full trace content #111 removed bounds, this PR restores them, and a user who wants full content again has only the all-or-nothing
OPENCODE_OTEL_RECORD_CONTENT=0. An env override (OPENCODE_OTEL_MAX_CONTENT_CHARS?) would let the next person who disagrees turn a knob instead of sending a third PR over the same lines. setup.tsconfigures nospanLimits, so these constants really are the only bound in the pipeline — worth knowing, since the OTel SDK's ownspanLimits.attributeValueLengthLimitwould cover every attribute including the ones this PR does not touch. It truncates bluntly and would corrupt the JSON-valued attributes, so it is not a substitute here, but as a backstop alongside this code it would catch future attributes for free.
| let low = 0 | ||
| let high = Math.min(CONTENT_MAX_CHARS, limit) | ||
| let best = safeStringify(capStrings(redacted, 0)) | ||
| while (low <= high) { | ||
| const middle = Math.floor((low + high) / 2) | ||
| const candidate = safeStringify(capStrings(redacted, middle)) | ||
| if (candidate.length <= limit) { | ||
| best = candidate | ||
| low = middle + 1 | ||
| } else { | ||
| high = middle - 1 | ||
| } | ||
| } |
There was a problem hiding this comment.
MEDIUM (confidence: medium — I could not run a benchmark, so this is analytic) — the search deep-copies and re-serializes the entire payload on every probe, synchronously on the event-handling path.
while (low <= high) {
const middle = Math.floor((low + high) / 2)
const candidate = safeStringify(capStrings(redacted, middle))high starts at 8000, so the loop runs ~13 times, plus the capStrings(redacted, 0) on line 111 and the initial safeStringify on line 106 — 15 full traversals and 14 full JSON serializations of the payload, each capStrings allocating a complete structural copy via Object.fromEntries/map. On top of redactDeep, which already walks every string through three global regexes plus redactSql.
Where this lands:
recordInputMessages→ once per provider turn, over the whole conversation (the hook receives the full history about to be sent).promptAttrfor output messages → once perstep-finish.boundedJson(state.input, …)→ once per tool-call state transition, and line 828 shows a call can be refreshed.
It only triggers when the value is already over budget, i.e. exactly the long sessions where the payload is multi-megabyte. A 5 MB history means ~70 MB of intermediate strings and objects churned per turn, inside a plugin whose stated job is to observe without perturbing.
The per-leaf budget is computable in one pass rather than searched: sum the string-leaf lengths and the structural overhead once, then solve for the allowance directly (one corrective pass if the marker arithmetic overshoots). Failing that, capping the loop at a handful of probes, or starting high at a value derived from limit / leafCount instead of the constant 8000, would cut most of the cost. Given that #111's motivation was span size and this one's is CPU-free correctness, it would be worth measuring on a real long session before merging.
| const args = String(tool!.attributes["gen_ai.tool.call.arguments"]) | ||
| expect(args.length).toBeLessThanOrEqual(8_000) | ||
| expect(JSON.parse(args).field_0).toBeDefined() |
There was a problem hiding this comment.
LOW (confidence: high) — this test sits just under the boundary it is named for, and the branch that changes the output's shape has no coverage at all.
const args = String(tool!.attributes["gen_ai.tool.call.arguments"])
expect(args.length).toBeLessThanOrEqual(8_000)
expect(JSON.parse(args).field_0).toBeDefined()200 fields × 100 chars floors at roughly 200 × (12 key + ~25 marker) ≈ 7,400 chars, just inside the 8,000 budget — so the search finds some allowance and the object shape survives. Push it to ~300 fields and the floor crosses the limit, boundedJson returns {"truncated":true}, and JSON.parse(args).field_0 is undefined: the assertion fails with "expected undefined to be defined" rather than naming what happened.
So the one branch in this PR that changes an attribute's JSON type — object/array to {"truncated":true} — is never exercised. A case at ~400 fields pinning the fallback's contract explicitly would document it, and would go red if someone later changes the sentinel.
Also worth noting for the diff as a whole: the two renamed tests moved from exact-equality to toContain plus a length bound, which is the right call for the new behavior, but it means nothing now pins where content is preserved. expect(JSON.parse(args).file).toContain("PASSWORD=<redacted>") passes because capTo keeps the tail; it would also pass if the head were dropped entirely. An assertion that the head is present too (toStartWith("xxxx")) would pin the head/tail contract this PR is built on, and the 8_000 / 32 * 1024 literals duplicated across these tests could be imported so a change to the constants shows up as a test failure rather than a silently stale bound.
|
Reviewed against A. Upstream invasiveness — no issues found Both changed files are under B. Clean fix, or a hole drilled around the problem? The premise is sound and the layer is right: Three findings in this category:
No dead code, no leftover debug logging, no unrelated drive-by edits. Also flagged, not strictly this category: the 8,000-char clamp on the prompt search ceiling (comment) means a prompt can never use more than a quarter of its 32 KiB budget — visible in this PR own new test, which lands ~11 KB under the bound it asserts. C. Regression risk No exported API, CLI flag, subcommand, config key or on-disk path changes. What could change for something already working:
Remaining lower-severity items are inline: All of the above are suggestions — accept or reject as you see fit. 🤖 Generated with Claude Code |
e9cd2fd to
7eae091
Compare
|
Review follow-up in
Local focused suite: 43 passed, 0 failed. |
| const STRUCTURAL_STRING_KEYS = new Set(["role", "type", "finish_reason", "id", "name", "mediaType", "filename"]) | ||
|
|
||
| function capStrings(value: unknown, limit: number, preserveStructural = false, key = ""): unknown { | ||
| if (typeof value === "string") { | ||
| if (preserveStructural && STRUCTURAL_STRING_KEYS.has(key)) return value | ||
| return capTo(value, limit) |
There was a problem hiding this comment.
MEDIUM (confidence: high on the mechanism, medium on how often it fires) — structural-key preservation is applied at every depth, so it can defeat the bound entirely.
const STRUCTURAL_STRING_KEYS = new Set(["role", "type", "finish_reason", "id", "name", "mediaType", "filename"])
function capStrings(value: unknown, limit: number, preserveStructural = false, key = ""): unknown {
if (typeof value === "string") {
if (preserveStructural && STRUCTURAL_STRING_KEYS.has(key)) return valuecapStrings recurses with innerKey at all depths, so the exemption is not scoped to the message/part skeleton it is meant to protect — it matches any key with one of those seven names, including keys inside arguments, which is arbitrary tool input (serializePart puts part.state.input there verbatim, and MCP tools have user-defined schemas).
Failure scenario: a tool call whose input is { name: "<200 KB>" , note: "short" }. In boundedRedactedJson, every binary-search candidate — including capStrings(redacted, 0) — returns that 200 KB string untouched, so no candidate is ever <= limit, best stays oversized, and because redacted is an array (every promptAttr call passes one) the object-fallback branch is skipped and the function returns "[]". The attribute is bounded, but by dropping 100% of the content. Same thing for a payload carrying a large type, id, or role value.
The smaller correct change: these keys are short by nature ("user", "tool_call", "stop"), so they don't need an unbounded exemption — cap them at a small floor instead of skipping the cap, e.g. return capTo(value, Math.max(limit, 200)). That keeps every role/type/finish_reason intact in practice while leaving the bound unconditional. Alternatively thread depth through and only exempt keys at the message/part level.
| if (best.length <= limit) return best | ||
|
|
||
| if (redacted && typeof redacted === "object" && !Array.isArray(redacted)) { | ||
| const result: Record<string, unknown> = { __truncated__: true } | ||
| for (const [entryKey, inner] of Object.entries(redacted as Record<string, unknown>)) { | ||
| const candidate = { ...result, [entryKey]: capStrings(inner, 0, preserveStructural, entryKey) } | ||
| if (safeStringify(candidate).length > limit) break | ||
| result[entryKey] = candidate[entryKey] | ||
| } | ||
| return safeStringify(result) | ||
| } | ||
| return "[]" |
There was a problem hiding this comment.
MEDIUM (confidence: high) — the last-ditch branch drops all content with no marker, and it is the branch every promptAttr call lands in.
if (redacted && typeof redacted === "object" && !Array.isArray(redacted)) {
const result: Record<string, unknown> = { __truncated__: true }
...
}
return "[]"The object path at least emits __truncated__: true, so a consumer knows it is looking at a reduced value. The array path emits a bare "[]", which is byte-identical to "this turn had no messages" — the loss is invisible downstream. Since promptAttr always passes an array (retained), and boundedJson is the only object caller, arrays get the unmarked variant and objects get the marked one. That is backwards from where the risk is.
Two reachable ways to land here, both on the recordInputMessages path (promptAttr(serialized, "tail"), no trimParts):
- A large string under a key in
STRUCTURAL_STRING_KEYS— see my other comment. - A single message whose skeleton exceeds 32 KiB.
retainArrayretains the boundary item unconditionally (theif (retained.length && ...)guard skips the first one), so one oversized message reachesboundedRedactedJsonwhole. WithpreserveStructural, atool_callpart's skeleton is roughly{"type":"tool_call","id":"call_…","name":"…","arguments":{…:""}}≈ 100 chars, so ~330 tool-call parts in one message pushcapStrings(redacted, 0)past 32768 and the result is"[]".experimental.chat.messages.transformruns before every request in a turn, including continuation steps, where the last message is the in-progress assistant message with all of that turn's tool calls — so this is a long-agentic-turn shape, not a synthetic one.
Suggest returning a marked array, e.g. safeStringify([{ __truncated__: true }]), so the shape stays an array and the loss is self-describing.
Also worth noting for coverage: neither fallback has a test. The new tests exercise the binary search only, so both of these branches are currently unexecuted — a test that forces one (a payload with a huge arguments.name) would have surfaced the "[]" behavior.
| if (trimParts && retained.length === 1) { | ||
| const message = retained[0] | ||
| if (message && typeof message === "object" && !Array.isArray(message)) { | ||
| const record = message as Record<string, unknown> | ||
| if (Array.isArray(record.parts)) { | ||
| retained = [{ ...record, parts: retainArray(record.parts, PROMPT_MAX_CHARS / 2, "tail") }] | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
MEDIUM (confidence: medium-high) — trimParts is a special case for one call site while the shared path stays exposed to the same failure.
if (trimParts && retained.length === 1) {
const message = retained[0]
...
retained = [{ ...record, parts: retainArray(record.parts, PROMPT_MAX_CHARS / 2, "tail") }]Only the step-finish call site passes true. But the condition it guards — "one retained message whose own parts array is the thing blowing the budget" — is not specific to completions. recordInputMessages hits it too: retainArray keeps the boundary message unconditionally, so a newest message with hundreds of parts arrives whole, and without parts-level trimming it falls through to capStrings and then to the return "[]" branch (see my comment there). So the flag fixes the completion path and leaves the input path with the worse outcome, for the same root cause.
retained.length === 1 is also always true at the one site that passes the flag (the step-finish caller builds a one-element array literal), so the guard isn't discriminating between callers — it is just describing the caller.
Smaller correct change: drop the parameter and always trim the parts of the boundary message when it is the item that is oversized. retainArray already handles the multi-message case; parts-level trimming is the same operation one level down and both callers want it.
Separately, this path drops old parts silently — same marker gap as the array fallback. A dropped-count sentinel would make truncation legible in both places.
| const message = failed | ||
| ? capTo(redactText(String(state.error || "unknown")), CONTENT_MAX_CHARS) | ||
| : "" |
There was a problem hiding this comment.
MEDIUM — please confirm this is intended; it is a second behavior change beyond bounding, and it can hide the cause of a failure.
const message = failed
? capTo(redactText(String(state.error || "unknown")), CONTENT_MAX_CHARS)
: ""capTo is the bound the PR is about. redactText is new here — before this change the error string was passed through verbatim. It now runs the argv-oriented redactor, whose ASSIGNMENT rule rewrites key: value / key=value whenever isSensitiveKey(key) is true, and SENSITIVE_KEYS (packages/cz-cli/src/telemetry.ts:11) includes token, auth, authorization, login, secret, credential, pat.
So ordinary diagnostic text gets eaten:
token: expired→token: <redacted>auth: failed for user alice→auth: <redacted>secret: not found in namespace prod→secret: <redacted>
The value this destroys is exactly the part an operator reads. And the blast radius is two surfaces, not one: span.setStatus({ code: ERROR, message }), span.setAttribute("error.message", message), and the opencode.tool.finished log attribute on line 942 all take this string — error-rate dashboards and alerts that group by message text will see new groupings.
I am not asserting it's wrong: redacting tool errors is a real improvement (state.output already goes through redactText, so this is consistency), and a credential genuinely can appear in an error. Two things worth deciding explicitly:
- whether the redaction belongs in this PR at all, given the title and issue are about size bounds
- if it stays, whether error text should use a narrower rule than the flag/config-file redactor, since
<key>: <prose>is the dominant shape in error messages and the dominant shape theASSIGNMENTrule was written for is<key>: <credential>
| const available = limit - marker.length | ||
| const head = Math.ceil(available / 2) | ||
| const tail = available - head | ||
| return `${text.slice(0, head)}${marker}${tail ? text.slice(-tail) : ""}` |
There was a problem hiding this comment.
LOW (confidence: medium) — slicing by UTF-16 code unit can split a surrogate pair at both seams.
return `${text.slice(0, head)}${marker}${tail ? text.slice(-tail) : ""}`When head is odd relative to the pair boundaries, slice(0, head) ends on a lone high surrogate and slice(-tail) begins on a lone low surrogate. Any emoji or non-BMP CJK in a prompt or tool result can land there, and with head = Math.ceil(available / 2) it is roughly a coin flip.
Consequences split by attribute:
- JSON-serialized attributes (
gen_ai.input.messages,.output.messages,.system_instructions,.tool.call.arguments) are fine — well-formedJSON.stringifyescapes a lone surrogate as the literal text\ud83d, so the JSON stays valid and parseable. The PR's central claim holds here. - Plain-string attributes are not:
gen_ai.tool.call.resultanderror.messagegetcapToapplied directly with no JSON layer, so the unpaired surrogate reaches the OTLP exporter as-is and is substituted with U+FFFD on UTF-8 encoding.
So the worst case is one replacement character at each seam, which is cosmetic. Cheap to avoid: back head off by one when text.charCodeAt(head - 1) is in 0xD800-0xDBFF, and advance the tail start by one when it lands on 0xDC00-0xDFFF.
Worth a test either way — "😀".repeat(n) through capTo at an odd head is the case, and the existing multibyte test ("用户输入\n\"内容\" ") doesn't reach it because those are all BMP characters.
| let low = 0 | ||
| let high = limit | ||
| let best = safeStringify(capStrings(redacted, 0, preserveStructural)) | ||
| while (low <= high) { | ||
| const middle = Math.floor((low + high) / 2) | ||
| const candidate = safeStringify(capStrings(redacted, middle, preserveStructural)) | ||
| if (candidate.length <= limit) { | ||
| best = candidate | ||
| low = middle + 1 | ||
| } else { | ||
| high = middle - 1 | ||
| } | ||
| } |
There was a problem hiding this comment.
LOW (confidence: medium) — the search does ~15 full deep clones plus 15 serializations per oversized payload, on a synchronous hot path.
let low = 0
let high = limit
let best = safeStringify(capStrings(redacted, 0, preserveStructural))
while (low <= high) {
const middle = Math.floor((low + high) / 2)
const candidate = safeStringify(capStrings(redacted, middle, preserveStructural))high = limit means log2(32768) ≈ 15 iterations for prompts and ~13 for tool arguments, and each one rebuilds the entire object graph (capStrings allocates a new object/array per node via Object.fromEntries/map) and re-serializes it. The cost is per node, not per character, so a single huge string is cheap — but a wide payload is not, and this runs inside experimental.chat.messages.transform, i.e. before every provider request, and on every tool running transition.
Mitigating facts, which is why this is LOW: the early if (serialized.length <= limit) return serialized skips it entirely in the common case, and for promptAttr the preceding retainArray already trims the array to the budget, so the search only runs when a single message is itself oversized. boundedJson has no such pre-trim, so tool arguments over 8 KiB (any write/edit with real file content) take the full 13 iterations.
Two cheap reductions if this shows up in a profile: seed high with the longest leaf length rather than limit, and start from a single capStrings(redacted, limit) pass — that alone resolves the single-huge-string case in one iteration, which is the shape the constants were chosen for.
Also a small correctness-adjacent note on the loop: monotonicity is what makes the search valid, and it does hold here (capTo length is non-decreasing in limit), with the if (best.length <= limit) guard on line 130 covering any off-by-a-few from JSON escaping. Worth a comment saying so — it is the non-obvious invariant a future edit to capTo could break silently.
| for (const index of indexes) { | ||
| const next = serialized[index]!.length + (retained.length ? 1 : 0) | ||
| if (retained.length && used + next > limit) break | ||
| if (keep === "head") retained.push(items[index]) | ||
| else retained.unshift(items[index]) | ||
| used += next | ||
| } |
There was a problem hiding this comment.
LOW/MEDIUM (confidence: high that the behavior changes, medium that you'll want to act) — dropped messages leave no trace, so a truncated history is indistinguishable from a short one.
for (const index of indexes) {
const next = serialized[index]!.length + (retained.length ? 1 : 0)
if (retained.length && used + next > limit) breakThis is the headline behavior change of the PR and it is worth being explicit about, because it is the reverse of #111's stated goal ("preserve full trace content"). gen_ai.input.messages was a complete array; it is now a suffix of one, and nothing in the emitted JSON says so. A consumer computing "messages per turn", diffing a conversation against session storage, or reconstructing a trace for replay silently gets the wrong answer instead of an obviously-clipped one.
The used = 2 / + (retained.length ? 1 : 0) accounting is exact against [a,b,c] framing, so the size math is right — this is only about the missing signal.
Two notes on the arithmetic since it took a minute to confirm:
- The first item considered is retained unconditionally (
retained.length &&short-circuits), so the returned array can exceedlimitby one item. That is deliberate and necessary — it is what guarantees the latest user input survives, which is the propertyrecordInputMessagesexists for — but it is the same thing that lets an oversized message reachboundedRedactedJsonand hit the"[]"branch. Worth a comment saying the overshoot is intentional. breakrather thancontinuemeans the retained set is a contiguous window, so one large message in the middle stops the walk even if older smaller ones would still fit. Reasonable for a conversation (a contiguous window is more interpretable than a sparse sample) and worth stating as the intent.
Suggestion: carry a sentinel, e.g. prepend { role: "system", parts: [{ type: "text", content: "[dropped N earlier messages]" }] } when retained.length < items.length. That keeps the array schema the PR is protecting and restores the one fact truncation destroys.
Also flagging for the ledger of expectations, not as a defect: README.md:89 already claims "existing redaction and size limits still apply", which was inaccurate after #111 and is accurate again after this PR. No doc change needed.
Review summaryScope read: both changed files in full, plus A. Upstream invasiveness — no issues foundThe diff touches only B. Clean fix or hole drilled around it — mostly clean, one special caseThe core approach is the right one and worth saying so: bounding happens on the data (per string leaf, before serialization) rather than on the serialized JSON, which is precisely what makes the output still parseable and is the defect #111 was reacting to when it removed the old caps. Redact-then-cap is also the correct order — capping first could sever a credential into a form the Two things I would push back on:
Also raised as confirm-intent rather than defect: adding No dead code, no leftover debug logging, and no unrelated drive-by edits. One scope observation with no action needed: C. Regression risk — enumerated, see inlineBehavior changes, each with what covers it:
No tests were deleted or skipped. Verdict: the approach is sound and I would take it. The one I would want addressed before merge is the unconditional-bound gap, since 🤖 Generated with Claude Code |
|
Review based on head
Both additional regression cases failed, while the existing 43 tests passed. The implementation restores content size limits, but it does not yet fully satisfy the promise to preserve the latest context. Please address these cases and add regression coverage. Branch status: freshly fetched |
| let retained = retainArray(redacted, PROMPT_MAX_CHARS, keep) | ||
| if (trimParts && retained.length === 1) { | ||
| const message = retained[0] | ||
| if (message && typeof message === "object" && !Array.isArray(message)) { | ||
| const record = message as Record<string, unknown> | ||
| if (Array.isArray(record.parts)) { | ||
| retained = [{ ...record, parts: retainArray(record.parts, PROMPT_MAX_CHARS / 2, "tail") }] | ||
| } | ||
| } |
There was a problem hiding this comment.
MEDIUM (confidence: high) — trimParts fires unconditionally when there is one message, and spends only half the budget, so it drops content that would have fitted.
let retained = retainArray(redacted, PROMPT_MAX_CHARS, keep)
if (trimParts && retained.length === 1) {Two things compound here:
retainArrayalways keeps its first item regardless of size (the guard isif (retained.length && used + next > limit) break), soretained.length === 1is not evidence that the message is oversized. For the completion path at line 836 the array is always exactly one element —[{ role: "assistant", parts: [...], finish_reason }]— so this branch runs on every single step-finish, fitting or not.recordInputMessageshits it too on the first user turn of a session.- The inner budget is
PROMPT_MAX_CHARS / 2= 16 384, while the budgetboundedRedactedJsonthen enforces is 32 768.
Failure scenario: an assistant message whose accumulated parts serialize to 25 KB. retainArray(parts, 16384, "tail") drops roughly 9 KB of the oldest parts; boundedRedactedJson then sees ~16.4 KB, short-circuits on serialized.length <= limit, and returns. Nine kilobytes of assistant output — the first text part, i.e. usually the answer preamble, and the earliest tool calls — are discarded even though the attribute had room for all of it. Same arithmetic for a single user message carrying several attached-file parts.
Gating the trim on the message actually exceeding the budget, and trimming to PROMPT_MAX_CHARS minus the envelope rather than to half of it, would keep the extra 16 KB:
if (trimParts && retained.length === 1 && safeStringify(retained).length > PROMPT_MAX_CHARS) {Worth confirming which end you meant to keep, too: retainArray(record.parts, …, "tail") keeps the newest parts of the assistant message, which drops the head of the answer body — the thing the daily report in #113 wants to read.
| const CONTENT_MAX_CHARS = 8_000 | ||
| const PROMPT_MAX_CHARS = 32 * 1024 |
There was a problem hiding this comment.
MEDIUM (confidence: medium-high) — these bounds are in UTF-16 code units, but the limit they exist to respect is measured in bytes, and the motivating workload is Chinese.
const CONTENT_MAX_CHARS = 8_000
const PROMPT_MAX_CHARS = 32 * 1024Every length check downstream (text.length in capTo, candidate.length in boundedRedactedJson, serialized[index]!.length in retainArray) counts JS string units. The collector that drops a batch counts OTLP protobuf bytes. For the LPS analytics-agent traffic this PR is aimed at — the prompts and answers in #113 are Chinese — CJK is 3 bytes per code unit in UTF-8, so PROMPT_MAX_CHARS = 32 * 1024 admits roughly 96 KB of gen_ai.input.messages, not 32 KB. With input + system + output on one chat span that is ~290 KB per span rather than the ~96 KB the constants suggest.
Two follow-ups worth answering before merge:
- What is the actual limit being targeted? I could not find one configured here —
setup.ts:57buildsnew BasicTracerProvider({ resource, spanProcessors })with nospanLimits, andnew BatchSpanProcessor(traceExporter)at :53 takes the defaultmaxExportBatchSizeof 512. At 290 KB/span a full batch is ~148 MB, well past a collector's defaultmax_recv_msg_size. If batch size is the failure mode, per-attribute caps alone will not prevent it and the numbers here need to be derived from that budget rather than picked. - For the two plain-string attributes (
gen_ai.tool.call.resultanderror.message, lines 951–956) the SDK's ownspanLimits: { attributeValueLengthLimit }would do the same job in one line ofsetup.tsand would also bound every other attribute this file sets. The custom machinery is genuinely needed for the JSON-shaped attributes — plain truncation there is what fix(otel): preserve full trace content #111 removed — but it is not needed for those two.
If you want the bound to mean bytes, Buffer.byteLength(text, "utf8") (or new TextEncoder().encode(...).length) is the measure; capTo would then need to slice to a byte budget without splitting a code point.
| if (redacted && typeof redacted === "object" && !Array.isArray(redacted)) { | ||
| const result: Record<string, unknown> = { __truncated__: true } | ||
| for (const [entryKey, inner] of Object.entries(redacted as Record<string, unknown>)) { | ||
| const candidate = { ...result, [entryKey]: capStrings(inner, 0, structuralContext, entryKey) } | ||
| if (safeStringify(candidate).length > limit) break | ||
| result[entryKey] = candidate[entryKey] | ||
| } | ||
| return safeStringify(result) | ||
| } | ||
| return "[]" |
There was a problem hiding this comment.
MEDIUM (confidence: medium) — the two last-resort branches both change the shape they promised to preserve, and one of them discards the attribute with no trace.
if (redacted && typeof redacted === "object" && !Array.isArray(redacted)) {
const result: Record<string, unknown> = { __truncated__: true }
for (const [entryKey, inner] of Object.entries(redacted as Record<string, unknown>)) {
const candidate = { ...result, [entryKey]: capStrings(inner, 0, structuralContext, entryKey) }
if (safeStringify(candidate).length > limit) break
result[entryKey] = candidate[entryKey]
}
return safeStringify(result)
}
return "[]"return "[]" (line 167). Reached when the value is an array and even capStrings(redacted, 0, …) exceeds the limit. promptAttr returns it verbatim, so gen_ai.input.messages / gen_ai.output.messages ship as an empty array — indistinguishable from "this turn had no input" and from the sessionInput.has(sessionID) false case at line 807. The head comment on the old promptAttr was "so long histories cannot hide the latest user input"; this branch hides it completely. Reachable via one message whose minimal serialization still exceeds the budget — e.g. a tool part whose arguments object carries a few thousand keys, since capStrings(_, 0) preserves every key and only empties the values, and trimParts only trims parts, never the object under arguments. Emitting [{"__truncated__":true}], or the envelope with empty parts, would at least be distinguishable from "no content".
__truncated__ (line 159). This contradicts the PR description's "不向 JSON 数组注入异构 marker,不改变下游字段结构": for boundedJson(state.input, …) at line 905 the value is an object, so a __truncated__: true key gets injected into the recorded tool arguments, and an unknown number of the tool's real arguments are dropped with no record of which. A consumer that decodes gen_ai.tool.call.arguments against the tool's own schema now sees a field that is not in it.
Minor, same branch: the loop breaks on the first key that does not fit rather than skipping it, so one large key early in iteration order suppresses every later key even when they would all fit. continue would keep more of them.
Neither branch is covered by a test in this PR, so neither the marker shape nor the "[]" outcome is pinned.
| entry.span.setAttribute( | ||
| "gen_ai.tool.call.result", | ||
| capTo(redactText(String(state.output)), CONTENT_MAX_CHARS), | ||
| ) |
There was a problem hiding this comment.
MEDIUM (confidence: medium) — the outer JSON stays valid but the payload inside the string does not, which is the half #113 asked for.
entry.span.setAttribute(
"gen_ai.tool.call.result",
capTo(redactText(String(state.output)), CONTENT_MAX_CHARS),
)capTo splices \n[truncated]\n into the middle of the value. The care taken elsewhere in this PR is about keeping the envelope parseable; for a tool result the envelope is the string itself. A sql tool's output is a JSON or delimited result set, so an 8 000-unit cap now produces {"rows":[{…head…}\n[truncated]\n…tail…}]} — no longer parseable, where before #111 it was hard-truncated (also unparseable) and after #111 it was complete.
That matters because the trace evidence quoted in the PR description is specifically about the daily report not being able to "验证答案正文、生成 SQL 和结果集". Generated SQL and result sets arrive through exactly this attribute and through arguments at line 905. A cap of 8 000 units on a tool result will cut most real result sets and many long generated statements, so this PR bounds the very payload #113 wants to start reading. Two options that keep both properties:
- keep
CONTENT_MAX_CHARSfor the tool span but do not cap thesql-family tools' output at the same figure, or - record the head only (no tail splice) plus a separate
gen_ai.tool.call.result.truncatedboolean and.original_lengthint, so a consumer can tell a prefix from a whole value and still parse a prefix-tolerant format.
Also LOW, same call: capTo slices by code unit, so text.slice(0, head) / text.slice(-tail) can split a surrogate pair and leave a lone surrogate. For the JSON-valued attributes JSON.stringify escapes it, but this attribute and error.message at line 951 reach the exporter raw, where the protobuf encoder replaces it with U+FFFD. Emoji and non-BMP CJK in tool output would hit it.
| const message = failed | ||
| ? capTo(redactText(String(state.error || "unknown")), CONTENT_MAX_CHARS) | ||
| : "" |
There was a problem hiding this comment.
MEDIUM (confidence: medium-high) — the sibling error path one case away is left both unbounded and unredacted, so the shared problem is only half fixed.
const message = failed
? capTo(redactText(String(state.error || "unknown")), CONTENT_MAX_CHARS)
: ""This is the right treatment. But session.error at line 661 builds its message the way this line used to:
const message =
(p.error?.data && typeof p.error.data === "object" && "message" in p.error.data
? String((p.error.data as Record<string, unknown>).message ?? "")
: "") || name— no redactText, no capTo. It goes to emitLog's "error.message" attribute (line 674) and through recordTurnError → pendingErrors → span.setStatus({ code: ERROR, message: failure }) on the turn span at line 500.
Failure scenario: the AI gateway rejects a request and the provider error body is echoed into error.data.message. A provider 4xx from an OpenAI-compatible endpoint commonly quotes the offending request back, so the turn span's status message carries an unbounded copy of the prompt — the exact payload this PR bounds everywhere else — and, per .github/claude-review-context.md's "Auth header construction" and credentials-in-telemetry notes, an auth failure that echoes the key reaches telemetry unredacted. Neither is bounded by anything today.
Hoisting the treatment to a shared helper and using it at both call sites, rather than at this one, is the smaller correct change:
const boundedErrorMessage = (value: unknown) =>
capTo(redactText(String(value || "unknown")), CONTENT_MAX_CHARS)Nothing in otel-span-build.test.ts covers the session.error message content, so this would not go red either way.
| let low = 0 | ||
| let high = limit | ||
| let best = safeStringify(capStrings(redacted, 0, structuralContext)) | ||
| while (low <= high) { | ||
| const middle = Math.floor((low + high) / 2) | ||
| const candidate = safeStringify(capStrings(redacted, middle, structuralContext)) |
There was a problem hiding this comment.
LOW (confidence: medium) — up to 16 full deep-clone-plus-serialize passes on the synchronous telemetry path.
let low = 0
let high = limit
let best = safeStringify(capStrings(redacted, 0, structuralContext))
while (low <= high) {
const middle = Math.floor((low + high) / 2)
const candidate = safeStringify(capStrings(redacted, middle, structuralContext))log2(32768) ≈ 15, so each recordInputMessages / step-finish that overshoots walks and re-serializes the whole structure ~16 times, on top of the redactDeep walk and retainArray's items.map(safeStringify). The allocation per pass is bounded by the cap so this is not unbounded, but the node count is not — a message carrying a few hundred thousand nodes is visited 16 times inside a plugin hook that runs per LLM request. recordInputMessages is called from experimental.chat.messages.transform, i.e. in the request path, not off it.
The search also exists only because retainArray does not recurse. A single downward pass with a running budget — the same idea as retainArray, applied one level deeper into parts and then into leaf strings — would be one walk instead of sixteen and would remove the need for the two fallback branches below. Worth weighing given the search converges to a uniform per-leaf cap anyway, which is a blunter result than a budget walk would give.
Not a blocker; flagging it as a cost, and the AGENTS.md note about not over-abstracting cuts the other way here (five helpers for one bound).
|
|
||
| describe("tool content", () => { | ||
| test("preserves complete arguments and results while redacting secrets beyond the old cap", () => { | ||
| test("bounds arguments and results after redacting secrets", () => { |
There was a problem hiding this comment.
LOW (confidence: high) — asking about intent rather than asserting a bug: this renames away the invariant #111 added, and the replacement assertions are weaker in a way that would not catch the regressions above.
test("bounds arguments and results after redacting secrets", () => {The old name was "preserves complete arguments and results while redacting secrets beyond the old cap" and the old body asserted exact equality (toEqual({ file: "x".repeat(20_000) + " PASSWORD=<redacted> tail" })). Reverting that is the point of this PR, so the rename is right. Two things I would like confirmed:
-
Does fix(otel): preserve full trace content #111's motivating issue come back? fix(otel): preserve full trace content #111 removed the cap because truncation broke JSON. This PR keeps the envelope valid, but
capTostill splices a marker into string values, so a consumer that parses a tool result or a generated SQL statement out of those strings is back where fix(otel): preserve full trace content #111 started (see the comment onhandlers.ts:953). If fix(otel): preserve full trace content #111's consumer only needed the envelope parseable, that's fine — worth saying so in the PR body since it is the property being traded away. -
The new assertions are all
length <= NplustoContain. That shape passes for"[]"(length 2, andtoContainis not asserted on the empty-array tests), passes withrole/typecollapsed to"…", and passes with__truncated__injected. So none of the three degradation paths this PR introduces is pinned. Specifically uncovered:- the small-
middleregime where structural discriminators collapse (handlers.ts:118) - the
__truncated__object fallback and the"[]"array fallback (handlers.ts:158,:167) session.error's message, which this PR leaves unbounded (handlers.ts:661)- byte length as opposed to code-unit length, which is what the collector limit is in
- the small-
A test asserting JSON.parse(input).at(-1).role === "user" under a cap small enough to force middle < 14 would cover the first, and is the one I would add before merge.
I cannot run these tests, so this is a reading of the assertions only — no claim about whether the suite passes.
|
Review summary A. Upstream invasiveness — no issues found The PR touches two files, both under B. Clean fix, or a hole drilled around the problem — mixed The direction is right: PR 111 removed the OTel content cap because blind truncation
Plus one dead-code finding: C. Regression risk — see inline findings Behavioural changes and their coverage:
No exported API, CLI flag, config key or on-disk path changes. One consumer-facing risk worth a decision rather than a code change: command output shape I could not run the test suite in this environment, so nothing here is a claim that tests |
| return Object.fromEntries( | ||
| Object.entries(value as Record<string, unknown>).map(([innerKey, inner]) => [ | ||
| innerKey, | ||
| capStrings( | ||
| inner, | ||
| limit, | ||
| structuralContext === "message" && innerKey === "parts" ? "parts" : "none", | ||
| innerKey, | ||
| ), | ||
| ]), | ||
| ) |
There was a problem hiding this comment.
MEDIUM (confidence: high) — the structural-field protection is unreachable, so the PR's stated guarantee ("不截断 role、type、name、id、finish_reason 等结构字段") does not hold.
capStrings(
inner,
limit,
structuralContext === "message" && innerKey === "parts" ? "parts" : "none",
innerKey,
),The object branch replaces the incoming context with "none" for every key except parts. Trace ["messages"]:
- array branch →
itemContext = "message", socapStrings(message, limit, "message")withkey = "". - object branch → for
innerKey === "role", the ternary is false → passes"none",key = "role". - string branch →
structuralContext === "message" && MESSAGE_STRUCTURAL_KEYS.has(key)can never be true, because the context arriving at a string leaf is always"none".
The same holds for "part": capStrings(part, limit, "part") → object branch → structuralContext === "message" is false → "none" for type/id/name. So both MESSAGE_STRUCTURAL_KEYS (line 94) and PART_STRUCTURAL_KEYS (line 95) are dead sets and the whole structuralContext parameter only ever affects which value reaches a nested object, never a leaf.
Failure scenario: an oversized single item with many string leaves drives the binary search's middle below 14, where capTo collapses every string to "…" (limit <= marker.length branch). A message then serializes as {"role":"…","parts":[{"type":"…","content":"…"}]} — valid JSON with the discriminators destroyed, which is exactly what a downstream reader filtering on part.type === "tool_call" or role === "user" cannot recover from. That is the case these two sets were added for.
Minimal fix is to keep the parent context for leaves:
| return Object.fromEntries( | |
| Object.entries(value as Record<string, unknown>).map(([innerKey, inner]) => [ | |
| innerKey, | |
| capStrings( | |
| inner, | |
| limit, | |
| structuralContext === "message" && innerKey === "parts" ? "parts" : "none", | |
| innerKey, | |
| ), | |
| ]), | |
| ) | |
| if (!value || typeof value !== "object") return value | |
| return Object.fromEntries( | |
| Object.entries(value as Record<string, unknown>).map(([innerKey, inner]) => [ | |
| innerKey, | |
| capStrings( | |
| inner, | |
| limit, | |
| structuralContext === "message" && innerKey === "parts" ? "parts" : structuralContext, | |
| innerKey, | |
| ), | |
| ]), | |
| ) | |
| } |
Note also that no test in this PR covers the small-middle regime, so nothing would go red if this stays broken. A test that asserts role/type survive at a cap small enough to trigger it would pin the behaviour.
|
Follow-up review of The two previously reported reproductions now pass: the oversized [P2] Structural-field context is reset before reaching the string leaves. In Reproduced through Object.fromEntries(
Array.from({ length: 2000 }, (_, index) => [`field_${index}`, "some content"]),
)The exported Please preserve the parent message/part context when processing its direct structural string fields, while resetting context when entering arbitrary tool arguments/results. Add a regression that forces a very small leaf budget and checks the envelope fields, including CI status checked separately: the latest |
| return Object.fromEntries( | ||
| Object.entries(value as Record<string, unknown>).map(([innerKey, inner]) => [ | ||
| innerKey, | ||
| capStrings( | ||
| inner, | ||
| limit, | ||
| structuralContext === "message" && innerKey === "parts" ? "parts" : "none", | ||
| innerKey, | ||
| ), | ||
| ]), | ||
| ) |
There was a problem hiding this comment.
MEDIUM (confidence: high on the unreachability, medium on how often it bites) — the structural-field protection this PR advertises is unreachable dead code.
capStrings(
inner,
limit,
structuralContext === "message" && innerKey === "parts" ? "parts" : "none",
innerKey,
),The child context is only ever "parts" or "none", never "message" or "part". So the guard in the string branch:
if (
(structuralContext === "message" && MESSAGE_STRUCTURAL_KEYS.has(key)) ||
(structuralContext === "part" && PART_STRUCTURAL_KEYS.has(key))
) {
return value
}can never fire. Tracing every path to a string leaf:
"messages"(array) → item context"message"→ item is an object → object branch → children get"parts"/"none"."parts"(array) → item context"part"→ item is an object → object branch → children get"none".- The only call that passes
"message"/"part"isvalue.map((item) => capStrings(item, limit, itemContext)), which omitskey, sokeydefaults to""— in neither key set.
Consequence: role, finish_reason, type, id, name, mediaType, filename are capped like any other string. In the ordinary binary-search path they survive only because they are short enough to fit whatever limit is found — not because the code protects them. In the capStrings(inner, 0, structuralContext, entryKey) fallback at line 161 every string becomes "", so a downstream reader gets {"role":"","finish_reason":""}. That directly contradicts 修改内容 bullet "不截断 role、type、name、id、finish_reason 等结构字段".
No test covers it: expect(JSON.parse(output)[0].finish_reason).toBe("stop") and expect(messages.at(-1)).toEqual({ role: "user", … }) both pass with the guard deleted, because the search settles on a limit well above 4 characters in those fixtures.
The smaller correct change is to decide structural-ness at the parent, where the key and the parent's role are both in scope — e.g. return inner unchanged when typeof inner === "string" and the current structuralContext/innerKey pair is structural, and otherwise recurse as today. That also removes the need for the key parameter to travel one level down from where it is meaningful.
| if (best.length <= limit) return best | ||
|
|
||
| if (redacted && typeof redacted === "object" && !Array.isArray(redacted)) { | ||
| const result: Record<string, unknown> = { __truncated__: true } | ||
| for (const [entryKey, inner] of Object.entries(redacted as Record<string, unknown>)) { | ||
| const candidate = { ...result, [entryKey]: capStrings(inner, 0, structuralContext, entryKey) } | ||
| if (safeStringify(candidate).length > limit) break | ||
| result[entryKey] = candidate[entryKey] | ||
| } | ||
| return safeStringify(result) | ||
| } | ||
| return "[]" |
There was a problem hiding this comment.
MEDIUM (confidence: high that the path exists, medium on how reachable it is in production) — for every prompt attribute this fallback is the return "[]" line, which throws the content away with no signal that it happened.
if (redacted && typeof redacted === "object" && !Array.isArray(redacted)) {
…
}
return "[]"promptAttr always hands boundedRedactedJson an array (retained), so gen_ai.input.messages, gen_ai.system_instructions and gen_ai.output.messages can only ever take the array branch. When best (all string leaves emptied) is still over the limit the attribute becomes the literal "[]" — indistinguishable from "this turn had no messages" for whoever reads the span. The object branch at least marks itself with __truncated__.
Reachability: retainArray always keeps its first item regardless of size, so a single message whose skeleton exceeds 32 KB reaches here — e.g. one tool_call part whose arguments object carries a few thousand keys, where emptying every value still leaves {"key_0":"","key_1":"", …} over the budget. trimParts does not help, because it also always keeps at least one part.
Given the whole point of the PR is that the daily report can still verify 答案正文、生成 SQL 和结果集, silently emitting an empty array is the one outcome that is worse than a truncated one. Two options that stay inside the PR's "no heterogeneous marker inside the array" rule:
- drop items instead of giving up — reuse
retainArrayon the skeleton (capStrings(redacted, 0, …)) result, keeping the tail, which yields the newest messages that do fit; - or record a sibling boolean/count attribute (e.g.
opencode.otel.content.truncated) so a[]from size pressure is distinguishable from a genuinely empty history.
Either way this branch and the __truncated__ branch are both untested — no test in otel-span-build.test.ts reaches either.
| if (redacted && typeof redacted === "object" && !Array.isArray(redacted)) { | ||
| const result: Record<string, unknown> = { __truncated__: true } | ||
| for (const [entryKey, inner] of Object.entries(redacted as Record<string, unknown>)) { | ||
| const candidate = { ...result, [entryKey]: capStrings(inner, 0, structuralContext, entryKey) } | ||
| if (safeStringify(candidate).length > limit) break | ||
| result[entryKey] = candidate[entryKey] | ||
| } | ||
| return safeStringify(result) |
There was a problem hiding this comment.
LOW (confidence: high) — this object fallback does change the downstream field structure, which the PR description says it does not ("不改变下游字段结构").
const result: Record<string, unknown> = { __truncated__: true }
for (const [entryKey, inner] of Object.entries(redacted as Record<string, unknown>)) {
const candidate = { ...result, [entryKey]: capStrings(inner, 0, structuralContext, entryKey) }
if (safeStringify(candidate).length > limit) break
result[entryKey] = candidate[entryKey]
}The only caller that can reach it is boundedJson(state.input, CONTENT_MAX_CHARS) for gen_ai.tool.call.arguments, so a consumer that parses tool arguments and iterates keys will see a synthetic __truncated__: true that no tool declares — and it collides outright if a tool ever has a field by that name. That may well be the intent; if so it is worth saying in the comment, and worth a line in the attribute's documentation, since the PR text currently promises the opposite.
Two smaller points on the same loop:
breakon the first key that overflows discards every later key even when it would have fit.continuekeeps more of the payload for the same budget; withObject.entriesorder that difference is arbitrary rather than "oldest first", so there is no ordering argument forbreak.capStrings(inner, 0, …)empties every string, so the kept keys arrive with""values. A caller cannot tell{"path":""}here from a tool that was genuinely called with an empty string.
Neither this branch nor __truncated__ appears in any test.
| } else if (_recordContent && state.output) { | ||
| entry.span.setAttribute("gen_ai.tool.call.result", redactText(String(state.output))) | ||
| entry.span.setAttribute( | ||
| "gen_ai.tool.call.result", | ||
| capTo(redactText(String(state.output)), CONTENT_MAX_CHARS), | ||
| ) |
There was a problem hiding this comment.
LOW / question on intent (confidence: medium) — worth confirming that 8 KB with a mid-string marker is the right shape for this particular attribute.
entry.span.setAttribute(
"gen_ai.tool.call.result",
capTo(redactText(String(state.output)), CONTENT_MAX_CHARS),
)Unlike the prompt attributes, this one is not routed through boundedRedactedJson, so it gets the raw capTo treatment: head + "\n[truncated]\n" + tail. For the sql tool the output is a serialized result set, so above 8 KB the attribute stops being parseable at all — the marker lands in the middle of the JSON. Issue #113's motivation is that the report currently cannot verify 生成 SQL 和结果集; a result set that is unparseable past 8 KB is only a partial answer to that.
Two things to confirm rather than change blindly, since only you know what the report reader does:
- is 8 KB enough for the result sets the report needs to check?
CONTENT_MAX_CHARSis 4× smaller thanPROMPT_MAX_CHARS, yet this is the attribute carrying the data the issue is about. - would head-only truncation plus an explicit
gen_ai.tool.call.result.truncatedboolean serve the reader better than a middle elision? Head-only keeps a prefix a JSON reader can at least stream, and the boolean removes the need to string-match[truncated].
The same applies to error.message on line 939, which is also both a span attribute and an opencode.tool.finished log attribute.
For what it is worth, the ordering here is right: redactText runs before capTo, so truncation cannot re-expose a secret by slicing through a <redacted> replacement.
| let low = 0 | ||
| let high = limit | ||
| let best = safeStringify(capStrings(redacted, 0, structuralContext)) | ||
| while (low <= high) { | ||
| const middle = Math.floor((low + high) / 2) | ||
| const candidate = safeStringify(capStrings(redacted, middle, structuralContext)) | ||
| if (candidate.length <= limit) { | ||
| best = candidate | ||
| low = middle + 1 | ||
| } else { | ||
| high = middle - 1 | ||
| } | ||
| } |
There was a problem hiding this comment.
LOW (confidence: medium) — cost of the search on a hot path.
let best = safeStringify(capStrings(redacted, 0, structuralContext))
while (low <= high) {
const middle = Math.floor((low + high) / 2)
const candidate = safeStringify(capStrings(redacted, middle, structuralContext))capStrings deep-clones the whole value and safeStringify serializes it, and with high = PROMPT_MAX_CHARS that runs ~16 times, plus the eager best clone before the loop even starts. recordInputMessages is called from experimental.chat.messages.transform, i.e. once per provider request, and redactDeep has already walked the same payload with three regexes per string immediately before.
Normally this is bounded cheaply, because retainArray has already trimmed the array to ≤ 32 KB. The case that is not bounded is the one retainArray cannot trim: a single always-retained message whose one part holds a multi-MB tool result. Then each of the ~16 iterations clones and stringifies the full multi-MB payload — roughly 16× the old single-stringify cost, on every request of that session, for a value that will be discarded down to 32 KB.
A cheap bound: clamp high to min(limit, longestStringLength) — there is no point testing per-string limits above the longest leaf — or seed the search with limit / stringLeafCount instead of the midpoint. Either keeps the result identical while cutting the iteration count on the pathological input.
| // process.exitCode is shared by every isolated file. A test that exercises an | ||
| // error path can leave it non-zero even when all assertions pass, which makes | ||
| // the whole Bun process exit 1. Clear it after each test like HOME above. | ||
| afterEach(() => { | ||
| process.exitCode = 0 | ||
| }) |
There was a problem hiding this comment.
MEDIUM (confidence: high that this is symptom treatment; medium on the masking risk, which I could not verify) — this is a hole drilled around the problem, and it is unrelated to the OTel change this PR is about.
afterEach(() => {
process.exitCode = 0
})Three separate concerns:
1. It treats the symptom. The comment names the cause exactly — "a test that exercises an error path can leave it non-zero" — and then fixes it globally instead of at that test. There are ~35 process.exitCode writers in src/ (output/index.ts:134, run-cli.ts:909, cli.ts:455, command-group.ts:117, …) and the leak comes from whichever test drives one of them without restoring. test/execute.ts-style save/restore is already the pattern in this repo (src/execute.ts:42,103 does exactly that), and the preload comment at line 22 notes several test files already save/restore their own globals. The smaller correct change is to find the leaking test — the PR body says the suite reports 1503 passed, 0 failed yet exits 1, so the leaker is identifiable — and restore the value there.
2. It can mask a real signal. bun test reports exit 1 for assertion failures, and I could not verify in this environment whether Bun assigns process.exitCode incrementally as failures occur or only once after all hooks have run. If it is the former, this hook clears it after each test and a failing suite exits 0 — a green CI over red tests, which is strictly worse than the exit-1 noise it replaces. Worth confirming against a deliberately failing test before this lands, given cz-test.yml gates on bun run test's exit code alone.
3. It is a drive-by. Nothing in handlers.ts or the new OTel tests touches process.exitCode, and the PR's own checklist says 未引入无关修改. The PR body already classifies the exit-1 as "仓库 CI runner/script 问题" — that argues for its own PR, where the leak can be found rather than blanketed.
Blast radius to note either way: this afterEach now runs after every test in the package, including the 16 files that assert on process.exitCode (login-command.test.ts, output.test.ts:57, studio-workspace-error.test.ts:110, agent-tui-mini.test.ts:48). Their in-body assertions run before this hook so they should still hold, but any file whose own hook reads process.exitCode after the test body now depends on Bun's inner-before-outer afterEach ordering to keep working.
| expect(String(first!.attributes["gen_ai.input.messages"]).length).toBeLessThanOrEqual(32 * 1024) | ||
| expect(String(first!.attributes["gen_ai.system_instructions"]).length).toBeLessThanOrEqual(32 * 1024) | ||
| expect(first!.attributes["gen_ai.input.messages"]).toContain(SESSION.repeat(20)) | ||
| expect(first!.attributes["gen_ai.system_instructions"]).toContain(SESSION.repeat(20)) |
There was a problem hiding this comment.
LOW (confidence: high) — flagging as a loosened assertion, not a bug.
expect(String(first!.attributes["gen_ai.input.messages"]).length).toBeLessThanOrEqual(32 * 1024)
expect(first!.attributes["gen_ai.input.messages"]).toContain(SESSION.repeat(20))The old assertion was toContain(SESSION.repeat(5_000)), i.e. the whole fixture. The replacement checks the bound plus a 20-repeat prefix, which is the right shape for a truncating implementation — but repeat(20) is short enough that it also passes if the attribute keeps only a small head fragment. If the intent is "the head survives and the size is bounded", asserting on something closer to the expected retained size (or on a tail marker) would pin the behaviour more tightly. Same for the three sibling assertions below.
Separately, no test in this file exercises the two give-up paths added to boundedRedactedJson — the __truncated__ object branch and the return "[]" array branch. Both are reachable via a skeleton that exceeds the budget once every string is emptied (see my comments on handlers.ts), and they are the paths where content disappears entirely, so they are the ones most worth a fixture.
Review summaryA. Upstream invasiveness — no issues found. B. Clean fix, with one exception.
No leftover debug logging, commented-out code or other unrelated edits found beyond the preload hook. C. Regression risk.
Not covered by any test: the two give-up branches in I could not run the test suite in this environment, so nothing above should be read as a claim that tests pass. |
Issue for this PR
Closes #113
类型
日报触发背景(不是直接复现证据)
本 PR 的触发背景来自 LPS 日报 2026-09-23,租户
900126、cz-cli2.0.6:raw.jsonl:L324-L330:Analytics Agent session1193连续执行 7 个业务问题;代表 traceae85acf825fe6b8023f798dc951d15c7/ span97c2b84a85d1325c,命令analytics-agent session run 1193 26 ... json,error_message=null,response_bytes=77304,session_id=null。raw.jsonl:L348:session1195复测 Imported Labour;trace5627179b761d1f4ce3e5f7aff71de489/ span2daee5ad6a1198e7,response_bytes=62658,error_message=null,session_id=null。这些 trace 直接证明的是:日报目前只能看到命令完成和响应长度,无法验证答案正文、生成 SQL 和结果集;它们不是直接证明 OTel span 超限的复现样本。
修改内容
PR #111 为了避免 JSON 被字符串截断,移除了 OTel content cap,但留下了无界 prompt history、tool arguments/results 和 completion 内容。本 PR:
finish_reason;role、type、name、id、finish_reason等结构字段;影响
避免长会话和大 tool 输出生成超大 span 属性,降低 collector 丢弃整批 telemetry 的风险;同时保持日报后续读取 OTel 内容时的 JSON 结构稳定。
验证
cd packages/cz-cli && bun test --isolate --timeout 30000 test/otel-span-build.test.ts43 passed, 0 failedgit diff --check通过7eae09132修复,并已逐条回复cz-test输出1503 passed, 0 failed, 65 skipped,但测试汇总后 Bun 脚本返回退出码 1;这被记录为仓库 CI runner/script 问题,不是测试断言失败bun typecheck属于 upstream/workspace 基线问题,本 PR 不将它作为阻塞项;本次使用变更路径的定向测试验证Screenshots / recordings
不适用。
Checklist
main