主题
OpenTelemetry:让 Go 服务产生、传播并交付遥测数据
在 Go 程序里调用了 tracer.Start,后端却没有 Trace,故障可能发生在完全不同的地方:应用使用的是空实现;Span 没有结束;采样器决定不记录;导出队列丢了数据;接收端没有配置这条信号的流水线。只检查“是否安装了 OTel”无法区分这些情况。
OpenTelemetry 提供遥测的接口、实现、数据格式和采集组件。它帮助应用以相对统一的方式产生并传送日志、指标和 Trace,但不替你完成长期存储、检索界面或业务告警设计。这篇从一条 Span 如何产生开始,把各层的责任和 Go 中容易接错的边界走一遍。
实验固定 OpenTelemetry Go API、Trace SDK、Metric SDK v1.44.0,使用 Go 1.27.2、Linux/amd64。该版本仓库把 Traces、Metrics 标为 Stable,Logs 标为 Beta;这是 v1.44.0 的状态,不声称它是最新版本,也不能据此推断所有语言、日志桥接库或自动埋点都同样稳定。本文实际使用 Trace/Metric SDK 和标准库 slog,没有运行 OTel Logs SDK。[1]
先分清谁负责什么
| 组件 | 它做什么 | Go 中常见入口 | 它不会替你做什么 |
|---|---|---|---|
| API | 让库表达“开始操作”“记录测量” | trace.Tracer、metric.Meter | 不等于已经存储或导出 |
| SDK | 实现 API,管理采样、聚合和信号处理 | sdktrace.NewTracerProvider、sdkmetric.NewMeterProvider | 不提供长期查询数据库 |
| Propagator | 把进程内上下文和协议载体互相转换 | propagation.TraceContext 的 Inject / Extract | 不会为每个业务阶段自动创建 Span |
| Processor | 在 SDK 或 Collector 的相应阶段处理数据 | Trace 的 Simple/Batch SpanProcessor | 同名概念不代表两处生命周期相同 |
| Exporter | 把处理后的数据交付给下一级 | OTLP exporter、测试内存 exporter | 接收成功不保证最终长期存储成功 |
| Collector | 独立进程,接收、处理、导出遥测 | receiver → processor → exporter 流水线 | 不是通用日志/Trace 长期数据库 |
| Backend | 存储、查询和呈现信号 | 依所选平台而定 | 接收 OTLP 不等于自动配置所有关联 |
这些边界来自 OTel 组件与 Collector 文档。应用可以直接导出到后端,也可以经 Collector;有 Collector 不是接入成立的必要条件。[2][3]
正在绘制图示…
查看图源代码
flowchart LR
A[业务代码 / 埋点库] --> B[API 调用]
B --> C[进程内 SDK]
C --> D[采样 / 聚合 / Processor]
D --> E[Exporter]
E -->|例如 OTLP| F[Collector Receiver]
F --> G[Collector Processor<br/>批量、过滤、资源保护]
G --> H[Collector Exporter]
H --> I[后端存储与查询]
E -.允许直接导出.-> I
库作者通常依赖 API,让应用决定 Provider、采样与导出目标。应用负责初始化 SDK,并把它传给用到的埋点。这样同一个库不会偷偷创建自己的全局导出器。下面的实验显式使用 Provider,避免多个测试反复修改全局状态。[4]
API 能调用,不代表会记录
空 Provider 允许业务代码调用追踪接口,代价是它不会记录。实验先用明确的 noop.NewTracerProvider 创建一个 Span,然后创建真正的 SDK Provider:
go
_, noSDK := noop.NewTracerProvider().Tracer("demo").Start(bg, "noop")
r.NoSDKRecording = noSDK.IsRecording()
exp := tracetest.NewInMemoryExporter()
tp := sdktrace.NewTracerProvider(
sdktrace.WithResource(res),
sdktrace.WithSyncer(exp),
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.AlwaysSample())),
)输出的一部分:
text
no_sdk_recording=false remote=true same_trace=true child_parent=trueno_sdk_recording=false 来自空实现。后面三个布尔值来自有 SDK 的 Trace 实验。这里没有把“没有配置全局 Provider”与“代码抛错”混为一谈:默认无操作行为的目的之一就是让库可被未启用追踪的应用使用。[1:1][4:1]
WithSyncer 让结束的已采样 Span 同步交给内存 exporter,便于测试立即检查结果。真实网络导出通常考虑批处理,把很多小次发送合成批次;代价是队列占内存、交付有延迟,而且进程退出前需要刷新。不要把测试里的同步内存方案原样当成生产性能推荐。[5]
Resource、Scope、Attribute 分别描述谁
| 数据 | 回答的问题 | 示例 | 选择要点 |
|---|---|---|---|
| Resource | 谁产生了数据 | service.name=checkout、service.version=demo-1 | 各信号使用一致的服务身份 |
| Instrumentation scope | 哪段埋点代码产生了它 | example.com/checkout 及其版本 | 应能区分业务埋点与某个 HTTP 库 |
| Span / Metric attribute | 这次操作或这一组测量属于什么 | http.route=/orders/{id} | 只保留解决问题需要的维度 |
语义约定规定名称、单位和取值含义,方便不同库的数据被一致理解;它不是“任意字段写进去都自动互通”。本文 Resource 使用 NewSchemaless 和明确字符串,示例指标使用自定义 demo.* 名称,不能把它们冒认为完整实现了官方 HTTP 语义约定。生产中应固定语义约定版本,升级时检查字段改名、单位和 Dashboard 的依赖。[6]
埋点与传播:前者产生操作,后者连接操作
应用收到请求后,先从协议载体提取远端父级,再创建本服务的 Span。本文用 MapCarrier 模拟 Header 的键值容器,不建立网络连接:
go
incoming := p.Extract(bg, propagation.MapCarrier{
"traceparent": "00-11111111111111111111111111111111-2222222222222222-01",
})
ctx, server := tracer.Start(incoming, "POST /orders",
trace.WithSpanKind(trace.SpanKindServer))
childCtx, child := tracer.Start(ctx, "inventory.check")输出中的 remote=true 表示提取出来的父上下文被标为远端;same_trace=true 表示子操作与服务端操作拥有相同 Trace ID;child_parent=true 则是从实际导出的 Span 里读取父 Span ID,与服务端 Span ID 比较的结果。
创建 Span 后要传递 Start 返回的新 Context。若继续把原来的 bg 传给下一个 Start,新操作就没有当前父级。发送请求时还需要 Inject:
go
outgoing := propagation.MapCarrier{}
p.Inject(childCtx, outgoing)
recovered := trace.SpanContextFromContext(p.Extract(bg, outgoing))本机把还原的 Trace ID、Span ID 和远端标记都检查一遍:
text
carrier_roundtrip=true这验证了 W3C 载体的编码与提取,没有验证代理是否会丢 Header、网关是否重写 Trace Context,或真实 HTTP 客户端是否接了埋点。Header 的格式由 W3C 定义,协议边界仍要在部署中验收。[7]
自动埋点负责协议细节,手动埋点负责业务意义
| 方式 | 适合覆盖什么 | 优点 | 仍需负责的部分 |
|---|---|---|---|
| 库包装器 | HTTP、RPC 等已支持库的边界 | 减少手写重复操作,通常包含协议属性与传播 | 正确包住客户端和服务端,核对版本与选项 |
| 零代码/自动埋点方案 | 该方案支持的框架、库和运行环境 | 减少源代码改动 | 平台权限、支持矩阵、升级兼容、开销测试 |
| 手动 API 埋点 | 库存校验、风控、批任务等业务阶段 | 名称和属性能表达业务步骤 | 生命周期、错误状态、基数和隐私都由代码负责 |
例如固定版本 otelhttp v0.69.0 的 NewHandler 包装服务端 Handler,NewTransport 包装客户端 RoundTripper。它们的存在不意味着普通 net/http 调用在只导入 SDK 后就自动有 Span。该包装器的具体 API 依据固定版本文档,本文没有实际运行它。[8]
零代码方案也不能概括成“Go 都支持”。需要逐项核对语言/平台、内核或构建限制、受支持库及版本;不要随意把另一种语言的动态 Agent 安装方法移植到 Go。混用自动和手动埋点时,先检查是否在同一个边界重复生成 Span。[9]
Context 生命周期与 Span 生命周期要分别管理
Span 的 End 表示这个被测操作结束。它不取消 Go Context,不等待 goroutine,不抹掉 Context 中的 SpanContext。反过来,Context 被取消也不会自动替你 End Span。[10][11]
实验先取消 Context,再开始 Span;又在服务端 Span 结束后从原 Context 开始一个子 Span:
go
canceled, cancel := context.WithCancel(bg)
cancel()
_, active := tracer.Start(canceled, "cancellation-is-not-End")
r.SpanStillRecording = active.IsRecording()
active.End()
server.End()
_, late := tracer.Start(ctx, "late-child")
late.End()实际输出的相关部分:
text
unsampled_recording=false canceled=true still_recording=true
after_end_child=true async_new_trace=true async_link=true exported=5canceled=true 与 still_recording=true 同时成立;after_end_child=true 表示 SDK 仍保留追踪关系。这不说明“所有工作都应该在父 Span 结束后继续”。它只说明追踪身份和任务调度没有自动绑定,业务代码必须自行决定寿命。
| 工作关系 | Context 选择 | Span 关系 | 为什么 |
|---|---|---|---|
| 请求内必须完成的并行子任务 | 继续使用请求 Context,并等待收尾 | 当前请求的子 Span | 请求取消应能停止无用工作 |
| 请求触发、以后独立执行的后台任务 | 独立且有界的任务 Context | 新 Trace 加 Link,或按约定保留父关系 | 任务寿命不依赖已经返回的 HTTP 请求 |
| 消费一批来自不同请求的消息 | 消费者自己的处理 Context | 可用多个 Links 关联来源 | 一个 Span 只有一个父级,批次却有多个来源 |
实验选择第二种:独立截止时间、新 Trace、一个 Link。async_new_trace 与 async_link 都由实际导出的 Span 验证。示例为了确定性顺序执行,没有启动后台 goroutine或消息队列,不代表验证了队列传播协议。
context.WithoutCancel 会去掉父级取消和截止时间,同时保留值。若用它延长任务寿命,必须重新设定截止时间并明确任务归属;否则“为了追踪不断链”反而可能让业务任务无界存活。[11:1]
记录错误与标记失败也是两件事
go
child.RecordError(errors.New("synthetic unavailable"))
child.SetStatus(codes.Error, "inventory unavailable")
child.End()RecordError 添加错误事件;它本身不等同于把 Span 状态设置为 Error,因此示例显式调用 SetStatus。也不必把每次可预期的业务分支都打成错误:例如“商品不存在”是否失败,应依接口约定和语义约定判断,而不是见到 error 类型就机械升级。[10:1]
采样决定能否记录,不改变业务请求是否执行
实验 Provider 配置为 ParentBased(AlwaysSample()):没有父级的根操作采样,远端父级已经带有采样决定时按默认规则跟随。把入站 traceparent 最后一段改成 00 后,输出 unsampled_recording=false,最终五个 Span 被导出,未采样的那个没有进入内存 exporter。
这解释了常见疑问:根采样器设成 AlwaysSample,为什么某个服务仍没有记录?因为外面包着 ParentBased,这个请求不是本地新建的根。具体默认分支可在 v1.44.0 的 sampling.go 核对。[12]
Head sampling 在操作开始时决定,不能凭结束后才知道的错误保证保留错误请求。Tail sampling 先收集数据再选择,通常在采集层实现;它需要缓冲、等待和相同 Trace 的关联处理,也不能恢复 SDK 已经丢弃的 Span。业务指标通常不应直接从偏向错误样本的 Trace 数量推断,除非明确设计了采样校正。[13]
三种信号怎样关联:字段、上下文和查询都要接上
实验使用同一个 Resource 初始化 Trace 与 Metric Provider,再创建 Counter 与 Histogram:
go
count.Add(ctx, 2, metric.WithAttributes(attribute.String("http.route", "/orders/{id}")))
latency.Record(ctx, .05, opts)
latency.Record(ctx, .45, opts)
reader.Collect(bg, &data)从 ManualReader 实际读到:
text
requests=2 histogram_count=2 histogram_sum=0.50两次观测总计 0.50 秒。ManualReader 由测试主动收集;生产可能使用定时 Reader 或 Prometheus 拉取,读取周期与聚合时间性需要匹配后端。传入 ctx 不表示要把 Trace ID 变成每条指标的标签,那会造成高基数。Exemplar 可以把部分测量关联到 Trace,但能否产生、保留和跳转,依 SDK、采样、导出和后端配置;本文没有检验 Exemplar。[14]
日志则需要显式取出追踪上下文:
go
sc := trace.SpanContextFromContext(ctx)
if sc.IsValid() {
attrs = append(attrs,
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()))
}
logger.LogAttrs(ctx, slog.LevelInfo, "order checked", attrs...)同一个实验把“显式加字段”和“仅传 Context 的普通 JSONHandler”各执行一遍,解码日志并检查:
text
explicit_log_trace=true plain_slog_trace=false所以 logger.InfoContext(ctx, ...) 或 LogAttrs(ctx, ...) 的参数中有 Context,并不意味着标准 JSONHandler 自己会提取 OTel Span。可以使用适合版本的日志桥接器,也可以像这里显式加字段;还要保证采集器不删字段、后端按同一命名读取。本文验证的是后者。[15]
| 关联路径 | 必要条件 | 缺一项会怎样 |
|---|---|---|
| 服务指标 → 日志时间窗口 | 服务/环境身份一致、时间可对齐 | 查到别的环境或错误时间段 |
| 日志 → Trace | 实际写入 Trace ID,Trace被保留,后端可检索 | 有 ID 但目标 Trace 已被采样或过期 |
| 指标样本 → Trace | Exemplar 链路真的支持并启用 | 有图表但没有单次样本跳转 |
| 版本 → 所有信号 | 资源或采集层附上可信版本 | 无法区分灰度与旧实例 |
数据离开应用以后,还要验证失败路径
批处理的队列总有容量。v1.44.0 的 BatchSpanProcessor 默认队列满时会丢弃;开启阻塞选项则可能把遥测拥塞传回业务。不能同时许诺有限内存、永不丢失和永不阻塞。进程内存队列也抵挡不了进程突然终止。[5:1]
Collector 可以集中做批量、路由、限流、过滤和身份补充,减少应用与具体后端的耦合;代价是增加一条需要运维的链路。应观察接收/导出失败、队列占用、被拒绝与丢弃的记录,给接收端、出口和磁盘分别设预算。持久队列及重试也有容量和期限,无法对无界故障承诺零丢失。[3:1][16]
应用退出时,应先停止接收新业务、等待有界收尾,再用独立、有超时的 Context 调 Provider 的 Shutdown。已经取消的请求 Context 不适合用来做最后刷新。Shutdown 成功的含义也取决于 exporter 和目的端,不能推导为所有下游存储副本已经持久化。
部署前至少做三种检查:正常路径的 Span 是否完整;接收端暂时不可用时业务与遥测如何变化;退出时是否有被遗留的数据。本文只完成第一种的进程内验证,没有启动 Collector、OTLP 网络导出或存储后端,后两种不能算本次实测结果。
依据与版本
资料核对日期为 2026-10-13。实现依据固定版本;动态官方文档用于解释概念和部署边界。
完整可复现程序与测试
把以下四个文件放进同一目录。先用 GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod download 下载固定模块,再执行 go run .、go test ./...、go test -race ./...、go vet ./...。本次四项全部通过;依赖校验记录在 go.sum。
测试只用内存 exporter 和 ManualReader,无网络端口、外部账号、Collector 或生产数据。随机 Span ID 不打印,测试验证关系而非写死 ID。负例包括空 Provider、远端未采样父级、取消与结束不绑定、没有显式字段的日志;结果都是断言的一部分。
go.mod
go.mod
module example.com/observability/otel
go 1.27.0
require (
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
golang.org/x/sys v0.45.0 // indirect
)go.sum
代码块 · 37 行 · 点击收起 / 展开
text
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=main.go
代码块 · 185 行 · 点击收起 / 展开
go
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
)
type report struct {
Propagated bool
NoSDKRecording, ExtractedRemote, SameTrace, ChildParent bool
UnsampledRecording, CanceledContext, SpanStillRecording bool
AfterEndChild, AsyncNewTrace, AsyncLinked bool
LogHasTrace, PlainLogHasTrace bool
Exported int
Requests int64
HistogramCount uint64
HistogramSum float64
}
// A logger does not inspect ctx for trace IDs unless a bridge or our code does it.
func writeLog(b *bytes.Buffer, ctx context.Context, addTrace bool) error {
logger := slog.New(slog.NewJSONHandler(b, &slog.HandlerOptions{
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
}))
attrs := []slog.Attr{slog.String("service", "checkout"), slog.String("route", "/orders/{id}")}
if sc := trace.SpanContextFromContext(ctx); addTrace && sc.IsValid() {
attrs = append(attrs, slog.String("trace_id", sc.TraceID().String()), slog.String("span_id", sc.SpanID().String()))
}
logger.LogAttrs(ctx, slog.LevelInfo, "order checked", attrs...)
var got map[string]any
return json.Unmarshal(b.Bytes(), &got)
}
func experiment() (r report, err error) {
bg := context.Background()
_, noSDK := noop.NewTracerProvider().Tracer("demo").Start(bg, "noop")
r.NoSDKRecording = noSDK.IsRecording()
noSDK.End()
exp := tracetest.NewInMemoryExporter()
res := resource.NewSchemaless(attribute.String("service.name", "checkout"), attribute.String("service.version", "demo-1"))
tp := sdktrace.NewTracerProvider(sdktrace.WithResource(res), sdktrace.WithSyncer(exp),
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.AlwaysSample())))
defer func() {
ctx, cancel := context.WithTimeout(bg, time.Second)
defer cancel()
err = errors.Join(err, tp.Shutdown(ctx))
}()
tracer := tp.Tracer("example.com/checkout")
p := propagation.TraceContext{}
carrier := propagation.MapCarrier{"traceparent": "00-11111111111111111111111111111111-2222222222222222-01"}
incoming := p.Extract(bg, carrier)
r.ExtractedRemote = trace.SpanContextFromContext(incoming).IsRemote()
ctx, server := tracer.Start(incoming, "POST /orders", trace.WithSpanKind(trace.SpanKindServer))
childCtx, child := tracer.Start(ctx, "inventory.check")
r.SameTrace = child.SpanContext().TraceID() == server.SpanContext().TraceID()
outgoing := propagation.MapCarrier{}
p.Inject(childCtx, outgoing)
recovered := trace.SpanContextFromContext(p.Extract(bg, outgoing))
r.Propagated = recovered.TraceID() == child.SpanContext().TraceID() && recovered.SpanID() == child.SpanContext().SpanID() && recovered.IsRemote()
var logBuf, plainBuf bytes.Buffer
if err = writeLog(&logBuf, childCtx, true); err != nil {
return
}
if err = writeLog(&plainBuf, childCtx, false); err != nil {
return
}
var logged, plain map[string]any
if err = json.Unmarshal(logBuf.Bytes(), &logged); err != nil {
return
}
if err = json.Unmarshal(plainBuf.Bytes(), &plain); err != nil {
return
}
r.LogHasTrace = logged["trace_id"] == child.SpanContext().TraceID().String()
_, r.PlainLogHasTrace = plain["trace_id"]
child.RecordError(errors.New("synthetic unavailable"))
child.SetStatus(codes.Error, "inventory unavailable")
child.End()
server.End()
// Ending a span does not erase its SpanContext from ctx.
_, late := tracer.Start(ctx, "late-child")
r.AfterEndChild = late.SpanContext().TraceID() == server.SpanContext().TraceID()
late.End()
canceled, cancel := context.WithCancel(bg)
cancel()
_, active := tracer.Start(canceled, "cancellation-is-not-End")
r.CanceledContext = canceled.Err() != nil
r.SpanStillRecording = active.IsRecording()
active.End()
// A detached job uses its own lifetime and a link to the triggering span.
jobCtx, stop := context.WithTimeout(bg, time.Second)
defer stop()
_, job := tracer.Start(jobCtx, "job", trace.WithLinks(trace.Link{SpanContext: server.SpanContext()}))
r.AsyncNewTrace = job.SpanContext().TraceID() != server.SpanContext().TraceID()
job.End()
unsampled := p.Extract(bg, propagation.MapCarrier{"traceparent": "00-33333333333333333333333333333333-4444444444444444-00"})
_, dropped := tracer.Start(unsampled, "remote-unsampled")
r.UnsampledRecording = dropped.IsRecording()
dropped.End()
spans := exp.GetSpans()
r.Exported = len(spans)
for _, s := range spans {
if s.Name == "inventory.check" {
r.ChildParent = s.Parent.SpanID() == server.SpanContext().SpanID()
}
if s.Name == "job" {
r.AsyncLinked = len(s.Links) == 1 && s.Links[0].SpanContext.TraceID() == server.SpanContext().TraceID() && s.Links[0].SpanContext.SpanID() == server.SpanContext().SpanID()
}
}
reader := sdkmetric.NewManualReader()
mp := sdkmetric.NewMeterProvider(sdkmetric.WithResource(res), sdkmetric.WithReader(reader))
defer func() { err = errors.Join(err, mp.Shutdown(bg)) }()
meter := mp.Meter("example.com/checkout")
count, e := meter.Int64Counter("demo.requests", metric.WithUnit("{request}"))
if e != nil {
return r, e
}
latency, e := meter.Float64Histogram("demo.request.duration", metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(.1, .3, 1))
if e != nil {
return r, e
}
opts := metric.WithAttributes(attribute.String("http.route", "/orders/{id}"))
count.Add(ctx, 2, opts)
latency.Record(ctx, .05, opts)
latency.Record(ctx, .45, opts)
var data metricdata.ResourceMetrics
if e = reader.Collect(bg, &data); e != nil {
return r, e
}
for _, sm := range data.ScopeMetrics {
for _, m := range sm.Metrics {
switch d := m.Data.(type) {
case metricdata.Sum[int64]:
for _, point := range d.DataPoints {
r.Requests += point.Value
}
case metricdata.Histogram[float64]:
for _, point := range d.DataPoints {
r.HistogramCount += point.Count
r.HistogramSum += point.Sum
}
}
}
}
return
}
func main() {
r, err := experiment()
if err != nil {
panic(err)
}
fmt.Printf("carrier_roundtrip=%v\n", r.Propagated)
fmt.Printf("no_sdk_recording=%v remote=%v same_trace=%v child_parent=%v\n", r.NoSDKRecording, r.ExtractedRemote, r.SameTrace, r.ChildParent)
fmt.Printf("unsampled_recording=%v canceled=%v still_recording=%v\n", r.UnsampledRecording, r.CanceledContext, r.SpanStillRecording)
fmt.Printf("after_end_child=%v async_new_trace=%v async_link=%v exported=%d\n", r.AfterEndChild, r.AsyncNewTrace, r.AsyncLinked, r.Exported)
fmt.Printf("explicit_log_trace=%v plain_slog_trace=%v\n", r.LogHasTrace, r.PlainLogHasTrace)
fmt.Printf("requests=%d histogram_count=%d histogram_sum=%.2f\n", r.Requests, r.HistogramCount, r.HistogramSum)
}main_test.go
go
package main
import (
"reflect"
"testing"
)
func TestExperiment(t *testing.T) {
got, err := experiment()
if err != nil {
t.Fatal(err)
}
want := report{Propagated: true, ExtractedRemote: true, SameTrace: true, ChildParent: true, CanceledContext: true,
SpanStillRecording: true, AfterEndChild: true, AsyncNewTrace: true, AsyncLinked: true,
LogHasTrace: true, Exported: 5, Requests: 2, HistogramCount: 2, HistogramSum: .5}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v; want %+v", got, want)
}
}OpenTelemetry Go v1.44.0 README:信号状态、API 和 exporter。 ↩︎ ↩︎
OTel components:API、SDK、Collector 的责任。 ↩︎
Collector architecture:接收、处理、导出流水线。 ↩︎ ↩︎
Go instrumentation:Provider、埋点与应用初始化。 ↩︎ ↩︎
v1.44.0 BatchSpanProcessor:队列、阻塞、批次和关闭。 ↩︎ ↩︎
Resources;semantic conventions:资源身份及跨实现语义。 ↩︎
W3C Trace Context 1,2021-11-23;Go v1.44.0 TraceContext propagator。 ↩︎
otelhttp v0.69.0:Handler、Transport;本次未执行。 ↩︎
Go zero-code instrumentation:不同自动埋点路径及支持条件。 ↩︎
Trace API specification;Go v1.44.0 Span API:Span、Links、End、RecordError 和 Status。 ↩︎ ↩︎
Go 1.27.2 context:取消和 WithoutCancel,本机 go doc 核对。 ↩︎ ↩︎
Go v1.44.0 sampling.go:ParentBased 默认分支。 ↩︎
OTel sampling:head/tail 边界。 ↩︎
Go v1.44.0 ManualReader;metrics exemplar specification:收集和样本关联。 ↩︎
Go 1.27.2 slog;非 OTLP 日志 Trace Context 约定:日志字段与显式关联。 ↩︎
Collector resiliency:队列、持久化、重试和丢失情形。 ↩︎