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

@@ -0,0 +1,395 @@
package trace
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
)
// 本文件钉住 M40 的**写侧**(stderr JSONL)边界:写下去的每一行都可以被
// `tail`/采集/粘进工单,因此它只允许携带脱敏后的投影 —— 正文、工具结果原文、
// 子代理回复原文、上游响应体原文,一个字节都不许出现。
//
// 🔴 断言一律打在**落盘输出/序列化结果**上(JSONL 字节、slog 输出),
// 不打在 error.Error() 的措辞上:措辞会随实现变,泄漏不会。
// sentinelToolBody 是「工具结果正文」的哨兵:只要它出现在落盘里,就说明写侧
// 又把正文抄了一遍。
const sentinelToolBody = "SENTINEL-TOOL-RESULT-9f3a2b-一段不该落盘的正文"
// writeRecords 把若干 span 记录经 JSONRecorder 写进缓冲区并解码回来。它直接用
// 记录(而不是跑一次真循环)是为了让「写侧投影」这件事在 trace 包内自证;
// 端到端那条链由 loop/orchestration 两个包的测试钉住。
func writeRecords(t *testing.T, recs ...SpanRecord) ([]SpanRecord, string) {
t.Helper()
var buf bytes.Buffer
rec := NewJSONRecorder(&buf)
for _, r := range recs {
if err := rec.WriteSpan(r); err != nil {
t.Fatalf("WriteSpan: %v", err)
}
}
return decodeLines(t, buf.Bytes()), buf.String()
}
// TestJSONRecorder_DropsRawContentAndKeepsDigest 是写侧投影的主钉:
// - 表外的 `result` / `text`(正文形态)绝不落盘;
// - 白名单键仍落盘(图上与日志里要能看见 tool / error 之类的元数据);
// - 被丢弃的键折进 omitted,留下 bytes/runes/sha256 —— 于是「同一次工具调用
// 成功还是失败、输出多大」仍可分辨(这是「脱敏不能把 span 变得没用」的验收点);
// - 字符串值一律过 RedactText(凭据形状打码)。
func TestJSONRecorder_DropsRawContentAndKeepsDigest(t *testing.T) {
t.Parallel()
okRec := spanRec("s1", "", "tool.fs.read", SpanKindTool, SpanStatusOK, map[string]any{
"tool": "fs.read",
"result": sentinelToolBody,
"result_summary": "SENTINEL-TOOL",
"result_digest": DigestOf(sentinelToolBody),
})
badRec := spanRec("s2", "", "tool.fs.read", SpanKindTool, SpanStatusError, map[string]any{
"tool": "fs.read",
"result": strings.Repeat("错", 400),
"error": "read failed: token=abcdef1234567890",
"text": sentinelToolBody,
"subagent": "sa-1",
})
recs, raw := writeRecords(t, okRec, badRec)
if strings.Contains(raw, sentinelToolBody) {
t.Fatalf("写侧 JSONL 里出现了工具结果/子代理回复原文:%s", raw)
}
if strings.Contains(raw, "abcdef1234567890") {
t.Fatalf("写侧 JSONL 里出现了凭据原文(字符串值没走 RedactText):%s", raw)
}
if n := strings.Count(strings.TrimSpace(raw), "\n") + 1; n != 2 {
t.Fatalf("落盘行数 = %d, want 2(一行一个 span): %s", n, raw)
}
okSpan, badSpan := byID(recs, "s1"), byID(recs, "s2")
// 表外键不得以原值出现。
for _, key := range []string{"result", "text"} {
if _, ok := badSpan.Props[key]; ok {
t.Fatalf("表外 prop %q 落盘了:%+v", key, badSpan.Props)
}
}
// 白名单键照旧落盘:tool / result_summary / error / subagent。
if okSpan.Props["tool"] != "fs.read" || okSpan.Props["result_summary"] != "SENTINEL-TOOL" {
t.Fatalf("白名单键没落盘:%+v", okSpan.Props)
}
if _, ok := badSpan.Props["subagent"]; !ok {
t.Fatalf("白名单键 subagent 没落盘:%+v", badSpan.Props)
}
if got, _ := badSpan.Props["error"].(string); !strings.Contains(got, redactedMarker) {
t.Fatalf("error prop = %v,凭据没被打码", badSpan.Props["error"])
}
// 诊断线索:成功/失败可分辨(status + Digest),输出大小可分辨(bytes)。
if okSpan.Status != SpanStatusOK || badSpan.Status != SpanStatusError {
t.Fatalf("status = %q/%q,成功与失败必须仍可分辨", okSpan.Status, badSpan.Status)
}
okDropped := omittedEntry(t, okSpan, "result")
badDropped := omittedEntry(t, badSpan, "result")
if okDropped.Bytes != len(sentinelToolBody) {
t.Fatalf("omitted.result.bytes = %d, want %d(输出大小必须仍可读)", okDropped.Bytes, len(sentinelToolBody))
}
if badDropped.Bytes != len(strings.Repeat("错", 400)) {
t.Fatalf("omitted.result.bytes = %d, want %d", badDropped.Bytes, len(strings.Repeat("错", 400)))
}
if okDropped.SHA256 == "" || okDropped.SHA256 == badDropped.SHA256 {
t.Fatalf("omitted.result.sha256 = %q/%q,want 两份不同内容的指纹不同", okDropped.SHA256, badDropped.SHA256)
}
// Digest 型 prop(生产侧明确声明「这不是正文」)原样落盘,且不再被再摘要一次。
if got, ok := okSpan.Props["result_digest"].(map[string]any); !ok {
t.Fatalf("result_digest 没落盘:%+v", okSpan.Props)
} else if int(got["bytes"].(float64)) != len(sentinelToolBody) {
t.Fatalf("result_digest.bytes = %v, want %d", got["bytes"], len(sentinelToolBody))
}
}
// TestJSONRecorder_SubagentReplyNeverLands 钉住子代理回复这条路径:整段回复
// 曾经以 `text` prop 与 `summary` 事件值的形式原样落盘(M40 修掉的最严重一条)。
// 现在 span 上只有 text_digest,事件上只有 chars/sha256。
func TestJSONRecorder_SubagentReplyNeverLands(t *testing.T) {
t.Parallel()
const reply = "SENTINEL-SUBAGENT-REPLY-77c1-她说的整段话"
rec := spanRec("sa", "", "subagent.sa-1", SpanKindSubagent, SpanStatusOK, map[string]any{
"text": reply,
"text_digest": DigestOf(reply),
})
rec.Events = []Event{{Type: "subagent.done", Props: map[string]any{
"status": "done",
"steps": 2,
"chars": len([]rune(reply)),
"sha256": DigestOf(reply).SHA256,
"text": reply,
}}}
recs, raw := writeRecords(t, rec)
if strings.Contains(raw, reply) {
t.Fatalf("写侧 JSONL 里出现了子代理回复原文:%s", raw)
}
got := byID(recs, "sa")
if _, ok := got.Props["text"]; ok {
t.Fatalf("子代理 span 的 text prop 落盘了:%+v", got.Props)
}
if d, ok := got.Props["text_digest"].(map[string]any); !ok || d["sha256"] == "" {
t.Fatalf("text_digest 缺失:%+v", got.Props)
}
if n := len(got.Events); n != 1 {
t.Fatalf("events = %d, want 1", n)
}
if ev := got.Events[0]; ev.Props["text"] != nil {
// 正文键的值必须已被换成 Digest(键留在原处,值不含正文)。
d, ok := ev.Props["text"].(map[string]any)
if !ok || d["sha256"] == "" {
t.Fatalf("事件里的正文键没折成 Digest:%+v", ev.Props)
}
}
}
// TestUpstreamError_DropsEchoedBody 是共享 helper 的行为钉(M41/M42 的用法模板):
// 一个**回显请求正文**的 422 报文,经 UpstreamError 收敛后,错误文本与日志里都
// 搜不到那段回显,但 status / code / type 仍可辨。
func TestUpstreamError_DropsEchoedBody(t *testing.T) {
t.Parallel()
const echoedInput = "SENTINEL-USER-INPUT-5d2e-用户在聊天框里说的话"
body := []byte(`{"error":{"code":"invalid_request_error","type":"invalid_request",` +
`"message":"Invalid value for 'messages[0].content'."},` +
`"param":"messages[0].content","input":"` + echoedInput + `",` +
`"echo":{"messages":[{"role":"user","content":"` + echoedInput + `"}]}}`)
err := UpstreamError("api.openai.example", 422, "application/json; charset=utf-8", body)
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, nil))
log.Error("upstream call failed", "err", err)
text := err.Error()
for _, surface := range []string{text, buf.String()} {
if strings.Contains(surface, echoedInput) {
t.Fatalf("响应体回显的输入原样出现了:%s", surface)
}
if strings.Contains(surface, "messages[0].content") && strings.Contains(surface, "role") {
t.Fatalf("响应体的非白名单字段被带出来了:%s", surface)
}
}
for _, want := range []string{"status=422", "invalid_request_error", "type=invalid_request", "api.openai.example"} {
if !strings.Contains(text, want) {
t.Fatalf("error = %q,want 含 %q(诊断信息不能一起丢掉)", text, want)
}
}
if !errors.Is(err, ErrUpstream) {
t.Fatalf("errors.Is(err, ErrUpstream) = false;降级链无法按类别判断")
}
info, ok := err.(*UpstreamErrorInfo)
if !ok {
t.Fatalf("err 类型 = %T, want *UpstreamErrorInfo", err)
}
if info.BodyLen != len(body) || info.ContentType != "application/json" || info.BodySHA256 == "" {
t.Fatalf("info = %+v,want bodyLen/contentType/sha256 作为诊断线索", info)
}
// Attrs() 是日志侧的第二出口,必须与 Error() 同源(同样不含正文)。
attrs := info.Attrs()
if joined := strings.TrimSpace(strings.Join(anyToStrings(attrs), " ")); strings.Contains(joined, echoedInput) {
t.Fatalf("Attrs() 带出了回显正文:%s", joined)
}
}
// TestUpstreamError_NonJSONBodyKeepsOnlyMeta 钉住「非 JSON 也绝不回落原文」:
// HTML 错误页 / 代理错误页 / 被截断的报文只留 status 与体积指纹。
func TestUpstreamError_NonJSONBodyKeepsOnlyMeta(t *testing.T) {
t.Parallel()
const echoed = "SENTINEL-PROXY-ECHO-0b71"
body := []byte("<html><body>upstream said: " + echoed + "</body></html>")
err := UpstreamError("", 502, "text/html", body)
if strings.Contains(err.Error(), echoed) {
t.Fatalf("非 JSON 响应体原文进了错误:%s", err.Error())
}
if !strings.Contains(err.Error(), "status=502") || !strings.Contains(err.Error(), "body_bytes=") {
t.Fatalf("error = %q,want status 与 body_bytes 仍在", err.Error())
}
}
// TestUpstreamErrorMessageIsBounded 钉住 message 的上限:上游把一大段东西写进
// error.message 时,进错误的只有前 128 rune。
func TestUpstreamErrorMessageIsBounded(t *testing.T) {
t.Parallel()
body := []byte(`{"error":{"code":"bad","message":"` + strings.Repeat("详", 400) + `"}}`)
err := UpstreamError("p", 400, "application/json", body)
info := err.(*UpstreamErrorInfo)
if n := len([]rune(info.Message)); n > maxUpstreamMessageRunes+1 {
t.Fatalf("message 长度 = %d rune,超过上限 %d", n, maxUpstreamMessageRunes)
}
if !strings.HasPrefix(info.Message, "详") {
t.Fatalf("message = %q,want 保留上游诊断语的前缀", info.Message)
}
}
// TestHostOf_DropsPathQueryAndUserInfo 钉住「日志里的 URL 只有来源」。
func TestHostOf_DropsPathQueryAndUserInfo(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{"普通 URL", "https://cdn.example.com/a/b?token=SECRET&x=1", "https://cdn.example.com"},
{"带端口", "http://api.example.com:8443/v1/chat", "http://api.example.com:8443"},
{"带 user:pass", "https://user:pass@h/x", "https://[redacted]@h"},
{"相对路径", "/a/b?token=SECRET", ""},
{"畸形串", "http://[::1", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := HostOf(tc.in)
if got != tc.want {
t.Fatalf("HostOf(%q) = %q, want %q", tc.in, got, tc.want)
}
for _, leak := range []string{"SECRET", "pass", "user", "/a/"} {
if strings.Contains(got, leak) {
t.Fatalf("HostOf(%q) = %q 泄漏了 %q", tc.in, got, leak)
}
}
})
}
}
// TestMaskQuery_MasksCredentialParams 钉住「查询串里的凭据值被打码」:OAuth token、
// 预签名 URL 的签名、API key 一律不明文出现,其余参数保持可读。
func TestMaskQuery_MasksCredentialParams(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
wantHas []string
leaks []string
}{
{
name: "access_token 打码",
in: "https://api.example.com/v1/x?access_token=xxxSECRETxxx&page=2",
wantHas: []string{"access_token=***", "page=2"},
leaks: []string{"xxxSECRETxxx"},
},
{
name: "user:pass 整体打码",
in: "https://user:pass@h/x?api_key=k3ySECRET&sig=SIGSECRET",
wantHas: []string{"[redacted]@h", "api_key=***"},
leaks: []string{"pass", "k3ySECRET", "SIGSECRET"},
},
{
name: "片段里的 token 同样打码",
in: "https://h/cb#access_token=FRAGSECRET&state=ok",
wantHas: []string{"access_token=***", "state=ok"},
leaks: []string{"FRAGSECRET"},
},
{
name: "非 URL 不回原串",
in: "token=RAW&x=1",
leaks: []string{"RAW"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := MaskQuery(tc.in)
for _, want := range tc.wantHas {
if !strings.Contains(got, want) {
t.Fatalf("MaskQuery(%q) = %q, want 含 %q", tc.in, got, want)
}
}
for _, leak := range tc.leaks {
if strings.Contains(got, leak) {
t.Fatalf("MaskQuery(%q) = %q 泄漏了 %q", tc.in, got, leak)
}
}
})
}
}
// TestJSONRecorder_DoesNotMutateCallerRecord 钉住投影是「拷贝」而非「就地改」:
// 调用方(例如 gateway 的 trace 环形缓冲出口)仍拿得到完整记录。
func TestJSONRecorder_DoesNotMutateCallerRecord(t *testing.T) {
t.Parallel()
rec := spanRec("m1", "", "tool.x", SpanKindTool, SpanStatusOK, map[string]any{"result": sentinelToolBody})
if _, raw := writeRecords(t, rec); strings.Contains(raw, sentinelToolBody) {
t.Fatalf("写侧泄漏了原文:%s", raw)
}
if got, _ := rec.Props["result"].(string); got != sentinelToolBody {
t.Fatalf("调用方记录被就地改写了:%+v", rec.Props)
}
}
// TestSanitizeRecordKeepsIdentity 钉住「脱敏不动身份」:id/parent/name/kind/status
// 与时间戳原样保留,否则落盘的链路就串不起来了。
func TestSanitizeRecord_KeepsIdentity(t *testing.T) {
t.Parallel()
rec := spanRec("c1", "p1", "llm.step2", SpanKindLLM, SpanStatusError, map[string]any{"prompt": "正文"})
out := SanitizeRecord(rec)
if out.ID != rec.ID || out.ParentID != rec.ParentID || out.Name != rec.Name ||
out.Kind != rec.Kind || out.Status != rec.Status ||
!out.StartedAt.Equal(rec.StartedAt) || !out.EndedAt.Equal(rec.EndedAt) {
t.Fatalf("sanitizeRecord 改动了身份字段:%+v vs %+v", out, rec)
}
if _, ok := out.Props["prompt"]; ok {
t.Fatalf("表外 prop prompt 落盘了:%+v", out.Props)
}
if _, err := json.Marshal(out); err != nil {
t.Fatalf("脱敏后的记录不可编码:%v", err)
}
}
// byID 在解码后的记录里按 id 找一条,找不到即失败。
func byID(recs []SpanRecord, id string) SpanRecord {
for _, r := range recs {
if r.ID == id {
return r
}
}
panic("span record not found: " + id)
}
// omittedEntry 反读某条记录 omitted 表里被丢弃的键的 Digest。
func omittedEntry(t *testing.T, rec SpanRecord, key string) Digest {
t.Helper()
omitted, ok := rec.Props[omittedPropKey].(map[string]any)
if !ok {
t.Fatalf("props 里没有 %q 表:%+v", omittedPropKey, rec.Props)
}
entry, ok := omitted[key].(map[string]any)
if !ok {
t.Fatalf("%s 表里没有 %q 的 Digest:%+v", omittedPropKey, key, omitted)
}
return Digest{
Bytes: int(entry["bytes"].(float64)),
Runes: int(entry["runes"].(float64)),
SHA256: entry["sha256"].(string),
}
}
// anyToStrings 把 Attrs() 的键值对压成字符串切片,便于做「搜不到」断言。
func anyToStrings(attrs []any) []string {
out := make([]string, 0, len(attrs))
for _, a := range attrs {
out = append(out, strings.Join(strings.Fields(toString(a)), " "))
}
return out
}
// toString 渲染一个 Attr 值(只用于测试断言)。
func toString(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
b, err := json.Marshal(x)
if err != nil {
return ""
}
return string(b)
}
}