Go slog 自定义 Handler 丢失上下文字段的修复
来源:17golang原创
时间:2026-10-16 21:51:48 213浏览 收藏
slog 自定义 Handler 在 logger.With 后丢字段,优先检查 WithAttrs:不能忽略传入属性后直接返回原对象,也不能只调用底层 WithAttrs 却丢掉它返回的新 Handler。包装器还要正确实现 WithGroup、转交 Enabled,并保留原始 Record。另一个容易混淆的点是:InfoContext 不会自动把任意 context.Value 转成日志字段。
Go slog 官方地址:https://pkg.go.dev/log/slog
把服务日志换成一层自定义包装器后,消息和等级还在,request_id 却消失,这种场景最容易让人怀疑输出格式。我更建议先把属性来源拆开,再写一个只验证派生行为的小实验。下面使用 Go 1.21 或更新工具链,示例未在本文生成过程中执行,不把测试预期冒充运行记录。
先把上下文字段分成三种来源
“上下文字段”不是一个单独的 slog 存储位置。业务上可能都叫请求上下文,技术上却有不同入口。
| 来源 | 例子 | Handler 需要保留的内容 |
|---|---|---|
| Logger.With 的固定属性 | request_id、service | WithAttrs 返回的新派生状态 |
| 单次日志调用的属性 | Info 中的 task、耗时 | 该次 Record 自带的属性 |
| context.Context 中的值 | 业务自定义 context key | 只有明确提取并添加后才成为日志属性 |
还有一个非常普通的遗漏:logger.With(...) 返回新 Logger,不会修改原 Logger。应该把返回值赋给请求级变量,然后用它记录后续事件。只调用 With 再继续用旧 logger,底层 Handler 即使完全正确,也不会凭空得到新字段。
如果每条调用附带的 task 仍在、With 设置的 request_id 不在,检查 WithAttrs 更有针对性。如果只有 Debug 整条不见,先看等级过滤。若 context 里有值但从未提取,问题不在 JSON 序列化,而是业务尚未约定属性如何进入日志。

建立一个不吞掉派生属性的 Handler
先不增加脱敏、字段提取或异步队列,仅恢复透明包装器应有的语义。创建 wrapper.go:
package logwrap
import (
"context"
"log/slog"
)
// Wrapper 的 next 构造后不再修改;调用方必须传入非 nil Handler。
type Wrapper struct {
next slog.Handler
}
func New(next slog.Handler) *Wrapper {
if next == nil {
panic("logwrap: nil Handler")
}
return &Wrapper{next: next}
}
// 编译期声明只检查接口形状,不会执行日志输出。
var _ slog.Handler = (*Wrapper)(nil)
func (h *Wrapper) Enabled(ctx context.Context, level slog.Level) bool {
return h.next.Enabled(ctx, level) // 保留底层等级策略和原上下文。
}
func (h *Wrapper) Handle(ctx context.Context, record slog.Record) error {
return h.next.Handle(ctx, record) // 不重建 Record,保留属性、时间与调用位置。
}
func (h *Wrapper) WithAttrs(attrs []slog.Attr) slog.Handler {
// 必须保存返回的派生 Handler,而不是调用后继续复用 h.next。
return New(h.next.WithAttrs(attrs))
}
func (h *Wrapper) WithGroup(name string) slog.Handler {
if name == "" {
return h // 空分组不改变状态。
}
return New(h.next.WithGroup(name)) // 派生对象仍由 Wrapper 包裹。
}
这个实现刻意没有“增强功能”,用途是给现有包装器一个不丢数据的基线。若不需要扩展,直接使用 JSONHandler 或 TextHandler 更简单;只有确实需要包装行为时才保留这层。
这里的关键取舍是不自行维护固定属性切片和分组栈,而是让底层成熟 Handler 负责。返回 h.next.WithAttrs(attrs) 本身可能保留属性,却会让派生 Logger 绕过包装层;如果包装器还有其他逻辑,仍会表现为 With 之后功能消失。因此返回值既要派生,也要继续包装。
不要改写为 h.next = h.next.WithAttrs(attrs) 后返回 h。这样会修改父对象共享状态,让父 Logger 和其他子 Logger 意外继承字段,并可能带来并发问题。
用父子日志与分组测试检查修复
接下来创建 wrapper_test.go。测试不仅看“有没有输出”,还检查父子隔离、组层级、单次属性、等级过滤和取消上下文。
package logwrap
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"testing"
)
func TestWrapperAttrs(t *testing.T) {
var output bytes.Buffer
base := slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelInfo})
root := slog.New(New(base))
child := root.With("request_id", "req-demo")
grouped := child.WithGroup("job").With("queue", "daily")
ctx, cancel := context.WithCancel(context.Background())
cancel() // 取消不应使已允许的日志被包装器吞掉。
child.InfoContext(ctx, "child", "task", "export")
root.Info("root")
grouped.Info("grouped", "attempt", 1)
child.Debug("hidden") // 底层 Info 阈值应继续有效。
decoder := json.NewDecoder(&output)
rows := make([]map[string]any, 0, 3)
for {
var row map[string]any
err := decoder.Decode(&row)
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
rows = append(rows, row)
}
if len(rows) != 3 {
t.Fatalf("日志数=%d,预期=3", len(rows))
}
if rows[0]["request_id"] != "req-demo" || rows[0]["task"] != "export" {
t.Fatalf("子日志缺少属性: %#v", rows[0])
}
if _, inherited := rows[1]["request_id"]; inherited {
t.Fatalf("父日志被子属性污染: %#v", rows[1])
}
job, ok := rows[2]["job"].(map[string]any)
if !ok {
t.Fatalf("缺少 job 分组: %#v", rows[2])
}
// encoding/json 把本例 JSON 数字解码成 float64。
if rows[2]["request_id"] != "req-demo" ||
job["queue"] != "daily" || job["attempt"] != float64(1) {
t.Fatalf("分组属性不符: %#v", rows[2])
}
}
request_id 在调用 WithGroup 之前绑定,所以仍在顶层;queue 与 attempt 在 job 分组内。若把所有固定属性都在最后一刻重新附加,很容易把顶层字段移动到错误分组。这个细节比只检索输出文本里是否出现 request_id 更值得保留。
测试使用内存 Buffer 且没有 t.Parallel;不要据此声称任意 writer 天然支持并发。包装器的方法可能被并发调用,新增共享状态仍需要单独设计。示例把状态留给底层,避免把“修字段”变成“引入全局可变切片”。

