“Trace 找不到”和“指标图不对”往往只是表象。同样是看不到一条请求,可能是 Context 已断、头采根本没记录、出口路径写错、Batch 仍在队列里,也可能是接收端之后的存储问题。对指标而言,数值总计正确也可能有过多按请求 ID 拆出的曲线。本篇不虚构一次生产事故:用四组相互独立、有对照修复的 Java 故障注入,说明如何把检查点落在具体边界上。

实验固定 Java SDK/OTLP exporter 1.31.0,源码 SHA c25c0a0ee0da01ab2f74ba83052d1c249ed57020,完整 JDK 21.0.12;不新增组件。本篇出口是教学 OTLP/HTTP protobuf 接收端,指标仅用进程内 reader,不运行 Collector、Agent 或可查询后端;26–27 的另一次真实 Collector/Jaeger 查询不能替本篇背书。逐项退出码、原始断言、环境和未验证项在 examples/opentelemetry-java/evidence/29/RUN.md。

先按生成、传输、接收分层

源码层面的顺序不能倒过来。Context.java L200–229 提供 makeCurrent()/wrap(Callable),调用点必须管理 Scope 生命周期;SdkSpanBuilder.java L169–208 先查父 Context 和 Sampler,DROP 只留下非 recording 的 SpanContext。只有真实结束的 sampled Span 才在 BatchSpanProcessor.java L98–103 入队;Worker 的 addSpan L223–230 会在队列满时计入 dropped。出口 OtlpHttpSpanExporter.java L74–83 export 完成的结果是这一跳的结果,不能证明后端已保存;单独的 exporter flush() 当即成功,也不等于 processor 的 forceFlush。SdkTracerProvider.java L116–134 的 flush/shutdown 是排队路径的显式生命周期入口。

指标系列问题与 Trace 不是同一个队列。SDK ViewBuilder.java L75–90 允许按 key 筛掉不应该参与聚合的属性;必须用点数、标签与总数对照,而不是仅凭仪表盘看起来“有曲线”。上游 BatchSpanProcessorTest、SdkSpanBuilderTest、metrics ViewTest 等相关路径按固定源码静态核对;没有本机运行上游测试。

四项最小注入与最小修复

Lab29Test 四个 JUnit 场景独立运行。故障前/修复后均保留可复验的计数:

症状与定位证据 注入原因 修复及回归观察
同一父请求下看到另一个 root Trace,但内存出口仍有三个 Span 同一单线程 worker 未包装 Context;另试 alwaysOff 的 Span isRecording=false、独立出口 0,是另一种缺失 Context.current().wrap(Callable) 后 child 与父 Trace ID 相同;任务结束后 worker Current Span 无效
出口 isSuccess=false、接收端 0,但内存 SpanData 有 1 OTLP URL 错指向 /wrong(404) 保持同一 SpanData,仅换成 /v1/traces,同步 export 成功,protobuf 接收数为 1
40 次调用在图上变成 40 个按 request.id 分出的 series,总计仍是 40 每条请求 ID 都进入点的属性集合 View 仅保留 route,点数减为 2,/a、/b 各 20,总计仍是 40
已生成三个 Span,教学端点仍是 0 Batch 延迟 1 小时且子 JVM 调用 halt(0) 新的独立 JVM 重新生成三个 Span,forceFlush、shutdown 在 5 秒期限内成功,端点累计 3

Context 对照代码只改任务的包装位置;不把丢父 Context 的修复误当成“关闭采样”:

1
2
3
4
5
6
7
8
9
Callable<String> repaired = Context.current().wrap(() -> {
var child = tracer.spanBuilder("wrapped").startSpan();
try {
return child.getSpanContext().getTraceId();
} finally {
child.end();
}
});
assertEquals(parentId, worker.submit(repaired).get(5, TimeUnit.SECONDS));

JAVA_HOME=/tmp/otel-20260930/jdk-extract/usr/lib/jvm/java-21-openjdk-amd64 ./mvnw -pl sdk-labs -Dtest=Lab29Test test 在工程目录退出 0,四项通过。实验的 receiver 仅有一个 /v1/traces 教学路由;wrong endpoint 的 HTTP 失败与修复后的 1 个本地收数,不会变成“真实 Collector 已查到”。强制退出和正常退出是两个不同进程,后一轮收到的是新生成的 Span,不是找回先前丢失的三个。View 过滤的是新聚合点,不保证请求 ID 不曾进入其他 sink,也不能修复既存后端中历史曲线。高基数导致的是曲线维度错误,不是这 40 次的总数错误;如果总量也不对,继续检查采集窗口、reader、temporality、溢出等边界,不能直接归咎于 View。

哪个断言该放在哪层

优先核 Context 有效性和 parent/child Trace ID,再看 sampler 的 recording/sampled 及 SpanData 数;随后核 processor 是否入队/丢弃,出口完成状态,接收端接收数,最后独立查后端。指标先核 instrument 与 reader 的点数和窗口,再核 View 后的标签集合及聚合总量。如果只剩教学接收端证据,必须止步于该层。真实运行的 Collector/Jaeger 查询是 26–27 自己的实验,不可在这里伪造同一条 Trace 已查询。没有执行生产部署、真实指标 OTLP 或日志 OTLP。

两道练习:

  1. 修改 Lab29Test 中接收端:对 /v1/traces 返回 HTTP 200,但携带 OTLP partial_success.rejected_spans=1;并排记 isSuccess()、原始响应、server 已解析的 Span 数。构造“出口报告成功却部分拒收”的反例,不能从 200 直接说无丢失。
  2. 先让 View 仅保留 route,再人为让两个 reader 在不同时间窗或不同 temporality 下采集同一计数器;记录每个点的 start/end、attributes、value 与读数,说明为何相同 route 的图仍可能被误解。不要凭页面曲线猜测前一轮的生命周期。

参考资料:Java SDK Context 传播(固定 SHA)、Java SDK ViewBuilder(固定 SHA)、Java SDK BatchSpanProcessor(固定 SHA)、examples/opentelemetry-java/evidence/29/RUN.md。

导航:28 可观测性成本测量 · 当前篇:29 综合故障诊断。