OpenTelemetry 是什么

可观测性(Observability)领域的事实标准。这篇讲清:OTel 到底解决什么问题、三大信号(Trace / Metric / Log)是什么、整体架构(API·SDK·Collector·OTLP)怎么串、W3C 跨服务传递标准,以及——重点——Go 生态里怎么手动接入、go-zero 又是怎么「开箱即用」地内置了它。

一句话结论 为什么需要 三大信号 核心数据模型 整体架构 跨服务传递标准 Go 手动接入 Go 常用包 go-zero 怎么用 方案对比 踩坑

一句话结论

先把最关键的说在前面。

OpenTelemetry(简称 OTel)是一套「与厂商无关」的可观测性标准与工具集,由 CNCF 托管(已是毕业项目)。它统一了追踪(Trace)、指标(Metric)、日志(Log)三类遥测数据的采集方式、数据格式、导出协议,让你可以「写一份埋点代码,数据想发到 Jaeger / Tempo / Prometheus / 阿里云 SLS / 任意厂商后端都行」,不再被某家 APM 厂商锁定。

对 Go 开发者的现实意义:① go-zero 内置的链路追踪底层就是 OTel(详见 go-zero traceId 跨 RPC 传递 那篇);② 你想在 Gin / 标准库 net/http / 任意自研服务里做可观测性,OTel 是当下最标准、生态最全的选择;③ 它的「采集 API 稳定、导出可插拔」设计,意味着业务代码几乎不随后端切换而改动。

为什么需要 OpenTelemetry

在 OTel 之前,可观测性是「各自为政」的。

被厂商锁定的痛点

早年做链路追踪,要么用 Zipkin 的 SDK、要么用 Jaeger 的客户端、要么用某云厂商的私有 SDK。一旦选定,埋点代码和厂商强绑定——哪天想换后端,全量重写 instrumentation。更糟的是指标、日志、追踪三家各一套 SDK、各一套格式,数据对不齐。

统一采集
一套 API 同时产出 Trace / Metric / Log,不用三套 SDK。
统一格式
所有语言用同一种数据模型(OTLP),跨语言链路能对上。
可插拔导出
采集端不变,换后端只换 Exporter 配置,业务代码零改动。

OTel 在可观测性体系里的位置

它只管「采集 + 导出」这一段(Instrumentation + Collector)。数据的存储与展示(Jaeger / Tempo / Prometheus / Grafana / 商业 APM)由后端负责。所以它不和你现有的监控栈冲突,而是「把数据喂给它们」的中间层。

三大信号:Trace / Metric / Log

OTel 把可观测性拆成三类信号,分别回答不同问题。

① Trace(追踪)
回答「这一次请求经过了哪些服务、每步花了多久、哪步出错」。由一系列有父子关系的 Span 组成,串起分布式调用树。Web / 微服务排障的主力。
② Metric(指标)
回答「系统整体水位如何」:QPS、P99 延迟、错误率、CPU、GC 次数等。聚合后的数值,适合画曲线、配告警。是 Prometheus 那一类。
③ Log(日志)
回答「当时发生了什么细节」。OTel 给日志加了「和哪条 Trace 关联」的能力(通过 trace_id),让日志能从链路图一键钻取到具体记录。

三者的关系

Metric 看趋势 → Trace 定位问题请求 → Log 看细节。OTel 的精髓是让三者通过 trace_id / span_id / Resource 关联起来,形成闭环,而不是三张互不相干的表。

核心数据模型:Trace / Span / Context / Resource / Scope

理解这几个名词,看代码才不会懵。

