Skip to content

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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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=true

no_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=5

canceled=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 已被采样或过期
指标样本 → TraceExemplar 链路真的支持并启用有图表但没有单次样本跳转
版本 → 所有信号资源或采集层附上可信版本无法区分灰度与旧实例

数据离开应用以后,还要验证失败路径 ​

批处理的队列总有容量。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)
	}
}

  1. OpenTelemetry Go v1.44.0 README:信号状态、API 和 exporter。 ↩︎ ↩︎

  2. OTel components:API、SDK、Collector 的责任。 ↩︎

  3. Collector architecture:接收、处理、导出流水线。 ↩︎ ↩︎

  4. Go instrumentation:Provider、埋点与应用初始化。 ↩︎ ↩︎

  5. v1.44.0 BatchSpanProcessor:队列、阻塞、批次和关闭。 ↩︎ ↩︎

  6. Resources;semantic conventions:资源身份及跨实现语义。 ↩︎

  7. W3C Trace Context 1,2021-11-23;Go v1.44.0 TraceContext propagator。 ↩︎

  8. otelhttp v0.69.0:Handler、Transport;本次未执行。 ↩︎

  9. Go zero-code instrumentation:不同自动埋点路径及支持条件。 ↩︎

  10. Trace API specification;Go v1.44.0 Span API:Span、Links、End、RecordError 和 Status。 ↩︎ ↩︎

  11. Go 1.27.2 context:取消和 WithoutCancel,本机 go doc 核对。 ↩︎ ↩︎

  12. Go v1.44.0 sampling.go:ParentBased 默认分支。 ↩︎

  13. OTel sampling:head/tail 边界。 ↩︎

  14. Go v1.44.0 ManualReader;metrics exemplar specification:收集和样本关联。 ↩︎

  15. Go 1.27.2 slog;非 OTLP 日志 Trace Context 约定:日志字段与显式关联。 ↩︎

  16. Collector resiliency:队列、持久化、重试和丢失情形。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准