Gin 可观测性三件套:链路追踪、指标监控与结构化日志
简介:面向 Go 与 Gin 后端开发者的可观测性集成示例包,围绕 porm-go、otelgin 与 zap,演示如何为 Gin 服务补齐指标采集、链路追踪和结构化日志能力,适合需要提升线上服务监控与排障效率的中级开发者参考。压缩包共 14 个文件,约 44KB,含 5 个 Go 源码、4 个 Markdown 说明、2 张效果图及 go.mod/go.sum 配置;代码按 metric、trace、log、observability 与 example 组织,便于按模块查阅。已有 113 人学习下载。读者可了解三类信号的代码组织思路,结合 README 理解 otelgin 中间件与 zap 日志的配合,并将 observability 模块复用到自己的 Gin 项目,作为改造起步模板。
1. 一个 404 请求引出的排查困境:Gin 服务为什么需要可观测性三件套
前阵子排查一个偶发的 502,三台机器上分别 grep 日志,日志里翻不到任何能串起请求的字段,指标面板上只有全局 QPS 和机器 CPU,链路系统里这个服务压根没接入。从出问题到定位,花了四个小时,最后发现是某个下游连接池耗尽。这种低效排查的根源,往往在于服务缺少可观测性,而非代码本身。这份资源就是围绕 Gin 做的一层可观测性接入:prom-go(prometheus/client_golang)负责指标采集与暴露,otelgin 负责 HTTP 请求的 Trace 埋点与上下文传播,zap 负责结构化日志并把 trace_id 写进每条日志。它适合已经用 Gin 写过 API、但还停留在 print 加手工 grep 阶段的团队,也适合想把这三件套一次性接齐、不想在每个新项目里重复造轮子的开发者。
2. 架构与目录:三条数据流如何在一个请求里汇合
先看清结构再动手,否则接完一堆依赖,不知道哪行代码在产生哪条数据,出了问题只能靠猜。这个项目的思路很直白:一个 HTTP 请求进来,在中间件链上被三个彼此独立的组件分别采样一次,最后分别在三个后端落地。
2.1 从目录结构看模块边界
项目把可观测性相关代码集中在一个独立包中,业务 handler 只依赖FromContext一个函数,这个边界划得很关键。
gin-observability/ ├── cmd/ │ └── server/ │ └── main.go # 装配入口:初始化顺序 + 中间件注册顺序 ├── internal/ │ ├── observability/ │ │ ├── tracing.go # TracerProvider / OTLP exporter / propagator │ │ ├── metrics.go # CounterVec / HistogramVec + Gin 指标中间件 │ │ ├── logger.go # zap 初始化、context 存取、trace 字段注入 │ │ └── middleware.go # 访问日志、Recovery 结构化输出 │ ├── handler/ │ │ ├── order.go # 业务示例:演示如何在 handler 内埋点 │ │ └── health.go │ └── config/ │ └── config.go # 从环境变量读 OTLP 地址、服务名、采样率 ├── deploy/ │ ├── docker-compose.yml # Jaeger + Prometheus + Grafana │ ├── prometheus.yml │ └── grafana/dashboards/ ├── go.mod └── Makefile| 目录/文件 | 职责 | 改动频率 |
|---|---|---|
internal/observability/tracing.go | 建 TracerProvider、换全局 propagator、返回 Shutdown | 低,一次成型 |
internal/observability/metrics.go | 定义指标向量、注册到默认 Registry、提供中间件 | 中,业务指标持续加 |
internal/observability/logger.go | zap 实例构建、trace 字段注入、context 存取 | 低 |
internal/handler/* | 业务逻辑,通过 context 拿 logger | 高 |
deploy/ | 本地观测后端的编排 | 低 |
把这三件套放进internal/observability而不是散在 main 里,好处很实际:新起一个服务时把这个包整体复制过去,改一个服务名就能用;同时初始化顺序有了唯一入口,不会出现 A 服务先建 logger 再建 tracer、B 服务反过来的混乱。我一般还会在这个包里放一个Init(ctx, cfg)的总入口,把 tracer、logger 的初始化串起来并统一返回清理函数,避免 main 函数被十几行初始化代码撑得看不清业务。
2.2 技术选型:为什么是 otelgin + client_golang + zap
指标这一层,最容易踩的坑是去用那些早已停止维护的 Gin Prometheus 插件。这类插件通常直接拿c.Request.URL.Path当标签,一条/api/order/10086和/api/order/10087就被当成两个不同的时间序列,跑一天下来 Prometheus 的内存曲线会难看得很。这套资源自己写二十行中间件,用c.FullPath()拿路由模板,标签基数立刻被控制住。相比之下,OpenTelemetry 的 Metrics SDK 也能出指标,但它对 Prometheus 的 exposition 需要额外的 exporter 桥接,对只想快速看到/metrics的团队来说是多余的一跳,prom-go 更直接。
链路这一层选 otelgin 而不是自己包otelhttp,理由是 Gin 有c.FullPath()这样的路由模板信息,otelhttp 拿不到,span name 只能退化成原始 URL,Jaeger 里同一个接口会被打散成几千个操作名。otelgin 内部会优先读 Gin 的路由模板作为 span name,这一点在排查时价值极高。另外 otelgin 自动处理了 context 注入、traceparent头的解析、span 的结束时机,不需要自己在 handler 里手动defer span.End()。
日志这一层选 zap 而不是 logrus 或标准库 slog,主要是三点:结构化字段的 API 天然适合把 trace_id 作为 field 挂上去;With返回子 logger 的开销极低,可以在每个请求上派生一个带字段的实例;生态上zapcore能同时输出 JSON 到文件、Console 到终端,本地开发和生产用同一套代码。slog 在 Go 1.21 之后确实够用,但如果项目里已经有 zap 依赖,混用两套日志库只会让日志格式更难统一。
2.3 一个请求经过中间件链的顺序
Gin 的中间件是洋葱模型,c.Next()之前的代码按注册顺序执行,之后的代码按逆序执行。这个特性决定了三个组件的注册顺序不能随便调。
正确的顺序是:otelgin.Middleware排第一,TraceLogger排第二,GinMetrics排第三,AccessLog排第四。原因在于 otelgin 在进入时创建 span 并把它塞进c.Request.Context(),后面的TraceLogger才能从这个 context 里取出有效的 SpanContext,把 trace_id 和 span_id 写进 logger 并重新放回 context。如果顺序反过来,TraceLogger取到的是一份空的 SpanContext,sc.IsValid()返回 false,日志里 trace_id 字段会静默缺失,这种沉默失败最难查。
执行时的大致时序是这样的:请求进来,otelgin 开 span 并设置http.method、http.route等属性;TraceLogger带出 trace_id,WithLogger把子 logger 挂到请求 context 上;GinMetrics记录开始时间;AccessLog记录开始时间;进业务 handler;返回时AccessLog先打出耗时,GinMetrics再根据c.Writer.Status()和c.FullPath()记指标,最后 otelgin 结束 span 并交给 BatchSpanProcessor 异步导出。
c.FullPath()在c.Next()之后调用是必须的,因为只有在路由匹配完成、handler 链执行完之后它才会返回注册时写下的模板。有同学在中间件开头就调它,拿到空字符串,所有请求被归到同一个标签下,面板上看不出任何区分度,这就是顺序带来的差异。
3. 核心链路拆解:Tracing、Metrics、Logging 三段代码
这一章把三块代码逐段拆开看,每段都给出可以直接抄进项目的版本,并说清每个参数改了会怎样。
3.1 TracerProvider 初始化与 OTLP 导出
初始化是整个链路的基础,写错一次,后面所有 span 都是白忙。
package observability import ( "context" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" ) // InitTracer 建立 TracerProvider 并替换全局 provider / propagator。 // endpoint 形如 "127.0.0.1:4317",是 OTLP gRPC 的接收地址。 func InitTracer(ctx context.Context, endpoint, serviceName, version string, ratio float64) (func(context.Context) error, error) { exp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithEndpoint(endpoint), otlptracegrpc.WithInsecure(), // 本地/内网走明文,公网部署换成 TLS 凭据 otlptracegrpc.WithTimeout(3*time.Second), // 避免后端不可达时初始化卡死 ) if err != nil { return nil, err } // resource 描述"这个进程是谁",服务名和版本是后端筛选的第一维度 res, err := resource.Merge(resource.Default(), resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceName(serviceName), semconv.ServiceVersion(version), )) if err != nil { return nil, err } tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(2*time.Second)), sdktrace.WithResource(res), // 头部采样:根 span 按比例采,子 span 跟随父级结果 sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))), ) otel.SetTracerProvider(tp) // 少了这段,跨服务传过来的 traceparent 头不会被解析,链路直接断在边界上 otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, )) return tp.Shutdown, nil }这段里三个参数值得单独说。WithInsecure()只适合本机和内网,一旦 OTLP collector 走公网,换成otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, "")),否则数据是明文传输。TraceIDRatioBased(ratio)里的 ratio 从环境变量读,本地开发填 1.0 全采,生产按流量填 0.1 到 0.2,采样率过高会让 collector 的磁盘和内存先撑不住。WithBatchTimeout(2*time.Second)决定了 span 从产生到可查的延迟上限,调太大会让排查时"刚发的请求查不到",调太小会增加网络往返次数。
返回值是一个Shutdown函数,这个函数必须被调用,而且要在进程退出之前调用完成。它负责把 Batcher 里还没发出去的 span 刷干净。很多项目在 main 里写defer tp.Shutdown(ctx)然后紧接着os.Exit(0),或者用log.Fatal提前退出,defer 根本不执行,最后一批 span 永远丢掉,表现就是"服务重启前后的链路缺失"。
3.2 Metrics 中间件:用 FullPath 控制标签基数
指标这块的代码量不大,但每一个标签的选择都在决定 Prometheus 未来的稳定性。
package observability import ( "strconv" "time" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus" ) var ( httpRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ Namespace: "demo", Subsystem: "http", Name: "requests_total", Help: "按路由模板、方法、状态码统计的请求总数", }, []string{"method", "route", "status"}) httpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: "demo", Subsystem: "http", Name: "request_duration_seconds", Help: "HTTP 请求耗时分布(秒)", // 分桶边界要贴合业务:多数接口在 50ms 内,少量慢接口到 2s Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}, }, []string{"method", "route", "status"}) ) func init() { // 注册到默认 Registry,promhttp.Handler() 才能抓到 prometheus.MustRegister(httpRequestsTotal, httpDuration) } // GinMetrics 返回记录 RED 指标的中间件,skip 里放需要排除的路由模板 func GinMetrics(skip map[string]struct{}) gin.HandlerFunc { return func(c *gin.Context) { // /metrics 自身不记录,否则每次抓取都写一条指标,形成自激噪声 if _, ok := skip[c.FullPath()]; ok { c.Next() return } start := time.Now() c.Next() // FullPath 返回注册时的模板,例如 /api/order/:id,而不是 /api/order/10086 route := c.FullPath() if route == "" { // 404/405 统一归并到一个标签,避免任意路径把序列数撑爆 route = "unmatched" } status := strconv.Itoa(c.Writer.Status()) httpRequestsTotal.WithLabelValues(c.Request.Method, route, status).Inc() httpDuration.WithLabelValues(c.Request.Method, route, status). Observe(time.Since(start).Seconds()) } }Buckets是最容易照抄默认值的地方。默认桶在 5 秒以上只有一档,如果你的接口 P99 在 800ms,histogram_quantile算出来的分位数会非常粗糙,因为所有样本都落在 1s 到 2.5s 之间的那个桶里,插值结果几乎没有参考价值。分桶要按真实分布设计,一般让目标 P99 落在中间偏右的位置,桶的数量控制在 10 到 15 个之间,太多会放大存储开销。
Namespace和Subsystem拼成的demo_http_requests_total是最终的指标名。如果团队有多个服务,建议 Namespace 用业务域而不是服务名,Subsystem 用服务名,这样在 Grafana 里可以用{__name__=~"demo_.*"}做统一面板,再靠 job 标签区分实例。
3.3 Zap 与 TraceID 绑定:让日志和链路对上号
日志和链路最大的价值在于能互相跳转:看到一条报错日志,能拿到 trace_id 直接跳到 Jaeger;在链路上发现某个 span 很慢,能查出它对应的是哪条日志。
package observability import ( "context" "github.com/gin-gonic/gin" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" ) type ctxKey struct{} // WithLogger 把派生的 logger 存进 context,供下游 handler 取用 func WithLogger(ctx context.Context, l *zap.Logger) context.Context { return context.WithValue(ctx, ctxKey{}, l) } // FromContext 取 context 里的 logger,取不到就退回传入的兜底实例 func FromContext(ctx context.Context, fallback *zap.Logger) *zap.Logger { if l, ok := ctx.Value(ctxKey{}).(*zap.Logger); ok && l != nil { return l } return fallback } // TraceLogger 必须在 otelgin 之后注册,才能取到有效的 SpanContext func TraceLogger(base *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { l := base sc := trace.SpanContextFromContext(c.Request.Context()) if sc.IsValid() { l = base.With( zap.String("trace_id", sc.TraceID().String()), zap.String("span_id", sc.SpanID().String()), ) } // 重新写回请求 context,handler 里 Through FromContext 就能拿到带字段的 logger c.Request = c.Request.WithContext(WithLogger(c.Request.Context(), l)) c.Next() } }访问日志可以合并成一个独立中间件,统一输出结构化 JSON,直接对接现有的日志采集。
// AccessLog 输出结构化访问日志,字段名与采集端的索引保持一致 func AccessLog(base *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() l := FromContext(c.Request.Context(), base) l.Info("http_request", zap.String("method", c.Request.Method), zap.String("route", c.FullPath()), zap.String("path", c.Request.URL.Path), zap.Int("status", c.Writer.Status()), zap.Duration("cost", time.Since(start)), zap.String("client_ip", c.ClientIP()), zap.String("error", c.Errors.ByType(gin.ErrorTypePrivate).String()), ) } }业务 handler 里取 logger 的方式很轻:
func CreateOrder(base *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { l := observability.FromContext(c.Request.Context(), base) l.Info("order_create_start", zap.String("sku", c.Query("sku"))) // ... 业务逻辑 } }注意c.Errors里塞进来的错误串如果不做截断,某些 ORM 会把整条 SQL 连参数一起写进日志,既撑大日志体积,也可能把敏感字段带出去。生产环境我一般会对 error 字段做长度上限,超过 512 字节就截断并标记。
3.4 组装中间件顺序与 /metrics 暴露
main 里的装配顺序是前面讲的顺序的落地,同时要注意 Shutdown 的时机。
func main() { cfg := config.Load() // 从环境变量读 SERVICE_NAME / OTLP_ENDPOINT / TRACE_RATIO logger, _ := zap.NewProduction(zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel)) defer logger.Sync() shutdown, err := observability.InitTracer( context.Background(), cfg.OTLPEndpoint, cfg.ServiceName, cfg.Version, cfg.TraceRatio) if err != nil { logger.Fatal("init tracer failed", zap.Error(err)) } gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.Use(otelgin.Middleware(cfg.ServiceName)) // 1. 开 span r.Use(observability.TraceLogger(logger)) // 2. 注入 trace 字段 r.Use(observability.GinMetrics(map[string]struct{}{"/metrics": {}})) // 3. 记指标 r.Use(observability.AccessLog(logger)) // 4. 打访问日志 r.GET("/metrics", gin.WrapH(promhttp.Handler())) // 用 WrapH 适配标准 http.Handler r.GET("/api/order/:id", handler.GetOrder(logger)) r.POST("/api/order", handler.CreateOrder(logger)) srv := &http.Server{Addr: ":8080", Handler: r} go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Fatal("listen failed", zap.Error(err)) } }() // 收到退出信号后先停服务,再 flush span quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() _ = srv.Shutdown(ctx) if err := shutdown(ctx); err != nil { logger.Error("shutdown tracer", zap.Error(err)) } }gin.WrapH(promhttp.Handler())这一行比自写一个返回文本的 handler 更稳,因为它正确处理了 gzip 压缩、Content-Type和并发抓取。shutdown(ctx)放在srv.Shutdown之后,是为了让还在处理中的请求先把 span 结束掉,再统一 flush,顺序反了会把正在执行的请求的 span 切成两半。
4. 跑起来:本地起服务与观测后端
代码能编译只是第一步,真正的验证是打开 Jaeger 能看到链路、打开 Prometheus 能查到指标、日志里 trace_id 能对上。这一章给出完整的启动路径。
4.1 依赖与启动命令
Go 版本建议 1.21 以上,几个核心依赖在 go.mod 里大致是这些:
go get github.com/gin-gonic/gin@latest go get github.com/prometheus/client_golang/prometheus go get github.com/prometheus/client_golang/prometheus/promhttp go get go.uber.org/zap@latest go get go.opentelemetry.io/otel@latest go get go.opentelemetry.io/otel/sdk@latest go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@latest go get go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin@latest这里有个版本约束:otelgin属于 contrib 仓库,它的版本号和go.opentelemetry.io/otel主库不是同步的,拉最新 contrib 之后一定要go mod tidy让 MVS 帮你选一个兼容的主库版本。手动指定主库版本时,如果 contrib 依赖的 API 在新主库里被改过签名,编译会直接失败,报一堆 undefined method。
配置用环境变量传入,避免不同环境改代码:
export SERVICE_NAME=gin-observability-demo export SERVICE_VERSION=0.1.0 export OTLP_ENDPOINT=127.0.0.1:4317 export TRACE_RATIO=1.0 # 本地全采,生产调低 go run ./cmd/server4.2 docker-compose 拉起 Prometheus、Jaeger、Grafana
本地不需要真搭一套后端,三个容器够用。Jaeger 的 all-in-one 镜像同时监听 16686(UI)和 4317(OTLP gRPC)。
services: jaeger: image: jaegertracing/all-in-one:1.57 environment: - COLLECTOR_OTLP_ENABLED=true # 开启 OTLP 接收,1.35 之后需要显式打开 ports: - "16686:16686" # Jaeger UI - "4317:4317" # OTLP gRPC prometheus: image: prom/prometheus:v2.53.0 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro ports: - "9090:9090" grafana: image: grafana/grafana:11.1.0 ports: - "3000:3000" environment: - GF_AUTH_ANONYMOUS_ENABLED=trueCOLLECTOR_OTLP_ENABLED=true这一行漏了,服务端会连不上 4317 端口,但错误信息藏在 Jaeger 容器日志里,客户端只看到一个连接被拒,很容易误判成自己的 exporter 写错了。
Prometheus 的抓取配置指向宿主机上的服务:
global: scrape_interval: 15s scrape_configs: - job_name: gin-api metrics_path: /metrics static_configs: - targets: ["host.docker.internal:8080"]Linux 环境下host.docker.internal默认不解析,需要在 compose 的 prometheus 服务上加extra_hosts: ["host.docker.internal:host-gateway"],或者在容器网络里用宿主机的实际网关地址。
4.3 验证:造流量后三处查结果
服务起来后先打几发请求,让指标和链路有数据:
# 正常请求 curl -s http://localhost:8080/api/order/10086 # 制造 404,验证 unmatched 标签的归并 curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/api/not-exist # 直接看指标文本 curl -s http://localhost:8080/metrics | grep demo_http_requests_total期望看到类似demo_http_requests_total{method="GET",route="/api/order/:id",status="200"} 1的行,路由是模板而不是具体 ID,说明标签控制生效了。
接着访问http://localhost:16686,在 Service 下拉里应该能看到gin-observability-demo,点开任意一条 trace,可以看到 server span 的http.route属性是/api/order/:id,耗时和请求对得上。Prometheus 那边在http://localhost:9090的 Graph 页输入指标名能查到序列,Targets 页面状态是 UP。
三处都对上之后,才算真正接完。任何一处缺失,都要回到前面查中间件的注册顺序。
4.4 用 PromQL 和 trace 反查慢请求
指标适合回答"整体怎么样",链路适合回答"这一条为什么慢"。两者配合的典型用法是先看 P95 找异常接口,再去链路里捞具体请求。
# 按路由模板统计 5 分钟窗口的 P95 耗时 histogram_quantile(0.95, sum by (le, route) ( rate(demo_http_request_duration_seconds_bucket[5m]) ) ) # 错误率:5xx 占比 sum(rate(demo_http_requests_total{status=~"5.."}[5m])) / sum(rate(demo_http_requests_total[5m]))第一条查询依赖le标签,sum by (le, route)不能写成sum by (route),否则分位数算不出来,返回 NaN。这是刚接触 Prometheus 时最容易翻车的一处。拿到 P95 偏高的 route 之后,在 Jaeger 里按 operation 过滤,看这条链路上的子 span 谁占了大部分时间,下游调用的网络耗时和自身处理耗时一眼就能分开。
5. 避坑与排查:五个把可观测性做成安慰剂的现场
接完不等于好用,下面是实际项目里反复出现的五个问题,每一个都会让三件套看起来在工作、实际查不出东西。
5.1 指标序列爆炸,Prometheus 先扛不住
现象:服务上线一天,Prometheus 内存从 2G 涨到 12G,/api/v1/series接口超时,抓取间隔内报 "out of bounds"。
原因:中间件用c.Request.URL.Path或c.Request.RequestURI当标签,任何带路径参数的接口都会生成无穷多序列;更隐蔽的是把user_id、order_id这类业务字段加进标签,单接口就能产生几十万条。
解决:标签只允许来自有限集合,路由用c.FullPath()模板,未匹配的路由统一映射成unmatched。上线前用curl /metrics | grep -c '^demo_'看一眼序列数量,超过几千就该停下来重新设计标签。业务维度的统计不要进 Prometheus,交给日志或链路。
5.2 链路在网关处断成两截
现象:Jaeger 里只有本服务的 span,上游网关或另一个服务的调用完全不出现,看起来像是"链路没生效"。
原因:没有设置全局 propagator,或者设置了但只设了TraceContext而实际调用方用的是 B3 格式;还有一种情况是自研网关没有透传traceparent请求头。
解决:在InitTracer里用CompositeTextMapPropagator同时支持多种格式,并在网关侧确认traceparent、tracestate头被原样转发。排查时可以临时在入口中间件里把c.Request.Header.Get("traceparent")打出来,为空基本就能确定是上游没传。
5.3 日志里 trace_id 字段是空的
现象:访问日志正常输出,但trace_id字段要么不存在,要么是00000000000000000000000000000000。
原因:TraceLogger注册在otelgin.Middleware之前,取 context 时 span 还没建;或者 span 虽然建了但采样率为 0,未采样的 span 其 SpanContext 虽然有效但 TraceID 全零,IsValid()判断为 false。
解决:调整中间件顺序让 otelgin 在最前;判断采样时不要只看IsValid(),还要检查sc.TraceID().IsValid()。本地开发把采样率设为 1.0 再测,排除采样造成的干扰。
5.4 启动直接 panic:conflicting Schema URL
现象:resource.Merge返回错误,日志里是conflicting Schema URL: https://opentelemetry.io/schemas/1.21.0 and .../1.24.0,进程起不来。
原因:代码里 import 了semconv/v1.26.0,但依赖树里另一个库(比如某个 contrib 包)引用了semconv/v1.21.0,两个版本的SchemaURL常量不一致,合并时冲突。
解决:统一 semconv 版本,不要在一个项目里同时 import 两个 semconv 包;如果必须混用,就不要把semconv.SchemaURL传给NewWithAttributes,只传属性,让 resource 不参与 schema 校验。排查用go mod graph | grep semconv看是哪个依赖带进来的。
5.5 面板查不到数据:语义约定字段名对不上
现象:接入后 Grafana 面板全空,但 Jaeger 里明明有 trace。
原因:OpenTelemetry 的 HTTP 语义约定在某个版本之后把http.method改成了http.request.method、http.status_code改成http.response.status_code,面板里的查询还是旧字段名,自然一条都匹配不上。
解决:先确认当前使用的 semconv 版本,再按版本改面板查询;或者干脆在面板里用http.method和http.request.method的or组合兼容一段时间。切换期我一般会在 observability 包里加一层属性映射函数,把 span 属性统一成内部约定的名字,避免面板跟着上游版本反复改。
6. 二次开发:从能看走向能用,三处具体改造
第一处改造是业务指标。上面只有 HTTP 层的 RED 指标,真正定位问题往往需要业务信号,比如下单失败数、缓存命中率。做法是在 handler 里注入*prometheus.CounterVec,而不是用全局变量——全局变量在测试里会重复注册 panic。命名上按业务域_动作_结果组织,例如order_create_failed_total{reason="stock"},reason 的取值必须枚举化,不能把 error 字符串直接塞进去,否则又回到序列爆炸的老路。
第二处是日志与链路的进一步融合。手写zap.String("trace_id", ...)能用,但字段名要全团队约定;更省事的做法是接otelzap桥接,让 zap 自动带上 span 上下文,同时用span.AddEvent把关键业务节点打进去,比如库存扣减开始和结束,这样在 Jaeger 的时间轴上能直接看到业务分段耗时,不用在日志里拼时间戳。
第三处是采样策略。ParentBased + TraceIDRatioBased是头部采样,请求一进来就决定了采不采,一旦这个请求后来报错,而它恰好没被采样,最需要的那条链路就没了。生产上我一般会换成尾部采样的思路:本地先用AlwaysSample记录,用一个缓冲的 SpanProcessor 暂存,请求结束发现status=error或耗时超过阈值就保留,否则丢掉。OpenTelemetry 的 Go SDK 没有开箱的尾部采样器,需要自己实现Sampler或在 collector 侧配置,成本不低,但错误链路的保留率能上一个台阶。
验证改造是否生效,我习惯用三个动作过一遍:curl一发错误请求,Jaeger 里能搜到且带 error 状态;/metrics里业务指标 +1;日志里 trace_id 和 Jaeger 里那一条完全一致。三步全对,才算改完。
说个血泪教训:有次图快,把TraceLogger挪到了otelgin前面想"反正都是挂字段",结果日志里 trace_id 静默消失,排查了两天才发现是顺序问题。从那以后我每次改中间件注册顺序,都强制用一发 curl 加 Jaeger 对照验证,不看代码只看结果。希望帮到你。
本文还有配套的精品资源,点击获取