Trace(链路)
一次完整请求的全局视图。整条共享同一个 traceId,是你在后端 UI 搜索、还原调用树的「主键」。
Span(跨度)
链路里每一段操作(一次 HTTP、一次 RPC、一段逻辑)就是一个 span。自带 spanId,用 parentSpanId 指向上游,拼成树。
Context(上下文)
Go 里用 context.Context 携带「当前活跃 span」。新 span 从 ctx 里取父,再返回带子 span 的新 ctx——这就是跨函数、跨 RPC 传递的机制。
Resource(资源)
描述「产生遥测的实体」:服务名、实例、版本、主机名。所有 span 都带它,后端据此分组(如「按服务名看 P99」)。
Scope(作用域)
产生遥测的库 / 模块标识(库名 + 版本)。区分「框架自动埋的」和「你手动埋的」。
Attribute / Event
span 上的键值标签(如 order_id=123)与时间点事件,用于过滤和钻取。
记住一句:TraceId 决定「是不是同一次请求」,SpanId + ParentSpanId 决定「谁调谁」。go-zero 那篇里 traceId 跨 RPC 不断,本质就是把同一个 TraceContext 塞进 gRPC metadata / HTTP 头传过去。

整体架构:API → SDK → Exporter → Collector → 后端

这是 OTel 最关键的一张图,看清「数据从哪来到哪去」。

你的应用 Gin / go-zero / net/http 服务 OTel API + SDK TracerProvider 生成 Span / Metric OpenTelemetry Collector 接收 / 处理 / 转发 OTLP OTLP Jaeger Tempo Prometheus 商业 APM

四个核心角色

① API (go.opentelemetry.io/otel) 只定义接口:Tracer / Meter / Context 提取。稳定、不动、被 SDK 实现。 你的业务代码只依赖 API,不依赖具体实现。 ② SDK (go.opentelemetry.io/otel/sdk) 实现 API 的「引擎」:TracerProvider 怎么生成 span、采样、批处理、 资源属性、把数据交给 Exporter。真正干活的在这。 ③ Exporter(导出器) 把 SDK 攒的数据发到某处:OTLP(gRPC/HTTP) / 控制台 / Zipkin。 换后端只换 Exporter,埋点代码不动。 ④ Collector(采集器,独立进程) 可选但生产推荐。接收 OTLP,做采样/过滤/富化,再转发给后端。 好处:后端地址变更不动应用;统一处理多语言多服务的数据。

关键认知:你的应用「只和 API + SDK 打交道」,数据往哪发由 Exporter / Collector 决定。所以业务代码写一次,后端随便换——这正是 OTel 对抗厂商锁定的核心。

关键标准:W3C traceparent / Propagator / Baggage

「链路不断」的底层密码——正是 go-zero 那篇讲的东西的标准版。

traceparent(W3C 标准头)

跨服务传递 trace 上下文的标准 HTTP 头,长这样:traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01。四段分别是:版本 - traceId - parentSpanId - traceFlags(是否采样)。只要所有服务都认识这个头,不管什么语言、什么框架,链路都能串起来。

Propagator(传播器)
负责「把 ctx 里的 span 上下文注入到 carrier(HTTP 头 / gRPC metadata)」和「从 carrier 抽取回 ctx」。OTel 默认用 TraceContext(即 W3C)。
Baggage(行李)
比 traceparent 多一层:可携带自定义键值(如 user_id)跨服务透传,供后端做标签/路由。属于可选的增强。
呼应 go-zero:go-zero 的 zrpc 拦截器做的事,本质就是「OTel 的 Propagator 把 TraceContext 注入 gRPC metadata.MD(或 HTTP 的 traceparent 头),对端再抽出」。go-zero 帮你自动做了,你不必手写 Propagator;但在 Gin / 标准库项目里,这一步需要你(或库)手动接上 otelhttp 等 instrumentation。

Go 生态手动接入:从 0 到出第一条 trace

在 Gin / net/http 等没内置 OTel 的服务里,要自己初始化 SDK。

① 初始化 TracerProvider(全局做一次)

在 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 服务
}

② 手动创建 Span(在你关心的业务逻辑上)

用全局 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)
}

③ 自动插桩:HTTP / gRPC 不用手埋

社区 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 第一参数」原则完全同源。

Go 生态里 OpenTelemetry 的常用包

记住「API / SDK / 导出器 / 插桩」四类去哪找。

