深入 OpenTelemetry 源码与实战(上篇)

0 阅读19分钟

引言

在分布式系统中,链路追踪是排查问题、优化性能的核心利器,而 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 首页,选择对应的服务,选中后,找到具体的追踪链路,这里主要设置这条链路属于哪个服务名的

就类似你先选择哪个数据库系统类型(mySQLNoSQL)再去建表

选择哪个数据库系统类型 就是我们这里设置的服务名,建表就是设置 TracerIDSpanID

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     │  ← 来自 AB 没有这个 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,97619位)
traceIDUpperBound = 922,337,203,685,477,58018位)

正好差了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{} 主要传递 TracerIDSpanID 防止断链 (核心)

第二个 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、如何实现跨服务的链路追踪。