Files
Cyrene_v2/internal/services/gateway/observability_logs.go
T
AskaEth fa5d3d23bf feat(gateway): 面板人工加记忆钉 long + skill 解析去回显 + 可观测读侧脱敏
三处遗留(M37/M38/M39)同落 internal/services/gateway:

① adminAddMemory 显式传 RetentionTier=long(主人 2026-10-26 裁定「人工动作
  默认 long」)。此前不传档位,面板典型输入(短寒暄/一次性祈使/时效查询/句首
  时点/进行中)会被 resolveRetention 判 temp 并写 7 天有效期 —— 主人在面板上
  亲手加的记忆会静默消失。只钉这一条入口,Store.Add 默认仍由规则判定
  (自动抽取路径不放宽)。

② adminInstallSkillPlugin 解析失败不再打 err.Error():新建
  admin_plugins_skill_error.go,把失败折叠成「类别(yaml_syntax /
  yaml_type_mismatch / skill_invalid / skill_source)+ 行列号」,日志与 400
  响应用同一份摘要。解码器会把出错值前 7 个字符嵌进错误串(M38 #17),
  原样日志等于把上传内容写进 viewer 可读的日志面。

③ 读侧脱敏走既有 helper,不另写一套:
  - adminLogOf 的 msg 过 trace.RedactText(与写侧同一函数);
  - adminSpanOf 先过 trace.SanitizeRecord(REST 尾部与 observability_trace
    推送帧在同一处收口)。⚠️ 有意改变 admin 响应内容:props 变白名单投影 +
    omitted 摘要;字段名与帧形状不变。行数/级别/时间戳/来源、span 的
    status/耗时/时间/名字一律保住。

测试(每条都验过「删掉实现 ⇒ FAIL」):
  - 面板加记忆 ⇒ 落库 (retention_tier='long', expires_at='');对照组证明同一句
    内容走 Store.Add 仍判 temp;
  - 非法 YAML 含哨兵 ⇒ 响应与日志均无哨兵、都有类别与 line 2(内联/文件/语法/
    来源四类);
  - 日志页 msg 含 sk- 形状凭据 ⇒ [redacted],level/source/time 与非敏感正文保留;
  - trace 响应 props 正文 ⇒ omitted Digest、args_summary 打码,排障字段原样。

验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... 全绿。未 push(按任务口径)。
2026-10-26 13:53:59 +08:00

148 lines
4.9 KiB
Go
Raw 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
)
// 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.RedactText` —— 与写侧(trace.JSONRecorder)**同一个函数**,不另写一套;
// 两套脱敏必然漂移,而漂移的方向总是「其中一面开始原样吐凭据」。
//
// 排障字段一律保住:`time`(RFC3339)、`level`、`source`(模块来源标签,编译期
// 常量构成,不含内容)原样透出,且 `msg` 的打码不改变条目条数、排序与级别过滤
// —— 面板因此照旧可诊断「谁在什么时候按什么级别报了哪一类错」。
//
// 注意 `RedactText` 同时按 `maxSummaryRunes` 截断(与出图/落盘同一口径):超长
// 正文会带省略号。这是复用同一个 helper 的既定代价,远小于「凭据原样上面板」。
func adminLogOf(e logger.Entry) AdminLogEntry {
return AdminLogEntry{
Time: e.Time.UTC().Format(time.RFC3339),
Level: e.Level,
Source: e.Source,
Msg: trace.RedactText(e.Msg),
}
}
// 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
}