引言
在分布式系统中,链路追踪是排查问题、优化性能的核心利器,而 OpenTelemetry 作为可观测性领域的标准,为我们提供了统一的追踪方案。 很多开发者在使用 OpenTelemetry 时,往往只是“复制粘贴代码”,既不清楚底层运行流程,也无法应对后续的定制化需求(比如更换追踪后端、调整采样策略)。更重要的是,OpenTelemetry 的 Go 实现中蕴含了大量经典的 Go 编程思想(面向接口、选项模式、协程安全等),吃透它不仅能搞定链路追踪,更能提升自身的 Go 编程功底。 本篇作为上篇,将聚焦 OpenTelemetry 追踪的核心初始化流程,带着大家逐行拆解初始化源码,搞懂每一个配置、每一个函数的底层逻辑。我们会重点掌握 TracerProvider 的构建、采样策略的实现、追踪后端(Jaeger / Zipkin)的注册等核心内容,同时记住关键结构体和常量的含义,为下篇的实战落地打下坚实基础。
从源码入手,搭建 OpenTelemetry 追踪基础框架
对于这段案例,会带着大家深入源码,理解每一步在做什么,这样大家在用的时候会少一些疑惑,不会用完就忘,清晰了解它的运行流程,更重要的是阅读源码会学到很多编程规范,了解go语言的编程思想。
当看这段代码看不懂没关系,后面主要是对这些代码的展开,但大家要记住的是 Options 结构体是我们赋值的,每个字段希望大家能记住,后续用到的时候大家也可以回过头来看一下代表什么意思,还有我自己定义的这些常量和变量,后续会用到
package trace
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/exporters/zipkin"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.38.0"
"log"
)
type Options struct {
Name string `json:"name"` // 服务名称,会显示在 Jaeger/Zipkin UI 中
Endpoint string `json:"endpoint"` // 收集器地址 如果是 jaeger 我们填的就是http://jaeger:14268/api/traces
Sampler float64 `json:"sampler"` // 采样率,0.0~1.0
Batcher string `json:"batcher"` // 后端类型: "jaeger" 或 "zipkin" 这个字段决定 Endpoint字段填什么
}
// 比如我们的系统现在只支持这两个 你传来的 Batcher 这个字段必须在这里面
const (
kindJaeger = "jaeger"
kindZipkin = "zipkin"
)
// 初始化函数
func InitTracerProvider(o Options) (*trace.TracerProvider,error) {
var sexp trace.SpanExporter
var err error
// 经典 构造选项
opts := []trace.TracerProviderOption{
// 下面先从这个函数介绍 从内到外
trace.WithResource(resource.NewSchemaless(semconv.ServiceNameKey.String(o.Name))),
trace.WithSampler(trace.ParentBased(trace.TraceIDRatioBased(o.Sampler))),
}
if len(o.Endpoint) > 0 {
switch o.Batcher {
case kindJaeger:
sexp, err = jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(o.Endpoint)))
if err != nil {
return nil, err
}
case kindZipkin:
sexp, err = zipkin.New(o.Endpoint)
if err != nil {
return nil, err
}
}
opts = append(opts, trace.WithBatcher(sexp))
}
tp := trace.NewTracerProvider(opts...)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) {
log.Printf("[otel] error: %v", err)
}))
return tp, nil
}
semconv.ServiceNameKey.String(o.Name)) 函数讲解
这个函数本质上就是把全局的 service.name 赋值上,返回的结构体 KeyValue 正是 NewSchemaless() 函数需要的
const ServiceNameKey = attribute.Key("service.name")
// 再 attribute 这个包下定义的 key
type Key string
// 有一系列方法 有int string bool 等等
func (k Key) String(v string) KeyValue {
return KeyValue{
Key: k,
Value: StringValue(v),
}
}
// 返回的结构体
type KeyValue struct {
Key Key
Value Value
}
// 其中的字段
type Value struct {
vtype Type
numeric uint64
stringly string
slice any
}
NewSchemaless() 函数
在 UI 首页,选择对应的服务,选中后,找到具体的追踪链路,这里主要设置这条链路属于哪个服务名的
就类似你先选择哪个数据库系统类型(mySQL、NoSQL)再去建表
选择哪个数据库系统类型 就是我们这里设置的服务名,建表就是设置 TracerID 和 SpanID
func NewSchemaless(attrs ...attribute.KeyValue) *Resource {
if len(attrs) == 0 {
return &Resource{}
}
/*
NewSetWithFiltered 去重、验证合法性并且重排 按照字符串 a - z 重排 重复只要最后一个 声明这里的
示例:a=1, ""=2, b=INVALID, a=3, c=4, a=6 这里简单写了其实 a 就是 KeyValue 结构体的 key 1 就是结构体的 value 做比喻, 先排序 [ ""=2, a=1, a=3, b=INVALID, c=4 ] 再去重(last-value-wins)[ ""=2, a=3, b=INVALID, c=4 ] 最后过滤(kv.Valid) [ a=3, c=4 ] 重点是 返回类型:Set 一个结构体 如果不封装成 Set,而是直接返回 []KeyValue 切片,会存在一个致命问题:切片是引用类型,外部拿到后可以随意修改(追加、删除、修改元素),很容易破坏唯一、合法的约束。
*/
s, _ := attribute.NewSetWithFiltered(attrs, func(kv attribute.KeyValue) bool {
return kv.Valid() // 过滤逻辑放在下个函数
})
if s.Len() == 0 {
return &Resource{}
}
return &Resource{attrs: s}
}
// 过滤逻辑我复制过来了
func (kv KeyValue) Valid() bool {
return kv.Key.Defined() && kv.Value.Type() != INVALID
}
OpenTelemetry 函数式选项模式
这种选项模式是不可变配置型,我之前的文章详细介绍过这种选项模式,大家有时间可以了解一下,主要体现出 Go 的编程思想
// 面向接口编程
type TracerProviderOption interface { //接口
apply(tracerProviderConfig) tracerProviderConfig
}
// 实现了上面接口
type traceProviderOptionFunc func(tracerProviderConfig) tracerProviderConfig
func (fn traceProviderOptionFunc) apply(cfg tracerProviderConfig) tracerProviderConfig {
return fn(cfg) // 调用函数类型本身 因为自己就是一个函数
}
WithResource() 函数
这就是 OpenTelemetry 封装一系列WithXXX命名的工厂函数,使用工厂函数达到赋值的目的
func WithResource(r *resource.Resource) TracerProviderOption {
// 可以理解成同签名函数类型的安全转换 转换的双方是 “同构” 的,没有数据丢失
return traceProviderOptionFunc(func(cfg tracerProviderConfig) tracerProviderConfig {
// apply 方法
var err error
// 拿出 环境变量里面的 Resource 主要针对在 Kubernetes 中运行服务 Merge 就是冲突的用r中的,不冲突就相融
// 在我们这个例子,只传入了一个并且没有环境变量 这个函数相当于不执行还是原来的 r *resource.Resource
cfg.resource, err = resource.Merge(resource.Environment(), r)
if err != nil {
// 错误
otel.Handle(err)
}
return cfg
})
}
核心逻辑图解
Resource A (来自环境变量) Resource B (代码中传入)
┌─────────────────────────┐ ┌─────────────────────────┐
│ service.namespace=shop │ │ service.name=order-svc │
│ deployment.env=prod │ │ │
└─────────────────────────┘ └─────────────────────────┘
│ │
└───────────┬───────────────┘
│
Merge(A, B)
│
▼
┌─────────────────────────┐
│ service.name=order-svc │ ← B 覆盖了 A
│ │
│ deployment.env=prod │ ← 来自 A(B 没有这个 key)
└─────────────────────────┘
trace.TraceIDRatioBased(o.Sampler) 函数
Sampler 是一个接口,便于扩展性,如果不想用基于采样率,或者后续有更好的方法,那只要实现这个接口就不需要更改代码,再一次体现了go语言面向接口编程
func TraceIDRatioBased(fraction float64) Sampler {
if fraction >= 1 {
return AlwaysSample() //采样率 100% 后续不需要判断是否采样
}
if fraction <= 0 {
/*
采样器会继承父 Span 的采样决定,如果父 Span 被采样了,即使子 Span 的采样比例是 0%,
子 Span 也会被采样,避免链路断裂
*/
fraction = 0
}
// 返回用采样的结构体 后面有例子来描述如何采样
return &traceIDRatioSampler{
// 由于那后8个字节对比 最大值为 2的64次方 - 1 这里防止溢出 范围调整 左移63位 也就是乘2的63次方
// 例如0.1 * 2的63次方
traceIDUpperBound: uint64(fraction * (1 << 63)),
description: fmt.Sprintf("TraceIDRatioBased{%g}", fraction), // 描述
}
}
// 上面用的结构体
type traceIDRatioSampler struct {
traceIDUpperBound uint64 // 采样阈值
description string // 描述信息
}
// 重点方法 用于判断是否采样
func (ts traceIDRatioSampler) ShouldSample(p SamplingParameters) SamplingResult {
psc := trace.SpanContextFromContext(p.ParentContext)
// 存储方式是大端 是高位在前 我们要取低8位 因为 TraceID 生成的时候后8位是纯随机数 更适合随机采样
// 左移一位是对齐 (1 << 63) 这样才能进行比较
x := binary.BigEndian.Uint64(p.TraceID[8:16]) >> 1
// 比较逻辑
if x < ts.traceIDUpperBound {
return SamplingResult{
Decision: RecordAndSample,
Tracestate: psc.TraceState(),
}
}
return SamplingResult{
Decision: Drop,
Tracestate: psc.TraceState(),
}
}
比较决策的例子
假设 fraction = 0.1 (10% 采样)
traceIDUpperBound = 0.1 × 2^63 = 922,337,203,685,477,580
x 的取值范围 (右移后): 0 ~ 2^63-1 = 0 ~ 9,223,372,036,854,775,807
数轴表示:
0 2^63-1
|<------ 10% 采样区间 ------>|<----- 90% 丢弃区间 ------->|
0 922,337,203,685,477,580 9,223,372,036,854,775,807
这是后一个请求过来生成一个 TraceID
TraceID = 0xaabbccdd11223344_fedcba9876543210
取出低八位并转成uint用于比较
uint64_val = 0xFEDCBA9876543210 = 18,364,758,544,493,543,952
右移 1 位 对齐逻辑
x = 18,364,758,544,493,543,952 >> 1 = 9,182,379,272,246,771,976
比较
x = 9,182,379,272,246,771,976 (19位)
traceIDUpperBound = 922,337,203,685,477,580 (18位)
正好差了10倍 想要真正取样的话,要比 traceIDUpperBound 这个小 比他小的从概率上就是10%
9,182,379,272,246,771,976 < 922,337,203,685,477,580 ✗ 不成立!
结果: Drop (丢弃)
trace.WithSampler(trace.ParentBased() 两个函数
// 同理 构造函数
func WithSampler(s Sampler) TracerProviderOption {
return traceProviderOptionFunc(func(cfg tracerProviderConfig) tracerProviderConfig {
if s != nil {
cfg.sampler = s
}
return cfg
})
}
// 返回一个 根 Span
func ParentBased(root Sampler, samplers ...ParentBasedSamplerOption) Sampler {
return parentBased{
root: root, // 把刚得到的结构体 traceIDRatioSampler 放上去
config: configureSamplersForParentBased(samplers),
}
}
// 这里了解一下就好
type parentBased struct {
root Sampler
config samplerConfig
}
type samplerConfig struct {
remoteParentSampled Sampler // 远程父 Span 已采样时用
remoteParentNotSampled Sampler // 远程父 Span 未采样时用
localParentSampled Sampler // 本地父 Span 已采样时用
localParentNotSampled Sampler // 本地父 Span 未采样时用
}
创建 Exporter
当执行第一个:创建一个 Jaeger Exporter ,负责把你服务产生的链路追踪数据(Span)打包成 Jaeger 能看懂的格式(Thrift 二进制),然后通过 HTTP 发送到 Jaeger 服务器的地址(比如 http://jaeger:14268/api/traces)。也就是新建一个实例
当执行第二个:创建一个 Zipkin Exporter ,负责把你服务产生的链路追踪数据(Span)打包成 JSON 格式,然后通过 HTTP 发送到 Zipkin 服务器的地址(比如 http://zipkin:9411/api/v2/spans)。也是新建实例
两者功能一样,只是发送的目标和数据格式不同
这里大家不用深究这两个的内部实现,只需要知道是做什么就好
if len(o.Endpoint) > 0 {
switch o.Batcher {
case kindJaeger: // 自己定义的常量
// 这里的 With...(With...) 就是标准的选项构造函数 只不过这个是配置里面增加配置而已
sexp, err = jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(o.Endpoint)))
if err != nil {
return err
}
case kindZipkin:
sexp, err = zipkin.New(o.Endpoint)
if err != nil {
return err
}
}
/*
重点 trace.WithBatcher(sexp) 这个函数内部做两件事
第一件:将 sexp 包装成一个 BatchSpanProcessor 这个的作用就是把产生的 Span 先缓存起来,达到一定数量或时间后再批量发 送,而不是每产生一条就立即发送,从而减少网络开销、提升性能。
第二件:通过 WithSpanProcessor() 函数进行注册,如果刚开始使用 可以直接WithSpanProcessor(sexp) 传入
opentelemetry作为一个标准他并不是真正的执行者,它规定接口,当Span 数据来临时,它会转发给注册的 jaeger 或者 zipkin 等等,如果不去注册,Span 数据无处可去会被直接丢弃,注册也可以注册很多 比如 jaeger 和 zipkin 都注册了,内部是一个切片
直接使用 WithSpanProcessor(jaeger) 在执行一遍 WithSpanProcessor(zipkin),内部逻辑我贴在补充 可以看一下
*/
opts = append(opts, trace.WithBatcher(sexp))
}
// 补充
func WithSpanProcessor(sp SpanProcessor) TracerProviderOption {
return traceProviderOptionFunc(func(cfg tracerProviderConfig) tracerProviderConfig {
cfg.processors = append(cfg.processors, sp)
return cfg
})
}
trace.NewTracerProvider(opts...) 函数
大家如果看过我之前的文章,我相信大家对于这种源码已经非常熟悉了,这是go语言经典的编程思想
func NewTracerProvider(opts ...TracerProviderOption) *TracerProvider {
// 创建一些默认的 这里只有一个默认 spanLimits 就是限制 span内容比如属性值最长 4096 字符,太大截断
// 防止某个 Span 写入过多数据导致内存爆炸或网络传输过大。做一些限制
o := tracerProviderConfig{
spanLimits: NewSpanLimits(),
}
// 了解一下就可以 同样先从环境变量读取 后续再通过传来的进行覆盖 主要应用在 K8s 中
o = applyTracerProviderEnvConfigs(o)
// 读取我们传来的
for _, opt := range opts {
o = opt.apply(o)
}
// 检查配置是否完整,比如 如果没有设置 sampler 取样率就使用默认全取样,还有 TraceID 和 SpanID 的ID生成器没传就使用默认
o = ensureValidTracerProviderConfig(o)
// 创建 TracerProvider 实例
tp := &TracerProvider{
namedTracer: make(map[instrumentation.Scope]*tracer), // 缓存已创建的 Tracer 后续大家会明白
sampler: o.sampler, // 采样器 到时候执行 它内部 ShouldSample 方法决定是否取样 上面已经讲过
idGenerator: o.idGenerator, // ID生成器
spanLimits: o.spanLimits, // 简单理解一些属性的限制
// 服务资源信息,包含 service.name、service.namespace 等标识当前服务身份的元数据
// 这些信息会附加到所有 Span 上,在 Jaege r或 Zipkin UI 中用于筛选和分组
resource: o.resource,
}
global.Info("TracerProvider created", "config", o)
// 由于可以有多个 processor 为了方便使用和关闭 必须要定义一种结构体,不然不知道咋关闭其中一个也不知道是否关闭
// 这个已经放在补充了 本质上就是切片 里面结构体一个是 processor 一个是 是否关闭
spss := make(spanProcessorStates, 0, len(o.processors))
for _, sp := range o.processors {
spss = append(spss, newSpanProcessorState(sp))
}
// 这里TracerProvider 可能被多个 goroutine 并发使用,所以要用原子操作
tp.spanProcessors.Store(&spss)
return tp
}
// 补充
type spanProcessorStates []*spanProcessorState
type spanProcessorState struct {
sp SpanProcessor // 具体的 processor
// 只允许写一次 后续在执行啥都不做 主要用于 Shutdown 防止n个 goroutine 都调用 Shutdown 导致导致重复关闭连接 panic
state sync.Once
}
func newSpanProcessorState(sp SpanProcessor) *spanProcessorState {
return &spanProcessorState{sp: sp} //这是初始化 不能设置 state,传入代表关闭了
}
// 重点逻辑 n 个 goroutine 执行这个 由于 sync.Once 特性 只执行一次 巧妙的 解决了问题
func (s *spanProcessorState) shutdown(ctx context.Context) error {
var err error
s.state.Do(func() {
// 只会执行一次,即使 shutdown 被调用多次
err = s.sp.Shutdown(ctx)
})
return err
}
otel.SetTracerProvider(tp) 核心函数
这个简单来说就是有一个全局的 TracerProvider ,把我们刚才创建的注册到 opentelemetry 上,为什么要去放到上面而不是直接用我们创建的这个呢,大家思考一下,opentelemetry 作为一个规范,第三方库肯定会支持的,比如 gRPC 中间件、 Gorm 数据库链路跟踪等等,它们怎么拿到这个链路呢,要不然链路就断了,所以统一放到一个地方,它们内部只需要调用 otel.Tracer() 这个函数就可以拿到全局的链路,这样就保证链路顺通,如果感觉下面这个代码有点繁琐,只需要记住注册上去就好,
// 解释一下为什么 Set里面套Set 而不是直接执行逻辑,第一就是让使用者一瞬间就知道只是一个注册而已隐藏内部细节
// 第二就是预留扩展性,比如后续来个校验,我可以直接在这层去做,而不去核心逻辑层修改
func SetTracerProvider(tp trace.TracerProvider) {
global.SetTracerProvider(tp)
}
/* 全局变量 初始化一个默认的 可以理解成空
首先第一点必须是线程安全的类型 atomic.Value
其次是内部是存放什么的: 是一个结构体 tracerProviderHolder
tracerProviderHolder struct {
tp trace.TracerProvider //里面是接口 后续用于存放我们刚刚新建的 TracerProvider
}
*/
var globalTracer = defaultTracerValue()
// 真正的内部
func TracerProvider() trace.TracerProvider {
//断言成 tracerProviderHolder 这个类型才能拿到内部tp 静态语言的规范
return globalTracer.Load().(tracerProviderHolder).tp
}
func SetTracerProvider(tp trace.TracerProvider) {
// 函数如上
current := TracerProvider()
//这里主要是防止一件事情 就是我们传入的 tp 就是默认初始化的globalTracer 也就是 current == tp
// 这个时候要报错的,因为后续 会设置委托 自己委托自己,而且委托只有一次 所以会陷入死循环 所以这里要判断一下
if _, cOk := current.(*tracerProvider); cOk {
if _, tpOk := tp.(*tracerProvider); tpOk && current == tp {
// delegate 这个英文是 委托 的意思
Error(
errors.New("no delegate configured in tracer provider"),
"Setting tracer provider to its current value. No delegate will be configured",
)
return
}
}
/*
这里就是设置委托了 为什么要设置委托 因为有些第三方库,如果在我还没有 SetTracerProvider 的时候 它就调用
otel.Tracer() 这个函数 它拿到的 TracerProvider 是默认空的占位符,这个时候并不报错,执行 tracer 空逻辑,
相当于没有记录。等这边 SetTracerProvider 执行之后,那边拿到的依旧是空占位符,这个时候它还是会执行 tracer 这个时候
delegate 这个代理已经有值了,就是下面这个语句存进去了,这个时候他就会转发给存入的这个tp
就相当于直接执行了 TracerProvider.tracer 这里 delegateTraceOnce 是 sync.Once 类型 只写一次
也很好理解 拿到默认占位符的那些第三方库,经过这个执行后都可以正常执行,以后调用的第三方库直接就拿了tp 根本不用走委托
*/
delegateTraceOnce.Do(func() {
if def, ok := current.(*tracerProvider); ok {
def.setDelegate(tp)
}
})
// 存入全局变量 这个时候第三方库和自己用都可以直接拿到 并不需要走委托
globalTracer.Store(tracerProviderHolder{tp: tp})
}
otel.SetTextMapPropagator() 核心函数
上下文传播器,负责在服务间传递追踪信息(trace-id、span-id 等)。如果没有 Propagator,每个服务都会生成新的 trace-id,无法形成完整的调用链。代码逻辑与上面几乎一模一样,看完上面再看这里会比较容易
// 包装层 隐藏内部细节
func SetTextMapPropagator(propagator propagation.TextMapPropagator) {
global.SetTextMapPropagator(propagator)
}
/* 全局变量 同上 初始化一个默认的 可以理解成空
首先第一点必须是线程安全的类型 atomic.Value
其次是内部是存放什么的: 是一个结构体 propagatorsHolder
propagatorsHolder struct {
tm propagation.TextMapPropagator // 这个放 我们放进去的 后面会讲
}
*/
var globalPropagators = defaultPropagatorsValue()
func TextMapPropagator() propagation.TextMapPropagator {
return globalPropagators.Load().(propagatorsHolder).tm
}
// 真正的内部
func SetTextMapPropagator(p propagation.TextMapPropagator) {
current := TextMapPropagator() //去拿默认的
//同样需要对比 避免自己委托自己
if _, cOk := current.(*textMapPropagator); cOk {
if _, pOk := p.(*textMapPropagator); pOk && current == p {
Error(
errors.New("no delegate configured in text map propagator"),
"Setting text map propagator to its current value. No delegate will be configured",
)
return
}
}
/*
这个也是有些第三方库,如果在我还没有 SetTextMapPropagator 的时候,就注入或者提取:p.Inject(spanCtx, header)
p.Extract(savedCtx, c.Request.Header) 这些后续会讲,简单理解:Inject 就是把第一个参数的 Trace 信息注入到第二个
Extract 就是反向,第二个参数的内容提取到第一个参数,其实是返回一个新的 context 以第一个参数为基础的新上下文
比如在 gin 官方支持的中间件中,你先初始化了中间件再注册,它内部是空的占位符,当来信息请求时,他会执行 Extract 提取
这时候就会走到这个代理,由于已经注册了,可以转发到真正的 globalPropagators.Extract
*/
delegateTextMapPropagatorOnce.Do(func() {
if def, ok := current.(*textMapPropagator); ok {
def.SetDelegate(p)
}
})
// 把我们传入的存到 全局变量中
globalPropagators.Store(propagatorsHolder{tm: p})
}
propagation.NewCompositeTextMapPropagator()核心函数
propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) 这个函数主要是传播器组合器,把多个独立的 TextMapPropagator(接口),组合成一个统一的、可批量调用的传播器。
我们这边注册了两个,该组合能够覆盖绝大多数分布式系统的追踪需求,也是最常用的。
首先第一个 propagation.TraceContext{} 主要传递 TracerID 和 SpanID 防止断链 (核心)
第二个 propagation.Baggage{} 主要是传递用户自定义的轻量元数据(称为行李),比如自己想传一些 user-id=123 或者 order-no=ABC789 存放一些自己想要的业务逻辑相关
// 类似构造函数 饭后这个组合器 接收参数为下面的接口 只要实现就可以传入 足够的扩展性,也可以自己实现一个放进去
func NewCompositeTextMapPropagator(p ...TextMapPropagator) TextMapPropagator {
return compositeTextMapPropagator(p)
}
// 实现这个接口就可以 propagation.TraceContext{} 和 propagation.Baggage{} 实现了这个接口
type TextMapPropagator interface {
Inject(ctx context.Context, carrier TextMapCarrier)
Extract(ctx context.Context, carrier TextMapCarrier) context.Context
Fields() []string
}
// 定义这个组合器 组合器顾名思义内部有多个传播器 所以是实现 TextMapPropagator 接口的切片
type compositeTextMapPropagator []TextMapPropagator
// 遍历所有组合进来的传播器,依次调用它们的 Inject 方法 比较简单
func (p compositeTextMapPropagator) Inject(ctx context.Context, carrier TextMapCarrier) {
for _, i := range p {
i.Inject(ctx, carrier)
}
}
/*
也是遍历,不同的是有嵌套关系 比如空 ctx 传进来 执行第一个 TraceContext 生成 ctx1 拿着 ctx1 再执行 Baggage 生成 ctx2
ctx2(追加了 order-no)→ctx1(包含 SpanContext/TraceID)→ctx 大家如果看过我的 Context 文章就知道,当去找存入的值的时候
它内部会从自己开始一直递归找到根,所以在用法上并不是嵌套,我们不需要一层层剥开
后续会讲他们各自的 Extract 方法,到时候结合这里的理解
*/
func (p compositeTextMapPropagator) Extract(ctx context.Context, carrier TextMapCarrier) context.Context {
for _, i := range p {
ctx = i.Extract(ctx, carrier)
}
return ctx
}
/*
这个主要是 告诉用户 这些传播器会向 header 放入那些字段,比如有的系统对 HTTP Header 有安全白名单,只允许修改指定的 Header 键
这个时候就可以调用这个方法,返回所有传播器向header填入的字段 拿我们这个举例子,["traceparent", "tracestate", "baggage"]
返回的是重后的切片,解释一下 前两个是由 TraceContext 返回的 内部肯定要实现Fields()方法 返回各自要填入的字段
traceparent: 00-4f9f9e8a7b6c5d4e3f2a1b0c9d8e7f6a-1a2b3c4d5e6f7a8b-01 格式: 00-TracerID-SpanID-01 (核心)
tracestate: <vendor1>=<value1>,<vendor2>=<value2>,... 这个就是可选的第三方的键值对,非核心但有用 避免污染核心
baggage: <key1>=<value1>,<key2>=<value2>,... 这个就纯自己业务的键值对 user_id=123 自己想咋填咋填
*/
func (p compositeTextMapPropagator) Fields() []string {
// 用map是用于去重的 切片去不了重 这样加进去自动更新成新的
unique := make(map[string]struct{})
// 还是遍历所有的传播器
for _, i := range p {
for _, k := range i.Fields() {
unique[k] = struct{}{}
}
}
// 再转换为切片 返回
fields := make([]string, 0, len(unique))
for k := range unique {
fields = append(fields, k)
}
return fields
}
otel.SetErrorHandler(otel.ErrorHandlerFunc()) 函数
内容跟 SetTracerProvider 和 SetTextMapPropagator 一样的 这里主要说一下为什么要设置
如果不设置 OpenTelemetry 内部出错比如某个传播器宕机了或者某处 panic 整个服务挂了,它也不知道该怎么处理这个错误
当设置之后自动调用你的 ErrorHandler,不会影响业务代码
错误处理流程
OpenTelemetry 内部发生错误
│
▼
otel.Handle(err)
│
▼
globalErrorHandler.Load() 获取 ErrorHandler
│
▼
ErrorHandler.Handle(err)
│
├─→ 记录到日志 //我的示例代码就只是简单的做了这个
├─→ 发送到监控 (后续可选)
└─→ 发送告警 (后续可选)
至此,我们完成了 TracerProvider 的完整初始化。下篇将带大家进入实战环节,看看如何创建 Span、如何实现跨服务的链路追踪。