Files
AskaEth 3157abcf3c fix(gateway): 日志页脱敏改用「可带上限」变体(4096),摘要侧仍是 80
塔 2026-10-26 复审裁定:复用 trace.RedactText 让面板日志页每条 msg 都被切到
80 rune,是实质性 UX 回归(日志页就是排障面)。修法不是网关侧另写脱敏,而是
在 trace 包给上限加一个参数。

- trace/redact.go:新增导出 `RedactTextLimited(s, limit)`,语义 = 同一套凭据形状
  清洗 + 按调用方给的上限截断;`RedactText` 改为它的 `maxSummaryRunes`(80)
  调用形态,既有调用方零改动,清洗逻辑仍只有一份(redactSecrets)。
  limit <= 0 = 不截断,留给体积已自己界定的调用方。
- observability_logs.go:新增常量 `maxLogMsgRunes = 4096`(含注释说明为什么不是
  80、也不是无上限:单条日志可能是 %v 的 MiB 级对象,1000 条会撑爆 JSON),
  adminLogOf 改用 `RedactTextLimited(e.Msg, maxLogMsgRunes)`。
- span props / 摘要侧不动,仍是 80(一个要摘要,一个要全文,两边不该被统一)。

测试(均验过变异打红):
- TestAdminObservabilityLogs_LongLineKeepsFullText:547 rune 的日志行(行首凭据 +
  行尾哨兵)⇒ 凭据打码、末尾哨兵仍在、rune 数只差「凭据→[redacted]」那一处;
  变异(改回 RedactText 80)⇒ 红:拿到 81 rune、末尾哨兵消失。
- TestAdminObservabilityTrace_PropsStayAtSummaryBound:200 rune 的 args_summary
  ⇒ 81 rune(80 + 省略号);变异(把 maxSummaryRunes 提到 4096)⇒ 红。

验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... ./internal/runtime/trace/... 全绿。未 push。
2026-10-26 13:58:02 +08:00

