基于Vue3组合式API实现类Jaeger的OpenTelemetry链路Span图谱
实现Vue3链路Span图谱方案
一、先把扁平数据转成树形结构
后端返回的traceSpanData是扁平数组,要实现树形标签,第一步得把它转成父子嵌套的结构:
// script setup里的数据处理逻辑 const buildSpanTree = (spans) => { const spanMap = new Map(); const rootSpans = []; // 先把所有span存入Map,方便快速查找父节点 spans.forEach(span => { spanMap.set(span.context.spanId, { ...span, children: [] }); }); // 遍历构建父子关系 spans.forEach(span => { if (span.parentId && spanMap.has(span.parentId)) { spanMap.get(span.parentId).children.push(spanMap.get(span.context.spanId)); } else { rootSpans.push(spanMap.get(span.context.spanId)); } }); return rootSpans; }; // 假设后端返回的数据存在response.data.traceSpanData里 const spanTree = buildSpanTree(response.data.traceSpanData);
二、用自定义Vue组件替代Chart.js
Chart.js对Y轴树形标签的支持有限,直接写自定义组件更灵活,核心分两部分:左侧递归渲染树形标签,右侧绘制时间轴和Span块。
1. 递归树形标签组件
<template> <div class="span-label-item"> <div class="label-content" :style="{ paddingLeft: `${level * 20}px` }"> {{ span.name }} <span v-if="span.attributes?.http.route" class="route-tag">({{ span.attributes.http.route }})</span> </div> <div v-for="child in span.children" :key="child.context.spanId"> <SpanLabel :span="child" :level="level + 1" /> </div> </div> </template> <script setup> const props = defineProps({ span: Object, level: { type: Number, default: 0 } }); </script> <style scoped> .span-label-item { margin: 8px 0; } .label-content { line-height: 28px; } .route-tag { font-size: 12px; color: #666; margin-left: 8px; } </style>
2. 时间轴与Span块渲染
主组件里计算全局时间范围,再根据每个Span的起止时间计算位置和宽度:
<template> <div class="span-chart-container"> <!-- 左侧树形标签列 --> <div class="label-column"> <SpanLabel v-for="root in spanTree" :key="root.context.spanId" :span="root" /> </div> <!-- 右侧时间轴区域 --> <div class="chart-column"> <!-- 时间刻度 --> <div class="time-axis"> <span v-for="tick in timeTicks" :key="tick" class="time-tick">{{ tick }}</span> </div> <!-- Span块容器 --> <div class="span-blocks-container"> <RenderSpanBlock v-for="root in spanTree" :key="root.context.spanId" :span="root" :start-timestamp="startTimestamp" :total-duration="totalDuration" /> </div> </div> </div> </template> <script setup> import { computed, h } from 'vue'; import SpanLabel from './SpanLabel.vue'; // 这里用之前构建好的spanTree const spanTree = /* 转换后的树形数据 */; // 把时间字符串转成时间戳(根据后端返回的时间格式调整) const parseTime = (timeStr) => new Date(timeStr).getTime(); // 计算全局时间范围 const allTimestamps = computed(() => { const timestamps = []; const traverse = (span) => { timestamps.push(parseTime(span.startTime)); timestamps.push(parseTime(span.endTime)); span.children.forEach(traverse); }; spanTree.forEach(traverse); return timestamps; }); const startTimestamp = computed(() => Math.min(...allTimestamps.value)); const endTimestamp = computed(() => Math.max(...allTimestamps.value)); const totalDuration = computed(() => endTimestamp.value - startTimestamp.value); // 生成时间刻度(简化版,可按需优化精度) const timeTicks = computed(() => { const ticks = []; const step = totalDuration.value / 5; for (let i = 0; i <= 5; i++) { const time = new Date(startTimestamp.value + step * i); ticks.push(time.toLocaleTimeString()); } return ticks; }); // 递归渲染Span块的组件 const RenderSpanBlock = defineComponent({ props: { span: Object, startTimestamp: Number, totalDuration: Number }, setup(props) { const spanLeft = computed(() => { const spanStart = parseTime(props.span.startTime); return ((spanStart - props.startTimestamp) / props.totalDuration) * 100 + '%'; }); const spanWidth = computed(() => { const spanStart = parseTime(props.span.startTime); const spanEnd = parseTime(props.span.endTime); return ((spanEnd - spanStart) / props.totalDuration) * 100 + '%'; }); return () => h('div', { class: 'span-block', style: { left: spanLeft.value, width: spanWidth.value }, title: `${props.span.name}\n开始: ${props.span.startTime}\n结束: ${props.span.endTime}` }, [ h('span', { class: 'span-name' }, props.span.name), props.span.children.map(child => h(RenderSpanBlock, { span: child, startTimestamp: props.startTimestamp, totalDuration: props.totalDuration })) ]); } }); </script> <style scoped> .span-chart-container { display: flex; width: 100%; margin-top: 20px; border: 1px solid #eee; } .label-column { width: 250px; border-right: 1px solid #eee; padding: 10px; } .chart-column { flex: 1; position: relative; padding: 10px; } .time-axis { display: flex; justify-content: space-between; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #eee; } .time-tick { font-size: 12px; color: #666; } .span-blocks-container { position: relative; height: 100%; } .span-block { position: absolute; height: 28px; background-color: #42b983; border-radius: 4px; margin: 8px 0; display: flex; align-items: center; padding: 0 8px; color: white; font-size: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .span-block:nth-child(odd) { background-color: #35495e; } </style>
三、可选方案:用专业链路可视化库
如果不想自己写组件,可以找现成的链路可视化库适配,比如:
- Jaeger UI的Span图谱逻辑:参考其实现,把你的数据转换成对应格式后复用核心渲染逻辑
- OpenTelemetry官方UI组件:原生支持Span父子层级展示和时间轴渲染,只需做数据格式转换
内容的提问来源于stack exchange,提问作者Shehab
相关产品推荐
相关产品推荐

