← 返回博客
go2026-09-12 21:32:542 分钟 · 658 0

Gin 结构化日志与链路追踪:zap + OpenTelemetry

用 zap 替换默认日志产出 JSON,写中间件记录方法、路径、耗时、状态码和 trace_id,再接 OpenTelemetry 把一次请求跨服务串成一条链路。

#gin#日志#OpenTeleme

功能上线只是开始,出问题能不能定位才是考验。散落的 fmt.Println 查不了、串不起一次请求经过的多个服务。这一篇把日志结构化,再给每次请求发一个 trace_id,跨函数跨服务都能追。

1. 用 zap 产出 JSON 日志

Gin 默认的日志是给人看的彩色文本,机器不好解析。换成 go.uber.org/zap

import "go.uber.org/zap"

func newLogger() *zap.Logger {
    cfg := zap.NewProductionConfig()
    cfg.Encoding = "json"       // 每行一条 JSON
    cfg.OutputPaths = []string{"stdout"}
    l, _ := cfg.Build()
    return l
}

func main() {
    logger := newLogger()
    defer logger.Sync()
    r := gin.New()
    r.Use(gin.Recovery())
}

JSON 日志字段规整,丢进 Loki 或 ES 后能按 pathstatuslatency 直接检索和画图。比翻文本日志快得多。

2. 请求日志中间件

一个中间件记录每次请求的关键信息,顺手生成 trace_id:

func RequestLog(logger *zap.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        traceID := c.GetHeader("X-Trace-Id")
        if traceID == "" {
            traceID = uuid.NewString()
        }
        c.Set("trace_id", traceID)

        c.Next()

        logger.Info("http",
            zap.String("trace_id", traceID),
            zap.String("method", c.Request.Method),
            zap.String("path", c.Request.URL.Path),
            zap.Int("status", c.Writer.Status()),
            zap.Duration("latency", time.Since(start)),
            zap.String("ip", c.ClientIP()),
        )
    }
}

c.Set("trace_id", ...) 让后面的 handler 也能取到,写业务日志时带上,一次请求的所有日志同一个 trace_id。

3. trace_id 贯穿整个处理链

handler 里打的日志带上 trace_id,排查时拿它一搜全出来:

func (h *OrderHandler) Create(c *gin.Context) {
    traceID, _ := c.Get("trace_id")
    logger.Info("下单开始",
        zap.String("trace_id", traceID.(string)),
        zap.String("uid", fmt.Sprint(c.GetString("uid"))),
    )
    // 调下游服务也把 trace_id 放进请求头
    req.Header.Set("X-Trace-Id", traceID.(string))
    ...
}

跨服务调用把 trace_id 放进 X-Trace-Id 头传下去,链路就连上了。下游日志带同一 id,问题发生在哪一步一目了然。

4. 接 OpenTelemetry 做真正链路追踪

trace_id 只能串日志,OpenTelemetry 能记录每个环节的耗时和父子关系,画成调用树。先初始化 tracer:

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

func initTracer(ctx context.Context) func() {
    exp, _ := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint("otel-collector:4317"),
        otlptracegrpc.WithInsecure(),
    )
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exp),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL, semconv.ServiceName("gin-demo")),
        ),
    )
    otel.SetTracerProvider(tp)
    return func() { tp.Shutdown(context.Background()) }
}

中间件里为每个请求起一个 span,handler 内部再起子 span:

func Trace() gin.HandlerFunc {
    tracer := otel.Tracer("gin")
    return func(c *gin.Context) {
        ctx, span := tracer.Start(c.Request.Context(), c.Request.URL.Path)
        defer span.End()
        c.Request = c.Request.WithContext(ctx)
        c.Next()
        span.SetAttributes(attribute.Int("http.status", c.Writer.Status()))
    }
}

c.Request.Context() 往下传,service 层用同一个 ctx 起子 span,整条调用链自动拼成树。导出到 Jaeger 或 Tempo,慢请求点开就能看到卡在查库还是调下游。

5. 错误也记录

Recovery 里用 zap 把 panic 记下来,带 trace_id 和堆栈:

func Recovery(logger *zap.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if err := recover(); err != nil {
                logger.Error("panic",
                    zap.String("trace_id", getTraceID(c)),
                    zap.Any("error", err),
                    zap.Stack("stack"),
                )
            }
        }()
        c.Next()
    }
}

堆栈只在日志里,不回传前端(第 4 篇讲过)。线上报错靠这条日志的 trace_id 反查整条链路。

6. 上线前清单

日志用 JSON 编码,方便采集检索。每条请求日志带 trace_id、方法、路径、状态码、耗时。trace_id 跨服务通过 X-Trace-Id 头传递。敏感字段(密码、 token)不进日志。OpenTelemetry 导出走 otel-collector 批处理,别每条直发。日志级别生产用 info,调试用 debug,靠环境变量切。

十二篇走完,从第一个接口到缓存、鉴权、部署、测试、实时推送、链路追踪,一套能上生产的 Gin 后端骨架齐了。后面想加什么按业务往里填就行。

相关推荐

本文为原创文章,采用CC BY-NC-SA 4.0协议授权,转载请保留署名与原文链接。原文链接:https://www.wxbuluo.com/article/188