本层判断标准

用户报「我打开笔记报了 404」,能不能只用他给的一串编号,在一条命令里定位到那一次请求?

答「否」,说明日志里没有 request_id,或它不是每行都有。要补的是中间件里统一绑定公共字段,不是在出问题的地方多打几行。

启动时换成 JSON

标准库的默认日志是文本,同一行里的字段顺序不稳定,用 grep 找到之后也没法按字段取数。在启动时安装 slog.NewJSONHandler 写到标准输出,再 slog.SetDefault——这一步决定后面所有直接调用 slog.Warn 的代码是不是 JSON。内置键是 time、level、msg、source。

cmd/server/main.go(启动时第一件事)

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)

请求中间件里绑定公共字段

公共字段只绑一次,后面的日志用这个 lg 打,不要每条手写同一组键。path 写路由模板而不是真实地址,否则按路由聚合时每条 id 都是一个新值。

lg := slog.Default().With(
	"request_id", requestID,
	"method", r.Method,
	"path", routePattern, // 例如 /notes/{id},不是 /notes/12
)
lg.Info("request start")

defer func(start time.Time) {
	lg.Info("request done",
		"status", status,
		"duration_ms", time.Since(start).Milliseconds(),
		"user_id", userID,
		"scope_id", scopeID,
	)
}(time.Now())

测试里默认丢弃,需要断言时指向缓冲区

不要让人眼看终端来判断。需要断言那一行时,把处理程序指到 bytes.Buffer,再在缓冲里找消息。

// 大多数测试:丢弃日志,避免正常输出干扰断言
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))

// 需要断言那一行时
var buf bytes.Buffer
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
// ... 触发越权请求
if !strings.Contains(buf.String(), "cross-scope access denied") {
	t.Fatalf("未记录越权警告:%s", buf.String())
}

必须在类型上拦截的敏感值

口令、令牌这类值若必须出现在某个结构里,让类型实现 LogValue,日志里就只出现改写后的值。没有这个方法时,在调用处就不要把该值传进 slog.Warn。

type sessionToken string

func (sessionToken) LogValue() slog.Value {
	return slog.StringValue("redacted")
}

三条不做的:开发时想看清结构可以临时用 NewTextHandler,但默认分支必须是 JSON。AddSource(记录文件与行号)会增加日志体积,留到单独一次改动,本次不加。日志不写文件——写标准输出由容器或进程管理器收集,这条对应十二要素里「把日志当事件流」的要求,见环境配置。

必采字段

字段名用英文小写加下划线,msg 用固定的英文短语。不要用中文句子拼 msg(例如「笔记 12 不存在」)——那样每个 id 都会变成一个新的字符串,按消息聚合就失效了;变化的部分放进字段。下面这张表就是本仓库的字段清单,新增字段时要同时更新本节。

字段类型何时必填说明
time字符串每行由处理程序自动填,带时区
level字符串每行INFO / WARN / ERROR
msg字符串每行固定英文短语,便于检索。越权固定为 cross-scope access denied
request_id字符串每个请求同时放进响应头 X-Request-Id,用户报错时由用户提供
method、path字符串每个请求path 用路由模板 /notes/{id},不用真实 id
status整数请求结束时与接口约定的状态码一致
duration_ms整数请求结束时用来找慢请求
user_id整数已登录时来自会话
scope_id整数已登录时来自会话,不从请求参数取
resource_id整数涉及具体资源时本次操作的对象
user_scope_id、resource_scope_id整数越权警告两个值必须不同,才能与普通未命中区分
err字符串失败时只写错误类型与一句信息。不写完整栈、不写 SQL

越权警告(响应是 404,服务端另有这一行)

{"time":"2026-10-01T20:00:00+08:00","level":"WARN","msg":"cross-scope access denied","request_id":"01J9Z3K7QF8YV9M2N4P6R8T0W2","method":"GET","path":"/notes/{id}","status":404,"user_id":10,"resource_id":2,"user_scope_id":10,"resource_scope_id":20}

正常请求结束

{"time":"2026-10-01T20:00:00+08:00","level":"INFO","msg":"request done","request_id":"01J9Z3K7QF8YV9M2N4P6R8T0W2","method":"GET","path":"/notes/{id}","status":200,"duration_ms":12,"user_id":10,"scope_id":10}

本次不加的字段:trace_id、指标与链路系统不在本次字段清单里。它们是另一套东西,需要采集端与查询端配套;仓库里还没有这些组件时,加上去只会多一个没人查的字段。这一条留到单独一次改动。

级别标准

级别决定这行日志会不会被人看到、看到之后要不要动手。判据是「收到它的人有没有事可做」,不是这条消息看起来严不严重。按错误类型决定级别的写法见语言特性。

级别什么时候用判据
Info一次请求按预期完成;查询没有命中属于这一类删掉它不会让任何一次排查变难。若删不掉,说明它其实该是 Warn
Warn拒绝了本不该发生的请求,例如越权访问有一条安全不变量被违反,或用户看到的响应与实际原因不一致
Error操作失败,需要人处理收到它的人有事可做。无事可做就降到 Warn
Debug开发时临时打开默认级别写 Info,提交时不默认打开

别拿 Error 条数考核:Error 的条数不是考核指标。把它当选指标,失败会被降级成 Warn 好让数字好看。评审看的是「这条级别对应的动作是否写在了运行手册里」,不是条数。这条与发布用的错误条数基线不冲突:那边用它判断回滚,不评价人。

出了事查什么

为了能用 jq,日志必须每行一个 JSON 且不带容器前缀,所以命令里都带 --no-log-prefix。

场景一:用户报「打开某条笔记报了 404」

先问他要 X-Request-Id;他给不出来时,用 user_id 加时间窗,再找状态码 404 的那一行。

