可观测性(Observability)领域的事实标准。这篇讲清:OTel 到底解决什么问题、三大信号(Trace / Metric / Log)是什么、整体架构(API·SDK·Collector·OTLP)怎么串、W3C 跨服务传递标准,以及——重点——Go 生态里怎么手动接入、go-zero 又是怎么「开箱即用」地内置了它。
先把最关键的说在前面。
OpenTelemetry(简称 OTel)是一套「与厂商无关」的可观测性标准与工具集,由 CNCF 托管(已是毕业项目)。它统一了追踪(Trace)、指标(Metric)、日志(Log)三类遥测数据的采集方式、数据格式、导出协议,让你可以「写一份埋点代码,数据想发到 Jaeger / Tempo / Prometheus / 阿里云 SLS / 任意厂商后端都行」,不再被某家 APM 厂商锁定。
在 OTel 之前,可观测性是「各自为政」的。
早年做链路追踪,要么用 Zipkin 的 SDK、要么用 Jaeger 的客户端、要么用某云厂商的私有 SDK。一旦选定,埋点代码和厂商强绑定——哪天想换后端,全量重写 instrumentation。更糟的是指标、日志、追踪三家各一套 SDK、各一套格式,数据对不齐。
它只管「采集 + 导出」这一段(Instrumentation + Collector)。数据的存储与展示(Jaeger / Tempo / Prometheus / Grafana / 商业 APM)由后端负责。所以它不和你现有的监控栈冲突,而是「把数据喂给它们」的中间层。
OTel 把可观测性拆成三类信号,分别回答不同问题。
Metric 看趋势 → Trace 定位问题请求 → Log 看细节。OTel 的精髓是让三者通过 trace_id / span_id / Resource 关联起来,形成闭环,而不是三张互不相干的表。
理解这几个名词,看代码才不会懵。
spanId,用 parentSpanId 指向上游,拼成树。context.Context 携带「当前活跃 span」。新 span 从 ctx 里取父,再返回带子 span 的新 ctx——这就是跨函数、跨 RPC 传递的机制。order_id=123)与时间点事件,用于过滤和钻取。这是 OTel 最关键的一张图,看清「数据从哪来到哪去」。
关键认知:你的应用「只和 API + SDK 打交道」,数据往哪发由 Exporter / Collector 决定。所以业务代码写一次,后端随便换——这正是 OTel 对抗厂商锁定的核心。
「链路不断」的底层密码——正是 go-zero 那篇讲的东西的标准版。
跨服务传递 trace 上下文的标准 HTTP 头,长这样:traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01。四段分别是:版本 - traceId - parentSpanId - traceFlags(是否采样)。只要所有服务都认识这个头,不管什么语言、什么框架,链路都能串起来。
TraceContext(即 W3C)。zrpc 拦截器做的事,本质就是「OTel 的 Propagator 把 TraceContext 注入 gRPC metadata.MD(或 HTTP 的 traceparent 头),对端再抽出」。go-zero 帮你自动做了,你不必手写 Propagator;但在 Gin / 标准库项目里,这一步需要你(或库)手动接上 otelhttp 等 instrumentation。在 Gin / net/http 等没内置 OTel 的服务里,要自己初始化 SDK。
在 main.go 里装配 Exporter + Resource + TracerProvider,并设为全局。这是「初始化写在哪」那篇强调的集中装配原则。
package main import ( "context" "log" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" ) func newTracerProvider(ctx context.Context) (*sdktrace.TracerProvider, error) { // 1) Exporter:数据发到 OTLP 端点(如 Collector :4317) exp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithEndpoint("localhost:4317"), otlptracegrpc.WithInsecure(), // 仅开发用,生产走 TLS ) if err != nil { return nil, err } // 2) Resource:服务名等元信息,所有 span 都带 r, err := resource.Merge(resource.Default(), resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceName("my-go-service"), semconv.ServiceVersion("1.0.0"), )) if err != nil { return nil, err } // 3) TracerProvider:批处理 + 资源属性 tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exp), // 批量导出,降开销 sdktrace.WithResource(r), sdktrace.WithSampler(sdktrace.AlwaysSample()), // 生产按需采样 ) return tp, nil } func main() { ctx := context.Background() tp, err := newTracerProvider(ctx) if err != nil { log.Fatalf("otel init: %v", err) } defer func() { _ = tp.Shutdown(ctx) }() // 优雅退出,别丢数据 otel.SetTracerProvider(tp) // 设为全局 otel.SetTextMapPropagator(propagation.TraceContext{}) // W3C 传播 // ... 启动你的 HTTP / gRPC 服务 }
用全局 Tracer 起 span,务必把返回的 ctx 往下传,否则链路断开(和 go-zero 的 ctx 铁律一致)。
import "go.opentelemetry.io/otel" import "go.opentelemetry.io/otel/attribute" func (s *svc) Deduct(ctx context.Context, orderID string) error { // 从全局 provider 拿 tracer,起一个 span tracer := otel.Tracer("my-go-service/billing") ctx, span := tracer.Start(ctx, "billing:deduct") defer span.End() // 结束才会被导出 span.SetAttributes(attribute.String("order_id", orderID)) // 打业务标签 // 调下游时继续传 ctx —— 链路自动接上 return s.payClient.Pay(ctx, orderID) }
社区 contrib/instrumentation 提供现成的中间件 / 拦截器,自动给每个请求起 span、自动注入/抽取 W3C 头。这是 Gin 项目接 OTel 最省力的一招。
// HTTP(gin / net/http)接入 otelhttp import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" handler := otelhttp.NewHandler(yourMux, "my-service") http.ListenAndServe(":8080", handler) // 每个请求自动带 trace // 作为客户端调别的服务时,用 otelhttp.Transport 包裹 client := &http.Client{ Transport: otelhttp.NewTransport(http.DefaultTransport) } // 发出去的请求会自动带 traceparent 头,对端能接上 // gRPC 服务端/客户端接入 otelgrpc import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" // server: grpc.NewServer(grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor())) // client: grpc.WithStatsHandler(otelgrpc.NewClientHandler())
main 或依赖装配处;所有下游调用都传 ctx;进程退出前 tp.Shutdown 否则最后一批 span 可能丢失。这三点与 go-zero 的「集中装配 + ctx 第一参数」原则完全同源。记住「API / SDK / 导出器 / 插桩」四类去哪找。
| 包 | 作用 |
|---|---|
go.opentelemetry.io/otel | 核心 API:Tracer / Meter / otel.SetTracerProvider / SpanFromContext。业务代码只依赖它。 |
go.opentelemetry.io/otel/sdk | SDK 实现:trace / metric / resource / 采样 / 批处理。 |
.../sdk/trace · .../sdk/metric | 具体 Provider 与处理器(WithBatcher / WithResource / WithReader)。 |
.../sdk/resource + semconv | 资源属性与服务名语义约定(semconv.ServiceName 版本化)。 |
.../exporters/otlp/otlptrace/otlptracegrpc | OTLP over gRPC 导出器(生产常用)。 |
.../exporters/otlp/otlptrace/otlptracehttp | OTLP over HTTP 导出器(4318 端口,无 gRPC 依赖时友好)。 |
.../exporters/stdout/stdouttrace | 控制台导出器,开发调试直出 JSON,不用起后端。 |
contrib/instrumentation/.../otelhttp | net/http / Gin 自动插桩中间件 + Transport。 |
contrib/instrumentation/.../otelgrpc | gRPC 服务端/客户端自动插桩拦截器。 |
contrib/exporters/autoexport | 用环境变量(OTEL_TRACES_EXPORTER 等)自动选 Exporter,减小二进制。 |
go get go.opentelemetry.io/otel go.opentelemetry.io/otel/trace go.opentelemetry.io/otel/sdk,再按需加 exporter 与 contrib 插桩包。版本化的 semconv/v1.xx.0 导入路径别写错。重点——go-zero 的 trace 能力底层就是 OpenTelemetry。
go-zero 的 core/trace 包封装了 OTel SDK:rest.MustNewServer / zrpc.MustNewServer 检测到配置里有 Trace 块,就自动 StartAgent(= 建 TracerProvider + Exporter)并挂上 HTTP tracing 中间件 / gRPC tracing 拦截器。你不用手写任何 OTel 初始化,也不用自己装 otel 包。
# user-api.yaml / user-rpc.yaml Name: user-api Host: 0.0.0.0 Port: 8888 Trace: Name: user-api # 本服务在链路中显示的名字 Endpoint: http://jaeger:4318/v1/traces # OTLP HTTP 地址(或 Zipkin) Sampler: 1.0 # 采样率 0~1,1.0=全采 Batcher: otlp # otlp 或 zipkin ServiceName: user-api # 可选,覆盖 Name
internal/config/config.go 里的 rest.RestConf / zrpc.RpcConf 已内嵌 Trace trace.Config,goctl 生成时已带,无需手加字段。只要 Trace 块非空,能力自动开启——典型的「配置即能力」。l.ctx,别写 context.Background()。trace 上下文随 ctx 自动跨 RPC(gRPC metadata)/ 跨 HTTP(traceparent)传递。详见 go-zero traceId 跨 RPC 传递。logx.WithContext(ctx) 打日志,跨服务自动带同一 trace 值;用 trace.TraceIDFromContext(ctx) 可取 traceId 返回给前端。二者底层是同一套 OTel 数据模型与 OTLP 协议,所以你在 Jaeger / Tempo 看到的链路格式完全兼容。区别只在「谁初始化」:go-zero 自动;Gin / 标准库项目手动。如果某服务用 go-zero、某服务用 Gin,只要都导出 OTLP 到同一个 Collector,跨框架链路依然能拼成一条——这就是 OTel 标准的价值。
trace.Start / trace.StartServerSpan,避免重复初始化 TracerProvider 导致双导出。目标一致(可观测),接入成本不同。
| 方案 | 初始化负担 | 跨服务传递 | 适合场景 |
|---|---|---|---|
| go-zero 内置 | 零(配置即开) | 拦截器自动(gRPC MD / HTTP 头) | go-zero 微服务,最快上手 |
| 手动 OTel SDK + contrib 插桩 | 中(main 装配 Provider) | otelhttp / otelgrpc 自动 | Gin / net/http / 混合技术栈 |
| 纯日志拼 traceId | 低(自己 uuid 塞 ctx) | 手动传参 | 小项目、不想引 OTel 依赖 |
| 某厂商私有 SDK | 低 | 厂商自定 | 已深度绑定单一 APM,不打算换 |
这些错误会让「链路看起来没生效」。
| 该做 / 别做 | 说明 |
|---|---|
| ❌ 不传 ctx | 手动起 span 后没把返回的 ctx 往下传,子调用抽不到父,链路断裂。 |
| ❌ 漏 Shutdown | 进程退出前没 tp.Shutdown,最后一批 span 滞留内存丢失。 |
| ❌ 重复初始化 Provider | go-zero 服务里又手动 SetTracerProvider,导致双导出 / 配置打架。优先用框架自带。 |
| ⚠️ 采样率太低 | Sampler < 1 时部分请求无 span,排查不到别慌,按需调高或定向采样。 |
| ⚠️ Propagator 不统一 | 多语言服务混用 W3C 与 B3 会抽不出上下文,统一用 TraceContext。 |
| ⚠️ 端点协议错配 | OTLP gRPC(4317)和 HTTP(4318)端口别写反;Collector 与后端地址别混淆。 |
| ✅ 开发用 stdout 导出器 | 不想起 Jaeger 时,stdouttrace 直出 JSON,最快验证埋点对不对。 |