结构化日志字段应该在调用处还是 Handler 中补齐
来源:17golang原创
时间:2026-10-07 14:14:00 406浏览 收藏
结构化日志字段最稳妥的分工是:事件事实写在调用处,稳定的请求或子系统字段用 Logger.With 在边界绑定,只有可由 context.Context 或进程环境统一推导的横切字段才交给 Handler 补齐。Handler 不应猜订单状态、重试次数或业务结果,因为它通常看不到这些语义。
官方文档:https://pkg.go.dev/log/slog
- 调用处:order_id、status、latency、error、rows 等当前事件事实。
- Logger.With:request_id、component、tenant 等在一个作用域内稳定的字段。
- Handler:从 context 提取的 trace_id、进程级 service/version、统一脱敏和键名规范化。
先按字段所有权划分三层
slog.Logger 会把时间、级别、消息和属性组成 Record,再交给关联的 Handler。这个接口边界很适合做统一输出,但不意味着所有字段都应该在 Handler 中生成。
| 字段类型 | 最合适的位置 | 原因 |
|---|---|---|
| 事件特有业务事实 | 调用处 | 只有当前代码知道准确语义和结果 |
| 一组日志共同字段 | Logger.With | 一次绑定,多次复用,作用域清晰 |
| 请求链路字段 | Handler 从 context 提取 | 横切关注点可统一实现 |
| 键名重写、脱敏 | ReplaceAttr 或包装 Handler | 输出策略应集中维护 |

调用处记录当前事件才能知道的事实
订单处理函数知道订单号、结果、耗时和错误,因此这些字段应跟消息一起出现。LogAttrs 只接收 slog.Attr,比交替传入键和值更明确,也能减少部分分配。
func finishOrder(ctx context.Context, logger *slog.Logger, orderID string, started time.Time, err error) {
attrs := []slog.Attr{
// 订单号属于当前业务事件
slog.String("order_id", orderID),
// 耗时由调用处掌握,Handler 不应重新推断
slog.Duration("latency", time.Since(started)),
}
if err != nil {
attrs = append(attrs, slog.Any("error", err))
logger.LogAttrs(ctx, slog.LevelError, "order failed", attrs...)
return
}
logger.LogAttrs(ctx, slog.LevelInfo, "order completed", attrs...)
}
如果把 order_id 放进 Handler,Handler 只能依赖隐式 context 值或全局变量。这样字段来源难追踪,后台任务、测试和没有请求 context 的调用还会产生不同结果。
稳定作用域字段用 Logger.With 绑定
官方文档建议对多条日志共享的属性使用 Logger.With。它会返回一个带附加属性的新 Logger,后续每次调用都会包含这些字段。内置 Handler 还能在 With 时预先格式化共同属性。
func handleRequest(ctx context.Context, base *slog.Logger, requestID string) {
requestLogger := base.With(
// request_id 在整个请求作用域内保持不变
slog.String("request_id", requestID),
slog.String("component", "checkout"),
)
requestLogger.InfoContext(ctx, "request accepted")
// 下游复用同一个请求级 Logger,避免每次重复字段
processCheckout(ctx, requestLogger)
}
这种方式比“每条日志重复写 request_id”更不容易漏字段,也比“让 Handler 从任意 context key 猜字段”更清楚。子系统还可以配合 WithGroup 隔离常见键名。
Handler 只补可以统一推导的横切字段
官方文档明确提到,某些 Handler 会从调用处提供的 context 中加入信息,例如当前 trace span 标识。包装 Handler 时,Enabled、Handle、WithAttrs 和 WithGroup 都要正确转发。
type traceKey struct{}
type ContextHandler struct {
next slog.Handler
}
func (h ContextHandler) Enabled(ctx context.Context, level slog.Level) bool {
// 先交给下游判断级别,避免无意义构造属性
return h.next.Enabled(ctx, level)
}
func (h ContextHandler) Handle(ctx context.Context, record slog.Record) error {
traceID, _ := ctx.Value(traceKey{}).(string)
if traceID != "" {
// Record 含共享内部状态,修改前必须 Clone
record = record.Clone()
record.AddAttrs(slog.String("trace_id", traceID))
}
return h.next.Handle(ctx, record)
}
func (h ContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
// 保留 Logger.With 添加的字段
return ContextHandler{next: h.next.WithAttrs(attrs)}
}
func (h ContextHandler) WithGroup(name string) slog.Handler {
// 保留 Logger.WithGroup 的分组语义
return ContextHandler{next: h.next.WithGroup(name)}
}
Record 的普通副本可能共享隐藏状态。Go 文档要求:Handler 在修改 Record 前调用 Clone,或创建新 Record 并复制属性。这里先 Clone,再添加 trace_id。

