Gin 可观测性三件套:指标、链路与结构化日志实践

发布时间:2026/10/26 4:33:36
Gin 可观测性三件套:指标、链路与结构化日志实践

简介:面向 Go 与 Gin 后端开发者及运维、SRE 学习者的可观测性实践包,围绕 porm-go 指标采集、otelgin 链路追踪与 zap 日志记录,解决 Gin 服务在指标、追踪、日志三条线上接入分散、缺少统一示例的问题。压缩包共 14 个文件、约 44KB,包含 5 个 go 源码、4 个 md 说明文档、2 个 png 示意图,以及 go.mod、go.sum、.gitignore 等工程配置;源码按 metric、trace、log、observability 与 example 等模块组织,便于对照理解 SDK 接入与分层封装。已有 113 人学习下载。读者可获得一套覆盖指标暴露、请求链路追踪、结构化日志与示例入口的可参考实现,并通过 README 与示意图快速梳理 Gin 项目可观测性改造的目录结构和关键接入点,适合做项目脚手架或二次开发起点。

1. Gin 服务上线后出了事,日志里翻不到一句有用的话

有个做后台的朋友吐槽,他们的 Gin 服务凌晨报 500,运维把日志发过来,全是level=info msg=request method=GET path=/api/v1/order这种。哪个请求慢、慢在哪一段、是不是数据库连接池打满、上一次调用的 trace 在哪,全都没有。这不是运维不给力,是服务压根没把可观测性当回事。

这份资源包干的就一件事:给 Gin 一次性接上三件套——Prometheus 指标(prom-go 那套 prometheus/client_golang 的落地封装)、OpenTelemetry 链路(otelgin 中间件)、结构化日志(zap)。它适合已经在跑 Gin、正准备补监控埋点,或者刚被线上问题教育过一次的团队。下面我按「三件套各自解决什么 → 怎么接进 Gin → 怎么和业务代码解耦 → 哪里最容易翻车」拆开讲。

2. 三件套的分工:什么时候要 Prometheus,什么时候必须上 trace

先说清楚一个很多人含糊的点:指标、链路、日志不是三选一,也不是重复建设,它们回答的是三个不同问题。搞混了这个,埋点就会写成一团,线上出问题时还是不知道看哪个。

2.1 指标回答“有没有异常”,链路回答“异常在哪”,日志回答“当时发生了什么”

指标是聚合的、低基数的,比如http_requests_total{method,path,status}、http_request_duration_seconds。它便宜、能长期存、能直接出告警。缺点是它只给“某个接口的错误率从 0.3% 涨到 8%”,不会告诉你是哪个用户、哪次请求。

链路是单次请求的因果树。OpenTelemetry 的 span 记录了这次请求经过了 Gin handler、往下调了哪个下游 HTTP 服务、执行了哪条 SQL、各段耗时多少。它贵,一般采样存,但定位问题快。

日志是散点事实,zap 写出来的结构化字段可以和 trace_id 串起来,把“这一次请求”的细节钉死。

我一般的判断逻辑是:告警看指标,止损看日志,追根因看链路。三者共用一套 label 语义和 trace_id 才是这套资源的核心价值,不然就是三个孤岛。

2.2 为什么这三个库凑在一起,而不是各装各的

这里有个选型理由值得讲。Gin 的中间件模型是c.Next()包住后续 handler,天然适合做“进请求打点、出请求结算”的埋点,otelgin.Middleware就是靠这个把 trace 注入c.Request.Context()的。而 zap 的logger.With()支持派生子 logger,正好能在中间件里把 trace_id、request_id 挂上去,往后的业务日志自动带上,不用每个 handler 自己去取。

Prometheus 这边,prom-go 类封装通常做的是:把 registry 暴露成/metrics,预置好 HTTP 侧的 histogram 和 counter,把 path 归一化(否则/user/123、/user/456会各自变成一个 label,直接把时序数打爆)。

三者共享的接入点是同一个中间件链。顺序很关键,常见做法是:

r := gin.New() r.Use(otelgin.Middleware("order-service")) // 1. 先建 trace,后续全都能拿到 ctx r.Use(ZapLogger(logger)) // 2. 日志中间件从 ctx 取 trace_id r.Use(PromMiddleware()) // 3. 指标记录状态码与耗时 r.Use(gin.Recovery())

顺序反了会出现「日志里有 trace_id 字段但永远是空」这种让人抓狂的情况,后面避坑章会细说。

2.3 依赖与初始化顺序:别在 init 里抢跑

