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 全绿。
This commit is contained in:
AskaEth committed 2026-10-26 13:17:42 +08:00
1 parent 240b59708d
commit 003d12a712
18 files changed
+1612 -71

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
package trace
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
// 本文件是**上游错误体的唯一收敛点**(M40 地基)。推理侧(M41/M42 的 provider
// 适配、模型路由降级、工具/渠道的上游调用)不许各写一份「截断一下响应体再
// 拼进 error」的实现 —— 那样的实现会漂移,而漂移的方向总是「某一家 provider
// 开始把响应体原文吐进错误与日志」。
//
// 🔴 契约(改本文件前先读):
// - 只保留:upstream 名 / status / contentType / bodyLen / body sha256 前缀 /
// JSON 里的 error.code / error.type / 截断到 maxUpstreamMessageRunes 的
// error.message;
// - 其余**一律丢弃**:响应体原文、请求参数、请求头、provider 自带的
// param/input/code_id 之流,绝不进入 error 的文本,也绝不进入日志;
// - Error() 与 Attrs() 是**唯二**的出口,两者都只渲染上面那些字段,
// 因此把 err 直接丢进 slog(或任何日志)是安全的。
// maxUpstreamMessageRunes 是上游 error.message 允许保留的 rune 上限。128 rune
// 够看清「上游为什么拒绝」,又短到不足以把一段用户正文整段带出来;超出部分
// 截断并标省略号(宁可丢信息,不可丢边界)。
const maxUpstreamMessageRunes = 128
// maxUpstreamLabelRunes 是 upstream 名保留的 rune 上限(它是代码里写死的名字
// 或主机名,本来就很短;上界只是防御)。
const maxUpstreamLabelRunes = 80
// ErrUpstream 是「上游返回了错误」这一类错误的哨兵,供调用方
// errors.Is(err, trace.ErrUpstream) 分类降级(主 → 备 → 本地)。
var ErrUpstream = errors.New("trace: upstream error")
// UpstreamErrorInfo 是一次上游失败**可安全出镜**的全部信息。它不含响应体原文,
// 也不含任何请求侧内容 —— 字段本身就是脱敏后的口径。
type UpstreamErrorInfo struct {
// Upstream 是上游标识(provider 名或主机,见 HostOf)。
Upstream string
// Status 是 HTTP 状态码(0 表示没有拿到状态码,例如连接中途断开)。
Status int
// ContentType 是响应的媒体类型(已去掉 charset 等参数)。
ContentType string
// BodyLen 是响应体的字节数(体积可诊断,内容不留)。
BodyLen int
// BodySHA256 是响应体 sha256 的十六进制前缀,用于相关性比对(同一份报文能
// 认出来,正文拿不到)。
BodySHA256 string
// Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染)。
Code string
// Type 是上游 JSON 里 error.type。
Type string
// Message 是上游 JSON 里 error.message,已清洗凭据形状、压平空白并截断。
Message string
}
// Error 渲染一句**只含上述字段**的单行诊断。可诊断性来自 status/code/type/
// bodyLen/bodySHA256,绝不来自响应体原文。
func (e *UpstreamErrorInfo) Error() string {
var b strings.Builder
b.WriteString("upstream error")
if e.Upstream != "" {
b.WriteString(" from ")
b.WriteString(e.Upstream)
}
if e.Status != 0 {
fmt.Fprintf(&b, ": status=%d", e.Status)
} else {
b.WriteString(": status=unknown")
}
if e.ContentType != "" {
b.WriteString(" content_type=")
b.WriteString(e.ContentType)
}
fmt.Fprintf(&b, " body_bytes=%d", e.BodyLen)
if e.BodySHA256 != "" {
b.WriteString(" body_sha256=")
b.WriteString(e.BodySHA256)
}
if e.Code != "" {
b.WriteString(" code=")
b.WriteString(e.Code)
}
if e.Type != "" {
b.WriteString(" type=")
b.WriteString(e.Type)
}
if e.Message != "" {
b.WriteString(" message=")
b.WriteString(strconv.Quote(e.Message))
}
return b.String()
}
// Unwrap 让 errors.Is(err, ErrUpstream) 成立,调用方据此走降级链而不必解析文本。
func (e *UpstreamErrorInfo) Unwrap() error { return ErrUpstream }
// Attrs 把同一份脱敏信息渲染成 slog 的键值对,供结构化日志直接展开
// (s.log.Error("provider call failed", info.Attrs()...))。它与 Error() 同源,
// 因此不存在「文本里干净、日志里脏」的第二条路径。
func (e *UpstreamErrorInfo) Attrs() []any {
attrs := []any{
"upstream", e.Upstream,
"status", e.Status,
"content_type", e.ContentType,
"body_bytes", e.BodyLen,
"body_sha256", e.BodySHA256,
}
if e.Code != "" {
attrs = append(attrs, "code", e.Code)
}
if e.Type != "" {
attrs = append(attrs, "type", e.Type)
}
if e.Message != "" {
attrs = append(attrs, "message", e.Message)
}
return attrs
}
// UpstreamError 把一次上游失败收敛成一个**绝不携带响应体原文**的错误。
//
// 用法(M41/M42 的用法模板):
//
// err := trace.UpstreamError(trace.HostOf(reqURL), resp.StatusCode,
// resp.Header.Get("Content-Type"), body)
// if errors.Is(err, trace.ErrUpstream) { ...降级到备用 provider... }
// s.log.Error("upstream call failed", err) // 安全:Error() 只渲染脱敏字段
//
// body 可以是任意字节(JSON、HTML、代理页、二进制):它只被用来数长度、取
// sha256 前缀,并在看起来是 JSON 时抽取 error.code / error.type / error.message。
// 其余字段、以及 body 的任何一个字节都不会进入返回的错误。
//
// 🔴 边界(调用方必须知道):error.message 是上游自己写的诊断语,本函数最多保留
// 128 rune。上游若把用户正文拼进 message(少数实现会这么做),那 128 rune 会随之
// 出来 —— 因此调用方**不得**在把响应体交给本函数之前再自行拼接任何请求内容,
// 也不得把 message 之外的字段(param / input / code_id …)自行补进错误。
func UpstreamError(upstream string, status int, contentType string, body []byte) error {
info := &UpstreamErrorInfo{
Upstream: sanitizeUpstreamLabel(upstream),
Status: status,
ContentType: sanitizeContentType(contentType),
BodyLen: len(body),
}
if len(body) > 0 {
info.BodySHA256 = DigestOf(string(body)).SHA256
}
if parsed, ok := parseUpstreamErrorBody(body); ok {
info.Code = parsed.Code
info.Type = parsed.Type
info.Message = sanitizeUpstreamMessage(parsed.Message)
}
return info
}
// upstreamErrorBody 是**唯一**从响应体里读出的结构:只有 error 的三个字段,
// 其余字段(param / input / code_id / 请求回显 …)根本不会被解码到任何地方。
type upstreamErrorBody struct {
Code string
Type string
Message string
}
// parseUpstreamErrorBody 尽力从 JSON 响应体里抽出 error.code / error.type /
// error.message。不是 JSON(HTML 错误页、纯文本、被截断的报文)⇒ ok=false,
// 此时错误里只有 status/contentType/bodyLen/bodySHA256 —— 正文一个字节都不留。
func parseUpstreamErrorBody(body []byte) (upstreamErrorBody, bool) {
if len(body) == 0 {
return upstreamErrorBody{}, false
}
var envelope struct {
Error struct {
Code json.RawMessage `json:"code"`
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return upstreamErrorBody{}, false
}
parsed := upstreamErrorBody{
Code: normalizeUpstreamCode(envelope.Error.Code),
Type: sanitizeUpstreamMessage(envelope.Error.Type),
Message: envelope.Error.Message,
}
if parsed.Code == "" && parsed.Type == "" && parsed.Message == "" {
return upstreamErrorBody{}, false
}
return parsed, true
}
// normalizeUpstreamCode 把 error.code 归一成短字符串:JSON 字符串取原值,
// JSON 数字取十进制文本(Google 风格用 int code),其它形态一律丢弃。
func normalizeUpstreamCode(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return sanitizeUpstreamMessage(s)
}
var n json.Number
if err := json.Unmarshal(raw, &n); err == nil {
return sanitizeUpstreamMessage(n.String())
}
return ""
}
// sanitizeUpstreamMessage 是上游**自由文本**的唯一清洗路径:压平空白(一条诊断
// 只占一行,也顺带挡掉换行注入),清洗凭据形状,再截断到
// maxUpstreamMessageRunes。
func sanitizeUpstreamMessage(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.Join(strings.Fields(s), " ")
return truncate(redactSecrets(s), maxUpstreamMessageRunes)
}
// sanitizeUpstreamLabel 清洗 upstream 名/主机:不允许它成为第二正文通道。
func sanitizeUpstreamLabel(s string) string {
return truncate(redactSecrets(strings.TrimSpace(s)), maxUpstreamLabelRunes)
}
// sanitizeContentType 只保留媒体类型本体(丢掉 charset/boundary 等参数),
// 并清洗凭据形状 —— Content-Type 的参数偶尔会带 provider 自造的 token。
func sanitizeContentType(s string) string {
if i := strings.IndexByte(s, ';'); i >= 0 {
s = s[:i]
}
return truncate(redactSecrets(strings.TrimSpace(s)), maxUpstreamLabelRunes)
}