当前位置:首页 > 文章列表 > Golang > Go问答 > Go slog 自定义 Handler 丢失上下文字段的修复

Go slog 自定义 Handler 丢失上下文字段的修复

来源:17golang原创 2026-10-16 21:51:48 0浏览 收藏

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、serviceWithAttrs 返回的新派生状态
单次日志调用的属性Info 中的 task、耗时该次 Record 自带的属性
context.Context 中的值业务自定义 context key只有明确提取并添加后才成为日志属性

还有一个非常普通的遗漏:logger.With(...) 返回新 Logger,不会修改原 Logger。应该把返回值赋给请求级变量,然后用它记录后续事件。只调用 With 再继续用旧 logger,底层 Handler 即使完全正确,也不会凭空得到新字段。

如果每条调用附带的 task 仍在、With 设置的 request_id 不在,检查 WithAttrs 更有针对性。如果只有 Debug 整条不见,先看等级过滤。若 context 里有值但从未提取,问题不在 JSON 序列化,而是业务尚未约定属性如何进入日志。

Logger With 固定属性、Record 单次属性和 context 值的静态来源关系
属性来源结构说明图:调用侧区分 With、单条调用和 Context;处理侧区分派生 Handler、Record 与底层 JSONHandler。连线是属性存放和委托关系,不代表执行流程,Context 不会自动变成属性。

建立一个不吞掉派生属性的 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 天然支持并发。包装器的方法可能被并发调用,新增共享状态仍需要单独设计。示例把状态留给底层,避免把“修字段”变成“引入全局可变切片”。

父 Logger、子 Logger 与分组 Logger 对应的派生 Handler 关系
派生结构说明图:Logger 边界区与 Handler 边界区分别展示 root、child、grouped 的对应对象。连线说明对象关联和派生状态,不能理解为修改父对象;图中不含测试运行结果。

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 说明。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
Go runtime trace 显示阻塞但代码无锁的分析思路Go runtime trace 显示阻塞但代码无锁的分析思路
上一篇
Go runtime trace 显示阻塞但代码无锁的分析思路
Go math/rand 并发测试结果不可复现的种子管理
下一篇
Go math/rand 并发测试结果不可复现的种子管理
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    497次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    552次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    578次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    532次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    343次使用