Spring Insight 里如何对 Span 进行清洗
作者:苏渡苇
项目地址 :https://github.com/iweidujiang/spring-insight(感谢 Star !)
本文要讲什么
上一篇把存储讲完了:saveTraceSpans 负责 snapshot、淘汰、可选刷盘,控制台查的也是那份内存列表。
数据并不是 POST 进来就往列表里扔,Controller 先交给 TraceSpanCollectorService,校验过了再清洗、补全,最后才调用存储。
Agent 这边字段有时会空着------服务名只写在批次信封上、耗时没算、statusCode 没填------直接入库的话,后面按服务聚合、按状态筛选都会对不上。
这篇要讲的就是门口这一步:一批 Span 怎样被判定有效,缺的字段怎样补上,以及为什么整批失败和单条跳过不是一回事。
一、先把问题说清楚
上报体对应的是 CollectorRequest,和 Agent 里的 TraceBatchReport 字段对齐:serviceName、serviceInstance、batchId、spans。
HttpInsightBatchSink 组包时,服务名写在批次这一层,每条 Span 里也可能再带一份;Server 不能假定两条都有,更不能假定 durationMs、spanKind、statusCode 都填好了。
所以收集服务要干三件事:这批能不能收、每条缺什么补什么、补完再交给上一篇那个存储。
代码在此:
io.github.iweidujiang.springinsight.collector.service.TraceSpanCollectorService

二、整批先过一道门
processBatchRequest 一进来就问 request.isValid(),不通过就返回错误,Controller 映射成 400,列表里不会出现这批数据。
isValid() 看的是硬条件:spans 不能空,每一条都得有 traceId 和 spanId,缺一个整批退回。
java
public boolean isValid() {
if (spans == null || spans.isEmpty()) {
return false;
}
for (int i = 0; i < spans.size(); i++) {
TraceSpan span = spans.get(i);
if (span.getTraceId() == null || span.getTraceId().trim().isEmpty()) {
return false;
}
if (span.getSpanId() == null || span.getSpanId().trim().isEmpty()) {
return false;
}
}
return true;
}
这和存储里 缺 ID 就 skip看起来像重复,层级却不一样:收集服务面对的是一次 HTTP 批次,宁可整批发回来,也不让半残数据进清洗;存储那层是入库兜底,防止还有别的入口把脏对象塞进来。
Controller 上还有 @Valid,serviceName、serviceInstance 是 @NotBlank,spans 是 @NotNull,框架校验比 isValid() 更早,信封都缺的请求到不了收集服务。
过了这道门才会去改计数器、开 StopWatch、进入清洗,成功则 202,处理异常则 500。batchId 只进日志摘要,Server 目前没有按它去重,同一批重发一次就会再存一遍。
三、缺的字段就地补上
清洗在 cleanAndEnrichSpans 里,按条处理:Span 上没有服务名或实例名,就用批次信封上的顶上,然后再走 cleanAndEnrichSingleSpan。
单条补全大致是这几件事:
spanKind 空着就写成 INTERNAL。 HTTP 进站一般是 SERVER,Feign 出站是 CLIENT,补不上的先给个内部类型,后面按 kind 筛选时至少不是 null。
statusCode 空着就猜。 先看 tags 里的 http.status_code,2xx 写成 OK;没有 HTTP 码再看 success,为 true 也写成 OK;两条都没有,落成 ERROR。字段不全会被标成失败,这是当前实现,不是业务真的抛了异常。
durationMs 空着就减一下。 同时有 startTime 和 endTime 时,用结束减开始填进去,拓扑平均耗时、链路列表的 duration 都靠这个数。
tags 保证不是 null,再挂两个收集器自己的标签: collector.process.time 和 collector.version。后者现在写死成 1.0,跟项目版本号不是一回事,当处理痕迹看就行。
java
if (span.getDurationMs() == null && span.getStartTime() != null && span.getEndTime() != null) {
span.setDurationMs(span.getEndTime() - span.getStartTime());
}
if (span.getTags() == null) {
span.setTags(new HashMap<>());
}
span.getTags().put("collector.process.time", Instant.now().toString());
span.getTags().put("collector.version", "1.0");
这些改动打在请求体里的原对象上,上一篇说的 snapshot() 发生在这之后,所以内存里留下的是补全后的拷贝,不是 Agent 刚发过来的那份引用。
单条清洗若抛异常,这条跳过、其余继续,和 isValid() 的整批失败是两条路:ID 这种硬字段整批拦,补全时的意外只丢掉出事的那一条。
四、和「入库时再判断」的区别
可以对照着看:
| 收集服务 | 存储 | |
|---|---|---|
| 看到的是 | 一次 HTTP 批次 | 已经洗过的列表 |
缺 traceId / spanId |
整批 400 | 单条 skip |
| 缺服务名、耗时、状态 | 补上再往下走 | 不再补,按现有字段存 |
| 失败时 | 可以整批拒绝 | 只决定进不进列表 |
收集放在存储前面,是因为 这批像不像监测数据 和列表怎么淘汰 」不是同一个问题。补字段、打 collector.* 标签、记成功失败条数,都不该塞进带锁的 ArrayList 里去做。
存储只认补完的结果:有 ID 就 snapshot 进去,超上限就挤旧的。两边各管一段,后面换文件还是换库,清洗规则不用跟着搬。
五、自己写收集入口时可以用到的
- 信封和明细分开补。 批次上有服务名,Span 上也可能有;明细空了用信封,明细有了别覆盖,跨服务一批里混着不同
serviceName时才不会全被改成上报进程的名字。 - 硬校验和软补全分开。 没有
traceId的批次没有清洗的意义;耗时没算、状态没填,可以猜,不该把整批打回。 - 猜状态要有默认值,也要知道默认值很凶。 现在缺
statusCode、又没有 HTTP 码、success也不是true,就会落成ERROR,控制台错误分析会被这些「空字段」抬高。 - 先改原对象,入库再拷贝。 补全和 snapshot 分两步,收集侧不用自己 new 一份,存储侧也不用知道字段是谁填的。
- 计数器和写入分开。
AtomicLong记收到、成功、失败,和列表内容不是同一件事;列表被挤掉旧数据之后,计数不会回滚。
六、小结
- POST 进来之后先过
CollectorRequest.isValid(),spans为空或任何一条缺traceId/spanId,整批不会入库。 - 过门之后
cleanAndEnrichSpans用批次上的服务名补空字段,再补spanKind、statusCode、durationMs和 tags。 - 单条清洗出错只跳过这一条;存储里缺 ID 再 skip 一次,属于入库兜底。
- 收集服务改的是请求里的原对象,
snapshot()在这之后,控制台看到的是补全后的拷贝。
数据放下之后,控制台还得把「谁调用了谁」画成图,边不是前端编的,是 Span 上的 remoteService 聚出来的。
下一篇预告:看拓扑的边是怎么从一批 CLIENT Span 里抠出来的,为什么只有 Feign 出站这类带远端服务名的记录才会变成箭头。
最后:欢迎围观 Spring Insight
如果你对轻量监测、Spring Boot Starter、链路埋点感兴趣,欢迎看看这个还在打磨的小项目:
当前形态很朴素:业务服务加 spring-insight-agent-starter,旁边单独跑 insight-server 看拓扑和链路。能力有限,代码也还糙,适合当练手和对照。
你的 Star 是对我最大的鼓励。