// main.go import ( "context" "github.com/gin-gonic/gin" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.24.0" "go.uber.org/zap" ) func initTracer(ctx context.Context) (func(context.Context) error, error) { // 常见的 OTLP/HTTP 上报方式,endpoint 从环境变量读,别写死 exp, err := otlptracehttp.New(ctx) if err != nil { return nil, err } res, _ := resource.New(ctx, resource.WithAttributes( semconv.ServiceName("order-service"), semconv.ServiceVersion("v1.4.2"), ), ) tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exp), sdktrace.WithResource(res), sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.2))), // 20% 采样 ) otel.SetTracerProvider(tp) return tp.Shutdown, nil } func main() { logger, _ := zap.NewProduction() defer logger.Sync() ctx := context.Background() shutdown, err := initTracer(ctx) if err != nil { logger.Fatal("init tracer failed", zap.Error(err)) } defer shutdown(ctx) // 关键:必须优雅关闭,否则最后一批 span 会丢 r := gin.New() // ... 中间件注册见上一节 _ = r.Run(":8080") }

几个参数需要解释一下。TraceIDRatioBased(0.2)是头部采样,20% 的请求进入链路记录,适合流量大但预算有限的场景;如果是要“错误请求必须被采”,得换成尾部采样,那通常得在 collector 侧做,不在进程内。WithBatcher是异步批量上报,默认 5 秒或 512 条触发,进程退出不调Shutdown就会丢尾巴,这就是很多人说“链路里缺最后几个请求”的真实原因。

ServiceName这种 resource 属性一定要填,它是链路系统里区分服务的唯一依据,不填就全叫unknown_service。

3. 把三件套接进 Gin:中间件、ctx 传递与 path 归一化

这一章是能直接抄的部分。核心就三块:otelgin 怎么让 trace 贯穿、zap 怎么带 trace_id、Prometheus 怎么不把时序打爆。

3.1 otelgin 中间件与 ctx 向下传递

import "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" r.Use(otelgin.Middleware("order-service"))

这行下去之后,Gin 的*gin.Context里就有了一个已注入 span 的Request.Context()。但注意,Gin 的c.Request.Context()是 request 级别的,你在 handler 里如果起了 goroutine 或者调了下游,必须显式把这个 ctx 传下去,否则新开的调用就是新的 root span,链路断在这里。

