Skip to content

fix(otel): bound recorded content safely - #114

Merged
hellozepp merged 3 commits into
mainfrom
telemetry-content-bounds
Sep 25, 2026
Merged

hellozepp merged 3 commits into
mainfrom
telemetry-content-bounds

Conversation

@hellozepp

@hellozepp hellozepp commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Issue for this PR

Closes #113

类型

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

日报触发背景(不是直接复现证据)

本 PR 的触发背景来自 LPS 日报 2026-09-23,租户 900126、cz-cli 2.0.6:

  • raw.jsonl:L324-L330:Analytics Agent session 1193 连续执行 7 个业务问题;代表 trace ae85acf825fe6b8023f798dc951d15c7 / span 97c2b84a85d1325c,命令 analytics-agent session run 1193 26 ... json,error_message=null,response_bytes=77304,session_id=null。
  • raw.jsonl:L348:session 1195 复测 Imported Labour;trace 5627179b761d1f4ce3e5f7aff71de489 / span 2daee5ad6a1198e7,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:

  • 对 OTel 内容先脱敏,再按总量限制序列化结果;
  • 输入历史丢弃最旧消息,保留最新输入;
  • system instructions 保留前部;
  • completion 保留 assistant envelope、最新 parts 和 finish_reason;
  • 不截断 role、type、name、id、finish_reason 等结构字段;
  • tool arguments 保持对象结构,tool result/error 都做脱敏和大小限制;
  • 不向 JSON 数组注入异构 marker,不改变下游字段结构。

影响

避免长会话和大 tool 输出生成超大 span 属性,降低 collector 丢弃整批 telemetry 的风险;同时保持日报后续读取 OTel 内容时的 JSON 结构稳定。

验证

  • cd packages/cz-cli && bun test --isolate --timeout 30000 test/otel-span-build.test.ts
  • 结果:43 passed, 0 failed
  • git diff --check 通过
  • 自动 review 提出的 HIGH/MEDIUM 问题已在 commit 7eae09132 修复,并已逐条回复
  • GitHub review check 已通过
  • GitHub cz-test 输出 1503 passed, 0 failed, 65 skipped,但测试汇总后 Bun 脚本返回退出码 1;这被记录为仓库 CI runner/script 问题,不是测试断言失败
  • 全量 bun typecheck 属于 upstream/workspace 基线问题,本 PR 不将它作为阻塞项;本次使用变更路径的定向测试验证

Screenshots / recordings

不适用。

Checklist

  • 已在本地测试变更
  • 未引入无关修改
  • PR 目标分支为 main
  • 不自动合并,等待负责人 review

