用户报「我打开笔记报了 404」,能不能只用他给的一串编号,在一条命令里定位到那一次请求?
答「否」,说明日志里没有 request_id,或它不是每行都有。要补的是中间件里统一绑定公共字段,不是在出问题的地方多打几行。
标准库的默认日志是文本,同一行里的字段顺序不稳定,用 grep 找到之后也没法按字段取数。在启动时安装 slog.NewJSONHandler 写到标准输出,再 slog.SetDefault——这一步决定后面所有直接调用 slog.Warn 的代码是不是 JSON。内置键是 time、level、msg、source。
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 |
{"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。
先问他要 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,只能靠时间猜是哪一次。第二条找得到,代价是把整个请求体都记下来了——查得到和该不该记是两件事,这里同时犯了两个错。
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 都无法区分。.env 的值、完整请求体不让它们进日志判据:搜字段名与类型上的 LogValue;用假令牌跑一次,确认日志里只出现 redacted。违反后果:一次日志导出等于一次凭据泄漏。能省的一类:第二条在健康检查与静态资源这类无用户上下文的请求上可以省略 request_id,但路由模板仍要记,否则慢请求统计里会缺一类。