Go 客户端埋点:OpenTelemetry Go SDK
先把本章最重要的一句话放在开头:给 Go 服务接 Jaeger v2,正确姿势是使用 OpenTelemetry(OTel)Go SDK,而不是 Jaeger 自家的旧客户端库。Jaeger v2 把 collector 内嵌进了同一个进程,OTLP(gRPC 4317 / HTTP 4318)是一等公民接入协议;客户端侧的 tracer、传播器、批处理器全部由 OTel SDK 提供。本章按"初始化 → 各协议埋点 → 错误与状态 → 排障 → 迁移"的顺序,给出可直接抄改的完整代码。所有 API 签名与默认值均对照 OTel Go v1.47.0 模块源码逐行核实(2026-10 实测),关键示例经本地 go1.27.0 编译验证。
为什么接 Jaeger v2 就是接 OTel
2021 年之前,Go 服务接 Jaeger 的标准做法是 github.com/uber/jaeger-client-go。这条路现在已经被官方封死:
jaeger-client-go仓库已 archived,README 原文标注 “This library is DEPRECATED”,并敦促所有用户迁移到 OpenTelemetry;最后一个 release 停在 v2.30.0(2021-12-07),最后一次 push 是 2023-04-24。配套工具库jaeger-lib同样 archived。- Jaeger 官方 FAQ 的立场非常直接:“Use OpenTelemetry SDKs directly”——不要找"Jaeger 版 SDK",Jaeger 团队自己就是这么做的。
- Jaeger v2 内嵌 collector,不再需要 1.x 时代独立部署的 jaeger-agent(那是 UDP 6831 接收 thrift 的旧架构)。v2.22 仍保留 6831/6832(legacy thrift)和 9411(Zipkin)端口,但官方文档把它们标注为 “Stable, Deprecated”——存量兼容用,新接入一律走 OTLP。端口口径详见
02-v2架构与组件.md。
所以对 Go 服务而言,埋点栈分四层,各层职责互不重叠:
- 产生 span:
otel.Tracer(...).Start(ctx, ...)或各框架的 contrib 插桩(otelgin / otelhttp / otelgrpc); - 组装与导出:OTel SDK 的 TracerProvider + BatchSpanProcessor + Resource + Sampler,决定"采不采、怎么攒批、发给谁";
- 跨进程传播:W3C TextMapPropagator(
traceparent/baggage头),负责把 span context 搬过网络边界; - 传输:OTLP exporter(gRPC 4317 / HTTP 4318)直连 Jaeger v2 内嵌的 collector(见
03-部署与Kubernetes.md)。
安装与版本基线
主线版本(2026-10 经 go list -m @latest 实测):
# 核心 API 与 SDK:全部 v1.47.0
go get go.opentelemetry.io/otel@v1.47.0
go get go.opentelemetry.io/otel/sdk@v1.47.0
go get go.opentelemetry.io/otel/trace@v1.47.0
# OTLP 导出链路(v1.47.0)
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace@v1.47.0
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.47.0
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.47.0
# 调试用 stdout 导出器(v1.47.0)
go get go.opentelemetry.io/otel/exporters/stdout/stdouttrace@v1.47.0
# contrib 框架插桩:v0.72.0(版本号与核心不同步,别写错)
go get go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin@v0.72.0
go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp@v0.72.0
go get go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc@v0.72.0
# 兼容旧系统的传播器:v1.47.0
go get go.opentelemetry.io/contrib/propagators/b3@v1.47.0
go get go.opentelemetry.io/contrib/propagators/jaeger@v1.47.0注意 otel v1.47.0 的 go.mod 要求 Go 1.26.0 及以上。老项目升不动 Go 工具链时,需要把 OTel 各模块一起钉在更低的 v1.x 版本上,不能只升一部分——otel/sdk 与 exporters 版本错配会在编译期报接口不匹配。
最小完整可运行程序
下面是一个完整的 main.go:初始化 TracerProvider(Resource、Batcher、采样器)、设置全局传播器、打一个父子 span、退出前冲刷。经本地编译验证。
package main
import (
"context"
"fmt"
"log"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
sdkresource "go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
"go.opentelemetry.io/otel/trace"
)
func initTracer(ctx context.Context) (func(context.Context) error, error) {
// 1) OTLP gRPC 导出器。otlptracegrpc 默认端点 localhost:4317;
// 本地 Jaeger all-in-one 是明文 gRPC,必须 WithInsecure,否则 TLS 握手失败。
exp, err := otlptrace.New(ctx, otlptracegrpc.NewClient(
otlptracegrpc.WithEndpoint("localhost:4317"),
otlptracegrpc.WithInsecure(),
))
if err != nil {
return nil, fmt.Errorf("creating OTLP exporter: %w", err)
}
// 2) Resource:描述"产生遥测的实体",随每个 ResourceSpans 只发一次。
res, err := sdkresource.Merge(
sdkresource.Default(),
sdkresource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("order-svc"), // v1.43.0 提供 ServiceName() 函数
attribute.String("deployment.environment", "prod"),
),
)
if err != nil {
return nil, fmt.Errorf("building resource: %w", err)
}
// 3) TracerProvider:批处理器 + Resource + 采样器。
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(res),
// 入口服务自定义比例时必须套 ParentBased,原因见下文"采样"一节。
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))),
)
otel.SetTracerProvider(tp)
// 4) 全局传播器——不调这一步,跨进程链路全部静默失效(默认是 no-op)!
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, // W3C traceparent/tracestate
propagation.Baggage{}, // W3C baggage
))
return tp.Shutdown, nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
shutdown, err := initTracer(ctx)
if err != nil {
log.Fatal(err)
}
// 进程退出前必须 Shutdown 冲刷;给它一个独立的短超时 ctx,避免退出卡死。
defer func() {
sctx, scancel := context.WithTimeout(context.Background(), time.Second)
defer scancel()
if err := shutdown(sctx); err != nil {
log.Println("otel shutdown:", err)
}
}()
tracer := otel.Tracer("order/handler")
cctx, span := tracer.Start(ctx, "place-order",
trace.WithAttributes(attribute.String("order.id", "A-1")))
doWork(cctx)
span.End() // 别忘了 End:批处理器只导出"已结束"的 span。
fmt.Println("done")
}
func doWork(ctx context.Context) {
tracer := otel.Tracer("order/db")
_, span := tracer.Start(ctx, "query-inventory")
defer span.End()
time.Sleep(10 * time.Millisecond)
}注意 全局传播器默认是 no-op(
otel@v1.47.0/internal/global/propagator.go:未 Set 时委托给空 composite)。这是 Go SDK 手工初始化与"环境变量自动配置"(operator/autoconfigure 下OTEL_PROPAGATORS默认tracecontext,baggage)的关键差异:别人家的服务不写SetTextMapPropagator也能传播,你的 Go 服务不写就提取不到traceparent,上游 span 和下游 span 各自成为孤立的根 trace——UI 上"每个服务都有数据但链路是断的",八成是这个原因。
service.name 的两种地道写法
Resource 的 service.name 决定你的服务在 Jaeger UI 服务列表里叫什么。上面代码里用 semconv.ServiceName() 硬编码;官方 getting-started 示例现在更倾向用环境变量注入,让同一个二进制在不同部署环境有不同服务名:
export OTEL_SERVICE_NAME=order-svc
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=prod,service.version=1.8.2"两种方式可以共存:sdktrace.WithResource(res) 在源码里自动与 resource.Environment() 合并(sdk@v1.47.0/trace/provider.go:393),env 值优先。地道做法是代码里写服务名、env 里补充环境维度,或全部交给部署侧 env——两种都别混着重复定义同一个 key 还不清楚谁赢。
跑通验证
# 1) 起 Jaeger v2.22 all-in-one(OTLP 4317/4318 + UI 16686)
docker run --rm -d --name jaeger \
-p 16686:16686 -p 4317:4317 -p 4318:4318 \
cr.jaegertracing.io/jaegertracing/jaeger:2.22.0
# 2) 跑上面的程序
go run .
# 3) 打开 http://localhost:16686,服务列表选 order-svc,Run Query看不到数据先别翻代码,按本章末尾"排障清单"的顺序查。
默认值速查(全部源码实测)
| 项 | 默认值 | 覆盖方式 | 备注 |
|---|---|---|---|
| BSP MaxQueueSize | 2048 | sdktrace.WithMaxQueueSize(n) / OTEL_BSP_MAX_QUEUE_SIZE |
队列满默认丢弃 span;sdktrace.WithBlocking() 改为阻塞 |
| BSP BatchTimeout | 5000ms | sdktrace.WithBatchTimeout(d) / OTEL_BSP_SCHEDULE_DELAY |
攒批最长等待 |
| BSP ExportTimeout | 30000ms | sdktrace.WithExportTimeout(d) / OTEL_BSP_EXPORT_TIMEOUT |
单次导出超时 |
| BSP MaxExportBatchSize | 512 | sdktrace.WithMaxExportBatchSize(n) / OTEL_BSP_MAX_EXPORT_BATCH_SIZE |
单批最大条数 |
| Sampler | ParentBased(AlwaysSample) |
sdktrace.WithSampler()(代码优先于 env)/ OTEL_TRACES_SAMPLER |
env 支持 always_on/always_off/traceidratio/parentbased_* 六种取值 |
| SpanLimits | 属性数/事件数/链接数各 128,单事件、单链接属性数各 128;属性值长度不限(-1)、深度 64 | sdktrace.WithSpanLimits() / OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT 等 |
超限静默丢弃 |
| 全局传播器 | no-op | otel.SetTextMapPropagator() |
见上方注意块 |
| OTLP gRPC 端点 | localhost:4317 |
otlptracegrpc.WithEndpoint() / OTEL_EXPORTER_OTLP_ENDPOINT(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT 优先级更高) |
HTTP 版默认 4318,路径 /v1/traces |
两个容易被忽略的点:
- SpanLimits 是静默的。加到第 129 个属性不会报错、不会告警,就是没了。“我的属性怎么少了"这类问题先查这里(
sdk@v1.47.0/trace/span_limits.go:9-35实测默认值)。 - 队列满丢 span 也是静默的。高吞吐服务如果把
WithBlocking()打开换背压,要评估导出失败时拖慢业务 goroutine 的代价;一般宁丢 span 不拖请求,保持默认。
采样器的选择与比例制定策略不在本章展开,见 06-采样.md。
gin HTTP 服务:otelgin
gin 是最常见的接入场景。otelgin.Middleware(service, opts...) 负责入站提取上下文并创建 server span。完整可运行程序(编译验证过):
package main
import (
"net/http"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
func main() {
r := gin.Default()
r.Use(otelgin.Middleware("order-svc",
// 噪声路由直接不建 span:健康检查、/metrics 这类高频调用
// 会把 trace 列表刷满毫无信息量的 GET /healthz。
otelgin.WithGinFilter(func(c *gin.Context) bool {
return c.Request.URL.Path != "/healthz" && c.Request.URL.Path != "/metrics"
}),
))
r.GET("/orders/:id", func(c *gin.Context) {
// 中间件已把 server span 放进 c.Request.Context(),
// 注意:取 span 用 trace.SpanFromContext,不存在 otel.SpanFromContext。
span := trace.SpanFromContext(c.Request.Context())
span.SetAttributes(attribute.String("order.id", c.Param("id")))
c.JSON(http.StatusOK, gin.H{"id": c.Param("id")})
})
r.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
_ = r.Run(":8080")
}值得知道的细节(otelgin@v0.72.0 源码实测):
- span 名默认是
METHOD + c.FullPath(),即路由模板(GET /orders/:id)而不是真实 URL。这是防高基数设计——用真实 path 当 span 名会让/orders/1、/orders/2… 各变成一个"操作名”,UI 聚合直接爆炸。 - 未匹配到路由的请求,span 名是
HTTP GET route not found这类占位格式(gin.go:104),扫描器乱敲 URL 时不会污染你的正常操作名。 - 入站提取用全局传播器(
gin.go:47),WithPropagators(p)可为该中间件单独覆盖——多数时候你应该修全局设置而不是在这里打补丁。 - 其他常用选项:
WithFilter(按*http.Request过滤)、WithSpanNameFormatter自定义命名、WithMeterProvider开启自带 server 请求时长直方图指标(指标侧联动见08-可观测性联动.md)。
出站 HTTP:otelhttp 与手写注入
调用下游服务最常见的是给 http.Client 换 Transport——每个出站请求自动创建 client span 并注入 traceparent:
package main
import (
"context"
"fmt"
"io"
"net/http"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func main() {
client := &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
req, _ := http.NewRequestWithContext(context.Background(),
http.MethodGet, "http://inventory-svc:8080/stock", nil)
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}otelhttp.NewTransport(base) 默认用全局传播器注入(otelhttp@v0.72.0/config.go 实测),WithTracerProvider / WithMeterProvider / WithPropagators 可按实例覆盖。给别人裸写的 http.Handler 套服务端埋点,用 otelhttp.NewHandler(handler, "操作名")——gin 场景不需要它,但老项目里散落的 http.HandleFunc 用它最省事。
不想引入 Transport 的场合(比如你封装的 SDK 只暴露 *http.Request),手写注入就三行:
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))propagation.HeaderCarrier 是把 http.Header 适配成 carrier 的薄包装;Inject 写 traceparent/tracestate/baggage,Extract 反向。任何能读写键值头的载体都有对应实现(MapCarrier 给 MQ 用,见下一节)。
gRPC:从 interceptor 到 stats.Handler(旧教程最大断代点)
注意 网上几乎所有 otelgrpc 教程写的
grpc.UnaryClientInterceptor(otelgrpc.UnaryClientInterceptor())四件套(Unary/Stream × Client/Server)已经不存在了:v0.58.0 起标记 Deprecated,v0.63.0 彻底移除。当前 v0.72.0 只有stats.Handler一种形态:otelgrpc.NewClientHandler()/otelgrpc.NewServerHandler()。老写法照抄会直接编译失败。
客户端(注意用 grpc.NewClient,grpc.Dial 在新 grpc-go 里已弃用):
package main
import (
"log"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func dialQuery() (*grpc.ClientConn, error) {
return grpc.NewClient("dns:///query-svc:9000",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
}
func main() {
if _, err := dialQuery(); err != nil {
log.Fatal(err)
}
}服务端:
package main
import (
"log"
"net"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
)
func main() {
s := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler(
otelgrpc.WithPublicEndpoint(), // 公网直连的边缘服务才需要,见下
)),
)
lis, err := net.Listen("tcp", ":9000")
if err != nil {
log.Fatal(err)
}
log.Fatal(s.Serve(lis))
}常用选项:WithFilter、WithPropagators、WithTracerProvider / WithMeterProvider、WithMessageEvents(请求/响应消息事件开关)、WithSpanAttributes、WithMetricAttributesFn。
WithPublicEndpoint 的语义(otelgrpc@v0.72.0/config.go:107 注释实测):不设置时,入站请求若带 remote span context,会建 child span 挂进对方 trace;设置后,改为建根 span + link 指向对方。被互联网直接打到入口的服务应当设置——否则外部随手伪造或乱发的 traceparent 会把无关 span 拼进你的 trace,或者把你的 trace 拉进陌生 trace。内网服务间调用保持默认(child 关联)即可。otelhttp 侧有同名选项,道理一致。
消息队列:Kafka / RabbitMQ 的手动传播
先给结论(franz-go v1.22.2 的 go.mod grep 实测):franz-go 自身不依赖 go.opentelemetry.io/*,它内部的 OTel 结构只是自家 OTLP 指标编码器,不产生 trace span。所以 Kafka 埋点没有现成插桩可开,要手动把上下文塞进消息头。原理与 HTTP 完全一致,只是载体换成 propagation.MapCarrier:
package main
import (
"context"
"log"
"github.com/twmb/franz-go/pkg/kgo"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)
// 生产端:在"业务处理"的 ctx 里建 producer span,注入消息头。
func produce(ctx context.Context, client *kgo.Client, topic, key, payload string) error {
tracer := otel.Tracer("order/producer")
cctx, span := tracer.Start(ctx, "send "+topic,
trace.WithSpanKind(trace.SpanKindProducer))
defer span.End()
carrier := propagation.MapCarrier{}
otel.GetTextMapPropagator().Inject(cctx, carrier)
headers := make([]kgo.RecordHeader, 0, len(carrier))
for k, v := range carrier {
headers = append(headers, kgo.RecordHeader{Key: k, Value: []byte(v)})
}
rec := &kgo.Record{Topic: topic, Key: []byte(key), Value: []byte(payload), Headers: headers}
if err := client.ProduceSync(cctx, rec).FirstErr(); err != nil {
span.RecordError(err)
return err
}
return nil
}
// 消费端:从消息头 Extract 出上下文,再建 consumer span 作为远程父级的 child。
func consume(ctx context.Context, rec *kgo.Record) {
carrier := propagation.MapCarrier{}
for _, h := range rec.Headers {
carrier[h.Key] = string(h.Value)
}
pctx := otel.GetTextMapPropagator().Extract(ctx, carrier)
tracer := otel.Tracer("order/consumer")
_, span := tracer.Start(pctx, "process "+rec.Topic,
trace.WithSpanKind(trace.SpanKindConsumer))
defer span.End()
span.SetAttributes(
attribute.String("messaging.kafka.message.key", string(rec.Key)),
attribute.Int64("messaging.kafka.source.offset", rec.Offset),
)
// ... 这里做真正的业务处理 ...
}
func main() {
cl, err := kgo.NewClient(kgo.SeedBrokers("localhost:9092"))
if err != nil {
log.Fatal(err)
}
defer cl.Close()
// 业务代码里:
// _ = produce(ctx, cl, "orders", "A-1", payload)
// for _, rec := range cl.PollFetches(ctx).Records() { consume(ctx, rec) }
}要点:
- 消费端必须从
rec.HeadersExtract 之后再tracer.Start,这样 consumer span 的父级是生产端 span,一次业务的 trace 才能横跨异步边界。忘了 Extract 的话,消费侧每个批次都是孤立根 trace。 - span 命名和属性遵守 semconv messaging 约定(
otel@v1.47.0/semconv/v1.43.0含messagingconv子包):操作名send {topic}/process {topic},属性messaging.system、messaging.destination.name等;Jaeger UI 的 span 检索对这些 key 有现成用法。 - RabbitMQ 同理:AMQP 0-9.1 的
amqp091.Message.Headers(amqp.Table键值对)或 AMQP 1.0 的 application-properties 转成MapCarrier即可,收发模式本仓库rabbitmq/手册有详述,这里不再展开。 - MQ 消费天然重试、重复投递,trace 会多次出现同一业务的片段——配合幂等设计看
08-可观测性联动.md。
baggage:跨进程的业务标签
propagation.Baggage{} 传播的是 W3C baggage 头——一个随 ctx 流动的键值包,适合塞租户 ID、灰度标记这类沿途都可能用到的业务维度。它与 span attribute 的分工要拎清:baggage 不会自动变成 span attributes,它只是"搭顺风车的数据";attribute 属于当前 span、进存储、可检索。
package main
import (
"context"
"fmt"
"log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/baggage"
"go.opentelemetry.io/otel/trace"
)
// 入口服务:设置 baggage,随后随传播器注入自动带给下游。
// 注意 baggage.New 返回 (Baggage, error)——没有 MustNew 这个函数,别凭直觉写。
func withTenant(ctx context.Context, tenant string) (context.Context, error) {
m, err := baggage.NewMemberRaw("tenant.id", tenant)
if err != nil {
return ctx, fmt.Errorf("build baggage member: %w", err)
}
b, err := baggage.New(m)
if err != nil {
return ctx, fmt.Errorf("build baggage: %w", err)
}
return baggage.ContextWithBaggage(ctx, b), nil
}
// 中间任一服务:读取,并按需转成当前 span 的属性(这才进 Jaeger 可检索字段)。
func tagTenant(ctx context.Context) {
m := baggage.FromContext(ctx).Member("tenant.id")
if m.Value() != "" {
trace.SpanFromContext(ctx).SetAttributes(attribute.String("tenant.id", m.Value()))
}
}
func main() {
ctx, err := withTenant(context.Background(), "acme")
if err != nil {
log.Fatal(err)
}
tagTenant(ctx)
}顺带一提:OTel 自动配置里 OTEL_PROPAGATORS 默认就是 tracecontext,baggage;纯 Go SDK 手工初始化时两个都得你自己 Set(见前文 no-op 注意块)。baggage 是明文头且全链路广播,不要往里塞敏感数据。
状态、错误与 panic:v1.47.0 的行为要点
SetStatus 只表达失败。OTel 语义里 status 默认 Unset(Jaeger UI 里看就是普通 span),只有确认业务失败才 span.SetStatus(codes.Error, "描述")。SDK 有条降级保护规则:已设 Error 后不允许再被 SetStatus 覆盖成 Ok(sdk@v1.47.0/trace/span.go:220 实测)——所以别指望"后面成功了就把前面的错抹掉"。
RecordError 是事件,不是状态。span.RecordError(err) 会加一个名为 exception 的 event,默认带两个属性:exception.type(完整包路径.类型名,源码用 reflect 拿 PkgPath)和 exception.message;它不改 status,SDK 注释明确要调用方另行 SetStatus。exception.stacktrace 默认不带,需要显式开:
package main
import (
"context"
"errors"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func charge(ctx context.Context) error {
return errors.New("sku-9 out of stock")
}
func placeOrder(ctx context.Context) error {
ctx, span := otel.Tracer("order/handler").Start(ctx, "place-order")
defer span.End()
if err := charge(ctx); err != nil {
// 惯用三件套:记事件 + 标失败 + 原样上抛。
span.RecordError(err, trace.WithStackTrace(true))
span.SetStatus(codes.Error, "charge failed")
return fmt.Errorf("place order: %w", err)
}
return nil
}
func main() {
fmt.Println(placeOrder(context.Background()))
}panic 自动记录(v1.47.0 新增,多数旧手册未覆盖):TracerProvider 默认在 span End() 时若检测到进程正在 panic,会自动 recover、把 panic 记为 exception 事件、再重新抛出——panic 不会因此被吞掉(sdk@v1.47.0/trace/span.go End 路径与 provider.go:376 实测)。想关掉这个行为用 sdktrace.WithoutPanicRecording()。对没套 recovery 中间件、崩溃前正好在 span 内的场景,这意味着 Jaeger 里至少能看到崩在哪一步的 exception;配合各框架的 recovery 中间件(gin 的 gin.Recovery() 里给当前 span SetStatus(Error))就是完整的失败画像。
service.name 与"找不到自己的服务"
UI 服务列表出现 unknown_service:order-svc(或 unknown_service:go)——说明你压根没设 service.name。SDK 默认 Resource 的回退值就是 unknown_service: + 可执行文件名(sdk@v1.47.0/resource/builtin.go:95-97 实测),这是"Jaeger 里有数据但找不到我的服务"的头号原因。
顺带解释为什么 service.name 属于 Resource 而不是某个 span 的 attribute:Resource 描述产生遥测的实体,每个导出的 ResourceSpans 只随包发一次;span attribute 每个 span 重复存一份。服务名这种全量恒定的字段放 Resource 既省存储又是 UI 分组依据。
第二个坑专属于 ES 存储后端:Elasticsearch 后端强制小写服务名(官方 troubleshooting 文档原文),服务名里带大写(如 orderSvc)会导致 UI 按原名搜索无结果。解法要么迁到新 Jaeger UI(大小写不敏感),要么把服务名改成全小写加中划线(order-svc)——后者也符合 K8s 命名惯例。用 Cassandra/Badger 后端则无此问题,见 04-存储后端.md。
排障清单:UI 没数据怎么查
按顺序,每一步都能在前文找到对应源码事实:
- span 有没有产生——把批处理器临时换成 stdout 导出器(
stdouttrace.New(stdouttrace.WithPrettyPrint())后WithBatcher(stdoutExp)),stderr 立刻打印 span JSON。有输出说明埋点没问题,问题在导出链路;没输出说明 span 根本没建(检查tracer.Start、检查全局 TracerProvider 是否在Start之前设置)。 - span 有没有发完——批处理器只导出已结束的 span:忘
End()等于没埋;进程退出忘Shutdown(),缓冲区里最多 5 秒的 span 全部丢失。trace 缺尾巴先查这两处。 - 连没连上——本地 all-in-one 明文 gRPC 必须
WithInsecure();确认 4317 端口映射存在;HTTP 接入则是 4318 且otlptracehttp默认路径/v1/traces。 - 链路断不断——跨进程断链且各服务单独都有数据:十有八九是没调
SetTextMapPropagator(no-op 陷阱)。 - 采没采到——默认
ParentBased(AlwaysSample)全采;若配了OTEL_TRACES_SAMPLER=traceidratio或代码比例采样,先临时拉到 1.0 复现。 - 名字对不对——服务列表有没有
unknown_service:;ES 后端查大写服务名。 - 属性全不全——超 128 个属性/事件/链接静默截断,查 SpanLimits。
服务端侧(收到但没入库、查询没结果)的问题见 09-性能与运维.md。
其他语言接入一句话速览
本章主线是 Go,但多语言服务拼链路时口径要一致——全部用各语言 OTel SDK 出 OTLP 到同一个 Jaeger:
- Java:OTel javaagent 零改码接入,opentelemetry-java-instrumentation 最新 v2.32.0;
JAVA_TOOL_OPTIONS="-javaagent:/path/opentelemetry-javaagent.jar"+ 环境变量OTEL_SERVICE_NAME、OTEL_EXPORTER_OTLP_ENDPOINT即可。 - Python:
pip install opentelemetry-distro opentelemetry-exporter-otlp && opentelemetry-bootstrap -a install(自动装常用框架插桩),再opentelemetry-instrument --exporter_otlp_endpoint http://jaeger:4318 python app.py,或全部走 OTEL_* 环境变量。 - Node.js:
@opentelemetry/sdk-node(npm 实测最新 0.223.0)配 OTLP exporter;方向就是 SDK 装 provider、OTEL_EXPORTER_OTLP_ENDPOINT指向 Jaeger,细节以官方文档为准。
传播协议两边都默认 W3C traceparent,跨语言拼链路不需要任何额外适配。
从 jaeger-client-go 迁移对照
存量服务如果还在用 github.com/uber/jaeger-client-go(已 archived),对照迁移:
| 旧(jaeger-client-go) | 新(OTel Go v1.47.0) |
|---|---|
jaeger.New(jaeger.WithAgentEndpoint(...)) 指 UDP 6831 |
otlptracegrpc + OTLP 4317(或 HTTP 4318),v2 不需要独立 agent |
AGENT_HOST/SAMPLING_SERVER 等自定义 env |
OTEL_EXPORTER_OTLP_ENDPOINT、OTEL_SERVICE_NAME、OTEL_TRACES_SAMPLER* |
tracer 来自 tracer.StartSpan(name) |
otel.Tracer("scope").Start(ctx, name),ctx 必须逐层透传 |
uber x-trace-id 头传播 |
W3C traceparent;过渡期可与 contrib/propagators/jaeger(v1.47.0 仍提供)组 composite 双头兼容 |
远端 per-operation 采样策略(*mgr.NewRemoteSamplingStrategy) |
Go SDK 不从后端拉策略:入口用 ParentBased(TraceIDRatioBased(r)),其余服务交给父级决策;错误/慢请求补采交给 collector tail sampling,见 06-采样.md |
两点口径说明:Jaeger v2.22 的 remote_sampling extension(5778/5779)健在,但它服务的是仍说 Jaeger 协议的存量 SDK(Java/C++ 旧客户端等)——Go 侧接 OTel SDK 后不需要也不能用它下发策略,策略归属改为 SDK sampler + collector tail sampling;旧 uber 格式仅是显式配置 propagator 时的兼容项,新部署不要主动选它。
实战建议
按踩坑成本从大到小排序,动手写埋点时记住这几条:
initTracer抄本章模板,三件事一件不能少:SetTextMapPropagator(no-op 默认是最阴的坑)、WithResource带 service.name(否则 unknown_service)、defer 里Shutdown(否则缺尾巴)。这三行是 Go 埋点区别于其他语言自动配置的地方,没人帮你兜底。- 接入顺序:框架插桩 → 手动 span → 传播器验证。gin/gRPC/HTTP 用 otelgin/otelgrpc/otelhttp 全自动覆盖 80% 的 span;业务关键路径(风控、扣库存)再手动
tracer.Start;起一条链路后先在 UI 确认父子关系正确,比事后修断链便宜十倍。 - otelgrpc 只认 stats.Handler。老教程的四个 interceptor 写法在 v0.63.0+ 已删除,见 code 就换
NewClientHandler/NewServerHandler。 - 边缘服务设
WithPublicEndpoint,内部服务不设。前者防外部 traceparent 污染,后者保正常父子链。 - 采样比例统一"入口决定":所有服务
ParentBased(TraceIDRatioBased(r))或干脆只给入口配、下游OTEL_TRACES_SAMPLER=parentbased_traceidratio,绝不裸用各服务独立 ratio(半采半丢、trace 碎成渣)——详见06-采样.md。 - 噪声路由用 WithFilter 挡掉,健康检查不进 trace;错误和慢请求靠 collector tail 补采,head 比例采样天然对它们不友好。
- RecordError 配 SetStatus 一起用。记了事件不标状态,UI 里错误 trace 不红;只标状态不记事件,丢了 exception.type/message 的检索抓手。
- 版本整体钉死:otel 全家 v1.47.0、instrumentation 全家 v0.72.0,升级一起升;Go 模块路径带版本号,别混 v1.46 的 sdk 配 v1.47 的 otel。