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 后,下游所有日志自动携带。
  • 验证 Trace 上下文:Handler 将 trace_id/span_id 写入日志后,核对标识是否与同一请求的链路一致。

什么是上下文日志,为什么需要它?

对比两条日志:

level=INFO msg="订单处理完成"
level=INFO msg="订单处理完成" request_id=7f3a9c user=zhangsan order_id=A-1024 service=order

第一条日志在高并发系统里几乎无用——你不知道它属于哪个请求。第二条可以按 request_id 过滤出这次请求的全部日志,按 user 追踪某个用户的行为轨迹。给日志附加上下文,是把日志从"流水账"变成"排查工具"的关键一步。

方案一:slog.With 显式传递(推荐基线)

在中间件生成带字段的子 logger,作为参数显式传给下游:

func processOrder(ctx context.Context, logger *slog.Logger) {
    logger.InfoContext(ctx, "开始处理请求")
}

func LoggingMiddleware(base *slog.Logger) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        reqLogger := base.With(slog.String("request_id", uuid.NewString()))
        processOrder(r.Context(), reqLogger) // 显式传 logger 参数
        w.WriteHeader(http.StatusNoContent)
    })
}

上例直接传递参数;若改为把 logger 放入 context,则属于方案二。显式传递的优点是依赖关系一目了然,缺点是改函数签名成本高。

方案二:context.Context 携带 logger

把 logger 塞进 context,函数签名里只要已有 ctx 参数就不用改。先在同一包定义私有键类型 type loggerContextKey struct{} 和 var loggerKey = loggerContextKey{};中间件用 context.WithValue(ctx, loggerKey, reqLogger) 传递派生 context:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    logger := FromContext(ctx)
    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 自动提取属性":

// import slogctx "github.com/veqryn/slog-context"
handler := slogctx.NewHandler(slog.NewJSONHandler(os.Stdout, nil), nil)
slog.SetDefault(slog.New(handler)) // 应用启动时配置一次
// 请求处理中,继续向下传递此派生 context
ctx := slogctx.Append(r.Context(), "request_id", uuid.NewString())
slog.InfoContext(ctx, "开始处理请求")

之后业务代码只需 slog.InfoContext(ctx, "msg"),request_id 自动附加——既不传 logger 也不改签名,是侵入性最低的方案。

如何自动注入 trace_id 和 span_id?

接入 OpenTelemetry 后,真正的威力才显现。思路:先由 OTel 插桩解析/生成 trace 上下文,再由日志 Handler 在每次记录时提取活动 Span 的标识。不能只在请求入口绑定一次 span_id,否则子 Span 日志会错误沿用父标识。以下包装器需导入 context、log/slog 和 go.opentelemetry.io/otel/trace:

type TraceHandler struct {
    next slog.Handler
}

func NewTraceHandler(next slog.Handler) *TraceHandler {
    return &TraceHandler{next: next}
}

func (h *TraceHandler) Enabled(ctx context.Context, level slog.Level) bool {
    return h.next.Enabled(ctx, level)
}

func (h *TraceHandler) Handle(ctx context.Context, record slog.Record) error {
    sc := trace.SpanContextFromContext(ctx)
    if sc.IsValid() {
        record = record.Clone()
        record.AddAttrs(
            slog.String("trace_id", sc.TraceID().String()),
            slog.String("span_id", sc.SpanID().String()),
        )
    }
    return h.next.Handle(ctx, record)
}

func (h *TraceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &TraceHandler{next: h.next.WithAttrs(attrs)}
}

func (h *TraceHandler) WithGroup(name string) slog.Handler {
    return &TraceHandler{next: h.next.WithGroup(name)}
}

输出效果:

{"time":"...","level":"ERROR","msg":"登录失败","user":"zhangsan","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}

启动时用 slog.New(NewTraceHandler(slog.NewJSONHandler(os.Stdout, nil))) 安装包装器;它不创建 Span,也不安装导出器。业务必须传入当前活动 Span 的 context。使用 WithGroup 时字段可能进入组内,需与平台期待的顶层字段一致;有效 ID 也不保证 Trace 已采样并保存。

输出上述日志后,选一个请求核对 Handler 写出的 trace_id 与对应 Span 是否一致。在观测云中,按 APM 日志关联说明检查关联字段及服务、环境、版本映射,再从 Trace 详情查看这次请求已采集到的日志。日志中只有 request_id 时仍可用于检索,但不能把它当作 trace_id。

常见问题(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 不会自动提取任意 context 值,也不应因 context 已取消而丢弃诊断日志。要让 ctx 里的值进入日志,必须用自定义 Handler(如 slogctx)显式提取——这正是方案三的机制。

系列阅读


获取专属方案

联系我们

加入社区

微信扫码
加入官方交流群

立即体验

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

立即开始

选择观测云版本

代码托管平台