一次 HTTP 请求会被几个方法观察?

HttpURLConnection 的 connect()、getInputStream() 与 getResponseCode() 可在同一次请求中先后调用。如果每个 Advice 都无条件新建、结束 Span,就可能留下重复记录或未结束的 Span。另一个反例是 getInputStream() 因 500 抛出 IOException:是否自动记录异常事件,不能仅看业务侧是否捕获异常。本篇把“是否启动”“结束时看到什么”与“为什么不重复”拆开,分别用源码和三个独立 JVM 检验。

层次 开始路径 结束/去重路径
Instrumenter shouldStart → start,提取名称和起点属性 end 更新终点属性、status 和结束时间
HTTP Advice 读取当前 Context、同连接的状态 CallDepth、VirtualField 保存的状态和 finished 标志
输出 Scope 临时成为 current 客户端 200/500/重复方法各得到一个 CLIENT Span

静态源码:谁负责抑制、属性和异常?

冻结 SDK v1.31.0 完整 SHA c25c0a0ee0da01ab2f74ba83052d1c249ed57020、instrumentation v1.31.0 完整 SHA 97d87f3f3b2bc61aa396d71a98387a1e857618af。以下是静态阅读,不是本地上游测试运行结果。

HttpUrlConnectionSingletons L24–54 构造 Instrumenter<HttpURLConnection, Integer>:HTTP 名称/属性提取器、HttpSpanStatusExtractor、peer service 提取器、GetOutputStreamContext.init 的 context customizer 与请求指标回调(本文未读取指标)。Instrumenter.shouldStart L108–119 在 enabled=false 或 spanSuppressor.shouldSuppress(...) 时返回 false;InstrumenterBuilder L356–369 从提取器的 SpanKey 构建抑制规则,SpanSuppressors L62–85 查询 parent Context 的键。并非所有嵌套都必须被抑制。

Instrumenter.doStart L162–208 先提取 onStart 属性,调用 context customizer,再创建 Span,将 Span 及抑制标记存入派生 Context;这不表示修改了原 parent。L211–246 在 error != null 时记录异常事件,运行 onEnd 属性提取器、状态提取器,最后 end。上游 InstrumenterTest L180–237 比较成功与异常状态;这些测试并未在本机执行。

对这个 HTTP 模块,Instrumenter 不是唯一的去重手段。HttpUrlConnectionAdvice L66–95 先以 CallDepth 排除递归,调用 shouldStart,再查同一连接 VirtualField 的既有状态。L98–149 在退出时恢复 Scope,响应码 >=400 的 getInputStream 抛异常时故意向 end 传 null,但传入 HTTP 响应码;状态由 HTTP status extractor 给出。这个特例避免把 HTTP 错误响应的客户端 IOException 当成独立异常事件,不等于所有 IOException 都被忽略。上游 HttpUrlConnectionTest L99–125 包含多次读取同一连接的测试。

三个进程,各一次受控请求

没有新依赖:Agent JAR 1.31.0 SHA-256 e866de5fa4e4c2c7d076072798fb9ca65b679d9c6be7792b37d4c94e2bc686f7,HTTP 实现是 JDK 21.0.12 内建 HttpURLConnection(lib/modules SHA-256 9a8e9e8a7aab3a5e010e56c65f13449c17b08e5eede93cd7bb796b7df31c572b)。从 examples/opentelemetry-java/ 运行:

1
JAVA_HOME=/tmp/otel-20260930/jdk-extract/usr/lib/jvm/java-21-openjdk-amd64 ./mvnw -q -pl sdk-labs -Dtest=Lab23Test test

退出码 0;JUnit 对正常 200、错误 500、同连接的 connect() → getInputStream() → getResponseCode() 分别启动独立的子 JVM,使用有期限的进程/接收屏障。教学 /v1/traces 端点解码 protobuf,断言每次恰好一个已结束的 CLIENT Span;原始摘要:

1
LAB23 normal=count:1,kind:CLIENT,status:UNSET error=count:1,kind:CLIENT,status:ERROR,exceptionEvents:0 nested=count:1,kind:CLIENT,status:UNSET

500 路径业务客户端确实捕获 IOException,但已知状态码,Span 为 ERROR 且 没有异常事件;这不是“异常被吞掉”,而是 Advice 选择了状态码语义。嵌套调用指同一个连接上的多个方法,不是两个不同 HTTP 客户端互相嵌套。一个 Span 的结果同时受 Advice call depth、同连接 VirtualField 和 Instrumenter 的 gate 影响,不能把这一次无重复直接归因于跨组件 SpanSuppressor。跨模块共享 SpanKey 的运行时抑制尚未单独验证。

失效边界、误解与练习

工程原则是把“记录异常”与“判定 HTTP 状态错误”分开检查,并对成对的 start/end 与 Scope 恢复逐段验收。实验只检查单请求、同步进程和内存教学 HTTP 端点,不能证明网络超时、并发取消、Collector 接收或后端查询。18–20 的三信号关联仍仅在进程内;此处只观察 Trace 的教学端点。两道练习:

  1. 修改 AgentHttpProbe 在同一连接上重复调用 getInputStream()(先读尽并关闭前一次流),比较 Span 数量;若出现异常,分别检查是业务 I/O 错误、Advice 抑制异常还是 end 时机改变。
  2. 把 /error 从 HTTP 500 改成连接拒绝,独立断言 status 与异常 event。为何不能从本篇的 exceptionEvents:0 推论所有 IOException 都不会记录?

参考资料:Instrumenter 与单元测试(固定 SHA)、HTTP URLConnection Advice(固定 SHA)、Java SDK(固定 SHA)、命令与原始输出:examples/opentelemetry-java/evidence/23/RUN.md。

导航:21 Agent 启动 · 22 匹配与 Advice · 当前篇:23 Instrumenter。