func GetOrder(c *gin.Context) { ctx := c.Request.Context() // 起 goroutine 时一定要带上 ctx,不然链路断 go func(ctx context.Context) { _, span := otel.Tracer("order").Start(ctx, "async.audit") defer span.End() // ... }(ctx) // 调下游 HTTP 时用 otelhttp 包一层 client req, _ := http.NewRequestWithContext(ctx, "GET", "http://inventory/stock", nil) resp, err := tracedClient.Do(req) if err != nil { c.JSON(502, gin.H{"err": err.Error()}) return } defer resp.Body.Close() }

tracedClient的构造方式是用otelhttp.NewTransport(http.DefaultTransport)包一层,它会自动从 ctx 里取 span 上下文并创建子 span。不包这一层,你就只能看到一个孤零零的 Gin span,下游耗时全是黑洞。

3.2 zap 派生 logger,把 trace_id 挂进每一行日志

func ZapLogger(base *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() ctx := c.Request.Context() // 从 ctx 里取当前 span 的 trace_id sc := trace.SpanContextFromContext(ctx) fields := []zap.Field{ zap.String("path", c.FullPath()), // 用 FullPath,别用 c.Request.URL.Path zap.String("method", c.Request.Method), } if sc.IsValid() { fields = append(fields, zap.String("trace_id", sc.TraceID().String()), zap.String("span_id", sc.SpanID().String()), ) } // 派生带字段的 logger,塞回 ctx l := base.With(fields...) c.Request = c.Request.WithContext(context.WithValue(ctx, loggerKey{}, l)) c.Next() l.Info("request done", zap.Int("status", c.Writer.Status()), zap.Duration("cost", time.Since(start)), zap.String("client_ip", c.ClientIP()), ) } }

逻辑说明:base.With()产生一个新 logger 而不是改原对象,这是 zap 的不可变设计,也是并发安全的前提。把派生 logger 放进 ctx,业务层用loggerFromCtx(ctx)取,就能保证一次请求内所有日志自动带 trace_id。

参数上要注意c.FullPath()而不是c.Request.URL.Path。前者返回路由模板/order/:id,后者返回真实路径/order/123,用后者会让日志字段基数失控,也没法和指标 label 对上。

提示:c.Writer.Status()在 handler 已经写过响应头后才是真实状态码;中间件里c.Next()之前读它永远是 200。

3.3 Prometheus 指标:histogram 分桶与 path 归一化

var ( httpDuration = prometheus.NewHistogramVec( prometheus.HistogramOpts{ Namespace: "app", Subsystem: "http", Name: "request_duration_seconds", Help: "HTTP request latency", // 分桶按业务 SLA 定,别照抄默认值 Buckets: []float64{0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5}, }, []string{"method", "path", "status"}, ) ) func PromMiddleware() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() path := c.FullPath() if path == "" { path = "unmatched" // 404 走这里,防止 label 爆炸 } httpDuration.WithLabelValues( c.Request.Method, path, strconv.Itoa(c.Writer.Status()), ).Observe(time.Since(start).Seconds()) } }

参数上最值得说的是Buckets。默认桶是秒级的线性分布,对你的 10ms 接口毫无分辨率。按自己的 SLA 定桶,比如 P99 目标是 300ms,就在 0.1、0.3 附近加密,否则算出来的histogram_quantile误差能大到没法用告警。

status用字符串化的整数,会导致 label 组合数=方法数×路由数×状态码数,路由多了之后时序数会膨胀得很快。如果路由规模大,常见做法是只保留2xx/3xx/4xx/5xx这种粗粒度。

/metrics端点别忘了注册,且要排除在自身指标统计之外,否则自监控噪声很大:

r.GET("/metrics", gin.WrapH(promhttp.Handler()))

4. 避坑清单:这五个坑我都在真实项目里见过

4.1 日志里 trace_id 永远是空

现象:日志字段里有trace_id,但值全是空串。原因是中间件顺序,ZapLogger注册在otelgin.Middleware之前,此时 ctx 里还没 span,SpanContextFromContext返回 invalid。

解决:otelgin必须第一个注册。另外要检查是不是在 handler 里用c.Request.Context()拿到了 ctx 又自己context.Background()新建了,那样也会断。

4.2 指标时序数暴涨,Prometheus 内存扛不住

现象:prometheus_tsdb_head_series一路涨,查询开始超时。原因基本是把真实 URL 当 label,用户 ID、订单号进了 path 字段。

解决:统一用c.FullPath(),404 时落成unmatched。存量数据没法救,改了之后老时序会自动过期,别慌。

4.3 链路里最后几条 span 总是缺

现象:压测时链路条数比请求数少一截,尤其进程重启前后。原因是BatchSpanProcessor有缓冲,进程退出没调Shutdown,缓冲里的 span 直接丢。

解决:把tp.Shutdown(ctx)挂到defer里,并且交给信号处理,收到SIGTERM后先 Stop server 再 Shutdown tracer。logger.Sync()同理,很多人写了defer logger.Sync()但没检查返回值,实际在容器里 stdout 场景下会返回invalid argument,属于正常现象。

4.4 日志量把磁盘打满

现象:上线三天磁盘告警。原因是NewProduction是 JSON 输出到 stderr,加上每个请求两条日志(进入+结束),QPS 一高就是灾难。

解决:请求级日志只打一条(在c.Next()之后),进入日志交给 trace 的 span 去表达;access log 和业务日志分两个 logger,级别分开控制;容器环境走 stdout 交给采集侧,别自己写轮转文件。

4.5 采样率设成 1.0 把 collector 打死

现象:全量采样上线后,链路后端写入超时,业务线程被阻塞。原因是TraceIDRatioBased(1.0)且导出是同步或队列太小。

解决:采样率按流量定,一般 0.05~0.2 起步;WithBatcher加上WithMaxQueueSize和WithExportTimeout显式控制;高价值错误链路用尾部采样兜住。

5. 进阶:用 span 属性反推慢接口,以及一个小验证脚本

三件套接上之后,很多人的用法停在“看板能出图”。真正省时间的是用 span 属性去反查。比如给关键 span 加上业务维度:

func QueryOrder(c *gin.Context) { ctx := c.Request.Context() ctx, span := otel.Tracer("order").Start(ctx, "db.query_order") defer span.End() orderID := c.Param("id") span.SetAttributes( attribute.String("db.system", "mysql"), attribute.String("db.operation", "SELECT"), // 注意:不要把订单号这种高基数值打成 attribute 又导出到指标 attribute.Bool("cache.hit", false), ) // ... }

加属性时有个边界要守住:span attribute 可以带请求级信息,因为它跟着单条链路走,采样后成本可控;但同样的字段绝对不能进 Prometheus label。这两者的基数容忍度差好几个数量级,混用是灾难的起点。

再给一个我常用的验证手法,上线前确认三件套真的串起来了:发一个带自定义头的请求,然后依次查指标、查日志、查链路,看 trace_id 是否一致。

# 1. 发一个请求,手动带 traceparent curl -s -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \ http://localhost:8080/api/v1/order/123 # 2. 指标有没有 curl -s http://localhost:8080/metrics | grep app_http_request_duration # 3. 日志里 trace_id 是否等于上面那个 4bf92f35... # (容器环境用 docker logs / kubectl logs 看)

traceparent的格式是版本-trace_id-span_id-标志位,手工构造一个固定 trace_id 发过去,如果日志里打出来的 trace_id 和它一致,说明 W3C 上下文传播这一环是通的;如果不一致,说明 otelgin 没生效或者被你的网关剥掉了头。这个检查我一般放在联调第一天做,比事后在链路系统里翻半天高效得多。

从那以后我每次接可观测性,都强制先跑一遍这个带固定 traceparent 的 curl,再看一眼/metrics里有没有unmatched这种异常 label,两分钟内就能确认接入是不是真的成了,而不是“看起来有数据”。希望帮到你。

本文还有配套的精品资源,点击获取