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 }