Go 上下文日志实战:用 Slog 让每个请求自动携带 request_id 与 trace_id
Go 上下文日志(Contextual Logging)中文实战:什么是上下文日志、slog.With 与 WithGroup 绑定字段、context.Context 传递 logger 的三种方案对比、slogctx 自动注入、OpenTelemetry trace_id/span_id 关联,以及观测云 APM 日志联动落地方案。
上下文日志(Contextual Logging)是指日志条目自动携带其产生时的环境信息——请求 ID、用户 ID、Trace ID 等——让每条日志都能被精确归位到"谁的哪个请求"。在 Go 中,Slog 提供了 With、Context 方法等一套完整机制来实现这一点。本文讲解三种主流的 logger 传递方案、trace_id 自动注入,以及如何与观测云 APM 打通。
核心要点速览
- 上下文日志解决的核心问题:微服务/高并发下,把一次请求产生的所有日志串起来,按 request_id 或 trace_id 一键过滤出完整链路。
- 三种 logger 传递方式:显式参数传递(最清晰)、context.Context 携带(少改签名)、全局 logger + context 方法(最省事),各有权衡。
- slog.With 是绑定字段的基础:中间件里绑定 request_id 后,下游所有日志自动携带。
- 与 OpenTelemetry 打通后价值最大:日志自动带 trace_id/span_id,观测云中日志与 APM 链路双向跳转。
什么是上下文日志,为什么需要它?
对比两条日志:
level=INFO msg="订单处理完成"
level=INFO msg="订单处理完成" request_id=7f3a9c user=zhangsan order_id=A-1024 service=order
第一条日志在高并发系统里几乎无用——你不知道它属于哪个请求。第二条可以按 request_id 过滤出这次请求的全部日志,按 user 追踪某个用户的行为轨迹。给日志附加上下文,是把日志从"流水账"变成"排查工具"的关键一步。
方案一:slog.With 显式传递(推荐基线)
在中间件生成带字段的子 logger,作为参数显式传给下游:
func LoggingMiddleware(next http.Handler, base *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := r.Header.Get("X-Request-ID")
if rid == "" {
rid = uuid.NewString()
}
reqLogger := base.With(slog.String("request_id", rid))
next.ServeHTTP(w, r.WithContext(
context.WithValue(r.Context(), loggerKey, reqLogger)))
})
}
下游从 context 取出使用(见方案二),或直接作为参数传递。显式传递的优点是依赖关系一目了然,缺点是改函数签名成本高。
方案二:context.Context 携带 logger
把 logger 塞进 context,函数签名里只要已有 ctx 参数就不用改:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logger := ctx.Value(loggerKey).(*slog.Logger)
logger.InfoContext(ctx, "开始处理请求")
}
配套一个安全的提取函数,context 里没有 logger 时返回默认 logger,避免 panic:
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return l
}
return slog.Default()
}
权衡:依赖变隐式(不看实现不知道函数会写日志);从 context 取值有微小开销;context 塞太多东西会失控。适合函数已接受 ctx 参数的场景,这也是 Go 生态目前的普遍做法。
方案三:全局 logger + InfoContext 方法
Slog 的 InfoContext/ErrorContext 等方法接受 ctx 参数,默认行为下它只是把 ctx 传给 Handler——配合自定义 Handler 或 slogctx 这类库,可以实现"全局 logger + 从 ctx 自动提取属性":
// 使用 slogctx 库
handler := slogctx.NewHandler(
slog.NewJSONHandler(os.Stdout, nil),
slogctx.WithAppender(func(ctx context.Context, r *slog.Record) error {
if rid, ok := ctx.Value(requestIDKey).(string); ok {
r.Add(slog.String("request_id", rid))
}
return nil
}),
)
slog.SetDefault(slog.New(handler))
之后业务代码只需 slog.InfoContext(ctx, "msg"),request_id 自动附加——既不传 logger 也不改签名,是侵入性最低的方案。
如何自动注入 trace_id 和 span_id?
接入 OpenTelemetry 后,真正的威力才显现。思路:OTel 中间件解析/生成 trace 上下文放进 ctx,日志 Handler 从 ctx 提取 TraceID/SpanID 附加到每条日志:
func TracingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sc := trace.SpanContextFromContext(ctx)
if sc.HasTraceID() {
logger := slog.Default().With(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
ctx = context.WithValue(ctx, loggerKey, logger)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
输出效果:
{"time":"...","level":"ERROR","msg":"登录失败","user":"zhangsan","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}
slog-context/otel 这类现成库可以免写中间件直接实现同样效果。
观测云落地:日志与链路双向跳转
上下文日志的最终价值在平台侧兑现:
- 统一字段契约:所有 Go 服务日志统一携带
trace_id、service字段,JSON 输出到 stdout。 - DataKit 采集:容器 stdout 自动采集,JSON 解析后 trace_id 成为可过滤字段。
- APM 关联:观测云 APM 探针(OpenTelemetry)与应用日志共用同一套 trace_id,在 Trace 详情页直接查看该链路关联的全部日志,反之在日志查看器可按 trace_id 反查调用链——排障时"从错误日志一键跳到完整调用链"。
- 按上下文检索:日志查看器按
request_id、user_id过滤,还原单次请求完整日志序列。 - 告警降噪:监控器规则里带上服务与接口维度(如按
service+ 路径分组统计错误率),告警信息自带上下文,值班同学不用再翻日志找现场。
常见问题(FAQ)
context 传 logger 和传字段哪个更好?
传 logger(方案二)实现简单,但 logger 里绑了什么字段对调用方不可见;传字段 + 全局 logger + ctx 提取(方案三)更解耦。团队约定比方案本身重要——选定一种写进规范,全项目统一。
With 绑定的字段越多越好吗?
不是。每个 With 字段都会出现在每条日志里,绑定过多低频字段会膨胀日志体积、干扰阅读。原则:request 级 logger 只绑 request_id、user_id 这类"每条日志都需要"的字段;偶发字段在具体日志调用处单独加。
没有接 OpenTelemetry,值得先做 request_id 吗?
非常值得。request_id 是上下文日志的最小可用集,成本几乎为零,排障收益立竿见影。后续接 OTel 时再平滑升级到 trace_id。
InfoContext 里的 ctx 会影响日志内容吗?
默认 Handler 只用 ctx 做取消判断,不会自动提取任何值。要让 ctx 里的值进入日志,必须用自定义 Handler(如 slogctx)显式提取——这正是方案三的机制。
系列阅读
- 上一篇:Go Slog 实战指南
- 下一篇:Zerolog 实战指南
- 相关阅读:微服务日志最佳实践 | 什么是结构化日志