Enabled 和 Record 处理还会在哪里丢数据
Enabled 接收等级和上下文,但没有本次 Record。不要把只能在 Record 中找到的 task 字段,拿来作为这个阶段必须存在的条件。对于透明包装器,转交底层的判断最容易保持原来的等级阈值。
Handle 中也不应该因为 ctx.Err() != nil 就无条件丢弃日志。请求结束或取消时的日志可能正是需要保留的线索;官方接口约定明确区分上下文值访问与取消信号。上面的测试让取消上下文仍能记录 Info,就是为了避免这种误删。
重建 Record 时只复制 Message 和 Level,会丢失其他信息。透明包装器直接转交最简单;若确实要追加属性,先对 Record 使用 Clone,再修改副本,避免共享内部存储造成意外。固定属性已经由派生底层持有,不必把它们再次塞进 Record,重复添加还可能产生同名键冲突。
完整自写格式化 Handler 的职责远不止遍历 Attrs:还需要处理属性值求解、空属性、空组与组层级。只想增加少量业务能力时,我更倾向于委托标准输出 Handler,而不是顺手重写全部编码规则。进阶实现可参考官方 testing/slogtest;本文给读者的测试聚焦当前修复,不声称覆盖了所有 Handler 契约。
扩展 context 字段前先固定约定
这个 Wrapper 不会扫描 context。若 request_id 本来存储在自定义 key 下,必须明确在哪里读取它、以哪个键添加、何时可以缺省,以及同名属性如何处理。对小型调用链,显式绑定请求级 Logger 往往比在每层 Handler 猜测 context 内容更直观。
// 片段:放在接收 ctx、root 和已提取 requestID 的业务函数中。
// 先把需要的业务值显式绑定;InfoContext 只负责把 ctx 交给 Handler。
requestLogger := root.With(slog.String("request_id", requestID))
requestLogger.InfoContext(ctx, "export started", slog.String("task", "export"))
如果选择在 Handler 中提取 context 值,要特别决定它属于顶层还是当前分组。直接在 Handle 追加的属性通常会受底层当前分组影响,不能未经约定就声称它一定顶层输出。还应限制字段集合,避免把整个上下文、凭据或个人数据写进日志。
# 在已有 Go 模块中保存 wrapper.go 与 wrapper_test.go 后运行。 go test -run '^TestWrapperAttrs$' -count=1 ./... # 并发检查只能发现实际执行路径上的竞态,不能证明全部语义正确。 go test -race ./...
预期是固定字段、单次字段与分组都按断言保留,父日志不继承子字段,Debug 继续被过滤。本文未执行命令,不展示 PASS、耗时或竞态检查结果。示例仅写内存,不需要清理日志文件或修改默认 Logger。
为什么 With 之后脱敏逻辑也没了?
常见原因是 WithAttrs 直接返回底层派生 Handler,绕过了包装器。正确方式是把派生结果重新包起来,同时保留包装器自己的不可变配置。
只在 Handle 遍历 Record.Attrs 能拿到所有 With 字段吗?
不能这样假设。With 的属性可能已由派生 Handler 保存或预格式化,并不保证合并进每次 Record 的属性集合。固定属性与单次属性应按各自入口处理。
把 Enabled 永远返回 true 就能修好吗?
这会改变过滤策略,而不是恢复已丢失的派生属性。应分别检查等级是否允许、WithAttrs 是否保留状态,以及输出阶段是否正确处理 Record。
这套最小修复适合包装标准 Handler 的项目。如果需求只是结构化输出,不加包装反而更省维护;需要扩展时,再以父子隔离、分组和等级测试固定语义,避免“字段回来了一次”掩盖其他行为被改变。
参考:slog Handler 官方接口、Go 官方 Handler 实现与约定、testing/slogtest 说明。
-
502 收藏
-
502 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
363 收藏
-
387 收藏
-
354 收藏
-
305 收藏
-
409 收藏
-
237 收藏
-
156 收藏
-
447 收藏
-
Golang · Go问答 | 1小时前 | net/http · HTTP客户端 · Go问答 · Transport gzip Content-Encoding Go 自动解压 Response Body365 收藏
-
360 收藏
-
218 收藏
-
403 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习