Spring Insight 里如何把 Span 画成瀑布时间线
作者:苏渡苇
项目地址 :github.com/iweidujiang...(感谢 Star !)
点开一行,扁平列表还画不出快慢
上一篇把链路列表收成一行摘要:入口、墙钟耗时、整行有没有 ERROR。
点进去之后,人真正想看的是 哪一段把时间吃掉了 。接口给回来的仍是一堆扁平 Span,有 startTime、durationMs、parentSpanId,但没有现成的左右坐标,也没有现成的缩进。
详情页把这堆数据交给前端的 buildTraceTimeline,算出每条的深度、相对整段链路的偏移、条宽,再画成瀑布。Server 不预计算图形,只保证同一 traceId 的 Span 按开始时间排好、以快照交出去。
这篇就顺着这条线:明细怎么捞,树怎么深,耗时时间条怎么落在时间轴上,橙边那条关键路径又是怎么爬出来的。

详情接口只负责把这条 Trace 捞齐
点列表某一行,浏览器再打一次:
text
GET /api/v1/ui/traces/{traceId}
后端是 TraceSpanPersistenceService.getTraceById:加锁扫内存,留下 traceId 相同的记录,按 startTime 升序,再 snapshot() 一份交出去。没有索引,也没有在 Server 里算深度。
java
return spans.stream()
.filter(s -> traceId.equals(s.getTraceId()))
.sorted(Comparator.comparing(s -> n(s.getStartTime())))
.map(TraceSpan::snapshot)
.collect(Collectors.toList());
页面顶部那几个数------总耗时、Span 数、涉及服务、异常条数------都是前端拿这份列表算的,不是列表接口里那行摘要原样搬下来。两边算法接近,数据源都是同一批 Span。

缩进靠 parentSpanId 往上爬
瀑布左侧的服务名会往里缩,一层父子缩一档,CSS 是 12 + depth * 16 像素。depth 不是接口字段,是前端用 parentSpanId 算的。
computeDepths 先按 spanId 建一张 Map,再对每个节点往父节点递归:找到父就 +1,父不在这批数据里、或根本没有 parent,深度就是 0。顺手用一个 visiting 集合防环,链路数据若把父子指成圈,直接停在 0,避免把浏览器递归炸了。
ts
const depthOf = (spanId: string, visiting: Set<string>): number => {
if (depthCache.has(spanId)) return depthCache.get(spanId)!
if (visiting.has(spanId)) return 0
visiting.add(spanId)
const span = byId.get(spanId)
if (!span || !span.parentSpanId || !byId.has(span.parentSpanId)) {
depthCache.set(spanId, 0)
return 0
}
const d = depthOf(span.parentSpanId, visiting) + 1
depthCache.set(spanId, d)
return d
}
跨服务时,下游进程如果没把上游的 spanId 当成 parent 传过来,这批里就会出现好几个深度为 0 的根,树会摊平。那不是画图算错,是采集时上下文没接上。
耗时条的位置:相对最早开始的偏移
时间轴的零点是这批 Span 里最早的 startTime,右端是最晚的结束。缺 durationMs 就用 endTime - startTime 补,还补不上就先记 1 毫秒,避免宽度算成 0 看不见。
每条相对零点的偏移、自身耗时,再除以整段墙钟,变成百分比:
ts
offsetMs = startTime - minStart
offsetPct = offsetMs / totalDurationMs * 100
widthPct = durationMs / totalDurationMs * 100
画的时候 left 和 width 吃这两个百分比,条太窄时宽度会保一个下限,免得 1ms 的调用缩成一条缝。颜色按 serviceName 轮换,同一服务同一色;异常条改成红色。轴上的刻度是把总时长切成 5 段,用 formatDuration 显示成 ms / s。
这里的总时长和上一篇列表里的 durationMs 是同一思路:maxEnd - minStart,不是把子 Span 耗时加总。并行的两条 Feign 并排出现在轴上,而不是首尾相接。

橙边那条:从最晚结束的叶子爬回根
页面提示里有一句:橙边是关键路径,也就是 最晚结束的那条路径 。实现在 computeCriticalPathIds。
先统计每个 spanId 有几个孩子,孩子数为 0 的当叶子。在叶子里挑结束时间最晚的那一个,再沿着 parentSpanId 一路爬到根,路上的节点打上 isOnCriticalPath。没有叶子时,退回结束最晚的任意一条再爬。爬的时候用 guard 防环,避免坏数据转圈。
这条路径回答的是:整段墙钟被谁拖到最后。它不一定是耗时最长的单条 Span------一个很短的尾部调用,只要结束得最晚,也会把祖先一起涂上橙边。红条是异常,橙边是时间,两套标记可以叠在同一条上。
时间线看完,慢在哪一段就有数了
详情接口只交扁平 Span,瀑布上的缩进、条的左右位置、关键路径,都是浏览器里 buildTraceTimeline 算出来的。深度跟 parentSpanId 走,偏移和条宽跟最早开始、最晚结束走,橙边从最晚结束的叶子爬回根。
看图时有两处容易误会。树摊平了,多半是跨进程没把 parent 带上,不是 CSS 没缩进;橙边不是自动等于最慢的那一条,它标的是把整段请求拖到结束的那条链。
最后:欢迎围观 Spring Insight
Spring Insight 是给 Spring Boot / Spring Cloud 用的轻量监测工具:业务服务加 spring-insight-agent-starter 上报,旁边跑一个 insight-server,控制台就能看服务拓扑和链路详情。
仓库在这:
🔗 GitHub :github.com/iweidujiang...
用得上的话欢迎 Star,遇到问题直接开 Issue。