Comment on lines +98 to +102
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])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +105 to +108
const marker = {
role: "system",
parts: [{ type: "text", content: `[truncated ${dropped} earlier messages]` }],
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with role: "system" at the front of the conversation, indistinguishable from a real system message to anything rendering the trace.
  • recordSystemInstructions (line 494) passes system.filter(Boolean).map((content) => ({ type: "text", content })) — a flat array of parts. Injecting { role, parts } into it makes the array heterogeneous, so a consumer reading item.content off each entry gets undefined for 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +110 to +112
while (retained.length > 1 && safeStringify(retained).length > PROMPT_MAX_CHARS) {
retained.splice(1, 1)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +80 to +85
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) : ""}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +129 to +133
for (let previewLimit = limit; previewLimit > 0; previewLimit -= 128) {
const candidate = safeStringify({
truncated: true,
preview: capTo(serialized, previewLimit),
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +828 to +834
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>") }],
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Keep a retention assertion here — expect(JSON.parse(input)).toHaveLength(2) — so a regression that starts dropping history fails.
  2. 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +60 to +63
function redactDeep(
value: unknown,
leafLimit = CONTENT_MAX_CHARS,
depth = 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e9cd2fd. promptAttr always delegates to boundedJson, so non-array values also receive a total serialized-size bound.

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

A. Upstream invasiveness — no issues found. Both changed files are packages/cz-cli/src/opencode-plugin/otel/handlers.ts and packages/cz-cli/test/otel-span-build.test.ts. Nothing under packages/opencode, packages/tui, packages/core or packages/schema is touched, so no banner and no UPSTREAM-PATCHES.md entry are required. This is exactly where the behavior belongs — the OTel recording already reaches upstream through the plugin's experimental.chat.messages.transform / experimental.chat.system.transform hooks and the event subscription, and the change stays inside that layer.

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 — capTo on every leaf, array-dropping in promptAttr, envelope-wrapping in boundedJson — and promptAttr's strategy does not fit all three shapes it is applied to. See handlers.ts:98-102 (array-dropping cannot bound a single-element array, so a large completion is replaced entirely by a marker), handlers.ts:105-108 (message-shaped marker injected into a parts-shaped array), and handlers.ts:66 (leaf cap fires unconditionally rather than when over budget, so it truncates payloads the 32 KiB limit had room for).

C. Regression risk — see inline findings. What already-working behavior this can change:

  • gen_ai.tool.call.arguments changes schema to {truncated, preview} above 8 KB — raised as a question for you at handlers.ts:129-133, since I can't tell from the repo whether anything downstream parses it. Covered by the rewritten test at test/otel-span-build.test.ts:477.
  • gen_ai.output.messages can lose the completion and its finish_reason entirely (handlers.ts:98-102). No test covers it.
  • gen_ai.input.messages / gen_ai.system_instructions can gain a synthetic element and undercount what was dropped (handlers.ts:105-108, handlers.ts:110-112). No test covers it.
  • Every recorded string leaf is now cut at 8 000 chars (handlers.ts:66). Covered by the updated tests.
  • Three tests moved from exact toEqual deep-equality to length bounds plus stringContaining; the loosening removed the assertion that history is retained, which is the behavior under change — test:828-834. No tests were deleted or skipped.
  • No exported API, config key, on-disk path or CLI surface changes. promptAttr, capTo, boundedJson and redactDeep are module-private; rg confirms the only callers of each are inside this file, and redactDeep's new positional parameter reaches no external caller.

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 rg over the test file. All of this is suggestions; take or leave as you see fit.

@hellozepp
hellozepp force-pushed the telemetry-content-bounds branch from b7580a2 to e9cd2fd Compare September 23, 2026 12:34
@hellozepp

Copy link
Copy Markdown
Collaborator Author

已根据本轮 review 更新到 commit e9cd2fd10:

  • 修复单条超大 completion 被 marker 替换、丢失 finish_reason 的问题:改为保留原 message/part schema,动态缩短字符串叶子。
  • 不再向 input/system/output 数组注入异构 marker,避免破坏下游 JSON shape。
  • tool arguments/results 保持原字段结构,并在序列化后总量超限时做 schema-preserving bounded JSON。
  • capTo 先保留脱敏后的头尾,避免敏感值位于尾部时被截断前跳过脱敏。
  • 新增结构化 tool input many-short-fields 覆盖。

本地验证:41 passed, 0 failed;git diff --check 通过。完整 bun typecheck 仍被仓库现有 upstream/workspace 类型错误阻塞。

if (serialized.length <= limit) return serialized

let low = 0
let high = Math.min(CONTENT_MAX_CHARS, limit)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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}'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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".
  2. 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.

Comment on lines +91 to +98
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)]),
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +74 to 81
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) : ""}`
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 floor boundedJson treats it as, which is what makes the {"truncated":true} fallback reachable (separate comment on line 122).
  • capTo's output length is non-monotone in limit across [0, ~24) — it stays flat at ~marker.length and even shrinks slightly as N loses a digit. Binary search over a non-monotone predicate is not guaranteed to find the true maximum. The final best.length <= limit guard 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:

Suggested change
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`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +100 to +105
/**
* 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 863 to +869
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),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 _positional before. A failing bash tool echoes its own command line, so a --password … or a connection string lands in error.message verbatim while the identical text in state.output two lines below goes through redactText. 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:

Suggested change
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.

Comment on lines +42 to +43
const CONTENT_MAX_CHARS = 8_000
const PROMPT_MAX_CHARS = 32 * 1024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 * 1024

CJK 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.ts configures no spanLimits, so these constants really are the only bound in the pipeline — worth knowing, since the OTel SDK's own spanLimits.attributeValueLengthLimit would 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.

Comment on lines +109 to +121
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
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).
  • promptAttr for output messages → once per step-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.

Comment on lines +514 to +516
const args = String(tool!.attributes["gen_ai.tool.call.arguments"])
expect(args.length).toBeLessThanOrEqual(8_000)
expect(JSON.parse(args).field_0).toBeDefined()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

Copy link
Copy Markdown
Contributor

Reviewed against .github/claude-review-context.md and packages/cz-cli/UPSTREAM-PATCHES.md. Nine inline findings; verdict per section below.

A. Upstream invasiveness — no issues found

Both changed files are under packages/cz-cli/. Nothing in packages/opencode, packages/tui, packages/core or packages/schema is touched, so no banner and no new INTRUSIVE ledger entry are required. The //: comments in this file are cz-layer explanatory comments and are fine.

B. Clean fix, or a hole drilled around the problem?

The premise is sound and the layer is right: setup.ts configures no spanLimits, so before this PR nothing bounded these attributes, and doing it in the cz-owned plugin rather than reaching into upstream is correct. The redaction order is also correct throughout — redact, then cap, so capping can never un-redact a secret.

Three findings in this category:

  • Uniform budget instead of the described message-dropping (comment). The PR description says the change "drops older messages before serializing history"; no such code exists. recordInputMessages serializes every message and the 32 KiB budget is split evenly, so the current user prompt gets the same allowance as a message 200 turns old — reversing the priority the comment removed by this PR stated. Please confirm which behavior was intended.
  • The truncated-sentinel fallback is reachable in ordinary sessions (comment) and replaces the array with an object. Because capTo returns the marker itself at tiny limits, the per-leaf floor is ~25 chars; an assistant message with three tool calls floors near 800 chars, crossing 32 KiB at roughly 40 messages. Dropping whole old messages removes this cliff.
  • The error path on the same span was left out (comment). gen_ai.tool.call.result is now capped and redacted; error.message, built two lines up from String(state.error), is neither. Pre-existing, not a regression, but it means any failing tool bypasses the bound this PR adds — and a failing bash tool echoing its own command line puts a --password into telemetry unredacted, which is the case the review context flags.

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. capStrings also caps role, type and finish_reason (comment), so the schema survives but a consumer grouping by role does not, contradicting the doc comment directly above it.

C. Regression risk

No exported API, CLI flag, subcommand, config key or on-disk path changes. _recordContent gating is untouched, so OPENCODE_OTEL_RECORD_CONTENT=0 still disables content recording entirely. No tests deleted or skipped.

What could change for something already working:

  1. Five span attributes change shape — gen_ai.tool.call.arguments, gen_ai.tool.call.result, gen_ai.input.messages, gen_ai.system_instructions, gen_ai.output.messages. Long values now carry a [truncated N chars] marker spliced into the middle, and the whole attribute can become the truncated sentinel. Anything downstream that parses these — a Langfuse view, a dashboard query, a script reading a tool argument as a path — sees new shapes. Head+tail preservation is deliberate per the PR description, but note it makes a long structured value (a JSON tool result, a file path) unparseable in a way a suffix-only truncation would not; only the JSON envelope stays valid, not the values inside it.
  2. Assertions loosened in the two renamed tests, from exact equality to toContain plus a length bound. Appropriate for the new behavior, but nothing now pins where content is kept: the PASSWORD=<redacted> assertion passes off the preserved tail and would still pass if the head were dropped. Covered at test/otel-span-build.test.ts:514 (comment).
  3. The fallback branch has no test. The one path that changes an attribute JSON type is uncovered, and the nearest test sits just under the threshold that triggers it.
  4. Callers checked. promptAttr has three call sites (handlers.ts:476, 484, 755) and all three go through the new bound; boundedJson/capTo are new and module-private. recordInputMessages / recordSystemInstructions are consumed only from otel/index.ts:96,102, whose call shape is unchanged. No new cross-package dependency edges. Tests touching these paths: test/otel-span-build.test.ts describe blocks "tool content" and "prompt and completion content" — the modified ones are the coverage, and I cannot run them, so I make no claim about whether they pass.
  5. Not measured: the binary search adds ~14 full traversals and serializations per over-limit attribute on the synchronous event path (comment) — worth a real timing on a long session, since it triggers precisely when the payload is largest.

Remaining lower-severity items are inline: capTo returning more than its limit and splitting surrogate pairs, the marker under-reporting the dropped count by ~24, and the budgets being UTF-16 chars rather than wire bytes (~3x for the CJK content this PR own fixtures use).

All of the above are suggestions — accept or reject as you see fit.

🤖 Generated with Claude Code

@hellozepp
hellozepp force-pushed the telemetry-content-bounds branch from e9cd2fd to 7eae091 Compare September 23, 2026 12:53
@hellozepp

Copy link
Copy Markdown
Collaborator Author

Review follow-up in 7eae09132:

  • input history now drops oldest whole messages and preserves the latest user message;
  • system instructions retain leading entries; output retains the assistant envelope, latest parts and finish_reason;
  • structural fields (role, type, name, id, finish_reason) are exempt from content truncation;
  • the generic fallback preserves container type ([] for arrays, object with __truncated__ for oversized tool args);
  • failed tool error.message is now redacted and capped, including the emitted log record;
  • prompt search uses the full 32 KiB budget instead of the 8 KiB tool budget;
  • new tests cover 800-message eviction and failed-tool credential redaction.

Local focused suite: 43 passed, 0 failed.

Comment on lines +92 to +97
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 value

capStrings 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.

Comment on lines +130 to +141
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 "[]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  1. A large string under a key in STRUCTURAL_STRING_KEYS — see my other comment.
  2. A single message whose skeleton exceeds 32 KiB. retainArray retains the boundary item unconditionally (the if (retained.length && ...) guard skips the first one), so one oversized message reaches boundedRedactedJson whole. With preserveStructural, a tool_call part's skeleton is roughly {"type":"tool_call","id":"call_…","name":"…","arguments":{…:""}} ≈ 100 chars, so ~330 tool-call parts in one message push capStrings(redacted, 0) past 32768 and the result is "[]". experimental.chat.messages.transform runs 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.

Comment on lines +167 to +175
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") }]
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +907 to +909
const message = failed
? capTo(redactText(String(state.error || "unknown")), CONTENT_MAX_CHARS)
: ""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the ASSIGNMENT rule 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) : ""}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-formed JSON.stringify escapes 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.result and error.message get capTo applied 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.

Comment on lines +117 to +129
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
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +154 to +160
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) break

This 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 exceed limit by one item. That is deliberate and necessary — it is what guarantees the latest user input survives, which is the property recordInputMessages exists for — but it is the same thing that lets an oversized message reach boundedRedactedJson and hit the "[]" branch. Worth a comment saying the overshoot is intentional.
  • break rather than continue means 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.

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

Scope read: both changed files in full, plus otel/context.ts, telemetry.ts (SENSITIVE_KEYS), logger.ts (redactSql), otel-defaults.ts, otel/index.ts, and the promptAttr/boundedJson call sites. I could not execute anything in this environment, so the size arithmetic in my inline comments is derived by reading, not measured — I have flagged it as such where it matters. I make no claim about whether the tests pass.

A. Upstream invasiveness — no issues found

The diff touches only packages/cz-cli/src/opencode-plugin/otel/handlers.ts and packages/cz-cli/test/otel-span-build.test.ts. Nothing under packages/opencode, packages/tui, packages/core, or packages/schema, so no banner and no UPSTREAM-PATCHES.md INTRUSIVE entry are required. The work sits in the cz plugin layer where the invariant wants it, reaching opencode through the experimental.chat.messages.transform / experimental.chat.system.transform hooks and the event subscription it already used.

B. Clean fix or hole drilled around it — mostly clean, one special case

The 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 ASSIGNMENT/FLAG_VALUE rules no longer match. And no new env var or config key was introduced to route around the problem, which was the tempting shortcut here.

Two things I would push back on:

  • trimParts is a per-caller special case (comment). Only the step-finish site passes true, but recordInputMessages has the same single-oversized-boundary-message shape and gets the worse outcome for it. The flag also can't be false at the one site that sets it, so it distinguishes callers rather than cases.
  • The bound is not unconditional, and where it fails it fails to total silent loss. STRUCTURAL_STRING_KEYS is honored at every recursion depth, so a large string under any key named name/id/type/role/filename — arbitrary tool input, which MCP tools define freely — is never capped, no binary-search candidate ever fits, and the array fallback returns a bare "[]". Two comments: structural keys, the "[]" branch. Neither fallback branch has a test.

Also raised as confirm-intent rather than defect: adding redactText to error.message is a second behavior change beyond bounding, and SENSITIVE_KEYS contains token/auth/login/secret, so ordinary diagnostic text like token: expired becomes token: <redacted> on the span status, the error.message attribute, and the opencode.tool.finished log (comment).

No dead code, no leftover debug logging, and no unrelated drive-by edits. One scope observation with no action needed: clickzetta.llm.raw_request.body in otel/context.ts sets the full provider request body, unbounded and unredacted, on the same step span that gets the now-32-KiB gen_ai.input.messages — but recordRawProviderRequest has no caller anywhere in the repo, so it is not a live gap in this fix.

C. Regression risk — enumerated, see inline

Behavior changes, each with what covers it:

  1. gen_ai.input.messages / .system_instructions / .output.messages are capped at 32 KiB; arrays may now be a suffix of the conversation, and string leaves may contain \n[truncated]\n. Covered by the two rewritten tests in prompt and completion content, which assert length and containment only. The old exact-equality assertions were necessarily replaced — that is inherent to the change, not a loosening to dodge a failure.
  2. Truncation is unsignalled, so a clipped history is indistinguishable from a short one for anything parsing these attributes (comment). Nothing covers this; it is a shape change for consumers, not a code defect.
  3. gen_ai.tool.call.arguments capped at 8 KiB and can gain a __truncated__ key in the object-fallback path. The new bounds structured arguments with many short fields test covers the binary search; nothing covers the __truncated__ path.
  4. gen_ai.tool.call.result capped at 8 KiB with an in-string marker. Covered by the rewritten bounds arguments and results after redacting secrets.
  5. error.message newly redacted and capped, on both the span and the log record. The new redacts and bounds failed tool output test pins it, including the log/span agreement.
  6. Tool results carry two different budgets depending on the surface — 8 KiB on the tool span, 32 KiB when the same result rides a message part via serializePart. Intentional-looking, just noting the asymmetry.

No tests were deleted or skipped. promptAttr gained two required-ish parameters and all three call sites were updated (handlers.ts:526, :534, :805); I grepped for other callers and there are none, and both new helpers are module-private, so the exported surface (recordInputMessages, recordSystemInstructions, handleEvent, initHandlers, shutdown) is unchanged. No new cross-package dependency edges.

Verdict: the approach is sound and I would take it. The one I would want addressed before merge is the unconditional-bound gap, since "[]" loses strictly more information than the oversized attribute this PR set out to prevent.

🤖 Generated with Claude Code

@suibianwanwank

suibianwanwank commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Review based on head 7eae0913289e16b0df7cecd2b21b185b43102806. I reran the existing targeted OTel tests against an isolated source snapshot: 43 passed / 0 failed. git diff --check also passed. Additional checks through the actual recordInputMessages → step-start → exported span path exposed two uncovered edge cases:

  1. [P2] Structural-field protection also applies to arbitrary tool arguments and can discard the entire message.
    In handlers.ts:92–104, STRUCTURAL_STRING_KEYS recursively exempts every field named name, id, type, etc., including business fields inside arguments. Reproduction: a single assistant message containing a tool part with state.input = { name: "x".repeat(200_000), note: "latest context" }. Because arguments.name cannot be shortened, boundedRedactedJson falls back to "[]" at line 141. The exported gen_ai.input.messages is []. Please restrict structural-field protection to the message/part envelope, apply the content budget to arbitrary tool arguments, and preserve the retained message when it exceeds the budget.

  2. [P2] Input messages lack parts-level trimming, so an oversized JSON skeleton discards the latest context.
    The parts trimming in handlers.ts:164–176 only runs when the completion caller passes trimParts=true; recordInputMessages does not enable it. Reproduction: a single user message with 1,200 short text parts, the last containing latest context. Even after every content string is reduced to an empty string, the JSON skeleton exceeds the 32,768-character budget, and the exported gen_ai.input.messages is again []. Please apply budget-aware parts trimming to the input path as well, retaining the latest parts before shortening strings instead of silently clearing the entire array.

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 origin/main is 6969856491. It contains #111's merge commit 5d6b55018d, but does not contain this PR, so unbounded content recording remains on main. Separately, this PR does not address missing answer text, generated SQL, result sets, or session_id=null in Analytics Agent command telemetry; those daily-report observability gaps need separate follow-up.

Comment on lines +197 to +205
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") }]
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • retainArray always keeps its first item regardless of size (the guard is if (retained.length && used + next > limit) break), so retained.length === 1 is 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. recordInputMessages hits it too on the first user turn of a session.
  • The inner budget is PROMPT_MAX_CHARS / 2 = 16 384, while the budget boundedRedactedJson then 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.

Comment on lines +42 to +43
const CONTENT_MAX_CHARS = 8_000
const PROMPT_MAX_CHARS = 32 * 1024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 * 1024

Every 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:

  1. What is the actual limit being targeted? I could not find one configured here — setup.ts:57 builds new BasicTracerProvider({ resource, spanProcessors }) with no spanLimits, and new BatchSpanProcessor(traceExporter) at :53 takes the default maxExportBatchSize of 512. At 290 KB/span a full batch is ~148 MB, well past a collector's default max_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.
  2. For the two plain-string attributes (gen_ai.tool.call.result and error.message, lines 951–956) the SDK's own spanLimits: { attributeValueLengthLimit } would do the same job in one line of setup.ts and 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.

Comment on lines +158 to +167
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 "[]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +953 to +956
entry.span.setAttribute(
"gen_ai.tool.call.result",
capTo(redactText(String(state.output)), CONTENT_MAX_CHARS),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_CHARS for the tool span but do not cap the sql-family tools' output at the same figure, or
  • record the head only (no tail splice) plus a separate gen_ai.tool.call.result.truncated boolean and .original_length int, 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.

Comment on lines +938 to +940
const message = failed
? capTo(redactText(String(state.error || "unknown")), CONTENT_MAX_CHARS)
: ""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +143 to +148
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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 capTo still 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 on handlers.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.

  2. The new assertions are all length <= N plus toContain. That shape passes for "[]" (length 2, and toContain is not asserted on the empty-array tests), passes with role/type collapsed to "…", and passes with __truncated__ injected. So none of the three degradation paths this PR introduces is pinned. Specifically uncovered:

    • the small-middle regime 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

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.

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

A. Upstream invasiveness — no issues found

The PR touches two files, both under packages/cz-cli:
src/opencode-plugin/otel/handlers.ts and test/otel-span-build.test.ts. Nothing under
packages/opencode, packages/tui, packages/core or packages/schema, so no banner
and no UPSTREAM-PATCHES.md INTRUSIVE entry is owed. The cz_change:-style explanatory
comments in this file are in the cz layer, where the ledger says they are ordinary
comments.

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
broke JSON, and re-introducing a bound that is JSON-aware is the correct response rather
than reverting it. But three things read as working around the problem rather than
through it:

  • The error path is special-cased for one caller. state.error on the tool branch now
    gets redactText + capTo; session.error one case away (handlers.ts:661) gets
    neither, and its message reaches the turn span status and a log attribute unbounded and
    unredacted. Inline comment on handlers.ts:938.
  • The bound may not reach the failure it targets. The constants count UTF-16 code units
    while collector limits count bytes; for the Chinese analytics-agent traffic in issue 113
    that is roughly 3x the apparent size. And setup.ts:53 uses a default
    BatchSpanProcessor, so if a batch size limit is what drops telemetry, per-attribute
    caps will not prevent it. Inline comment on handlers.ts:42.
  • Two fallback branches hide the failure instead of reporting it: return "[]" ships an
    empty prompt array indistinguishable from "no content", and the __truncated__ object
    branch injects a key into the recorded tool arguments — which the PR description says
    it does not do. Inline comment on handlers.ts:158.

Plus one dead-code finding: MESSAGE_STRUCTURAL_KEYS / PART_STRUCTURAL_KEYS are
unreachable, because the object branch of capStrings rewrites the context to "none"
before any string leaf sees it. The stated guarantee about not truncating role / type
/ finish_reason does not currently hold. Inline comment on handlers.ts:118 — this is
the one I would fix before merge.

C. Regression risk — see inline findings

Behavioural changes and their coverage:

Change Covered by
gen_ai.input.messages / system_instructions / output.messages no longer complete; string values carry an embedded truncation marker new tests at otel-span-build.test.ts:799, :857, :884 — length and toContain only
gen_ai.tool.call.arguments bounded to 8 000 units; may gain a __truncated__ key :514 for the bound; the marker path is NOT covered
gen_ai.tool.call.result bounded to 8 000 units :476
error.message on the tool span, the span status and the opencode.tool.finished log record is now redacted as well as bounded :544
Assistant output parts silently reduced to 16 KB even when 32 KB was available NOT covered — see handlers.ts:197
Structural discriminators collapsing at small caps NOT covered
"[]" emitted for an oversized prompt array NOT covered

No exported API, CLI flag, config key or on-disk path changes. promptAttr gained
parameters but it is module-local and both call sites are updated (handlers.ts:557,
:565, :836). recordInputMessages / recordSystemInstructions keep their signatures
— I grepped for other callers and the only ones are opencode-plugin/otel/index.ts and
the test file.

One consumer-facing risk worth a decision rather than a code change: command output shape
is unaffected, but the content of these span attributes is what the LPS daily report
reads, and after this PR long generated SQL and result sets arrive with a marker spliced
into them. Since issue 113 asks for exactly the ability to verify the answer body,
generated SQL and result set, it is worth confirming the 8 000-unit tool cap is not below
what that report needs.

I could not run the test suite in this environment, so nothing here is a claim that tests
pass or fail — only a reading of the assertions.

Comment on lines +118 to +128
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([innerKey, inner]) => [
innerKey,
capStrings(
inner,
limit,
structuralContext === "message" && innerKey === "parts" ? "parts" : "none",
innerKey,
),
]),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"]:

  1. array branch → itemContext = "message", so capStrings(message, limit, "message") with key = "".
  2. object branch → for innerKey === "role", the ternary is false → passes "none", key = "role".
  3. 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:

Suggested change
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.

@suibianwanwank

Copy link
Copy Markdown
Collaborator

Follow-up review of e03e5a0d9adbbc44631de8ed113189023cd96a40:

The two previously reported reproductions now pass: the oversized arguments.name preserves the input message, and the 1,200-part input preserves the latest context. I also reran the focused suite: 45 passed / 0 failed, with git diff --check passing.

[P2] Structural-field context is reset before reaching the string leaves.

In packages/cz-cli/src/opencode-plugin/otel/handlers.ts:119–125, the object traversal passes "none" for every property except a message's parts. Consequently, a message's role / finish_reason and a part's type / id / name are all visited with structuralContext === "none". The exemptions at lines 105–106 never protect those string fields.

Reproduced through recordInputMessages → step-start → exported span with one assistant tool part (callID: "call_latest", tool: "lookup") whose input is:

Object.fromEntries(
  Array.from({ length: 2000 }, (_, index) => [`field_${index}`, "some content"]),
)

The exported gen_ai.input.messages is bounded to 30,957 characters, but its envelope contains role: "", type: "", name: "", and id: "". JSON remains parseable, but consumers can no longer identify the message role or tool call. The new regression assertion expect(messages[0].role).toBe("assistant") fails.

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 finish_reason on the completion path. The two new tests do not catch this because their budgets still leave enough room for the short structural strings.

CI status checked separately: the latest cz-test log reports 1,505 passed / 0 failed / 65 skipped, followed by exit code 1. This is still a failing check; the log summary alone does not establish its root cause.

Comment on lines +118 to +128
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([innerKey, inner]) => [
innerKey,
capStrings(
inner,
limit,
structuralContext === "message" && innerKey === "parts" ? "parts" : "none",
innerKey,
),
]),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" is value.map((item) => capStrings(item, limit, itemContext)), which omits key, so key defaults 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.

Comment on lines +156 to +167
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 "[]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 retainArray on 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.

Comment on lines +158 to +165
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • break on the first key that overflows discards every later key even when it would have fit. continue keeps more of the payload for the same budget; with Object.entries order that difference is arbitrary rather than "oldest first", so there is no ordering argument for break.
  • 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.

Comment on lines 952 to +956
} 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),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_CHARS is 4× smaller than PROMPT_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.truncated boolean 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.

Comment on lines +143 to +155
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
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +59 to +64
// 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
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +799 to +802
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

A. Upstream invasiveness — no issues found.
All three changed files live under packages/cz-cli: src/opencode-plugin/otel/handlers.ts, test/otel-span-build.test.ts, test/preload.ts. Nothing under packages/opencode, packages/tui or packages/core is touched, so no cz-cli change banner and no new INTRUSIVE row in UPSTREAM-PATCHES.md is required. The cz_change: comments visible nearby in bunfig.toml are pre-existing and correct where they are.

B. Clean fix, with one exception.
The core of the change is the right fix in the right place: bounding happens inside the same helpers that already build the attributes, after redaction, so there is no new env flag, no per-caller special case, and all three prompt attributes share one path. redactText before capTo also means truncation cannot re-expose a secret by slicing through a <redacted> replacement. Three things to look at:

  • test/preload.ts resetting process.exitCode globally after every test — symptom treatment for a leak that belongs to one identifiable test, unrelated to the OTel change, and possibly masking a real failure signal. Inline comment.
  • boundedRedactedJson has two give-up branches that swallow content instead of reporting it: the array case returns "[]" with no marker, the object case injects __truncated__ into tool arguments. Two inline comments.
  • The structural-field protection the description promises is unreachable dead code. Inline comment.

No leftover debug logging, commented-out code or other unrelated edits found beyond the preload hook.

C. Regression risk.
Everything below is intended by the PR, but it is all observable in exported telemetry:

  • gen_ai.tool.call.arguments: was complete JSON, now bounded to 8000 chars and can gain a synthetic __truncated__ key. Covered by the two new tool-content tests.
  • gen_ai.tool.call.result, and error.message on both the span and the opencode.tool.finished log record: were unbounded, now 8000 chars with a [truncated] marker spliced into the middle. Covered by the new failed-tool test and the rewritten test at otel-span-build.test.ts:472.
  • gen_ai.input.messages, gen_ai.system_instructions, gen_ai.output.messages: were complete JSON, now bounded to 32 KiB, with oldest messages dropped and string leaves elided. Covered by four new tests.
  • No exported API, config key, CLI flag, subcommand or on-disk path changes. promptAttr, boundedJson, boundedRedactedJson, capTo and retainArray are all file-local, and all call sites were updated — verified by grep across src/.
  • No tests deleted or skipped. Two assertions were deliberately loosened at otel-span-build.test.ts:799-814; separate inline comment.
  • The new preload afterEach now runs after every test in the package (~1500), including the 16 files that assert on process.exitCode.

Not covered by any test: the two give-up branches in boundedRedactedJson, which are exactly the paths where content disappears entirely.

I could not run the test suite in this environment, so nothing above should be read as a claim that tests pass.

@hellozepp
hellozepp merged commit 5ebf0bb into main Sep 25, 2026
2 checks passed
@hellozepp
hellozepp deleted the telemetry-content-bounds branch September 25, 2026 07:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

修复 OTel 内容记录无界导致 span 过大的问题

3 participants