161 lines
5.9 KiB
Go
Raw Permalink 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 gateway
import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 日志尾部端点的调参。编译期常量(项目规范 §10)。
const (
defaultLogLimit = 200
maxLogLimit = 1000
// maxLogMsgRunes 是日志页**单行 msg** 的截断上限(M42 r2)。
//
// 🔴 为什么不是 80:`trace.RedactText` 的 80 rune 是**出图摘要**那一档(span
// props / 工具结果摘要要的是「一眼看完的摘要」),而日志页是**排障面** ——
// 把一条长错误行拦腰截断就是丢诊断信息(实测一条 500 rune 的上游报错会被切到
// 80 + 省略号,恰好切掉「谁拒了、为什么」)。
//
// 🔴 为什么也不是无上限:单条日志可能是 `%v` 一个大对象(MiB 级),面板一次
// 要 1000 条 ⇒ 原样透出会把 JSON 响应撑到 GB 量级。4096 rune 足够装下完整的
// 技术栈 / 长错误行 / 一次请求的回显,而 4096 × 1000 条仍只是几 MB 量级。
maxLogMsgRunes = 4096
)
// AdminLogEntry 是 GET /api/v1/admin/observability/logs 中一个条目的
// 线上结构:{time,level,source,msg}。`time` 是
// RFC3339 UTC(与 trace/audit 一致);`level` ∈ debug|info|warn|error;`source`
// 是记录的来源标签,记录未携带时为空。
type AdminLogEntry struct {
Time string `json:"time"`
Level string `json:"level"`
Source string `json:"source"`
Msg string `json:"msg"`
}
// SetLogBuffer 注入 GET /api/v1/admin/observability/logs 背后的
// 进程级日志环形缓冲。组合根在网关存在后调用它
// (logger 先于网关构建);在那之前该
// 端点返回 501。并发调用是安全的。
func (s *Server) SetLogBuffer(buf *logger.RingBuffer) {
if s == nil || s.obs == nil {
return
}
s.obs.logBuf.Store(buf)
}
// logBuffer 返回已注入的日志环形缓冲,未注入时为 nil。
func (s *Server) logBuffer() *logger.RingBuffer {
if s == nil || s.obs == nil {
return nil
}
return s.obs.logBuf.Load()
}
// adminObservabilityLogs 返回最近的后端日志记录,最新
// 优先,可选按最低级别过滤(管理读)。它是面板日志
// 尾部背后的后端:记录来自 logger 自身的环形
// 缓冲,没有任何东西被重新渲染或编造。
//
// 读取同时充当实时推送的订阅握手(裁决 05.4.3 + 16.4.3-WS,见
// observability_push.go):此后新日志由 observability_log 帧推来,
// 面板不再轮询,分级过滤改在本地进行(帧是广播的,无法按连接分级)。
func (s *Server) adminObservabilityLogs(w http.ResponseWriter, r *http.Request) {
s.watchObservability(r)
minLevel, filtered, ok := parseLogLevel(r.URL.Query().Get("level"))
if !ok {
writeError(w, &apperr.AppError{
Code: "invalid_level",
Message: "level 只能为 debug/info/warn/error 或留空",
HTTPCode: http.StatusBadRequest,
})
return
}
buf := s.logBuffer()
if buf == nil {
writeError(w, &apperr.AppError{
Code: "not_implemented",
Message: "日志环形缓冲未注入(组合根未挂载 logger.RingBuffer)",
HTTPCode: http.StatusNotImplemented,
})
return
}
limit := parseLogLimit(r)
entries := buf.Snapshot(0)
out := make([]AdminLogEntry, 0, min(len(entries), limit))
for _, e := range entries {
if filtered && logger.ParseLevel(e.Level) < minLevel {
continue
}
out = append(out, adminLogOf(e))
if len(out) == limit {
break
}
}
writeJSON(w, http.StatusOK, out)
}
// adminLogOf 把捕获的日志条目映射为驼峰式管理线上结构。
//
// 🔴 读侧脱敏(M42 #3):`msg` 是日志正文,也是唯一可能夹带凭据/正文的字段
// (本仓有过「把上游报文整段打进日志」的先例),因此出面板前过一遍 trace 包的
// 共享清洗 —— 与写侧同一条路径,**不另写一套**:两套脱敏必然漂移,而漂移的方向
// 总是「其中一面开始原样吐凭据」。
//
// 🔴 上限与出图摘要**故意不同**(M42 r2,塔 2026-10-26 裁定):这里用
// `trace.RedactTextLimited(msg, maxLogMsgRunes)`(4096),而不是 `RedactText`
// 的 80 —— 日志页是排障面,80 会把长错误行拦腰截断(实质性 UX 回归)。出图那一侧
// (span props / 摘要)仍是 80,两边**不该被统一**:一个要摘要,一个要全文。
//
// 排障字段一律保住:`time`(RFC3339)、`level`、`source`(模块来源标签,编译期
// 常量构成,不含内容)原样透出,且 `msg` 的打码不改变条目条数、排序与级别过滤
// —— 面板因此照旧可诊断「谁在什么时候按什么级别报了哪一类错」。
func adminLogOf(e logger.Entry) AdminLogEntry {
return AdminLogEntry{
Time: e.Time.UTC().Format(time.RFC3339),
Level: e.Level,
Source: e.Source,
Msg: trace.RedactTextLimited(e.Msg, maxLogMsgRunes),
}
}
// parseLogLevel 解析 ?level= 过滤器。空值关闭过滤
// (filtered=false);debug/info/warn/error 选中该级别及以上;其他任何
// 取值都以 ok=false 拒绝,使 handler 返回 400。
func parseLogLevel(raw string) (min slog.Level, filtered, ok bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
return 0, false, true
case "debug":
return slog.LevelDebug, true, true
case "info":
return slog.LevelInfo, true, true
case "warn":
return slog.LevelWarn, true, true
case "error":
return slog.LevelError, true, true
default:
return 0, false, false
}
}
// parseLogLimit 解析 ?limit= 查询参数,夹取到 [1, maxLogLimit] 并在
// 缺失或非法时选用 defaultLogLimit。
func parseLogLimit(r *http.Request) int {
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || n <= 0 {
return defaultLogLimit
}
if n > maxLogLimit {
return maxLogLimit
}
return n
}