没有 context 的调用要有明确降级规则
Handler 只有在日志调用传入 context 时才能读取其中的信息。代码持有 context 时,优先使用 InfoContext、ErrorContext 或 LogAttrs;普通 Info 不应被期待自动拥有请求字段。
func newLogger(w io.Writer) *slog.Logger {
jsonHandler := slog.NewJSONHandler(w, &slog.HandlerOptions{
// 生产环境从 INFO 起输出,可替换为 LevelVar 动态调整
Level: slog.LevelInfo,
})
return slog.New(ContextHandler{next: jsonHandler}).With(
// 进程级字段在构造 Logger 时一次绑定
slog.String("service", "checkout-api"),
)
}
建议约定:没有 trace_id 就省略该字段,不输出空字符串;后台任务用 job_id 或 run_id,不要伪造 request_id;调用处已经显式提供的业务字段不要再由 Handler 添加同名键。
兼容键名、分组和重复字段
大型系统最容易出问题的不是“缺一个字段”,而是同名字段含义不一致。可以制定一个很小的日志字段契约:
trace_id只表示分布式追踪标识,由 Handler 从 context 提取。request_id由入口层创建并通过Logger.With绑定。order_id、user_id等业务标识由调用处写入。- HTTP、数据库或队列字段使用
WithGroup分组,避免status含义冲突。
不要依赖“后写字段覆盖前写字段”的隐式规则。不同 Handler 对重复键的呈现方式可能不同;最简单的做法是让字段只有一个明确所有者。
性能和敏感信息放在输出边界统一处理
如果分析显示日志占用明显时间,常用字段应优先通过 Logger.With 绑定,热路径使用 LogAttrs。日志参数会在调用前求值,昂贵值可以实现 LogValuer,只在事件确实启用时计算。
密码、Token、身份证号等敏感内容不应进入日志。对已知键做统一脱敏,可在 HandlerOptions.ReplaceAttr 中返回替换值或空属性:
replaceSensitive := func(groups []string, attr slog.Attr) slog.Attr {
switch attr.Key {
case "password", "token":
// 输出固定标记,不保留原始秘密
return slog.String(attr.Key, "[REDACTED]")
default:
return attr
}
}
ReplaceAttr 更适合重写、删除和脱敏已有属性;从 context 提取 trace_id 则适合包装 Handler。两者职责不同,不要把一个超大函数同时做业务推断、环境探测、字段改名和输出格式化。
最终判断清单
- 字段是否只有当前业务代码知道?放调用处。
- 字段是否在一个请求或子系统内保持稳定?用
Logger.With。 - 字段是否能从 context 或运行环境统一推导?由 Handler 补齐。
- 包装 Handler 是否完整转发四个接口方法,并在修改 Record 前 Clone?
- 没有 context 时是否省略横切字段,而不是伪造空值?
- 敏感键是否在统一输出边界脱敏?
- 是否避免重复键和不明确的覆盖规则?
常见问题
request_id 应该由 Handler 从 context 自动取吗?
如果全项目都有统一 context 契约,可以由 Handler 取;若只有部分入口设置 request_id,入口层用 Logger.With 更直观。关键是只选一个所有者。
错误对象应该放在 Handler 中统一读取吗?
不应该。错误属于当前事件,调用处最清楚它对应哪个动作和结果,应使用 slog.Any("error", err) 显式记录。
为什么修改 Record 前必须 Clone?
Record 的普通副本可能共享内部属性状态。直接 AddAttrs 可能影响原 Record;Clone 会创建不共享状态的副本。
所有公共字段都用 Handler 补齐更省代码吗?
短期看似省代码,长期会隐藏字段来源并扩大耦合。稳定作用域字段用 With,Handler 只保留真正横切且可统一推导的字段。
一句话收口:调用处负责“发生了什么”,Logger.With 负责“这一组日志属于谁”,Handler 负责“输出时统一补充和治理什么”。按字段所有权分层,比把所有内容集中到一个万能 Handler 更容易维护。
-
341 收藏
-
354 收藏
-
195 收藏
-
274 收藏
-
200 收藏
-
479 收藏
-
347 收藏
-
400 收藏
-
341 收藏
-
183 收藏
-
313 收藏
-
195 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习