package trace import ( "encoding/json" "fmt" "io" "sync" ) // Writer persists a completed span. Implementations must be safe for concurrent // use, as WriteSpan may be called from several goroutines. A Writer such as // *JSONRecorder provides structured local logging for the M9 panel. type Writer interface { // WriteSpan persists a single completed span and returns any encoding or // write error. WriteSpan(rec SpanRecord) error } // JSONRecorder writes each completed span as one line of JSON (JSONL) to an // underlying io.Writer. It is safe for concurrent use. // // 🔴 它是**脱敏边界**,不是搬运工:stderr 的 JSONL 是无主人在场的落盘面(会被 // 日志采集、tail、粘贴进工单),所以 WriteSpan 在落盘前把记录投影一遍 —— // props 走共享白名单(与出图同一张表,span_props.go),表外键只留 Digest;事件 // 先经 RedactEvent(凭据形状打码 + 截断),正文键的值再折成 Digest。这样即使上游 // 某天又往 span 里塞了正文(工具结果、子代理回复、上游报文),它也到不了磁盘上。 // // 端口/文件格式不变:一行一个 span 对象,只是这一行里不再有正文。 type JSONRecorder struct { mu sync.Mutex enc *json.Encoder } // NewJSONRecorder builds a JSONRecorder that appends one JSON object per span to // out. func NewJSONRecorder(out io.Writer) *JSONRecorder { return &JSONRecorder{enc: json.NewEncoder(out)} } // WriteSpan encodes rec as JSON followed by a newline. Concurrent calls are // serialized so a single span object is never interleaved with another. rec 在 // 编码前先经 SanitizeRecord 投影 —— 落盘的永远是脱敏后的形态。 func (r *JSONRecorder) WriteSpan(rec SpanRecord) error { r.mu.Lock() defer r.mu.Unlock() if err := r.enc.Encode(SanitizeRecord(rec)); err != nil { return fmt.Errorf("trace: encode span %q: %w", rec.ID, err) } return nil } // SanitizeRecord 返回 rec 的写侧形态:props 走白名单投影(表外键折成 Digest), // events 逐条脱敏(凭据形状打码 + 截断 + 正文键折成 Digest)。它不改动调用方持有 // 的 rec 的其它字段(id/name/kind/status/时间),因此脱敏后的记录仍能按链路串起来。 // // 导出是因为「写出去」的出口不止一个:stderr 的 JSONL 由 *JSONRecorder 负责, // 而网关的 trace 环形缓冲(admin 可观测面)、离线导出等出口都拿同一份 SpanRecord。 // 它们应当复用**同一个**投影,而不是各写一份 —— 各写一份必然漂移,漂移的方向总是 // 「某个出口开始原样吐正文」。当前 HTTP 响应形状受 M40 红线冻结,那些出口尚未接入; // 接入时应直接调用本函数。 func SanitizeRecord(rec SpanRecord) SpanRecord { out := rec out.Props = writeSpanProps(rec.Props) out.Events = writeSpanEvents(rec.Events) return out }