Skip to content
Go 客户端埋点:OpenTelemetry Go SDK

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

两个容易被忽略的点:

  1. SpanLimits 是静默的。加到第 129 个属性不会报错、不会告警,就是没了。“我的属性怎么少了"这类问题先查这里(sdk@v1.47.0/trace/span_limits.go:9-35 实测默认值)。
  2. 队列满丢 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.Headers Extract 之后再 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 没数据怎么查

按顺序,每一步都能在前文找到对应源码事实:

  1. span 有没有产生——把批处理器临时换成 stdout 导出器(stdouttrace.New(stdouttrace.WithPrettyPrint()) 后 WithBatcher(stdoutExp)),stderr 立刻打印 span JSON。有输出说明埋点没问题,问题在导出链路;没输出说明 span 根本没建(检查 tracer.Start、检查全局 TracerProvider 是否在 Start 之前设置)。
  2. span 有没有发完——批处理器只导出已结束的 span:忘 End() 等于没埋;进程退出忘 Shutdown(),缓冲区里最多 5 秒的 span 全部丢失。trace 缺尾巴先查这两处。
  3. 连没连上——本地 all-in-one 明文 gRPC 必须 WithInsecure();确认 4317 端口映射存在;HTTP 接入则是 4318 且 otlptracehttp 默认路径 /v1/traces。
  4. 链路断不断——跨进程断链且各服务单独都有数据:十有八九是没调 SetTextMapPropagator(no-op 陷阱)。
  5. 采没采到——默认 ParentBased(AlwaysSample) 全采;若配了 OTEL_TRACES_SAMPLER=traceidratio 或代码比例采样,先临时拉到 1.0 复现。
  6. 名字对不对——服务列表有没有 unknown_service:;ES 后端查大写服务名。
  7. 属性全不全——超 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 时的兼容项,新部署不要主动选它。

实战建议

按踩坑成本从大到小排序,动手写埋点时记住这几条:

  1. initTracer 抄本章模板,三件事一件不能少:SetTextMapPropagator(no-op 默认是最阴的坑)、WithResource 带 service.name(否则 unknown_service)、defer 里 Shutdown(否则缺尾巴)。这三行是 Go 埋点区别于其他语言自动配置的地方,没人帮你兜底。
  2. 接入顺序:框架插桩 → 手动 span → 传播器验证。gin/gRPC/HTTP 用 otelgin/otelgrpc/otelhttp 全自动覆盖 80% 的 span;业务关键路径(风控、扣库存)再手动 tracer.Start;起一条链路后先在 UI 确认父子关系正确,比事后修断链便宜十倍。
  3. otelgrpc 只认 stats.Handler。老教程的四个 interceptor 写法在 v0.63.0+ 已删除,见 code 就换 NewClientHandler/NewServerHandler。
  4. 边缘服务设 WithPublicEndpoint,内部服务不设。前者防外部 traceparent 污染,后者保正常父子链。
  5. 采样比例统一"入口决定":所有服务 ParentBased(TraceIDRatioBased(r)) 或干脆只给入口配、下游 OTEL_TRACES_SAMPLER=parentbased_traceidratio,绝不裸用各服务独立 ratio(半采半丢、trace 碎成渣)——详见 06-采样.md。
  6. 噪声路由用 WithFilter 挡掉,健康检查不进 trace;错误和慢请求靠 collector tail 补采,head 比例采样天然对它们不友好。
  7. RecordError 配 SetStatus 一起用。记了事件不标状态,UI 里错误 trace 不红;只标状态不记事件,丢了 exception.type/message 的检索抓手。
  8. 版本整体钉死:otel 全家 v1.47.0、instrumentation 全家 v0.72.0,升级一起升;Go 模块路径带版本号,别混 v1.46 的 sdk 配 v1.47 的 otel。