OpenTelemetry、Trace、Span 与采样:调用链怎样记录且控制成本
GET /lab/operations/{mode} SERVER Span
├─ operation.attempt 第一次:异常 INTERNAL Span
├─ operation.attempt 第二次:异常 INTERNAL Span
└─ operation.attempt 第三次:成功 INTERNAL Span这次请求经过三次尝试后成功返回。追踪中有四个 Span,其中两个记录 ERROR,最外层请求仍可正常完成。Span 的数量与请求数量不同,子操作失败也不必然意味着整个操作失败。
OpenTelemetry 把操作的起止、父子关系和属性交给采集链。追踪平台再按 Trace ID 把不同执行点的记录组合起来;缺失的节点需要沿生成、采样、导出和存储分别查找。
一条 Trace 包含哪些对象
Span 的字段与拓扑
| 字段 | 描述什么 | 常见使用方式 |
|---|---|---|
| traceId / spanId | 所属 Trace 与当前操作 | 查询整条链或定位一个片段 |
| parentSpanId | 当前操作的直接父级 | 还原嵌套和跨服务调用 |
| name / kind | 操作类别与调用角色 | 按路由或稳定操作名聚合 |
| start / end | 当前片段的时间范围 | 看持续时间、并行与等待 |
| attributes | 操作属性 | HTTP方法、路由、结果类型 |
| events | 片段内带时间的事件 | 异常、阶段切换、重要动作 |
| links | 与其他 SpanContext 的关联 | 批量消息、多源触发 |
| status | UNSET、OK 或 ERROR | 描述该操作的结果状态 |
SERVER 表示接收入站调用,CLIENT 表示发出远程调用,PRODUCER/CONSUMER 用于消息交互,INTERNAL 用于进程内操作。Span 名称选 GET /orders/{id} 这种稳定类别,具体订单号作为经评估允许的属性。把每个订单号拼进名称,会让聚合和检索产生大量分组。
父 Span 可以包含多个并行子 Span。两条各耗时100ms的并行调用,父操作可能只用110ms;把所有子耗时相加会重复计算重叠区间。缺少某个子 Span 时,空白区间也可能是未插桩代码、队列等待或数据丢失,不能直接命名为“网络耗时”。
Link 只表达关联,不会把当前 Span 变成对方的子 Span。一批消息来自多个 Trace,可以创建独立批处理 Span 并 Link 到多个消息上下文。Span 属性、事件、Link 与状态的 API 说明见 OpenTelemetry Java API。
从插桩到检索的组成
应用与库
├─ 自动插桩:Java Agent / 框架或库插桩
└─ 手工插桩:业务代码调用 Tracer API
↓
SDK
├─ Resource:service.name、service.version 等来源属性
├─ InstrumentationScope:哪一个插桩库及其版本
├─ Sampler:创建 Span 时作出记录/采样决定
└─ SpanProcessor → Exporter
↓ OTLP
Collector:接收、处理、路由、再导出
↓
存储与检索后端Resource 描述产生遥测的实体,InstrumentationScope 描述产生记录的代码来源。服务版本变化通常体现在 Resource;插桩库升级则应更新其 scope 版本。把这两个版本分开,才能判断是应用行为改变,还是遥测字段发生了变化。
API 让库可以记录操作,SDK 决定实际实现。Java Agent 在启动时为受支持库插桩,适合覆盖 HTTP、数据库等公共调用;手工 Span 适合框架看不到的业务步骤。二者可以合作,但应使用同一套上下文与有效 SDK,避免 Agent、框架桥接和自建 SDK 重复创建 SERVER Span。方案适用条件见 Java 插桩生态。
Collector 是可组合的采集服务。定义 receiver、processor 或 exporter 后,还要把它接入 service.pipelines 才会生效。处理器按配置顺序执行,先脱敏再发往外部后端,和到外部后端后再删除字段,暴露范围显然不同。组件连接规则见 Collector 配置。
让一次 HTTP 请求到达 Collector
构建应用并启动本地采集链
下载指标与追踪实验包,解压后进入 observability-signals-lab。Linux 需要 Docker、Compose V2、Bash、curl、jq 和 unzip。使用有 Docker 权限的宿主普通用户,构建容器指定同一个 UID/GID,项目目录和缓存须可写。
组合固定为 Spring Boot 4.1.1、Java 17 编译目标、Maven 3.9.12、OpenTelemetry 1.62.0、Collector 0.148.0、Prometheus 3.5.1。OTel 依赖由 Boot BOM 管理,不能只因在线 SDK 示例出现更新版本就混换部分 JAR。依赖版本表见 Boot 管理的依赖。
mkdir -p .m2 out
docker run --rm --user "$(id -u):$(id -g)" \
--entrypoint mvn -e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 -B -ntp \
-Dmaven.repo.local=/m2 -Duser.home=/tmp clean verify
export LAB_UID=$(id -u) LAB_GID=$(id -g)
docker compose -p ob17-signals up -d
curl -q --noproxy '*' --fail-with-body \
http://127.0.0.1:17781/actuator/health
curl -q --noproxy '*' --fail-with-body \
http://127.0.0.1:17733/构建应有21项测试通过,生成可执行 JAR。应用容器以10001:10001运行,Collector 以宿主 UID/GID 写 out,管理与业务端口只发布到宿主回环。首次启动可能需要数秒,健康失败先执行 docker compose -p ob17-signals logs --tail=50 app collector。Collector 报文件权限错误时检查 out 的宿主所有权;应用找不到 JAR 时先修复构建,不能直接在缺文件状态启动。
内网环境使用可信镜像仓库和 Maven 私服;离线时先准备精确镜像和依赖缓存,再转移到实验机。该 Compose 为单机学习配置,未启用跨机器 TLS、认证和持久存储副本,不能直接把回环端口改成公网地址作为生产接入。
发送 retry 请求并保存响应:
curl -q --noproxy '*' --fail-with-body -sS \
http://127.0.0.1:17780/lab/operations/retry -o out/retry.json
jq -e '.outcome == "success" and .attempts == 3' out/retry.json
TRACE_ID=$(jq -r '.traceId' out/retry.json)
jq -s --arg trace "$TRACE_ID" \
'[.[].resourceSpans[].scopeSpans[].spans[] |
select(.traceId == $trace) |
{name,spanId,parentSpanId,status}]' out/traces.jsonl应用 SDK 每200ms尝试发送批次,Collector file exporter 也有短暂缓冲。如果文件尚未出现或查询为空,先等一次导出周期并查看日志再重查。正常应找到一个 SERVER Span 和三个 attempt Span;它们 traceId 相同,三个子 Span 的 parentSpanId 指向响应中的 SERVER spanId。前两次 attempt 主动抛出实验异常并记录 ERROR,最后一次成功。这里没有制造真实网络超时,也没有实现生产重试退避。
文件是实际 OTLP 接收后的导出结果,尚未经过检索后端。检查四种请求的完整脚本可运行 bash scripts/verify-http.sh;它会额外产生四次操作,按本次返回的 Trace ID 检查 Span 数量,不依赖固定随机 ID。
SDK 与业务 Span 怎样配置
本实验手工创建一个 SdkTracerProvider 和 OTLP HTTP exporter,未安装 Java Agent,也没有再安装一套自动 HTTP 追踪。入口 TraceFilter 仅覆盖这些同步 Servlet 请求;异步分派还需跟随实际完成、超时或取消结束 Span,不能直接套用这里的 Filter 返回时点。核心配置取自 TelemetryConfiguration.java:
SpanExporter exporter = OtlpHttpSpanExporter.builder()
.setEndpoint(endpoint)
.setTimeout(Duration.ofSeconds(2))
.build();
SdkTracerProvider provider = SdkTracerProvider.builder()
.setSampler(Sampler.parentBased(Sampler.alwaysOn()))
.addSpanProcessor(BatchSpanProcessor.builder(exporter)
.setMaxQueueSize(128)
.setMaxExportBatchSize(16)
.setScheduleDelay(Duration.ofMillis(200))
.setExporterTimeout(Duration.ofSeconds(3))
.build())
.build();完整配置还设置 service.name、service.version,并由 Spring Bean 的关闭回调关闭 provider。Exporter 交由 processor 管理,避免额外创建无人关闭的实例。这里 root 采样器为 alwaysOn,适合低流量实验;生产应按数据量调整。SDK 的 provider、processor 和 exporter 关系见 Java SDK。
环境变量 LAB_OTLP_ENDPOINT 在 Compose 中指向 http://collector:4318/v1/traces,由应用自己的配置读取。手工 builder 不会因为设置一个同名相近的 OTEL_* 变量就自动改变所有参数;使用自动配置组件时才按对应配置规则读取。
业务操作的生命周期为:
Span attempt = tracer.spanBuilder("operation.attempt").startSpan();
try (Scope ignored = attempt.makeCurrent()) {
// 执行一个有独立诊断意义的业务片段。
} catch (RuntimeException failure) {
attempt.recordException(failure);
attempt.setStatus(StatusCode.ERROR);
throw failure;
} finally {
attempt.end();
}Scope 只改变当前上下文,关闭时恢复原值;end 标记操作结束并通知处理器。实验分别验证“关闭 Scope 后没有任何 Span 导出”和“end 后才导出”。捕获异常后若最终成功处理了该操作,应按它的语义决定 status,不能给所有曾经发生异常的父操作一律标 ERROR。
recordException 记录异常事件,单独调用并不会自动把 status 改成 ERROR。实验测试在记录异常后检查到一个事件且状态仍为 UNSET。手工 Span 要显式表达结果,已由框架插桩处理的异常则检查框架实际规则,避免重复事件。
属性和事件应选择能解释这个操作的有限信息。原始 SQL 参数、Authorization、完整请求体或异常中的个人信息可能沿采集链复制到多个存储位置。先在生成处裁剪,再配置 Collector 的删除或转换作为补充。
SDK 的 SpanLimits 还限制属性、事件和 Link 数量,可配置属性值长度。超过数量上限的新内容会被丢弃,过长值按配置截断;采样保留一个 Span,不代表它的所有字段都完整。出现异常栈或属性缺失时,先检查 SDK 限额和导出数据中的丢弃计数,再检查 Collector 与后端裁剪。相关参数列在前面的 Java SDK 文档中。
采样与队列分别在哪一步减少数据
头采样决定创建时记录什么
采样器在 startSpan 时运行,此刻能使用父上下文、名称、kind、创建时属性和 Link;操作最终耗时与后来发生的异常还不存在。因此按比例头采样可能舍弃一个最终失败的请求。
| 决定 | 是否记录 Span 内容 | sampled 标志 | 默认处理器的导出 |
|---|---|---|---|
| DROP | 否 | false | 不导出 |
| RECORD_ONLY | 是 | false | 标准 Simple/Batch 处理器不导出 |
| RECORD_AND_SAMPLE | 是 | true | 结束后进入处理器的导出流程 |
DROP 仍可保留有效 SpanContext 用于传播。Java SDK 1.62.0 在 DROP 时创建非记录 Span,不调用常规记录型 SpanProcessor 的 onStart/onEnd;RECORD_ONLY 则可以被自定义 processor 观察,但标准导出处理器按 sampled 标志筛选。实验用真实自定义 processor 计数验证两者,具体分支见 1.62.0 SdkSpanBuilder,通用契约见 Tracing SDK 规范。
ParentBased 按父采样状态作决定,再把无父根 Span 交给根采样器。实验的 parentBased(alwaysOn()) 会记录新根,但收到 sampled=false 的远程父上下文时,子 Span 仍不采样。后续即使 recordException 和 setStatus(ERROR),也不会让这条非记录 Span 重新导出。
比例头采样适合用较低成本保留一部分代表性请求。比例表示长期选择规则,不承诺每1000条恰好保留100条;小样本、不同流量类别和独立采样链可能偏离直觉比例。业务总体错误率应从未按 Trace 策略筛选的操作指标计算。
尾采样在 Collector 等待结果
尾采样先接收多个 Span,再根据错误、延迟或属性决定保留哪些 Trace。它能优先保留到达采集器的失败链,代价是缓冲、等待时间和按 Trace 聚合的状态。
应用产生并发送 Span
→ 按 traceId 路由到同一尾采样决策点
→ 在决策窗口内积累已经到达的片段
→ 按错误、耗时、属性等策略决定保留或丢弃
→ 后端存储这里的“尾”不保证所有 Span 已到齐。网络延迟、异步长任务和队列拥塞可能让片段在决策后才到达;超过容量时也可能提前淘汰 Trace。多个 Collector 实例要让同一 Trace 的片段到同一个决策点,普通随机负载均衡无法保证这一点。
头采样已经丢掉的 Span 不会因为尾采样规则“保留所有错误”重新出现。若入口先只保留10%,尾采样看到的只是这一部分,再按错误策略保留也无法得到全量错误。选择两级策略时明确哪类损失可接受,容量按进入尾采样前的数据量预算。概念与部署取舍见 OpenTelemetry 采样说明。
批处理改变发送时点
BatchSpanProcessor 在 Span end 后把记录放入有界队列,后台按批量或时间条件导出。它减少业务线程的网络开销,但增加应用崩溃前尚未发出的数据量。队列单位是 Span 数,不能直接当成字节;一个带大量事件的 Span 比一个短 Span 占更多内存。
实验将队列缩到2、批次设为1,并暂停第一次 exporter 完成。随后连续结束20个 Span,会有一部分因队列满而未进入导出。释放 exporter 并排空后,新建的 after-drain 可以再次到达;队列满时已舍弃的那些 Span 仍然缺失。
另一项测试让 exporter 第一次明确失败、第二次成功。标准 BatchSpanProcessor 不把第一次失败批次重新入队,后续 after-recovery 成功也不会带回旧批次。OTLP exporter 自己可能在一次导出内对可重试错误进行有限重试;它与 processor 的队列不是同一层重试机制。
退出应用时先停止接纳新的业务工作,让在途 Span 有机会 end,再给予 provider 关闭时间。forceFlush 是要求处理已有数据的操作,其返回结果需要按实现解释;进程被 KILL、容器停机时限过短或导出后端持续不可用,都会造成遥测缺口。Trace 通道不适合作为唯一的持久业务审计记录。
导出失败、断链和重复怎样排查
OTLP 协议、地址和管道
常见 OTLP 传输是 gRPC 和 HTTP/protobuf。实验选择 HTTP exporter,地址明确包含 /v1/traces;Collector 在容器内监听4318。gRPC 通常使用4317,不能把 HTTP 路径拼到任意 gRPC 配置里。协议、基础地址和信号专用地址的处理不同,完整配置规则见 Java SDK 配置。
实验 Collector 的 traces 管道为:
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [file]完整 collector.yaml 定义了 OTLP HTTP receiver、memory_limiter、batch 和追加写出的 file exporter。只写 exporters.file 而没有把 file 加入 traces 管道,不会产生输出。memory_limiter 会在压力下拒绝数据以保护进程,后续能否重试依赖上游和错误类型;它不能把全部输入无损放进有限内存。
OTLP 接收端口只在实验 Compose 网络内部可达,没有发布到宿主公网。health_check 和自身指标端口只绑定宿主回环。生产采集入口还需认证、TLS、访问来源限制,以及属性和载荷大小限制。
暂停 Collector 再恢复
在实验目录执行:
docker compose -p ob17-signals stop collector
curl -q --noproxy '*' --fail-with-body -sS \
http://127.0.0.1:17780/lab/operations/ok -o out/during-stop.json
docker compose -p ob17-signals logs --tail=30 app业务仍可成功,应用 exporter 会报告连接或发送失败。SDK 异步导出使业务结果与遥测发送结果分开;不能因为接口200就断言追踪完整。
恢复采集器后,用一条新请求判断:
docker compose -p ob17-signals start collector
curl -q --noproxy '*' --fail-with-body http://127.0.0.1:17733/
curl -q --noproxy '*' --fail-with-body -sS \
http://127.0.0.1:17780/lab/operations/ok -o out/after-recovery.json
TRACE_ID=$(jq -r '.traceId' out/after-recovery.json)
jq -s --arg trace "$TRACE_ID" \
'[.[].resourceSpans[].scopeSpans[].spans[] | select(.traceId == $trace)] | length' \
out/traces.jsonl等待导出周期后,新的 ok 请求应有2个 Span。若仍是0,先查看 exporter 错误和 Collector 文件写入权限,再检查采样上下文。停机期间的旧 Trace 是否保存,需用旧响应中的 Trace ID 单独查证,不能由新请求成功推断。Collector 的发送队列、重试和持久队列可提高容错,但仍受磁盘、时限和数据丢弃条件约束,见 Collector Resiliency。
用第一处缺失定位问题
| 观察结果 | 先检查什么 |
|---|---|
| 有 Trace ID,Span 不记录 | 当前 isRecording、sampled、父采样状态和有效 SDK |
| Span 已结束,本地测试 exporter 收到,Collector 没有 | OTLP协议、地址、证书、代理、SDK队列和发送错误 |
| Collector 接收数增长,文件或后端没有 | 管道处理器是否过滤、下游 exporter 错误及输出权限 |
| 全是独立根 Span | 请求头 inject/extract、Context 包装、网关头转发 |
| 同一次HTTP有两个相同SERVER节点 | Agent、框架桥接、自建Filter是否重复插桩 |
| 时间线异常重叠或倒置 | 父子关系、时钟偏差、异步生命周期和后端展示规则 |
Collector 自身指标可以检查:
curl -q --noproxy '*' --fail-with-body -sS \
http://127.0.0.1:17788/metrics |
grep -E '^otelcol_(receiver|processor|exporter)_'
docker compose -p ob17-signals logs --tail=60 collector具体序列名称随 Collector 版本和指标详细度变化,先看实际 scrape 中存在的名称,再建查询。接收、拒绝、发送和发送失败可能使用不同统计点;失败发送包含可重试的尝试,不能把失败计数直接命名为永久丢失。语义和配置见 Collector 内部遥测。
升级插桩或采集器时保留固定请求样本,比较服务名、Span 名称、kind、属性键、父子关系和 Span 数。语义约定变化可能影响检索条件,重复部署则可能让同一个请求被采两份。先在少量实例比较,再切换规则;回退时恢复兼容的 SDK、插桩和 Collector 配置组合。
结束实验执行 docker compose -p ob17-signals down。out 仍保留本地 Trace 文件,文件含原始时间、ID 和异常信息,分享前应另行检查。
权威资料与规范地址
API、SDK 与插桩
- Java API:https://opentelemetry.io/docs/languages/java/api/
- Java 插桩生态:https://opentelemetry.io/docs/languages/java/instrumentation/
- Java SDK:https://opentelemetry.io/docs/languages/java/sdk/
- Java SDK 配置:https://opentelemetry.io/docs/languages/java/configuration/
- Tracing SDK 规范:https://opentelemetry.io/docs/specs/otel/trace/sdk/
- SDK 1.62.0 SdkSpanBuilder:https://raw.githubusercontent.com/open-telemetry/opentelemetry-java/v1.62.0/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java
- Spring Boot 依赖版本:https://docs.spring.io/spring-boot/appendix/dependency-versions/coordinates.html
