You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.28 07:05:16