Go 上下文日志实战:用 Slog 让每个请求自动携带 request_id 与 trace_id

Go 上下文日志(Contextual Logging)中文实战:什么是上下文日志、slog.With 与 WithGroup 绑定字段、context.Context 传递 logger 的三种方案对比、slogctx 自动注入、OpenTelemetry trace_id/span_id 关联,以及观测云 APM 日志联动落地方案。

最佳实践
Go 上下文日志实战:用 Slog 让每个请求自动携带 request_id 与 trace_id技术指南封面

上下文日志(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 这类现成库可以免写中间件直接实现同样效果。

观测云落地:日志与链路双向跳转

上下文日志的最终价值在平台侧兑现:

  1. 统一字段契约:所有 Go 服务日志统一携带 trace_idservice 字段,JSON 输出到 stdout。
  2. DataKit 采集:容器 stdout 自动采集,JSON 解析后 trace_id 成为可过滤字段。
  3. APM 关联:观测云 APM 探针(OpenTelemetry)与应用日志共用同一套 trace_id,在 Trace 详情页直接查看该链路关联的全部日志,反之在日志查看器可按 trace_id 反查调用链——排障时"从错误日志一键跳到完整调用链"。
  4. 按上下文检索:日志查看器按 request_iduser_id 过滤,还原单次请求完整日志序列。
  5. 告警降噪:监控器规则里带上服务与接口维度(如按 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)显式提取——这正是方案三的机制。

系列阅读


获取专属方案

联系我们

加入社区

微信扫码
加入官方交流群

立即体验

在线开通,按量计费,真正的云服务!

立即开始

选择观测云版本

代码托管平台