Files
Cyrene_v2/internal/runtime/trace/graph_props.go
T
AskaEth 003d12a712 feat(trace): M40 span 写侧脱敏 + 共享上游错误收敛 helper
stderr 的 JSONL 是无主人在场的落盘面,却在此前原样落工具结果(result)、
子代理回复(text/summary)与上游报文,等于一次 tail 就能拷走整段对话。
本切片交付两件地基:

1) 写侧脱敏(trace.JSONRecorder)
   - 新增 trace/span_props.go:safePropKeys 白名单与 projectSpanProps 投影,
     读侧(safeSpanProps)与写侧共用同一张表、同一个投影函数;
   - WriteSpan 落盘前经 trace.SanitizeRecord 投影:白名单键清洗,表外键只留
     Digest(bytes/runes/sha256 前缀,收进 omitted),事件先 RedactEvent 再把
     正文键折成 Digest。落到磁盘的只有元数据,正文一个字节都不留;
   - 新增 trace/digest.go:Digest/DigestOf/DigestOfValue,给「脱敏后仍要能诊断」
     一个构造上不含正文的取值形态(体积 + 指纹),两条投影都按类型放行它。

2) 共享上游错误体收敛 helper(给 M41/M42,禁止三家各写一份)
   - trace.UpstreamError(upstream, status, contentType, body):只保留 status /
     contentType / bodyLen / body sha256 前缀 / 上游 JSON 的 error.code /
     error.type / 截断 128 rune 的 error.message,其余一律丢弃,响应体原文绝不
     进 error 或日志;*UpstreamErrorInfo 提供 Error()/Attrs()/Unwrap()(errors.Is
     可判 ErrUpstream 走降级链);
   - trace.HostOf / trace.MaskQuery:日志里的 URL 只回来源,凭据类 query 与
     fragment 值打码为 ***,user:pass@ 整段打码。

3) 剥掉三处正文上 span
   - loop:tool span 不再放 result,改放 result_summary(与 tool_execution.end
     帧同源、已脱敏截断)与 result_digest;
   - orchestration:subagent span 不再放 text,subagent.done 事件不再放 summary,
     改记 status/steps/chars/sha256;
   - automations:AddRuleByName 不再 %v 整个 payload,改记 subject/type/bytes/sha256。

测试:以哨兵正文在落盘字节上断言(JSONL / slog 输出),并分别对「编排层收到的
原始 span」与「写侧投影」两层各钉一遍;UpstreamError 用回显 input 的 422 报文
与 HTML 错误页各钉一条;解析出/未解析出 JSON 都不回原文。以上均做过变异验证
(去掉保护即变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:17:42 +08:00

74 lines
2.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package trace
import (
"encoding/json"
"fmt"
)
// maxSummaryRunes bounds how many runes of a user string / tool result are
// rendered into a node label or prop summary, so sensitive or long content is
// collapsed to a safe, dashboard-friendly prefix.
const maxSummaryRunes = 80
// safeSpanProps carries only a whitelist of span props to the frontend. It never
// forwards raw arguments, raw tool results or secrets, and every string is
// scrubbed of credential shapes and truncated, so long or sensitive content
// cannot leak a full payload (design/19 §3).
//
// 白名单与投影都是**共享**的(span_props.go:safePropKeys + projectSpanProps),
// 与写侧 stderr JSONL 用的是同一份 —— 读侧多放一个键,写侧就会跟着多放一个,
// 不存在「某一边悄悄开始吐正文」的漂移面。
//
// 工具节点的 result_summary 有两条来源,优先级明确:
// 1. **生产侧直接写**的那个 prop(loop/recordToolResult 用 toolFrameResultSummary
// 写入的已脱敏、已截断文本,与 tool_execution.end 帧逐字同源)—— 正常路径;
// 2. 存量退路:某个生产者仍把结果原文放在 `result` 里时,这里只为它合成一份
// **经 RedactText 清洗 + 截断**的摘要。合成出来的只是摘要,原文本身绝不投影,
// 因此这条退路不构成新的泄漏面(写侧投影会把 `result` 整体折成 Digest)。
func safeSpanProps(rec SpanRecord) map[string]any {
props := projectSpanProps(rec.Props)
if rec.Kind != SpanKindTool {
return props
}
if _, ok := props["result_summary"]; ok {
return props
}
v, ok := rec.Props["result"]
if !ok {
return props
}
if props == nil {
props = make(map[string]any, 1)
}
props["result_summary"] = RedactText(summarize(v))
return props
}
// summarize renders an arbitrary value as a short text string for a prop summary.
func summarize(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
default:
b, err := json.Marshal(x)
if err != nil {
return fmt.Sprintf("%v", x)
}
return string(b)
}
}
// truncate cuts s to at most max runes, appending an ellipsis when it overflows.
func truncate(s string, max int) string {
if max <= 0 {
return s
}
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}