Files
Cyrene_v2/internal/runtime/trace/upstream.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

237 lines
8.9 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"
"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)
}