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 全绿。
237 lines
8.9 KiB
Go
237 lines
8.9 KiB
Go
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)
|
||
}
|