# 有 request_id
docker compose logs --no-log-prefix --since 2h api |
  jq -c 'select(.request_id=="01J9Z3K7QF8YV9M2N4P6R8T0W2")'

# 没有 request_id
docker compose logs --no-log-prefix --since 30m api |
  jq -c 'select(.user_id==10 and .status==404)'

判读一

看同一段日志里还有没有一条 WARN 的 cross-scope access denied,它决定这是两种 404 里的哪一种。

{"level":"WARN","msg":"cross-scope access denied","user_id":10,"resource_id":2,"user_scope_id":10,"resource_scope_id":20}
→ 记录属于别的所属范围。响应是有意做成 404 的,不用改代码。

{"level":"INFO","msg":"note not found","user_id":10,"resource_id":99999}
→ 这条 id 确实不存在。检查前端是不是在用旧链接。

场景二:越权警告突然变多

同一个用户在短时间内反复触发,是遍历 id 的特征,不是正常误操作。

docker compose logs --no-log-prefix --since 1h api |
  jq -r 'select(.msg=="cross-scope access denied") | .user_id' |
  sort | uniq -c | sort -rn | head

场景三:某个接口变慢

按 duration_ms 排序,取最慢的 20 条。判读时看它们是不是同一个 path:集中在一条路由上,说明问题在那个查询;分散在多个路由上,看是不是数据库或外部服务。

docker compose logs --no-log-prefix --since 30m api |
  jq -r 'select(.duration_ms > 200) | "\(.duration_ms)\t\(.path)\t\(.request_id)"' |
  sort -rn | head -20

场景四:错误变多,先看是哪一类

按 err 聚合。同一条错误占多数就顺着它查;分散且没有共同点,多半是上游依赖或数据库连接的问题。

docker compose logs --no-log-prefix --since 10m api |
  jq -r 'select(.level=="ERROR") | .err' |
  sort | uniq -c | sort -rn | head

场景五:确认记录链路本身没断

「查不到」也是一种结论。做过一次越权核对之后应当至少有一条警示;条数是 0 时,先怀疑日志配置,而不是怀疑没有越权发生。这条就是发布里那个回滚阈值。

docker compose logs --no-log-prefix --since 10m api |
  grep -c 'cross-scope access denied'

命令失效时的处理:这五条查询都依赖「每行一个完整 JSON」。日志被加上时间前缀、或被采集端包了一层之后,jq 会解析失败。遇到解析失败先去掉包装再查,不要改用 grep 硬凑——那样会写出一批只用一次的、没人能复用的命令。jq 的具体语法以官方手册为准。

评审日志改动看什么

字段名用下划线还是驼峰、消息首字母大写还是小写,这类评审不再讨论。要看的是下面七类,其中前三类会直接造成安全事故。

现象说明什么处理
新增日志里出现完整请求体、Cookie、口令、令牌、.env 的值凭据与个人数据进入日志,日志往往比数据库更容易被看到挡回;若已经合并且部署过,按环境配置的四步轮换
err 里塞了完整栈或 SQL 语句日志体积失控,内部路径与表名外泄只留错误类型与一句信息,栈在本地调试时再看
把对象直接传给 slog(例如整个用户结构体)结构体里新增字段时会静默被记录逐个字段传;敏感字段所在类型实现 LogValue
msg 是中文句子或带变量检索与聚合失效,每个 id 变成一条新消息改成固定英文短语,变化部分放进字段
path 写真实 id(/notes/12)按路由聚合时每条 id 各算一类,id 也进了统计改成路由模板 /notes/{id}
新增日志没有 request_id,或每处手写用户报错时对不上这一次请求要求在中间件统一用 With 绑定,不逐处手写
用 fmt.Println 或 log.Print 打日志绕过了 JSON 处理程序,这一行不是 JSON挡回,改成 slog。一次请求只保留入口与出口两行,中间只打异常分支

能放宽的一处:第三条在纯值类型(例如一个只含 id 与名称的结构)上可以放宽,但前提是它的字段里不会有敏感值。判断方法看这个类型的所有定义位置,而不是看这一次的用法。拿不准就逐个字段传。

助手交出来的样子

log.Printf("查询笔记失败: %v", err)     ← 文本,没有 request_id
log.Info("req", "body", string(raw))    ← 整个请求体进了日志

认它的办法:用户报一串编号,能不能只用它在一条命令里定位到那次请求。第一条找不到:它是文本、没有 request_id,只能靠时间猜是哪一次。第二条找得到,代价是把整个请求体都记下来了——查得到和该不该记是两件事,这里同时犯了两个错。

必须死守的四条

让每行都是完整 JSON,不写日志文件判据:docker compose logs --no-log-prefix --since 5m api | jq -c . >/dev/null 不报错。违反后果:查询命令都要改成硬凑的 grep,只能用一次。
给每个请求唯一 request_id,并随 X-Request-Id 返回判据:请求头里有该值,且同一 id 的日志行都带同一个 request_id。违反后果:用户报错时只能按时间翻日志。
把越权固定记成一行 Warn,消息为 cross-scope access denied,带两个不同的 scope_id判据:断言这一行的测试存在并通过,见接口的契约测试。违反后果:响应不泄露存在性,但服务端也失去了判据,等于两种 404 都无法区分。
口令、哈希、令牌、Cookie、.env 的值、完整请求体不让它们进日志判据:搜字段名与类型上的 LogValue;用假令牌跑一次,确认日志里只出现 redacted。违反后果:一次日志导出等于一次凭据泄漏。

能省的一类:第二条在健康检查与静态资源这类无用户上下文的请求上可以省略 request_id,但路由模板仍要记,否则慢请求统计里会缺一类。