包作用
go.opentelemetry.io/otel核心 API:Tracer / Meter / otel.SetTracerProvider / SpanFromContext。业务代码只依赖它。
go.opentelemetry.io/otel/sdkSDK 实现:trace / metric / resource / 采样 / 批处理。
.../sdk/trace · .../sdk/metric具体 Provider 与处理器(WithBatcher / WithResource / WithReader)。
.../sdk/resource + semconv资源属性与服务名语义约定(semconv.ServiceName 版本化)。
.../exporters/otlp/otlptrace/otlptracegrpcOTLP over gRPC 导出器(生产常用)。
.../exporters/otlp/otlptrace/otlptracehttpOTLP over HTTP 导出器(4318 端口,无 gRPC 依赖时友好)。
.../exporters/stdout/stdouttrace控制台导出器,开发调试直出 JSON,不用起后端。
contrib/instrumentation/.../otelhttpnet/http / Gin 自动插桩中间件 + Transport。
contrib/instrumentation/.../otelgrpcgRPC 服务端/客户端自动插桩拦截器。
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:内置 OTel,开箱即用

重点——go-zero 的 trace 能力底层就是 OpenTelemetry。

go-zero 的 core/trace 包封装了 OTel SDK:rest.MustNewServer / zrpc.MustNewServer 检测到配置里有 Trace 块,就自动 StartAgent(= 建 TracerProvider + Exporter)并挂上 HTTP tracing 中间件 / gRPC tracing 拦截器。你不用手写任何 OTel 初始化,也不用自己装 otel 包。

① 配置文件开启(etc 目录 yaml)

# 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 块非空,能力自动开启——典型的「配置即能力」。

② 你只做两件事

传好 ctx(铁律)
logic 里调下游 RPC / HTTP 用 l.ctx,别写 context.Background()。trace 上下文随 ctx 自动跨 RPC(gRPC metadata)/ 跨 HTTP(traceparent)传递。详见 go-zero traceId 跨 RPC 传递。
日志带 trace
用 logx.WithContext(ctx) 打日志,跨服务自动带同一 trace 值;用 trace.TraceIDFromContext(ctx) 可取 traceId 返回给前端。

③ go-zero 内置 vs 手动 OTel 的关系

二者底层是同一套 OTel 数据模型与 OTLP 协议,所以你在 Jaeger / Tempo 看到的链路格式完全兼容。区别只在「谁初始化」:go-zero 自动;Gin / 标准库项目手动。如果某服务用 go-zero、某服务用 Gin,只要都导出 OTLP 到同一个 Collector,跨框架链路依然能拼成一条——这就是 OTel 标准的价值。

go-zero 自动用的是它内置的 OTel 封装。如果你想在 go-zero 服务里额外用官方 otel 包自定义 span,需注意两套 provider 不能冲突——优先用 go-zero 提供的 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,不打算换
统一建议:新项目优先 OTel 标准。go-zero 直接吃内置;其它 Go 服务用官方 SDK + contrib 插桩。后端统一接 Collector,未来换厂商零成本。

常见踩坑清单

这些错误会让「链路看起来没生效」。

该做 / 别做说明
❌ 不传 ctx手动起 span 后没把返回的 ctx 往下传,子调用抽不到父,链路断裂。
❌ 漏 Shutdown进程退出前没 tp.Shutdown,最后一批 span 滞留内存丢失。
❌ 重复初始化 Providergo-zero 服务里又手动 SetTracerProvider,导致双导出 / 配置打架。优先用框架自带。
⚠️ 采样率太低Sampler < 1 时部分请求无 span,排查不到别慌,按需调高或定向采样。
⚠️ Propagator 不统一多语言服务混用 W3C 与 B3 会抽不出上下文,统一用 TraceContext。
⚠️ 端点协议错配OTLP gRPC(4317)和 HTTP(4318)端口别写反;Collector 与后端地址别混淆。
✅ 开发用 stdout 导出器不想起 Jaeger 时,stdouttrace 直出 JSON,最快验证埋点对不对。