一、仓库结构与模块划分
bash
opentelemetry-go/
├── otel.go / version.go # 全局 API 入口(Tracer/Meter/版本)
├── trace/ # 【API 层】链路接口定义
│ ├── trace.go # Tracer/TracerProvider/Span 接口 + SpanContext
│ ├── noop.go # 空实现(未注入 SDK 时用)
│ ├── nonrecording.go # 不记录的 Span(采样 Drop 时返回它)
│ └── config.go # SpanConfig(WithAttributes 等选项)
├── metric/ # 【API 层】指标接口
├── logs/ (log/) # 【API 层】日志接口(较新)
├── baggage/ propagation/ # Baggage 与传播器(W3C 实现)
├── codes/ attribute/ semconv/# 枚举、属性类型、语义约定常量
├── sdk/
│ ├── trace/ # 【SDK 层】★ 核心,本章主角
│ ├── metric/ # 【SDK 层】指标实现(聚合、Reader、视图)
│ ├── log/ # 【SDK 层】日志实现
│ └── resource/ # Resource 检测与合并
├── exporters/
│ ├── otlp/otlptrace/ # OTLP 链路导出(gRPC/HTTP 两种 client)
│ ├── stdout/ # 打到控制台(调试用)
│ └── ...
├── internal/global/ # ★ 全局实例与"委托"机制(理解 API/SDK 解耦的关键)
└── example/ # 官方示例,读源码起步点
Go 多模块(multi-module)仓库,核心 import 路径:
| 模块路径 | 内容 |
|---|---|
go.opentelemetry.io/otel |
API + 全局实例(业务代码只应依赖这个) |
go.opentelemetry.io/otel/sdk/trace |
链路 SDK 实现 |
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc |
OTLP gRPC 导出器 |
go.opentelemetry.io/contrib/...(另一仓库) |
框架自动埋点、桥接器 |
二、分层设计:API / 全局实例 / SDK
2.1 为什么分三层
业务代码和埋点库(otelgin 等)只依赖 trace.TracerProvider接口 ;具体实现(SDK)在 main 里注入。若没人注入,则使用 noop 空实现------埋点代码永远不 panic、零成本可禁用,这是 OTel 的设计基石。
2.2 全局委托机制(internal/global)
问题:otel.Tracer("x") 被调用时,otel.SetTracerProvider(tp) 可能还没执行(包初始化顺序不可控)。解法就是"委托(delegate)"三步:
otel.Tracer(...)返回的是internal/global里的占位 tracer;otel.SetTracerProvider(tp)被调用后,全局状态切换,并对已创建的占位 tracer 执行SetDelegate(级联下发给它们创建过的 Span);- 之后所有操作转发给真正的 SDK tracer。
关键源文件:
internal/global/state.go------ 全局单例与 SetXxx 系列函数internal/global/tracer.go------ 占位 tracer 与委托转发(tracer.Ids()、delegate方法)internal/global/tracer_provider.go、meter.go、handler.go(错误处理器otel.Handle)
读代码技巧:IDE 里对
otel.Tracer按 F12,先跳到otel.go的全局函数,再跟进internal/global,就能看到整个委托链。
三、Span 创建与生命周期(sdk/trace/span.go)
3.1 tracer.Start() 做了什么
sdk/trace/tracer.go 的 Start(ctx, name, opts...) 流程:
scss
1. 解析 SpanConfig(kind、属性、links、时间戳)
2. 生成新的 span id + 继承/生成 trace id
└─ id_generator.go(默认随机;可自定义雪花等单调 id)
3. 执行采样决策 sampler.ShouldSample(SamplingParams)
├─ Decision == Drop
│ → 返回 nonRecordingSpan(接口满足、啥也不记,业务代码无感)
└─ Decision == RecordAndSample → 继续创建真实 span
4. 组装 span:属性、links、parent(来自 ctx 里的 SpanContext)
5. 构造新 ctx = context.WithValue(ctx, currentSpanKey, span)
6. 逐个调用 spanProcessor.OnStart(span) ← 注意:OnStart 是同步的
7. 返回 (ctx, span)
细节亮点:
- Drop 时不产生任何内存开销------nonRecordingSpan 是接口空实现,这是"采样率成为性能开关"的原理;
- SamplingParams 里有
ParentContext:父已采/未采的信息就是 ParentBased 采样器的输入; - trace id 生成规则:如果 ctx 里已有 SpanContext(即有父),trace id 继承父,span id 新生成。
3.2 span.End() 做了什么
sdk/trace/span.go:
markdown
1. ended 原子标记(cas),防止重复 End → 重复 End 是 no-op
2. 若 SetStatus 为 Error 且没有 exception 事件,部分版本会补记
3. 截断超限数据:属性/事件/links 按 SpanLimits 裁剪(超出计数进 dropped_*_count)
4. 逐个调用 spanProcessor.OnEnd(span) ← BatchSpanProcessor 从这里接手
5. End 之后 SetAttributes 等方法全部变 no-op
内部同步:span 结构体用一把 sync.Mutex 保护属性切片,atomic.Uint32 表达状态------Span 是热路径对象,源码里大量见到"先检查原子状态再进锁"的写法,值得学习。
3.3 属性的存储:evictedqueue
sdk/trace/evictedqueue.go:一个固定容量的环形队列,超出容量丢最老的并累加 dropped 计数。Span 的 attributes / events / links 都用它承载------这就是 SpanLimits 的底层实现。
四、BatchSpanProcessor 精读(重点)
文件:sdk/trace/batch_span_processor.go。这是生产上最重要的组件,也是面试高频。
4.1 设计:生产者(业务 goroutine)与消费者(后台 goroutine)解耦
scss
业务 goroutine 后台 goroutine
───────────── ──────────────
span.End()
→ bsp.OnEnd(span)
→ queue 满了吗?
├─ 满:dropped++(丢弃,默认非阻塞)
└─ 未满:入队(batch 累积)
loop:
每 BatchTimeout(5s) 或攒够
MaxExportBatchSize(512) →
exportSpans(batch)
→ exporter.ExportSpans(ctx, batch)
→ 失败:按重试策略重试/丢弃计数
Shutdown() → 停止 ticker、关闭队列、drain 剩余、最后一次 export
4.2 默认参数(规范定义,源码常量)
| 参数 | 默认值 | 说明 |
|---|---|---|
| MaxQueueSize | 2048 | 队列上限,满了开始丢 |
| BatchTimeout | 5s | 定时导出间隔 |
| MaxExportBatchSize | 512 | 触发导出的批量大小 |
| ExportTimeout | 30s | 单次导出超时 |
对应环境变量:OTEL_BSP_MAX_QUEUE_SIZE、OTEL_BSP_SCHEDULE_DELAY、OTEL_BSP_MAX_EXPORT_BATCH_SIZE、OTEL_BSP_EXPORT_TIMEOUT(见 sdk/trace/env.go)。
4.3 定制示例
less
sdktrace.WithBatcher(exp,
sdktrace.WithMaxQueueSize(10000),
sdktrace.WithBatchTimeout(2*time.Second),
sdktrace.WithMaxExportBatchSize(1000),
)
4.4 读源码时关注的 4 个问题
- 丢数据语义 :队列满 → 直接丢(默认),只把 dropped 计数打到日志/指标。所以监控 OTel 自身指标很重要(队列使用率、dropped spans);
- 关停正确性 :
Shutdown如何保证"进程收到 SIGTERM 时不丢队列里的 Span"(drain 逻辑;这也是 03 章强调 defer shutdown 的原因); - 与 SimpleSpanProcessor 的选择 :
simple_span_processor.go在OnEnd里同步调 exporter------仅用于调试,生产同步导出会阻塞业务请求; - 单飞批处理:exportSpans 期间新数据继续入队,不会互相阻塞。
五、采样器源码(sdk/trace/sampling.go)
文件结构一目了然,每个采样器一个小结构体:
scss
// TraceIDRatioBased:按 trace_id 前若干 bit 与阈值比较
// 同一 trace_id 永远得到同一决策(确定性,这是不产生"半截链路"的根本)
func (ts TraceIDRatioBased) ShouldSample(p SamplingParams) SamplingResult {
// x = traceID 的前 64bit / 2^64 → [0,1)
// x < ratio → RecordAndSample,否则 Drop
}
// ParentBased:看父的采样位
func (ps ParentBased) ShouldSample(p SamplingParams) SamplingResult {
if 父存在 {
父 sampled → root(默认 AlwaysOn)......即"跟随父"
父未采 → remoteNotSampled/localNotSampled(默认 AlwaysOff)
}
// 无父 → root sampler
}
对照 前面的文章的概念表再读一遍源码,概念立刻落地。也可以读 sdk/trace/sampling.go 顶部的注释,规范引用写得很全。
六、Resource 自动检测
sdk/resource/ 目录:
resource.go:Merge、Default、NewWithAttributes的实现,合并冲突时后(高优先级)覆盖前;env.go:读OTEL_SERVICE_NAME、OTEL_RESOURCE_ATTRIBUTES;builtin.go:进程内置属性(SDK 语言/版本、PID 等);host*.go:hostname / host id 检测;auto.go:resource.Default()的编排入口。
优先级链:代码显式传入 > 环境变量 > 自动检测 > 内置默认。K8s/云厂商元数据一般不放 SDK 端,而是 Collector 用 resourcedetection processor 补(见 05 章)。
七、OTLP Exporter 与重试
目录:exporters/otlp/otlptrace/
csharp
otlptrace/
├── otlptrace.go # Client 接口 + NewExportStream 组合入口
├── otlptracegrpc/ # gRPC 传输实现
│ ├── options.go # WithEndpoint / WithTLSCredentials / WithHeaders...
│ ├── client.go # 启动 gRPC 连接、Export 调用
│ └── internal.go # reattempt 调度(导出失败的重试节奏)
├── otlptracehttp/ # HTTP/protobuf 传输实现(结构对称)
└── internal/ # proto 转换:SDK 的 ReadOnlySpan → pb.Span
└── otlp/transform/ # ★ 属性/SpanContext 转 protobuf 的地方
三个阅读点:
- transform :SDK 内部 Span 结构如何映射为 OTLP 的
ExportTraceServiceRequest(衔接 06 章 proto); - 重试 :导出失败按指数退避 重试(受
MaxAttempts、backoff控制,尊重服务端返回的 retry 建议);重试仍失败 → 这批数据丢弃并计数; - partial success:服务端可能返回"接收了 1000 条、拒绝 3 条",exporter 会上报相应指标/日志。
八、性能控制:SpanLimits
sdk/trace/provider.go 的 WithSpanLimits + sdk/trace/sampler_env.go 同级的 limits 相关代码:
php
sdktrace.WithSpanLimits(sdktrace.SpanLimits{
AttributeCountLimit: 128, // 单 Span 属性上限,默认 128
EventCountLimit: 256,
LinkCountLimit: 128,
AttributeValueLengthLimit: -1, // 无限制;可设字节数
})
配合 evictedqueue(3.3 节),保证单个 Span 内存占用有上界------这是"埋点不会拖垮业务"的内存侧保障。SDK 的性能设计可以总结为三条:采样前置(Drop 零成本)、批量异步(End 不阻塞)、上限裁剪(内存有界)。
九、contrib 仓库:otelhttp 源码走读
仓库:github.com/open-teleme...目录:instrumentation/net/http/otelhttp/
9.1 客户端 Transport(出站)
css
// transport.go / client.go
client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
RoundTrip 流程:
markdown
1. 从 req.Context() 取当前 SpanContext
2. 创建 CLIENT Span(name = 请求方法+URL 路径模板)
3. Inject(ctx, req.Header) ← 03 章 4.3 手写的那行
4. 真正发起 http.DefaultTransport.RoundTrip
5. defer:按响应状态码设置 Span 状态、记录语义约定属性、End()
9.2 服务端 Handler(入站)
less
// handler.go / server.go
http.Handle("/api", otelhttp.NewHandler(h, "api"))
流程:Extract(r.Header) → 创建 SERVER Span → 把带 Span 的 ctx 放进 r.Context() → 业务 handler 从 r.Context() 拿到链路上下文。
9.3 阅读建议
先读 example/ 下的两个最小示例,再顺着 client.go/server.go 的注释读属性注入(语义约定应用)。读懂 otelhttp 后,otelgin/otelgrpc 都是同构的"包一层 + 注入/提取 + 语义属性"模式。
十、推荐阅读顺序与关键文件清单
| 顺序 | 文件 | 目标 |
|---|---|---|
| 1 | example/ + 03 章代码 |
建立直觉 |
| 2 | trace/trace.go |
只读接口,建立词汇表 |
| 3 | internal/global/tracer.go |
理解委托机制(30 分钟) |
| 4 | sdk/trace/provider.go |
Provider 装配了什么 |
| 5 | sdk/trace/tracer.go → span.go |
Span 生命周期主线 ★ |
| 6 | sdk/trace/batch_span_processor.go |
批处理与丢数据语义 ★ |
| 7 | sdk/trace/sampling.go |
采样落地 |
| 8 | exporters/otlp/otlptrace/(transform + grpc client) |
数据出门 |
| 9 | contrib:instrumentation/net/http/otelhttp/ |
自动埋点范式 |
| 10 | sdk/metric/(aggregation、reader、view) |
指标进阶(可选) |
注意:OpenTelemetry 迭代快,文件名/函数名可能随版本微调(如重构进
client.go/server.go),以当前仓库为准;本清单给的是稳定的阅读路径,不是逐行地图。