fix(traces): zoom controls on trace graphs, per-branch tree metrics, catalog errors, org switch with Auto Run off

Trace Graph (Tree View and Graph View) and Flame Graph had no zoom
buttons, so zooming needed a mouse wheel or trackpad gesture. They now
share the zoom in / zoom out / fit control and zoom logic that Service
Graph already had (useChartZoom + ChartZoomControls). Flame Graph zooms
by narrowing the visible time range and pans by dragging or by
horizontal / shift wheel; selecting a span outside the window brings it
into view, and a new trace resets the zoom.

Tree View gave every copy of a repeated service the same latency and
error state, built from all of that service's spans. Each node now takes
its numbers from the spans on its own ancestor path, and the hover
tooltip resolves the node by its position in the tree the chart draws,
so it stays right while the service search filters the tree.

Service Catalog selected infer_service_system and infer_service_type
whenever infer_service_name existed, so streams without them got a 400,
and the failure was shown as "No services found" or as the previous
stream's rows. Absent columns are now selected as NULL, and a failed
query clears the rows and shows the load-error state with the server
message and Retry.

With Auto Run off, a header org switch still searched: the remounted
page could read the previous org's URL, whose stream= counted as a
shared link, and the Rate and Duration panels queried because
searchApplied survived the reset. During an org switch a URL that still
names the org being left is no longer treated as a shared link, and the
reset clears searchApplied. A shared link for another org still
searches.
This commit is contained in:
omkark06
2026-11-03 23:51:30 +05:30
committed by Omkar Kesarkhane
parent cd1264bfd1
commit 1c06082a82
22 changed files with 1439 additions and 212 deletions
@@ -0,0 +1,44 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/locales";
import ChartZoomControls from "@/components/traces/ChartZoomControls.vue";
const mountControls = () =>
mount(ChartZoomControls, {
props: { dataTestPrefix: "trace-graph" },
global: { plugins: [i18n] },
});
describe("ChartZoomControls", () => {
it("prefixes every data-test with dataTestPrefix", () => {
const wrapper = mountControls();
for (const suffix of ["zoom-controls", "zoom-in", "zoom-out", "fit-screen"]) {
expect(wrapper.find(`[data-test="trace-graph-${suffix}"]`).exists()).toBe(true);
}
});
it.each([
["zoom-in", "zoom-in"],
["zoom-out", "zoom-out"],
["fit-screen", "fit"],
])("clicking %s emits %s", async (suffix, event) => {
const wrapper = mountControls();
await wrapper.find(`[data-test="trace-graph-${suffix}"]`).trigger("click");
expect(wrapper.emitted(event)).toHaveLength(1);
});
});
@@ -0,0 +1,72 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div
class="rounded-default border-border-default bg-surface-panel absolute right-3 bottom-3 z-10 flex flex-col overflow-hidden border"
:data-test="`${dataTestPrefix}-zoom-controls`"
>
<OButton
variant="ghost"
size="icon-sm"
icon-left="add"
class="rounded-none"
:data-test="`${dataTestPrefix}-zoom-in`"
@click="emit('zoom-in')"
>
<OTooltip side="left" :content="t('traces.serviceGraph.zoomIn')" />
</OButton>
<OSeparator />
<OButton
variant="ghost"
size="icon-sm"
icon-left="remove"
class="rounded-none"
:data-test="`${dataTestPrefix}-zoom-out`"
@click="emit('zoom-out')"
>
<OTooltip side="left" :content="t('traces.serviceGraph.zoomOut')" />
</OButton>
<OSeparator />
<OButton
variant="ghost"
size="icon-sm"
icon-left="fullscreen"
class="rounded-none"
:data-test="`${dataTestPrefix}-fit-screen`"
@click="emit('fit')"
>
<OTooltip side="left" :content="t('traces.serviceGraph.fitToScreen')" />
</OButton>
</div>
</template>
<script setup lang="ts">
import { useI18nTyped } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
defineProps<{ dataTestPrefix: string }>();
const emit = defineEmits<{
"zoom-in": [];
"zoom-out": [];
fit: [];
}>();
const { t } = useI18nTyped();
</script>
@@ -1617,6 +1617,259 @@ describe("FlameGraphView span event markers", () => {
});
});
describe("FlameGraphView zoom", () => {
const span = (span_id: string, startOffsetMs: number, durationMs: number) =>
createMockSpan({ span_id, startOffsetMs, durationMs, events: [] });
beforeEach(() => {
config.global.stubs = {
ChartRenderer: ChartRendererStub,
TraceDetailsSidebar: TraceDetailsSidebarStub,
};
});
afterEach(() => {
config.global.stubs = {};
});
const mountView = () =>
mount(FlameGraphView, {
props: {
spans: [span("left", 0, 5), span("edge", 5, 10), span("middle", 40, 20)],
traceDuration: 100,
selectedSpanId: null,
},
});
const xRange = (wrapper: any) => {
const { min, max } = wrapper.vm.chartOptions.xAxis;
return [min, max];
};
// Mirrors ECharts' value-to-pixel mapping for a 480px plot starting at x = 10.
const renderAt = (wrapper: any, dataIndex: number) => {
const [min, max] = xRange(wrapper);
const coordSys = { x: 10, y: 10, width: 480, height: 100 };
const item = wrapper.vm.chartOptions.series[0].data[dataIndex];
const api = {
value: (i: number) => item.value[i],
coord: ([xPct]: number[]) => [coordSys.x + ((xPct - min) / (max - min)) * coordSys.width, 0],
style: (s: any) => s,
};
return wrapper.vm.chartOptions.series[0].renderItem({ dataIndex, coordSys }, api);
};
it("shows the zoom controls when there is data", () => {
const wrapper = mountView();
expect(wrapper.find('[data-test="flame-graph-zoom-controls"]').exists()).toBe(true);
});
it("hides the zoom controls without data", () => {
const wrapper = mount(FlameGraphView, {
props: { spans: [], traceDuration: 0, selectedSpanId: null },
});
expect(wrapper.find('[data-test="flame-graph-zoom-controls"]').exists()).toBe(false);
});
it("zoom in narrows the x range around its centre and zoom out widens it", async () => {
const wrapper = mountView();
expect(xRange(wrapper)).toEqual([0, 100]);
await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click");
expect(xRange(wrapper)).toEqual([10, 90]);
await wrapper.find('[data-test="flame-graph-zoom-out"]').trigger("click");
const [min, max] = xRange(wrapper);
expect(min).toBeCloseTo(0);
expect(max).toBeCloseTo(100);
});
it("stops zooming in at a 1% window", async () => {
const wrapper = mountView();
for (let i = 0; i < 40; i++) {
await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click");
}
const [min, max] = xRange(wrapper);
expect(max - min).toBeCloseTo(1);
});
it("fit resets the x range to the whole trace", async () => {
const wrapper = mountView();
await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click");
await wrapper.find('[data-test="flame-graph-fit-screen"]').trigger("click");
expect(xRange(wrapper)).toEqual([0, 100]);
});
it("labels the ruler with the visible window", async () => {
const wrapper = mountView();
await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click");
const labels = wrapper.vm.timelineTicks.map((tick: any) => tick.label);
expect(labels[0]).toBe("10.00ms");
expect(labels[4]).toBe("90.00ms");
});
it("maps the cursor onto the visible window", async () => {
const wrapper = mountView();
await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click");
// offsetX 10 is the plot's left edge, so it reads the window start.
wrapper.vm.handleChartMouseMove({
clientX: 10,
currentTarget: { getBoundingClientRect: () => ({ left: 0, width: 500 }) },
});
expect(wrapper.vm.cursorTimeLabel).toBe("10.00ms");
});
it("hides the time cursor while the pointer is over the zoom controls", async () => {
const wrapper = mountView();
const chartWrapper = wrapper.find('[data-test="flame-graph-view-chart-wrapper"]');
const controls = wrapper.find('[data-test="flame-graph-zoom-controls"]');
const rect = { left: 0, top: 0, width: 500, height: 300, right: 500, bottom: 300 };
chartWrapper.element.getBoundingClientRect = () => rect as DOMRect;
await chartWrapper.trigger("mousemove", { clientX: 100 });
expect(wrapper.vm.cursorVisible).toBe(true);
await controls.trigger("mouseenter");
expect(wrapper.vm.cursorVisible).toBe(false);
await controls.trigger("mousemove", { clientX: 480 });
expect(wrapper.vm.cursorVisible).toBe(false);
await chartWrapper.trigger("mousemove", { clientX: 100 });
expect(wrapper.vm.cursorVisible).toBe(true);
});
it("skips blocks outside the window and cuts blocks at its edge", async () => {
const wrapper = mountView();
await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click");
expect(renderAt(wrapper, 0)).toBeNull();
const edge = renderAt(wrapper, 1);
expect(edge.shape.x).toBe(10);
expect(edge.shape.width).toBeCloseTo((5 / 80) * 480);
const middle = renderAt(wrapper, 2);
expect(middle.shape.x).toBeCloseTo(10 + (30 / 80) * 480);
expect(middle.shape.width).toBeCloseTo((20 / 80) * 480);
});
const zoomInTimes = async (wrapper: any, times: number) => {
for (let i = 0; i < times; i++) {
await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click");
}
};
// A 500px scroll area leaves a 480px plot between the 10px grid insets.
const scrollArea = (wrapper: any) => {
const area = wrapper.find('[data-test="flame-graph-view-chart-scroll"]');
Object.defineProperty(area.element, "clientWidth", { configurable: true, value: 500 });
return area;
};
it("pans the zoomed window when the chart is dragged, and not on a click-sized move", async () => {
const wrapper = mountView();
await zoomInTimes(wrapper, 1);
const area = scrollArea(wrapper);
await area.trigger("mousedown", { button: 0, clientX: 200 });
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 197 }));
expect(xRange(wrapper)).toEqual([10, 90]);
// Dragging left by 48px moves the window right by 48 / 480 of its 80% width.
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 152 }));
expect(xRange(wrapper)[0]).toBeCloseTo(18);
expect(xRange(wrapper)[1]).toBeCloseTo(98);
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 0 }));
expect(xRange(wrapper)).toEqual([20, 100]);
window.dispatchEvent(new MouseEvent("mouseup"));
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 400 }));
expect(xRange(wrapper)).toEqual([20, 100]);
});
it("pans on horizontal and shift wheel but leaves a vertical wheel to scroll", async () => {
const wrapper = mountView();
await zoomInTimes(wrapper, 1);
const area = scrollArea(wrapper);
const vertical = new WheelEvent("wheel", { deltaY: 48, cancelable: true });
area.element.dispatchEvent(vertical);
expect(vertical.defaultPrevented).toBe(false);
expect(xRange(wrapper)).toEqual([10, 90]);
const horizontal = new WheelEvent("wheel", { deltaX: 48, cancelable: true });
area.element.dispatchEvent(horizontal);
expect(horizontal.defaultPrevented).toBe(true);
expect(xRange(wrapper)[0]).toBeCloseTo(18);
const shifted = new WheelEvent("wheel", { deltaY: -48, shiftKey: true, cancelable: true });
area.element.dispatchEvent(shifted);
expect(shifted.defaultPrevented).toBe(true);
expect(xRange(wrapper)[0]).toBeCloseTo(10);
});
it("pans to a span selected from outside the chart when it lies outside the window", async () => {
const wrapper = mountView();
await zoomInTimes(wrapper, 10);
const [min, max] = xRange(wrapper);
const width = max - min;
expect(min).toBeGreaterThan(5);
await wrapper.setProps({ selectedSpanId: "left" });
expect(xRange(wrapper)[0]).toBeCloseTo(0);
expect(xRange(wrapper)[1]).toBeCloseTo(width);
// "middle" (40-60%) is wider than the window, so the window starts at the span.
await wrapper.setProps({ selectedSpanId: "middle" });
expect(xRange(wrapper)[0]).toBeCloseTo(40);
// "edge" (5-15%) no longer overlaps; a span already in view does not move the window.
await wrapper.setProps({ selectedSpanId: "edge" });
const afterEdge = xRange(wrapper);
expect(afterEdge[0]).toBeLessThan(15);
await wrapper.setProps({ selectedSpanId: "left" });
expect(xRange(wrapper)).toEqual(afterEdge);
});
it("resets to the whole trace when the spans change", async () => {
const wrapper = mountView();
await zoomInTimes(wrapper, 2);
await wrapper.setProps({ spans: [span("other", 0, 50)] });
expect(xRange(wrapper)).toEqual([0, 100]);
await zoomInTimes(wrapper, 2);
await wrapper.setProps({ traceDuration: 200 });
expect(xRange(wrapper)).toEqual([0, 100]);
});
it("keeps the event flag of a narrow block cut at the window's left edge", async () => {
const startNs = 1752490492843000000;
const wrapper = mount(FlameGraphView, {
props: {
spans: [
createMockSpan({
span_id: "cut",
start_time: startNs,
startOffsetMs: 8,
durationMs: 3,
events: [{ name: "a", _timestamp: startNs }],
}),
],
traceDuration: 100,
selectedSpanId: null,
},
});
await zoomInTimes(wrapper, 1);
const result = renderAt(wrapper, 0);
expect(result.type).toBe("group");
const [block, flag] = result.children;
expect(block.shape.x).toBe(10);
expect(flag.shape.x).toBe(10);
});
});
describe("FlameGraphView severity colours", () => {
// renderItem returns raw ECharts shapes, which cannot take Tailwind classes,
// so this surface resolves token values at draw time via getComputedStyle.
+154 -26
View File
@@ -27,7 +27,7 @@
<!-- Ruler + chart: outer flex column, mousemove for cursor badge on ruler -->
<div
data-test="flame-graph-view-chart-wrapper"
class="flex min-h-0 flex-1 flex-col"
class="relative flex min-h-0 flex-1 flex-col"
@mousemove="handleChartMouseMove"
@mouseleave="cursorVisible = false"
>
@@ -79,7 +79,13 @@
</div>
<!-- Scrollable chart area: grows to fit all rows, scrolls vertically -->
<div ref="chartScrollRef" class="relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<div
ref="chartScrollRef"
data-test="flame-graph-view-chart-scroll"
class="relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
@mousedown="startPan"
@wheel="handleWheel"
>
<div
:style="{
height: chartContentHeight + 'px',
@@ -102,6 +108,16 @@
></div>
</div>
</div>
<ChartZoomControls
v-if="hasData"
data-test-prefix="flame-graph"
@mouseenter="cursorVisible = false"
@mousemove.stop
@zoom-in="zoomIn"
@zoom-out="zoomOut"
@fit="fitToScreen"
/>
</div>
<!-- Empty State -->
@@ -155,7 +171,7 @@
</template>
<script setup lang="ts">
import { ref, computed, defineAsyncComponent, nextTick, watch } from "vue";
import { ref, computed, defineAsyncComponent, nextTick, watch, onBeforeUnmount } from "vue";
import { useStore } from "vuex";
import { useI18nTyped } from "@/types/i18n";
import useResizer from "@/composables/useResizer";
@@ -165,6 +181,13 @@ import { maxOf } from "@/plugins/traces/threadView.utils";
import { getOrSetServiceColor } from "@/utils/traces/serviceColorRegistry";
import { getContrastTextColor } from "@/utils/traces/traceColors";
import { escapeHtml } from "@/utils/html";
import ChartZoomControls from "@/components/traces/ChartZoomControls.vue";
import {
CHART_ZOOM_STEP,
panRange,
zoomRange,
type ZoomRange,
} from "@/composables/traces/useChartZoom";
import {
normalizeSpanEvents,
toSpanEventMarkers,
@@ -248,6 +271,11 @@ const { value: bottomPanelHeight, onMouseDown: startResize } = useResizer({
const chartScrollRef = ref<HTMLElement | null>(null);
// Constants
const FULL_RANGE: ZoomRange = { min: 0, max: 100 };
const MIN_VISIBLE_SPAN = 1;
// Same distance ECharts allows before a press stops counting as a click.
const DRAG_THRESHOLD_PX = 4;
const MIN_SPAN_WIDTH_PERCENT = 0.1;
const BLOCK_PADDING = 2;
const MIN_BLOCK_WIDTH = 1;
const BLOCK_HEIGHT = 24;
@@ -371,6 +399,8 @@ defineExpose({ buildSpanEventMarkers, severityColor });
const GRID_LEFT = 10;
const GRID_RIGHT = 10;
const viewRange = ref<ZoomRange>({ ...FULL_RANGE });
// Computed
const totalSpans = computed(() => props.spans.length);
@@ -389,12 +419,99 @@ const timelineTicks = computed(() => {
const duration = props.traceDuration || 0;
const totalPad = GRID_LEFT + GRID_RIGHT;
return [0, 0.25, 0.5, 0.75, 1].map((fraction) => ({
label: formatDuration(duration * fraction),
label: formatDuration(duration * viewFractionToTrace(fraction)),
left: `calc(${GRID_LEFT}px + ${fraction} * (100% - ${totalPad}px))`,
transform: fraction === 1 ? "translateX(-100%) translateY(-50%)" : "translateY(-50%)",
}));
});
function viewFractionToTrace(fraction: number): number {
const { min, max } = viewRange.value;
return (min + fraction * (max - min)) / 100;
}
const zoomIn = () => {
viewRange.value = zoomRange(viewRange.value, CHART_ZOOM_STEP, FULL_RANGE, MIN_VISIBLE_SPAN);
};
const zoomOut = () => {
viewRange.value = zoomRange(viewRange.value, 1 / CHART_ZOOM_STEP, FULL_RANGE, MIN_VISIBLE_SPAN);
};
const fitToScreen = () => {
viewRange.value = { ...FULL_RANGE };
};
const isZoomed = () => viewRange.value.max - viewRange.value.min < FULL_RANGE.max - FULL_RANGE.min;
function pixelsToRange(px: number): number {
const plotWidth = (chartScrollRef.value?.clientWidth ?? 0) - GRID_LEFT - GRID_RIGHT;
if (plotWidth <= 0) return 0;
return (px / plotWidth) * (viewRange.value.max - viewRange.value.min);
}
let panStart: { clientX: number; range: ZoomRange; dragging: boolean } | null = null;
const onPanMove = (event: MouseEvent) => {
if (!panStart) return;
const dx = event.clientX - panStart.clientX;
if (!panStart.dragging && Math.abs(dx) <= DRAG_THRESHOLD_PX) return;
panStart.dragging = true;
viewRange.value = panRange(panStart.range, -pixelsToRange(dx), FULL_RANGE);
};
const stopPan = () => {
panStart = null;
window.removeEventListener("mousemove", onPanMove);
window.removeEventListener("mouseup", stopPan);
};
const startPan = (event: MouseEvent) => {
if (event.button !== 0 || !isZoomed()) return;
stopPan();
panStart = { clientX: event.clientX, range: { ...viewRange.value }, dragging: false };
window.addEventListener("mousemove", onPanMove);
window.addEventListener("mouseup", stopPan);
};
const handleWheel = (event: WheelEvent) => {
const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY);
if (!isZoomed() || (!horizontal && !event.shiftKey)) return;
event.preventDefault();
const delta = horizontal ? event.deltaX : event.deltaY;
viewRange.value = panRange(viewRange.value, pixelsToRange(delta), FULL_RANGE);
};
const revealSpan = (spanId: string) => {
const span = props.spans.find((s) => s.span_id === spanId);
const duration = props.traceDuration;
if (!span || !duration) return;
const start = (span.startOffsetMs / duration) * 100;
const end = start + Math.max((span.durationMs / duration) * 100, MIN_SPAN_WIDTH_PERCENT);
const { min, max } = viewRange.value;
if (end > min && start < max) return;
const width = max - min;
const target = end - start < width ? (start + end) / 2 - width / 2 : start;
viewRange.value = panRange(viewRange.value, target - min, FULL_RANGE);
};
watch([() => props.spans, () => props.traceDuration], fitToScreen);
onBeforeUnmount(stopPan);
// Clips only at edges the zoom has cut; the unzoomed view keeps blocks that overhang the grid.
function clipToVisibleRange(
x: number,
width: number,
coordSys?: { x: number; width: number },
): { x: number; width: number } {
const { min, max } = viewRange.value;
const left = coordSys && min > FULL_RANGE.min ? Math.max(x, coordSys.x) : x;
const right =
coordSys && max < FULL_RANGE.max ? Math.min(x + width, coordSys.x + coordSys.width) : x + width;
return { x: left, width: right - left };
}
// Selected span lookup from spanMap
const selectedSpan = computed(() => {
if (!props.selectedSpanId) return null;
@@ -483,7 +600,7 @@ const flameGraphDataAndDepth = computed(() => {
// Spans with durationPercent less than 0.1% are invisble in chart
// Added min 0.1% durationPercent
if (durationPercent < 0.1) durationPercent = 0.1;
if (durationPercent < MIN_SPAN_WIDTH_PERCENT) durationPercent = MIN_SPAN_WIDTH_PERCENT;
const visualRow = rowMap.get(span.span_id) ?? span.depth;
const isSelected = span.span_id === props.selectedSpanId;
@@ -572,8 +689,8 @@ const chartOptions = computed(() => {
},
xAxis: {
type: "value",
min: 0,
max: 100,
min: viewRange.value.min,
max: viewRange.value.max,
show: false,
},
yAxis: {
@@ -594,9 +711,13 @@ const chartOptions = computed(() => {
const point1 = api.coord([startX, 0]);
const point2 = api.coord([startX + width, 0]);
const x = point1[0];
const fullX = point1[0];
const y = ROW_ORIGIN_Y + depth * (BLOCK_HEIGHT + BLOCK_PADDING);
const rectWidth = point2[0] - point1[0];
const fullWidth = Math.max(point2[0] - point1[0], MIN_BLOCK_WIDTH);
// The series is not clipped (markers overhang the grid), so cut blocks at a zoomed-in edge by hand.
const { x, width: rectWidth } = clipToVisibleRange(fullX, fullWidth, params.coordSys);
if (rectWidth <= 0) return null;
const blockWidth = Math.max(rectWidth, MIN_BLOCK_WIDTH);
const span = data[params.dataIndex].spanData;
@@ -605,22 +726,27 @@ const chartOptions = computed(() => {
// coordinate system and cannot drift from it. They are `silent` —
// hover and click stay on the block, and per-event precision lives in
// the sidebar mini-timeline.
const markerShapes = buildSpanEventMarkers(span, blockWidth).map((marker) => ({
type: "rect",
shape: {
x: x + (marker.isFlag ? 0 : (blockWidth * marker.left) / 100),
y: y - MARKER_OVERHANG_PX,
// 3px matches the DOM surfaces' `w-0.75` tick. The flag is wider
// so it stays distinguishable from a positioned marker.
width: marker.isFlag ? MARKER_FLAG_WIDTH_PX : MARKER_WIDTH_PX,
height: BLOCK_HEIGHT + 2 * MARKER_OVERHANG_PX,
r: 1,
},
style: {
fill: severityColor(marker.severity),
},
silent: true,
}));
const markerShapes = buildSpanEventMarkers(span, fullWidth)
.map((marker) => ({
marker,
markerX: marker.isFlag ? x : fullX + (fullWidth * marker.left) / 100,
}))
.filter(({ markerX }) => markerX >= x && markerX <= x + blockWidth)
.map(({ marker, markerX }) => ({
type: "rect",
shape: {
x: markerX,
y: y - MARKER_OVERHANG_PX,
// 3px matches the DOM surfaces' `w-0.75` tick; the wider flag stays distinct from a positioned marker.
width: marker.isFlag ? MARKER_FLAG_WIDTH_PX : MARKER_WIDTH_PX,
height: BLOCK_HEIGHT + 2 * MARKER_OVERHANG_PX,
r: 1,
},
style: {
fill: severityColor(marker.severity),
},
silent: true,
}));
const spanRect = {
type: "rect",
@@ -699,7 +825,7 @@ const handleChartMouseMove = (event: any) => {
const fraction = Math.max(0, Math.min(1, (offsetX - GRID_LEFT) / gridWidth));
cursorX.value = offsetX;
cursorTimeLabel.value = formatDuration(fraction * props.traceDuration);
cursorTimeLabel.value = formatDuration(viewFractionToTrace(fraction) * props.traceDuration);
cursorVisible.value = true;
};
@@ -738,7 +864,9 @@ watch(
(newVal) => {
if (!newVal) {
sidebarVisible.value = false;
return;
}
revealSpan(newVal);
},
);
@@ -0,0 +1,125 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it, vi } from "vitest";
import useChartZoom, {
CHART_ZOOM_MAX,
CHART_ZOOM_MIN,
CHART_ZOOM_STEP,
panRange,
zoomRange,
} from "@/composables/traces/useChartZoom";
const fakeChart = (initialZoom?: number) => {
let zoom = initialZoom;
const calls: any[] = [];
return {
calls,
chart: {
getOption: () => ({ series: [zoom === undefined ? {} : { zoom }] }),
setOption: (opt: any, flags: any) => {
calls.push({ opt, flags });
zoom = opt.series[0].zoom;
},
},
};
};
describe("useChartZoom", () => {
it("steps the series zoom by 1.25 from the chart's live level", () => {
const { chart, calls } = fakeChart(2);
const { zoomIn, zoomOut } = useChartZoom(() => chart);
zoomIn();
expect(calls[0].opt.series[0].zoom).toBeCloseTo(2 * CHART_ZOOM_STEP);
expect(calls[0].flags).toEqual({ lazyUpdate: true });
zoomOut();
expect(calls[1].opt.series[0].zoom).toBeCloseTo(2);
});
it("treats a missing zoom as 1", () => {
const { chart, calls } = fakeChart();
useChartZoom(() => chart).zoomIn();
expect(calls[0].opt.series[0].zoom).toBeCloseTo(CHART_ZOOM_STEP);
});
it("clamps the zoom to the min and max", () => {
const { chart, calls } = fakeChart(1);
const { zoomIn, zoomOut } = useChartZoom(() => chart);
for (let i = 0; i < 30; i++) zoomIn();
expect(calls.at(-1).opt.series[0].zoom).toBe(CHART_ZOOM_MAX);
for (let i = 0; i < 30; i++) zoomOut();
expect(calls.at(-1).opt.series[0].zoom).toBe(CHART_ZOOM_MIN);
});
it("does nothing without a chart", () => {
const { zoomIn, zoomOut } = useChartZoom(() => undefined);
expect(() => {
zoomIn();
zoomOut();
}).not.toThrow();
});
it("fit runs the callback then bumps the chart key", () => {
const onBeforeFit = vi.fn();
const { chartKey, fitToScreen } = useChartZoom(() => null, onBeforeFit);
fitToScreen();
expect(onBeforeFit).toHaveBeenCalledTimes(1);
expect(chartKey.value).toBe(1);
});
});
describe("zoomRange", () => {
const bounds = { min: 0, max: 100 };
it("narrows and widens around the centre", () => {
expect(zoomRange({ min: 0, max: 100 }, 1.25, bounds, 1)).toEqual({ min: 10, max: 90 });
const widened = zoomRange({ min: 10, max: 90 }, 1 / 1.25, bounds, 1);
expect(widened.min).toBeCloseTo(0);
expect(widened.max).toBeCloseTo(100);
});
it("never narrows below the minimum span", () => {
let range = { min: 0, max: 100 };
for (let i = 0; i < 50; i++) range = zoomRange(range, 1.25, bounds, 1);
expect(range.max - range.min).toBeCloseTo(1);
expect(range.min).toBeCloseTo(49.5);
});
it("never widens past the bounds and shifts back inside them", () => {
expect(zoomRange({ min: 0, max: 100 }, 1 / 1.25, bounds, 1)).toEqual({ min: 0, max: 100 });
const shifted = zoomRange({ min: 80, max: 100 }, 1 / 1.25, bounds, 1);
expect(shifted.max).toBeCloseTo(100);
expect(shifted.max - shifted.min).toBeCloseTo(25);
});
});
describe("panRange", () => {
const bounds = { min: 0, max: 100 };
it("shifts the window by the delta and keeps its width", () => {
expect(panRange({ min: 10, max: 30 }, 15, bounds)).toEqual({ min: 25, max: 45 });
expect(panRange({ min: 10, max: 30 }, -5, bounds)).toEqual({ min: 5, max: 25 });
});
it("stops at the bounds", () => {
expect(panRange({ min: 10, max: 30 }, -50, bounds)).toEqual({ min: 0, max: 20 });
expect(panRange({ min: 10, max: 30 }, 500, bounds)).toEqual({ min: 80, max: 100 });
});
});
@@ -0,0 +1,74 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ref } from "vue";
export const CHART_ZOOM_MIN = 0.4;
export const CHART_ZOOM_MAX = 3;
export const CHART_ZOOM_STEP = 1.25;
export interface ZoomRange {
min: number;
max: number;
}
/** Narrows (factor > 1) or widens a range around its centre, kept inside `bounds` and no narrower than `minSpan`. */
export function zoomRange(
range: ZoomRange,
factor: number,
bounds: ZoomRange,
minSpan: number,
): ZoomRange {
const fullSpan = bounds.max - bounds.min;
const span = Math.min(fullSpan, Math.max(minSpan, (range.max - range.min) / factor));
const centre = (range.min + range.max) / 2;
const min = Math.min(bounds.max - span, Math.max(bounds.min, centre - span / 2));
return { min, max: min + span };
}
/** Shifts a range by `delta` without changing its width, stopping at `bounds`. */
export function panRange(range: ZoomRange, delta: number, bounds: ZoomRange): ZoomRange {
const span = range.max - range.min;
const min = Math.min(bounds.max - span, Math.max(bounds.min, range.min + delta));
return { min, max: min + span };
}
/** Button zoom for roam-enabled ECharts tree/graph series; `fitToScreen` recreates the chart through `chartKey`. */
export default function useChartZoom(getChart: () => any, onBeforeFit?: () => void) {
const chartKey = ref(0);
// Read the live zoom so a button press continues from the wheel's level.
const currentZoom = (chart: any): number => {
const z = chart.getOption?.()?.series?.[0]?.zoom;
return typeof z === "number" && z > 0 ? z : 1;
};
const zoomBy = (factor: number) => {
const chart = getChart();
if (!chart) return;
const next = Math.min(CHART_ZOOM_MAX, Math.max(CHART_ZOOM_MIN, currentZoom(chart) * factor));
chart.setOption({ series: [{ zoom: next }] }, { lazyUpdate: true });
};
const zoomIn = () => zoomBy(CHART_ZOOM_STEP);
const zoomOut = () => zoomBy(1 / CHART_ZOOM_STEP);
const fitToScreen = () => {
onBeforeFit?.();
chartKey.value++;
};
return { chartKey, zoomIn, zoomOut, fitToScreen };
}
@@ -16,14 +16,13 @@
import { describe, expect, it } from "vitest";
import { ref } from "vue";
import { useTracePatternTree } from "@/composables/useTracePatternTree";
import { buildPatternConsolidatedTree } from "@/utils/traces/patternDetection";
import { buildPathPatterns, buildPatternConsolidatedTree } from "@/utils/traces/patternDetection";
import { patternTraceTrees } from "@/test/unit/mockData/traces";
// Runs the same pipeline TraceDetails uses for the Trace Graph tab:
// traceTree → buildPatternConsolidatedTree → useTracePatternTree.treeData
// Runs the same pipeline TraceDetails uses for the Trace Graph tab.
const getTreeData = (traceTree: any[]) => {
const patterns = ref(buildPatternConsolidatedTree(traceTree));
const { treeData } = useTracePatternTree(patterns, ref(false));
const { treeData } = useTracePatternTree(patterns, ref(false), ref(buildPathPatterns(traceTree)));
return treeData.value;
};
@@ -85,8 +84,61 @@ describe("useTracePatternTree", () => {
expect(treeData[0].children?.map((c) => c.name)).toEqual(["querier"]);
});
it("should give a service called by two parents each parent's own latency and errors", () => {
const [root] = getTreeData(patternTraceTrees.sharedChildTwoParents);
const databaseUnder = (parent: string) =>
root.children?.find((c) => c.name === parent)?.children?.find((c) => c.name === "database");
const viaOrder = databaseUnder("order-service");
expect(viaOrder?.value).toBe(15);
expect(viaOrder?.metadata?.avg).toBe(15);
expect(viaOrder?.errorRate).toBe(0);
expect(viaOrder?.metadata?.errorCount).toBe(0);
const viaInventory = databaseUnder("inventory-service");
expect(viaInventory?.value).toBe(10);
expect(viaInventory?.metadata?.avg).toBe(10);
expect(viaInventory?.errorRate).toBe(100);
expect(viaInventory?.metadata?.errorCount).toBe(1);
});
it("should give a leaf reached along two branches each branch's own metrics", () => {
const [root] = getTreeData(patternTraceTrees.sharedLeafTwoBranches);
const child = (node: any, name: string) => node?.children?.find((c: any) => c.name === name);
const leafViaFrontend = child(child(root, "catalog-rpc"), "product-catalog");
expect(leafViaFrontend?.value).toBe(3);
expect(leafViaFrontend?.metadata?.count).toBe(1);
expect(leafViaFrontend?.metadata?.errorCount).toBe(0);
expect(leafViaFrontend?.errorRate).toBe(0);
const leafViaCheckout = child(
child(child(root, "checkout"), "catalog-rpc"),
"product-catalog",
);
expect(leafViaCheckout?.value).toBe(1.5);
expect(leafViaCheckout?.metadata?.count).toBe(2);
expect(leafViaCheckout?.metadata?.errorCount).toBe(1);
expect(leafViaCheckout?.errorRate).toBe(50);
});
it("should keep the self-pattern metrics on a root service", () => {
const [root] = getTreeData(patternTraceTrees.sharedChildTwoParents);
expect(root.name).toBe("frontend");
expect(root.value).toBe(100);
expect(root.metadata?.errorCount).toBe(0);
});
it("should show a measured average of 0 on a node instead of its children's average", () => {
const [root] = getTreeData(patternTraceTrees.zeroDurationRoot);
expect(root.name).toBe("scheduler");
expect(root.value).toBe(0);
expect(root.metadata?.avg).toBe(0);
});
it("should return an empty tree when the pattern map is empty", () => {
const { treeData } = useTracePatternTree(ref(new Map()), ref(false));
const { treeData } = useTracePatternTree(ref(new Map()), ref(false), ref(new Map()));
expect(treeData.value).toEqual([]);
});
+14 -10
View File
@@ -13,7 +13,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Ref, ref } from "vue";
import { Ref } from "vue";
import { useTreeVisualization, TreeNode } from "@/composables/useTreeVisualization";
import { CallPattern } from "@/utils/traces/patternDetection";
@@ -23,7 +23,8 @@ import { CallPattern } from "@/utils/traces/patternDetection";
*/
export function useTracePatternTree(
patterns: Ref<Map<string, CallPattern>>,
isDarkMode: Ref<boolean> = ref(false),
isDarkMode: Ref<boolean>,
pathPatterns: Ref<Map<string, CallPattern>>,
) {
/**
* Transform simplified service relationships to TreeNode format
@@ -84,6 +85,8 @@ export function useTracePatternTree(
const buildServiceTree = (
serviceName: string,
visited = new Set<string>(),
edgePattern?: CallPattern,
parentPath = "",
): TreeNode | null => {
if (visited.has(serviceName)) return null; // Prevent cycles
visited.add(serviceName);
@@ -92,12 +95,13 @@ export function useTracePatternTree(
if (!serviceInfo) return null;
globalVisited.add(serviceName);
const path = parentPath ? `${parentPath}→${serviceName}` : serviceName;
// Create children from outgoing relationships
const children: TreeNode[] = [];
serviceInfo.children.forEach((pattern) => {
const targetService = pattern.pathSignature.split("→")[1];
const childNode = buildServiceTree(targetService, new Set(visited));
const childNode = buildServiceTree(targetService, new Set(visited), pattern, path);
if (childNode) {
// Preserve child's own metrics (already set by its buildServiceTree call).
// Only add edge-specific info — don't override with pattern.metrics spread.
@@ -111,20 +115,20 @@ export function useTracePatternTree(
}
});
// Use the service's own self-pattern for its metrics (own span durationMs).
// Falls back to children aggregation only if no self-pattern exists.
const selfPattern = patternMap.get(serviceName);
// Metrics come from the spans on this node's own branch; the edge and self patterns cover paths the trace never took.
const metricsPattern =
pathPatterns.value.get(path) ?? edgePattern ?? patternMap.get(serviceName);
const totalCalls =
selfPattern?.metrics?.count ||
metricsPattern?.metrics?.count ||
serviceInfo.children.reduce((sum, p) => sum + p.metrics.count, 0);
const avgDuration =
selfPattern?.metrics?.avg ||
metricsPattern?.metrics?.avg ??
(serviceInfo.children.length > 0
? serviceInfo.children.reduce((sum, p) => sum + p.metrics.avg * p.metrics.count, 0) /
Math.max(totalCalls, 1)
: 0);
const avgErrorRate =
selfPattern?.metrics?.errorRate ??
metricsPattern?.metrics?.errorRate ??
(serviceInfo.children.length > 0
? serviceInfo.children.reduce(
(sum, p) => sum + p.metrics.errorRate * p.metrics.count,
@@ -139,7 +143,7 @@ export function useTracePatternTree(
errorRate: avgErrorRate,
children: children.length > 0 ? children : undefined,
metadata: {
...selfPattern?.metrics,
...metricsPattern?.metrics,
serviceName,
count: totalCalls,
avg: Math.round(avgDuration * 100) / 100,
+7
View File
@@ -244,6 +244,13 @@ describe("useTraces", () => {
expect(searchObj.meta.lastRunSignature).toBeNull();
});
it("clears searchApplied, so a new org does not mount the Rate/Duration panels before a run", () => {
const { searchObj, resetSearchObj } = useTraces();
searchObj.searchApplied = true;
resetSearchObj();
expect(searchObj.searchApplied).toBe(false);
});
it("clears errorDetail", () => {
const { searchObj, resetSearchObj } = useTraces();
searchObj.data.errorDetail = "detail";
+2
View File
@@ -276,6 +276,8 @@ const useTraces = () => {
searchObj.meta.lastRunSignature = null;
urlColumns = null;
searchObj.runQuery = false;
// Only a run sets it again; left true, a new org mounts the Rate/Duration panels and they query without a run.
searchObj.searchApplied = false;
searchObj.data.traceDetails.isLoadingTraceDetails = false;
searchObj.data.traceDetails.isLoadingTraceMeta = false;
};
+82
View File
@@ -3143,6 +3143,52 @@ describe("Index.vue (Main Traces Page)", () => {
});
});
// The header org switch re-keys MainLayout's content, so the page remounts; the router may not have left the old URL yet.
describe("remount after a header org switch", () => {
afterEach(() => {
delete store.state.zoConfig.auto_query_enabled;
globalThis.localStorage.removeItem("oo_toggle_auto_run");
});
async function remountWith(query: Record<string, string>, autoRun: boolean, lastOrg: string) {
store.state.zoConfig.auto_query_enabled = true;
globalThis.localStorage.setItem("oo_toggle_auto_run", String(autoRun));
mockSearchObj.meta.liveMode = autoRun;
mockSearchObj.organizationIdentifier = lastOrg;
routerCurrentRouteSpy.mockReturnValue({
value: { query, name: "traces", path: "/traces" },
} as any);
wrapper = mountIndexStubbed();
await vi.waitFor(() => expect(mockSearchObj.loadingStream).toBe(false), { timeout: 10000 });
await new Promise((resolve) => setTimeout(resolve, 50));
await flushPromises();
}
it.each([
["the old org's URL", { stream: "default", org_identifier: "old-org" }],
["the new org's bare URL", { org_identifier: "default" }],
])("does not search with Auto Run off while the router still shows %s", async (_l, query) => {
await remountWith(query, false, "old-org");
expect(mockFetchQueryDataWithHttpStream).not.toHaveBeenCalled();
expect(mockSearchObj.data.stream.selectedStream.value).toBeTruthy();
});
it("still searches with Auto Run on", async () => {
await remountWith({ stream: "default", org_identifier: "old-org" }, true, "old-org");
expect(mockFetchQueryDataWithHttpStream).toHaveBeenCalled();
});
it("searches a shared link for the org being switched to with Auto Run off", async () => {
await remountWith({ stream: "default", org_identifier: "default" }, false, "old-org");
expect(mockFetchQueryDataWithHttpStream).toHaveBeenCalled();
});
it("searches a shared link for another org opened without an org switch", async () => {
await remountWith({ stream: "default", org_identifier: "other-org" }, false, "default");
expect(mockFetchQueryDataWithHttpStream).toHaveBeenCalled();
});
});
describe("onActivated", () => {
// Helper: mount Index inside a KeepAlive wrapper so that toggling
// the `show` ref produces real deactivate/activate lifecycle calls.
@@ -3204,6 +3250,42 @@ describe("Index.vue (Main Traces Page)", () => {
parentWrapper.unmount();
});
it.each([
["does not search", "false", false],
["still searches", "true", true],
])("%s on an org switch when the Auto Run toggle is %s", async (_label, toggle, searches) => {
store.state.zoConfig.auto_query_enabled = true;
globalThis.localStorage.setItem("oo_toggle_auto_run", toggle);
// The page's own URL sync always writes stream=, so the URL looks like a shared link.
routerCurrentRouteSpy.mockReturnValue({
value: { query: { stream: "default" }, name: "traces", path: "/traces" },
} as any);
mockSearchObj.organizationIdentifier = "default";
const { parentWrapper, show } = mountWithKeepAlive();
// The first load must settle, or reactivation re-runs the first-load path instead.
await vi.waitFor(() => expect(mockSearchObj.loadingStream).toBe(false));
await flushPromises();
show.value = false;
await flushPromises();
mockFetchQueryDataWithHttpStream.mockClear();
mockSearchObj.organizationIdentifier = "old-org";
show.value = true;
await flushPromises();
if (searches) {
await vi.waitFor(() => expect(mockFetchQueryDataWithHttpStream).toHaveBeenCalled());
} else {
await vi.waitFor(() => expect(mockSearchObj.loadingStream).toBe(false));
await new Promise((resolve) => setTimeout(resolve, 50));
await flushPromises();
expect(mockFetchQueryDataWithHttpStream).not.toHaveBeenCalled();
}
expect(mockSearchObj.data.stream.selectedStream.value).toBe("default");
parentWrapper.unmount();
delete store.state.zoConfig.auto_query_enabled;
});
it("should read oo_toggle_auto_run from localStorage and set liveMode on activation", async () => {
globalThis.localStorage.setItem("oo_toggle_auto_run", "true");
+12 -8
View File
@@ -1469,10 +1469,14 @@ function generateHistogramData() {
const isAutoRunOff = () => !!store.state.zoConfig?.auto_query_enabled && !searchObj.meta.liveMode;
async function loadPageData() {
/** `leftOrg`: the org an org switch left; a URL still on that org is the page's own sync, not a shared link. */
async function loadPageData({ leftOrg = "" } = {}) {
const generation = pageGeneration;
pageLoaded = false;
const streamFromUrl = typeof router.currentRoute.value.query.stream === "string";
const query = router.currentRoute.value.query;
// The header switch re-keys the page before the router may have left the old org's URL.
const urlFromLeftOrg = !!leftOrg && (query.org_identifier ?? leftOrg) === leftOrg;
const streamFromUrl = !urlFromLeftOrg && typeof query.stream === "string";
// Claimed before any early return, so a restore that finds no stream is not left for a later landing.
// A newer restore wins over one a left load never acted on.
const landing = takeRecentLanding("traces") ?? pendingLanding;
@@ -1559,10 +1563,9 @@ function stopInFlightRequests() {
async function initializePage() {
const generation = pageGeneration;
pageLoaded = false;
if (
searchObj.organizationIdentifier &&
searchObj.organizationIdentifier !== store.state.selectedOrganization.identifier
) {
const lastOrg = searchObj.organizationIdentifier;
const leftOrg = lastOrg && lastOrg !== store.state.selectedOrganization.identifier ? lastOrg : "";
if (leftOrg) {
resetSearchObj();
}
restoreUrlQueryParams();
@@ -1574,7 +1577,7 @@ async function initializePage() {
await loadTracesParser().catch((e) => console.error("Failed to load the traces parser", e));
if (generation !== pageGeneration) return;
if (!searchObj.loading) {
await loadPageData();
await loadPageData({ leftOrg });
} else {
searchObj.loadingStream = false;
pendingUrlPage = null;
@@ -1642,9 +1645,10 @@ onActivated(async () => {
}
if (searchObj.organizationIdentifier != store.state.selectedOrganization.identifier) {
const leftOrg = searchObj.organizationIdentifier;
resetSearchObj();
restoreUrlQueryParams();
await loadPageData();
await loadPageData({ leftOrg });
if (left()) return;
reloaded = true;
}
+15 -79
View File
@@ -299,45 +299,12 @@
@click="handleNodeClick"
/>
<!-- Zoom controls: explicit buttons drive zoom + fit-to-screen,
floated bottom-right like a map control. -->
<div
class="rounded-default border-border-default bg-surface-panel absolute right-3 bottom-3 z-10 flex flex-col overflow-hidden border"
data-test="service-graph-zoom-controls"
>
<OButton
variant="ghost"
size="icon-sm"
icon-left="add"
class="rounded-none"
data-test="service-graph-zoom-in"
@click="zoomIn"
>
<OTooltip side="left" :content="t('traces.serviceGraph.zoomIn')" />
</OButton>
<OSeparator />
<OButton
variant="ghost"
size="icon-sm"
icon-left="remove"
class="rounded-none"
data-test="service-graph-zoom-out"
@click="zoomOut"
>
<OTooltip side="left" :content="t('traces.serviceGraph.zoomOut')" />
</OButton>
<OSeparator />
<OButton
variant="ghost"
size="icon-sm"
icon-left="fullscreen"
class="rounded-none"
data-test="service-graph-fit-screen"
@click="fitToScreen"
>
<OTooltip side="left" :content="t('traces.serviceGraph.fitToScreen')" />
</OButton>
</div>
<ChartZoomControls
data-test-prefix="service-graph"
@zoom-in="zoomIn"
@zoom-out="zoomOut"
@fit="fitToScreen"
/>
<!-- Service Graph Side Panel (node) -->
<ServiceGraphSidePanel
@@ -428,11 +395,14 @@ import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import ServiceGraphNoDataState from "./ServiceGraphNoDataState.vue";
import ChartZoomControls from "@/components/traces/ChartZoomControls.vue";
import useChartZoom from "@/composables/traces/useChartZoom";
import { getEffectiveTimeRange } from "@/utils/date";
export default defineComponent({
name: "ServiceGraph",
components: {
ChartZoomControls,
OSeparator,
ChartRenderer,
ServiceGraphSidePanel,
@@ -652,44 +622,6 @@ export default defineComponent({
applyFilters();
};
// ── Zoom controls ─────────────────────────────────────────────────────────
// The mouse still zooms (roam:true) so you can focus an area with the wheel;
// scaleLimit on the series bounds it so it can't run away (the old "erratic"
// feel). The +/- buttons drive the SAME series `zoom` option via setOption,
// reading the chart's CURRENT zoom first so button + wheel stay in sync.
// Match the series `scaleLimit` (convertTraceData.ts) so the +/- buttons and
// box-zoom never compute a factor the chart will silently clamp away.
const ZOOM_MIN = 0.4;
const ZOOM_MAX = 3;
// The live zoom level from the chart (kept in sync with wheel zoom), so a
// button press adjusts from where the user actually is, not a stale ref.
const currentZoom = (chart: any): number => {
const opt = chart.getOption?.();
const z = opt?.series?.[0]?.zoom;
return typeof z === "number" && z > 0 ? z : 1;
};
const zoomBy = (factor: number) => {
const chart = chartRendererRef.value?.chart;
if (!chart) return;
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, currentZoom(chart) * factor));
// Merge only the zoom so positions/data stay put.
chart.setOption({ series: [{ zoom: next }] }, { lazyUpdate: true });
};
const zoomIn = () => zoomBy(1.25);
const zoomOut = () => zoomBy(1 / 1.25);
// Fit-to-screen: recreate the chart (keyed on chartKey) so ECharts re-fits
// the full graph bounding box into the panel at zoom 1, re-centered — the
// reliable reset for both tree (layout:none) and graph series (also clears
// any wheel pan/zoom the user applied).
const fitToScreen = () => {
lastChartOptions = null;
chartKey.value++;
};
const toggleKindVisibility = (kind: string) => {
const s = new Set(hiddenKinds.value);
s.has(kind) ? s.delete(kind) : s.add(kind);
@@ -821,8 +753,12 @@ export default defineComponent({
// filtered — the "Show types" button reflects this (active state + count).
const activeFilterCount = computed(() => hiddenKinds.value.size);
// Key to control chart recreation - only change when layout/visualization type changes
const chartKey = ref(0);
const { chartKey, zoomIn, zoomOut, fitToScreen } = useChartZoom(
() => chartRendererRef.value?.chart,
() => {
lastChartOptions = null;
},
);
// Restore drag-to-pan (map-style). The shared ChartRenderer arms the
// `dataZoomSelect` global cursor on every render, which turns a drag into a
+105 -1
View File
@@ -1759,7 +1759,13 @@ describe("ServicesCatalog", () => {
it("keeps inferred dependencies with the same name but different identities separate", async () => {
mockStreamSchema.mockResolvedValueOnce({
data: { schema: [{ name: "infer_service_name" }] },
data: {
schema: [
{ name: "infer_service_name" },
{ name: "infer_service_system" },
{ name: "infer_service_type" },
],
},
});
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
callbacks.data(null, {
@@ -2275,6 +2281,34 @@ describe("ServicesCatalog", () => {
expect(wrapper.find('[data-test="services-catalog-requests-nightly-cron"]').text()).toBe("0");
});
it("selects NULL for inferred system and type columns the stream does not have", async () => {
const sql = await loadWithSchema(
schemaWith("infer_service_name", "reference_parent_span_id"),
);
expect(selectItem(sql, "_infer_service_system").trim()).toBe(
"NULL AS _infer_service_system,",
);
expect(selectItem(sql, "_infer_service_type").trim()).toBe("NULL AS _infer_service_type,");
expect(sql).not.toContain("NULLIF(infer_service_system");
expect(sql).not.toContain("NULLIF(infer_service_type");
const groupBy = sql.split("GROUP BY")[1].split("ORDER BY")[0];
expect(groupBy.trim().split(",\n")).toEqual([
"COALESCE(NULLIF(infer_service_name, ''), service_name)",
" NULLIF(infer_service_name, '')",
]);
});
it("groups by only the inferred column the stream has", async () => {
const sql = await loadWithSchema(schemaWith("infer_service_name", "infer_service_type"));
expect(selectItem(sql, "_infer_service_system").trim()).toBe(
"NULL AS _infer_service_system,",
);
expect(selectItem(sql, "_infer_service_type")).toContain("NULLIF(infer_service_type, '')");
const groupBy = sql.split("GROUP BY")[1].split("ORDER BY")[0];
expect(groupBy).toContain("NULLIF(infer_service_type, '')");
expect(groupBy).not.toContain("infer_service_system");
});
it("discards a schema fetched for a stream that is no longer selected", async () => {
let release!: (value: any) => void;
mockStreamSchema.mockImplementationOnce(
@@ -3080,6 +3114,76 @@ describe("ServicesCatalog", () => {
});
});
describe("search error state", () => {
const failSearch = () =>
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
callbacks.error(null, {
type: "error",
content: { message: "Search field not found: infer_service_system" },
});
});
const errorState = () => wrapper.find('[data-test="services-catalog-error"]');
const retry = () => errorState().find('[data-test="o2-empty-state"] button');
it("shows the load-error state with the server message instead of the empty state", async () => {
failSearch();
wrapper = mountServicesCatalog();
await flushPromises();
expect(wrapper.find('[data-test="services-catalog-empty"]').exists()).toBe(false);
expect(errorState().find('[data-test="o2-empty-state"]').exists()).toBe(true);
expect(errorState().text()).toContain("Search field not found: infer_service_system");
expect(retry().text()).toContain("Retry");
});
it("retry runs the search again and clears the error once it succeeds", async () => {
failSearch();
wrapper = mountServicesCatalog();
await flushPromises();
const calls = mockFetchQueryDataWithHttpStream.mock.calls.length;
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
callbacks.complete(null, {});
});
await retry().trigger("click");
await flushPromises();
expect(mockFetchQueryDataWithHttpStream.mock.calls.length).toBe(calls + 1);
expect(errorState().exists()).toBe(false);
expect(wrapper.find('[data-test="services-catalog-empty"]').exists()).toBe(true);
});
it("falls back to a generic message when the error has none", async () => {
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
callbacks.error(null, { type: "error", content: {} });
});
wrapper = mountServicesCatalog();
await flushPromises();
expect(errorState().text()).toContain("Unknown error");
});
it("drops the previous rows and shows the error when a later search fails", async () => {
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
callbacks.data(null, {
type: "search_response_hits",
content: { results: { hits: [{ service_name: "frontend", total_requests: 3 }] } },
});
callbacks.complete(null, {});
});
wrapper = mountServicesCatalog();
await flushPromises();
expect(wrapper.find('[data-test="services-catalog-row-name-frontend"]').exists()).toBe(true);
failSearch();
await wrapper.vm.loadServicesCatalog();
await flushPromises();
expect(wrapper.find('[data-test="services-catalog-row-name-frontend"]').exists()).toBe(false);
expect(errorState().text()).toContain("Search field not found: infer_service_system");
});
});
describe("side panel time range", () => {
afterEach(() => {
vi.useRealTimers();
+52 -10
View File
@@ -318,9 +318,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<div
v-if="!isLoading && loadError !== null"
class="flex flex-1 items-center justify-center"
data-test="services-catalog-error"
>
<OEmptyState
size="hero"
preset="load-error"
:description="loadError"
@action="loadServicesCatalog"
/>
</div>
<!-- Empty state (shown when not loading and no data) -->
<div
v-if="!isLoading && services.length === 0"
v-else-if="!isLoading && services.length === 0"
class="flex flex-1 flex-col items-center justify-center"
data-test="services-catalog-empty"
>
@@ -500,7 +513,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import OTableColumnToggle from "@/lib/core/Table/sub-components/OTableColumnToggle.vue";
@@ -528,6 +541,7 @@ import OSelect from "@/lib/forms/Select/OSelect.vue";
import type { SelectModelValue } from "@/lib/forms/Select/OSelect.types";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
@@ -640,6 +654,8 @@ const isSearching = ref(false);
// Epoch-ms of the last completed load, shown on the page header's refresh control.
const lastRunAt = ref<number | null>(null);
const services = ref<ServiceRow[]>([]);
// The failed search's server message; shown instead of the empty state so a broken query never reads as "no services".
const loadError = ref<I18nText | null>(null);
const viewState = useListViewState("servicesCatalog", {
filterText: "",
typeFilter: "service" as TypeFilter,
@@ -678,6 +694,8 @@ interface StreamSchemaFlags {
org: string;
stream: string;
hasInferColumns: boolean;
hasInferSystemColumn: boolean;
hasInferTypeColumn: boolean;
hasParentColumn: boolean;
}
const schemaFlags = ref<StreamSchemaFlags | null>(null);
@@ -1108,10 +1126,19 @@ async function fetchSchemaFlags(org: string, stream: string): Promise<StreamSche
org,
stream,
hasInferColumns: has("infer_service_name"),
hasInferSystemColumn: has("infer_service_system"),
hasInferTypeColumn: has("infer_service_type"),
hasParentColumn: has("reference_parent_span_id"),
};
} catch {
return { org, stream, hasInferColumns: false, hasParentColumn: false };
return {
org,
stream,
hasInferColumns: false,
hasInferSystemColumn: false,
hasInferTypeColumn: false,
hasParentColumn: false,
};
}
}
@@ -1153,19 +1180,27 @@ ORDER BY total_requests DESC`;
}
// infer_service_name is only set on CLIENT and PRODUCER spans (traces/inferred.rs).
const inferPred = `((NULLIF(infer_service_name, '') IS NOT NULL AND CAST(span_kind AS VARCHAR) IN ('3','4')) OR ${requestPred})`;
const inferredColumn = (column: string) =>
`CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(${column}, '') END`;
// A column the stream has never seen makes the whole query fail, so absent ones select NULL and stay out of GROUP BY.
const systemExpr = flags.hasInferSystemColumn ? inferredColumn("infer_service_system") : null;
const typeExpr = flags.hasInferTypeColumn ? inferredColumn("infer_service_type") : null;
const groupBy = [
"COALESCE(NULLIF(infer_service_name, ''), service_name)",
"NULLIF(infer_service_name, '')",
systemExpr,
typeExpr,
].filter((expr): expr is string => expr !== null);
return `SELECT
COALESCE(NULLIF(infer_service_name, ''), service_name) AS service_name,
NULLIF(infer_service_name, '') AS _infer_service_name,
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_system, '') END AS _infer_service_system,
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_type, '') END AS _infer_service_type,
${systemExpr ?? "NULL"} AS _infer_service_system,
${typeExpr ?? "NULL"} AS _infer_service_type,
MAX(CASE WHEN service_name IS NOT NULL AND (infer_service_name IS NULL OR infer_service_name = '') THEN 1 ELSE 0 END) AS _is_real_service,
${redAggregates(inferPred)}
FROM "${streamName}"
GROUP BY
COALESCE(NULLIF(infer_service_name, ''), service_name),
NULLIF(infer_service_name, ''),
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_system, '') END,
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_type, '') END
${groupBy.map((expr) => ` ${expr}`).join(",\n")}
ORDER BY total_requests DESC`;
}
@@ -1315,6 +1350,7 @@ async function loadServicesCatalog() {
// stay on screen while the next search runs rather than the panel emptying.
isLoading.value = services.value.length === 0;
isSearching.value = true;
loadError.value = null;
const load = ++catalogLoad;
const { start_time, end_time } = getTimeRange();
@@ -1423,8 +1459,14 @@ async function loadServicesCatalog() {
if (hits.length > 0) publish();
}
},
error: () => {
error: (_payload: any, err: any) => {
if (load !== catalogLoad) return;
const errData = err?.content ?? err;
loadError.value = raw(
errData?.message || errData?.error_detail || t("common.unknownError"),
);
// Rows from an earlier search (possibly another stream) would otherwise sit beside the error.
services.value = [];
closePanelIfRowGone();
isLoading.value = false;
isSearching.value = false;
@@ -936,6 +936,39 @@ describe("TraceDetails", () => {
expect(series()).toMatchObject({ left: 55, right: 55 });
});
it("zooms both views from the chart's live level and fit recreates the chart", async () => {
let liveZoom = 1;
fakeChart.getOption = () => ({ series: [{ zoom: liveZoom }] });
fakeChart.setOption = vi.fn((opt: any) => {
liveZoom = opt.series[0].zoom;
});
await mountMap();
for (const view of ["tree", "graph"]) {
if (view === "graph") {
await graphBtn().trigger("click");
await flushPromises();
}
expect(wrapper.find('[data-test="trace-graph-zoom-controls"]').exists()).toBe(true);
liveZoom = 1;
await wrapper.find('[data-test="trace-graph-zoom-in"]').trigger("click");
expect(liveZoom).toBeCloseTo(1.25);
await wrapper.find('[data-test="trace-graph-zoom-out"]').trigger("click");
expect(liveZoom).toBeCloseTo(1);
const keyBefore = wrapper.vm.traceGraphChartKey;
await wrapper.find('[data-test="trace-graph-fit-screen"]').trigger("click");
expect(wrapper.vm.traceGraphChartKey).toBe(keyBefore + 1);
}
});
it("hides the zoom controls when no service matches", async () => {
await mountMap();
wrapper.vm.traceGraphSearch = "nope";
await flushPromises();
expect(wrapper.find('[data-test="trace-graph-zoom-controls"]').exists()).toBe(false);
});
it("truncates graph labels and hides overlapping ones", async () => {
await mountMap();
await graphBtn().trigger("click");
@@ -989,6 +1022,29 @@ describe("TraceDetails", () => {
expect(fakeChart.off).toHaveBeenCalledWith("mouseover", expect.any(Function));
});
it("resolves the hovered node in the searched tree, not the full one", async () => {
const tooltipSetupDelay = () => new Promise((resolve) => setTimeout(resolve, 350));
const chartDom = document.createElement("div");
fakeChart.getDom = () => chartDom;
await mountMap();
wrapper.vm.setTraceGraphView("tree");
await tooltipSetupDelay();
const fullCatalog = series().data[0].children.find((n: any) => n.name === "catalog");
wrapper.vm.traceGraphSearch = "catalog";
await flushPromises();
await tooltipSetupDelay();
const catalog = series().data[0].children[0];
expect(catalog.name).toBe("catalog");
expect(catalog.treePath).not.toEqual(fullCatalog.treePath);
const handlers = fakeChart.on.mock.calls.filter(([event]: any[]) => event === "mouseover");
handlers.at(-1)[1]({ data: catalog, event: { offsetX: 10, offsetY: 10 } });
const tooltip = chartDom.lastElementChild as HTMLElement;
expect(tooltip.innerHTML).toContain("catalog");
expect(tooltip.innerHTML).not.toContain("checkout");
});
it("does not attach the tree tooltip when Graph View is chosen during the setup delay", async () => {
const tooltipSetupDelay = () => new Promise((resolve) => setTimeout(resolve, 350));
await mountMap();
+45 -5
View File
@@ -882,13 +882,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
<!-- Chart Container -->
<div class="min-h-0 flex-1 overflow-hidden p-2.5">
<div class="relative min-h-0 flex-1 overflow-hidden p-2.5">
<ChartRenderer
ref="chartRendererRef"
data-test="trace-details-service-map-chart"
:data="traceServiceMapChartOptions"
:key="traceGraphChartKey"
class="h-full"
/>
<ChartZoomControls
v-if="traceGraphHasChart"
data-test-prefix="trace-graph"
@zoom-in="zoomTraceGraphIn"
@zoom-out="zoomTraceGraphOut"
@fit="fitTraceGraph"
/>
</div>
</div>
</div>
@@ -1016,7 +1024,7 @@ import {
import { getAllSpanColors } from "@/utils/traces/traceColors";
import { resolveReplaySpan, resolveSessionId, resolveUrlTimeRange } from "./traceDetails.utils";
import { buildFilterTerm, applyFilterTerm } from "@/utils/traces/filterUtils";
import { buildPatternConsolidatedTree } from "@/utils/traces/patternDetection";
import { buildPathPatterns, buildPatternConsolidatedTree } from "@/utils/traces/patternDetection";
import { useTracePatternTree } from "@/composables/useTracePatternTree";
import type { TreeNode as PatternTreeNode } from "@/composables/useTreeVisualization";
import type { EnrichedSpan } from "@/ts/interfaces/traces/span.types";
@@ -1074,6 +1082,8 @@ import {
import type { ServiceDetectionConfig } from "@/ts/interfaces/traces/serviceDetection.types";
import { useServiceCorrelation, type KeyFieldsConfig } from "@/composables/useServiceCorrelation";
import { getOrSetServiceColor } from "@/utils/traces/serviceColorRegistry";
import ChartZoomControls from "@/components/traces/ChartZoomControls.vue";
import useChartZoom from "@/composables/traces/useChartZoom";
// Import FlameGraphView
const FlameGraphView = defineAsyncComponent(() => import("@/components/traces/FlameGraphView.vue"));
@@ -1289,6 +1299,7 @@ export default defineComponent({
TraceHeader,
TraceErrorSpansMenu,
FlameGraphView,
ChartZoomControls,
OToggleGroup,
OToggleGroupItem,
OButton,
@@ -1342,6 +1353,18 @@ export default defineComponent({
// Chart renderer ref for tooltip integration
const chartRendererRef = ref<any>(null);
// Recreating the chart drops the tree view's hover listeners, so fit re-attaches them.
const {
chartKey: traceGraphChartKey,
zoomIn: zoomTraceGraphIn,
zoomOut: zoomTraceGraphOut,
fitToScreen: fitTraceGraph,
} = useChartZoom(
() => chartRendererRef.value?.chart,
() => {
setupTooltips();
},
);
// The tree view's span sidebar; it owns the correlation lookup.
const treeSidebarRef = ref<any>(null);
@@ -1365,6 +1388,7 @@ export default defineComponent({
// Pattern View - new functionality
const consolidatedPatterns = ref(new Map());
const tracePathPatterns = ref(new Map());
const { isDark: isDarkMode } = useTheme();
// Set up pattern tree composable and visualization engine
@@ -1374,7 +1398,10 @@ export default defineComponent({
getNodeLabel: getPatternNodeLabel,
getNodeTooltip: getPatternNodeTooltip,
getNodeErrorRate: getPatternNodeErrorRate,
} = useTracePatternTree(consolidatedPatterns, isDarkMode);
} = useTracePatternTree(consolidatedPatterns, isDarkMode, tracePathPatterns);
const traceGraphTree = computed(() =>
filterTraceTree(patternTreeData.value, traceGraphSearch.value),
);
// Computed chart options that switches between pattern and span views
const traceServiceMapChartOptions = computed(() => {
@@ -1436,7 +1463,7 @@ export default defineComponent({
errorRate: node.errorRate ?? 0,
metadata: node.metadata,
});
const treeData = filterTraceTree(patternTreeData.value, traceGraphSearch.value);
const treeData = traceGraphTree.value;
// ECharts' tree series throws on empty data and leaves the chart stuck until reload.
if (!treeData.length) return { options: {}, notMerge: true };
const chartOptions = generateEChartsOptions(
@@ -1462,6 +1489,10 @@ export default defineComponent({
};
});
const traceGraphHasChart = computed(
() => !!(traceServiceMapChartOptions.value.options as { series?: unknown }).series,
);
const spanDimensions = {
height: 30,
barHeight: 8,
@@ -2151,7 +2182,10 @@ export default defineComponent({
tooltipCleanup = setupTraceNodeTooltips(
chart,
{
treeData: patternTreeData.value,
// A getter, so a hover resolves its tree path in the tree the chart draws now, even after a search.
get treeData() {
return traceGraphTree.value;
},
getNodeTooltip: getPatternNodeTooltip,
getNodeErrorRate: getPatternNodeErrorRate,
} as TreeVisualizationData,
@@ -2871,6 +2905,7 @@ export default defineComponent({
});
// Build consolidated patterns for pattern view
tracePathPatterns.value = buildPathPatterns(traceTree.value);
consolidatedPatterns.value = buildPatternConsolidatedTree(traceTree.value);
// console.log('[DEBUG] consolidatedPatterns size:', consolidatedPatterns.value?.size || 0);
// console.log('[DEBUG] consolidatedPatterns keys:', Array.from(consolidatedPatterns.value?.keys() || []));
@@ -3435,6 +3470,11 @@ export default defineComponent({
traceServiceMap,
traceServiceMapChartOptions,
chartRendererRef,
traceGraphChartKey,
traceGraphHasChart,
zoomTraceGraphIn,
zoomTraceGraphOut,
fitTraceGraph,
treeSidebarRef,
activeVisual,
traceVisuals,
+51
View File
@@ -40,6 +40,8 @@ const treeSpan = (
export const patternTraceTrees = {
// Baseline: single root, root service calls one downstream service
singleRoot: [treeSpan("a1", "scheduler", "", [treeSpan("q1", "querier", "a1", [], 80)], 100)],
// Root span with a measured duration of 0 calling a 10 ms downstream service
zeroDurationRoot: [treeSpan("a1", "scheduler", "", [treeSpan("q1", "querier", "a1", [], 10)], 0)],
// Two root spans with distinct services, each with its own downstream call
multiRootDistinctServices: [
treeSpan("a1", "scheduler", "", [treeSpan("q1", "querier", "a1", [], 80)], 100),
@@ -62,6 +64,55 @@ export const patternTraceTrees = {
],
// Single root span, single service, no relationships at all
singleServiceOnly: [treeSpan("a1", "scheduler", "", [], 100)],
// Two callers reach the same intermediate service, which calls the same leaf on each branch
sharedLeafTwoBranches: [
treeSpan(
"f1",
"frontend",
"",
[
treeSpan("m1", "catalog-rpc", "f1", [treeSpan("l1", "product-catalog", "m1", [], 3)], 8),
treeSpan(
"c1",
"checkout",
"f1",
[
treeSpan(
"m2",
"catalog-rpc",
"c1",
[
{ ...treeSpan("l2", "product-catalog", "m2", [], 1), spanStatus: "ERROR" },
treeSpan("l3", "product-catalog", "m2", [], 2),
],
4,
),
],
20,
),
],
100,
),
],
// One service called by two parents: a clean 15ms call and a failing 10ms call
sharedChildTwoParents: [
treeSpan(
"f1",
"frontend",
"",
[
treeSpan("o1", "order-service", "f1", [treeSpan("d1", "database", "o1", [], 15)], 40),
treeSpan(
"v1",
"inventory-service",
"f1",
[{ ...treeSpan("d2", "database", "v1", [], 10), spanStatus: "ERROR" }],
30,
),
],
100,
),
],
};
export default {
+44 -1
View File
@@ -14,7 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { buildPatternConsolidatedTree } from "./patternDetection";
import { buildPathPatterns, buildPatternConsolidatedTree } from "./patternDetection";
const span = (id: string, service: string, durationMs: number, children: any[] = []) => ({
spanId: id,
@@ -53,3 +53,46 @@ describe("buildPatternConsolidatedTree", () => {
expect(patterns.get("a→c")!.metrics).toMatchObject({ count: 1, min: 30, max: 30 });
});
});
describe("buildPathPatterns", () => {
it("keys spans by their service ancestor path, merging consecutive spans of one service", () => {
const tree = [
span("f1", "frontend", 100, [
span("f2", "frontend", 90, [span("l1", "leaf", 3)]),
span("c1", "checkout", 20, [span("l2", "leaf", 1), span("l3", "leaf", 2)]),
]),
];
const patterns = buildPathPatterns(tree);
expect([...patterns.keys()].sort()).toEqual([
"frontend",
"frontend→checkout",
"frontend→checkout→leaf",
"frontend→leaf",
]);
expect(patterns.get("frontend")?.metrics.count).toBe(2);
expect(patterns.get("frontend→leaf")?.metrics.avg).toBe(3);
expect(patterns.get("frontend→checkout→leaf")?.metrics.count).toBe(2);
expect(patterns.get("frontend→checkout→leaf")?.metrics.avg).toBe(1.5);
});
it("stops tracking a path once it revisits a service", () => {
const tree = [
span("a1", "a", 10, [span("b1", "b", 5, [span("a2", "a", 2, [span("c1", "c", 1)])])]),
];
expect([...buildPathPatterns(tree).keys()].sort()).toEqual(["a", "a→b"]);
});
it("handles a 20,000-level chain alternating two services", () => {
const root: any = span("s0", "a", 20_000);
let tail = root;
for (let i = 1; i < 20_000; i++) {
const next = span(`s${i}`, i % 2 ? "b" : "a", 20_000 - i);
tail.spans = [next];
tail = next;
}
const patterns = buildPathPatterns([root]);
expect(patterns.size).toBe(2);
expect(patterns.get("a")?.metrics.count).toBe(1);
expect(patterns.get("a→b")?.metrics.count).toBe(1);
});
});
+87 -32
View File
@@ -199,43 +199,59 @@ export function buildPatternConsolidatedTree(traceTree: any[]): Map<string, Call
// would incorrectly show child durations. Self-patterns fix this by capturing
// each service's own span metrics directly from allServices.
allServices.forEach((serviceInfo, serviceName) => {
const durations = serviceInfo.spans.map((s: any) => s.durationMs || 0);
const errorCount = serviceInfo.errorCount;
patterns.set(serviceName, {
pathSignature: serviceName,
instances: serviceInfo.spans.map((span: any) => ({
spanId: span.span_id || span.spanId || "",
duration: span.durationMs || 0,
isError: isSpanError(span),
timestamp: span.start_time || 0,
attributes: span.attributes || {},
serviceName,
operationName: span.operationName || span.operation_name || "",
})),
metrics: {
count: serviceInfo.spans.length,
avg: calculateAverage(durations),
min: durations.length > 0 ? durations.reduce((m, d) => Math.min(m, d), Infinity) : 0,
max: durations.length > 0 ? durations.reduce((m, d) => Math.max(m, d), -Infinity) : 0,
p75: calculatePercentile(durations, 75),
p95: calculatePercentile(durations, 95),
p99: calculatePercentile(durations, 99),
totalDuration: durations.reduce((sum, d) => sum + d, 0),
errorRate: serviceInfo.spans.length > 0 ? (errorCount / serviceInfo.spans.length) * 100 : 0,
errorCount,
traceTimePercent:
totalTraceDuration > 0 && durations.length > 0
? (calculateAverage(durations) / totalTraceDuration) * 100
: 0,
},
spanIds: serviceInfo.spans.map((span: any) => span.span_id || span.spanId || ""),
});
patterns.set(
serviceName,
spansToPattern(serviceName, serviceName, serviceInfo.spans, totalTraceDuration),
);
});
return patterns;
}
/** Groups spans by their service ancestor path ("frontend→checkout→database"), so each branch keeps its own metrics. */
export function buildPathPatterns(traceTree: any[]): Map<string, CallPattern> {
const patterns = new Map<string, CallPattern>();
if (!traceTree || traceTree.length === 0) return patterns;
const totalTraceDuration = traceTree.reduce(
(max, rootSpan) => Math.max(max, rootSpan.durationMs || 0),
0,
);
const spansByPath = new Map<string, { serviceName: string; spans: any[] }>();
// A path that revisits a service is never a Tree View node (the tree drops cycles), so it stops being tracked; this keeps keys short on deep alternating chains.
type Frame = [span: any, key: string | null, service: string, seen: Set<string> | null];
const stack: Frame[] = [];
for (let i = traceTree.length - 1; i >= 0; i--) stack.push([traceTree[i], "", "", new Set()]);
while (stack.length) {
const [span, parentKey, parentService, parentSeen] = stack.pop()!;
const serviceName = span.resolvedIdentity || span.serviceName || "unknown";
let key = parentKey;
let seen = parentSeen;
if (parentKey !== null && parentSeen && serviceName !== parentService) {
if (parentSeen.has(serviceName)) {
key = null;
seen = null;
} else {
key = parentKey ? `${parentKey}→${serviceName}` : serviceName;
seen = new Set(parentSeen).add(serviceName);
}
}
if (key !== null) {
if (!spansByPath.has(key)) spansByPath.set(key, { serviceName, spans: [] });
spansByPath.get(key)!.spans.push(span);
}
const children = span.spans ?? [];
for (let i = children.length - 1; i >= 0; i--) {
stack.push([children[i], key, serviceName, seen]);
}
}
spansByPath.forEach(({ serviceName, spans }, key) => {
patterns.set(key, spansToPattern(key, serviceName, spans, totalTraceDuration));
});
return patterns;
}
/**
* Check if a span represents an error
*/
@@ -259,6 +275,45 @@ export function isSpanError(span: any): boolean {
return false;
}
function spansToPattern(
signature: string,
serviceName: string,
spans: any[],
totalTraceDuration: number,
): CallPattern {
const durations = spans.map((s: any) => s.durationMs || 0);
const errorCount = spans.filter((span: any) => isSpanError(span)).length;
return {
pathSignature: signature,
instances: spans.map((span: any) => ({
spanId: span.span_id || span.spanId || "",
duration: span.durationMs || 0,
isError: isSpanError(span),
timestamp: span.start_time || 0,
attributes: span.attributes || {},
serviceName,
operationName: span.operationName || span.operation_name || "",
})),
metrics: {
count: spans.length,
avg: calculateAverage(durations),
min: durations.length > 0 ? durations.reduce((m, d) => Math.min(m, d), Infinity) : 0,
max: durations.length > 0 ? durations.reduce((m, d) => Math.max(m, d), -Infinity) : 0,
p75: calculatePercentile(durations, 75),
p95: calculatePercentile(durations, 95),
p99: calculatePercentile(durations, 99),
totalDuration: durations.reduce((sum, d) => sum + d, 0),
errorRate: spans.length > 0 ? (errorCount / spans.length) * 100 : 0,
errorCount,
traceTimePercent:
totalTraceDuration > 0 && durations.length > 0
? (calculateAverage(durations) / totalTraceDuration) * 100
: 0,
},
spanIds: spans.map((span: any) => span.span_id || span.spanId || ""),
};
}
/**
* Calculate average of an array of numbers
*/
@@ -15,6 +15,7 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { createTreeVisualizationEngine } from "./treeVisualizationEngine";
import { CHART_ZOOM_MAX, CHART_ZOOM_MIN } from "@/composables/traces/useChartZoom";
// Mock useStore
const mockStore = {
@@ -132,11 +133,10 @@ describe("createTreeVisualizationEngine tooltip system", () => {
const cleanup = setupTraceNodeTooltips(mockChart, mockData, false);
// Simulate mouseover event that hits the test node
// The handler receives ECharts params and looks up the node by name
const mouseMoveHandler = mockChart.on.mock.calls.find((call) => call[0] === "mouseover")[1];
const mockParams = {
data: { name: "Test Node" },
data: { name: "Test Node", treePath: [0] },
event: { offsetX: 100, offsetY: 200 },
};
mouseMoveHandler(mockParams);
@@ -146,6 +146,50 @@ describe("createTreeVisualizationEngine tooltip system", () => {
cleanup();
});
it("shows each copy of a repeated service its own tooltip", () => {
const { generateEChartsOptions, setupTraceNodeTooltips } = createTreeVisualizationEngine();
const frontendCatalog = { id: "product-catalog", name: "product-catalog", value: 0.31 };
const checkoutCatalog = { id: "product-catalog", name: "product-catalog", value: 0.26 };
const treeData = [
{
id: "frontend",
name: "frontend",
value: 5,
children: [
frontendCatalog,
{ id: "checkout", name: "checkout", value: 2, children: [checkoutCatalog] },
],
},
];
const mockData = {
treeData,
getNodeLabel: () => "",
getNodeTooltip: vi.fn().mockReturnValue("<div></div>"),
getNodeErrorRate: () => 0,
};
const options = generateEChartsOptions(mockData, {
layoutType: "horizontal",
isDarkMode: false,
nodeSize: "fixed",
});
const root = options.series[0].data[0];
const frontendDatum = root.children[0];
const checkoutDatum = root.children[1].children[0];
const cleanup = setupTraceNodeTooltips(mockChart, mockData, false);
const onMouseOver = mockChart.on.mock.calls.find((call) => call[0] === "mouseover")[1];
onMouseOver({ data: checkoutDatum, event: { offsetX: 10, offsetY: 10 } });
expect(mockData.getNodeTooltip).toHaveBeenLastCalledWith(checkoutCatalog);
onMouseOver({ data: frontendDatum, event: { offsetX: 10, offsetY: 10 } });
expect(mockData.getNodeTooltip).toHaveBeenLastCalledWith(frontendCatalog);
expect(mockData.getNodeTooltip).toHaveBeenCalledTimes(2);
cleanup();
});
it("should hide tooltip on mouse leave", () => {
const { setupTraceNodeTooltips } = createTreeVisualizationEngine();
@@ -197,7 +241,7 @@ describe("createTreeVisualizationEngine tooltip system", () => {
// Simulate mouse near edge that should trigger repositioning
// mouseover handler receives ECharts params object with event coordinates
const mockParams = {
data: { name: "Test Node" },
data: { name: "Test Node", treePath: [0] },
event: { offsetX: 750, offsetY: 550 },
};
mouseMoveHandler(mockParams);
@@ -392,3 +436,19 @@ describe("createTreeVisualizationEngine node detection with ECharts hit detectio
expect(nullResult).toBeNull();
});
});
describe("createTreeVisualizationEngine generateEChartsOptions", () => {
it("bounds wheel zoom with the same limits as the zoom buttons", () => {
const { generateEChartsOptions } = createTreeVisualizationEngine();
const options = generateEChartsOptions(
{
treeData: [{ id: "a", name: "a", value: 1 }],
getNodeLabel: () => "a",
getNodeTooltip: () => "",
getNodeErrorRate: () => 0,
},
{ layoutType: "horizontal", isDarkMode: false, nodeSize: "fixed" },
);
expect(options.series[0].scaleLimit).toEqual({ min: CHART_ZOOM_MIN, max: CHART_ZOOM_MAX });
});
});
+25 -32
View File
@@ -16,6 +16,7 @@
import type { I18nText } from "@/types/i18n";
import { getServiceColorHex } from "@/utils/traces/traceColors";
import { CHART_ZOOM_MAX, CHART_ZOOM_MIN } from "@/composables/traces/useChartZoom";
/**
* Tree node interface for tree visualization with coordinate support
@@ -85,8 +86,9 @@ export function createTreeVisualizationEngine() {
};
// Convert TreeNode format to ECharts tree format
const convertToEChartsFormat = (nodes: TreeNode[]): any[] => {
return nodes.map((node) => {
const convertToEChartsFormat = (nodes: TreeNode[], parentPath: number[] = []): any[] => {
return nodes.map((node, index) => {
const treePath = [...parentPath, index];
const errorCount = getNodeErrorRate(node);
const serviceColor =
data.getNodeServiceColor?.(node) ||
@@ -103,6 +105,8 @@ export function createTreeVisualizationEngine() {
return {
name: node.name,
value: node.value,
// Repeated services share name and id, so the tooltip resolves the node by position.
treePath,
symbol: getCircleSvg(fillColor, borderColor, 4),
symbolSize:
nodeSize === "dynamic"
@@ -181,7 +185,7 @@ export function createTreeVisualizationEngine() {
},
},
},
children: node.children ? convertToEChartsFormat(node.children) : undefined,
children: node.children ? convertToEChartsFormat(node.children, treePath) : undefined,
};
});
};
@@ -221,6 +225,8 @@ export function createTreeVisualizationEngine() {
initialTreeDepth: -1,
symbolSize: symbolSize,
roam: true, // Enable panning and zooming
// Same bounds as the zoom buttons, so wheel and buttons agree.
scaleLimit: { min: CHART_ZOOM_MIN, max: CHART_ZOOM_MAX },
selectedMode: "single", // Enable single node selection
label: {
position: layoutType === "vertical" ? "bottom" : "right",
@@ -305,26 +311,15 @@ export function createTreeVisualizationEngine() {
}
};
/**
* Helper to find tree node by name (more reliable than index)
* Traverses the tree data to find the node with matching name
*/
const findNodeByName = (nodes: TreeNode[], targetName: string): TreeNode | null => {
const traverse = (nodeList: TreeNode[]): TreeNode | null => {
for (const node of nodeList) {
if (node.name === targetName || node.id === targetName) {
return node;
}
if (node.children && node.children.length > 0) {
const found = traverse(node.children);
if (found) return found;
}
}
return null;
};
return traverse(nodes);
const findNodeByPath = (nodes: TreeNode[], treePath: number[]): TreeNode | null => {
let level: TreeNode[] | undefined = nodes;
let node: TreeNode | null = null;
for (const index of treePath) {
node = level?.[index] ?? null;
if (!node) return null;
level = node.children;
}
return node;
};
/**
@@ -387,7 +382,7 @@ export function createTreeVisualizationEngine() {
chartDom.appendChild(tooltipEl);
let hideTimer: ReturnType<typeof setTimeout> | null = null;
let activeNodeId: string | null = null;
let activeNode: TreeNode | null = null;
// Positioning logic from Service Graph with proper dimension measurement
const positionTooltip = (mouseX: number, mouseY: number) => {
@@ -433,23 +428,22 @@ export function createTreeVisualizationEngine() {
const hideTooltip = () => {
tooltipEl.style.display = "none";
activeNodeId = null;
activeNode = null;
};
// ECharts event handling - use built-in hover events directly on chart elements
const onNodeMouseOver = (params: any) => {
if (!params || !params.data) return;
if (!params?.data?.treePath) return;
// Use node name to find matching TreeNode instead of unreliable dataIndex
const hoveredNode = findNodeByName(data.treeData, params.data.name);
if (hoveredNode && hoveredNode.id !== activeNodeId) {
const hoveredNode = findNodeByPath(data.treeData, params.data.treePath);
if (hoveredNode && hoveredNode !== activeNode) {
// Clear any existing timer to prevent race conditions
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
activeNodeId = hoveredNode.id;
activeNode = hoveredNode;
// Use event pixel coordinates if available, otherwise use chart center
const mouseX = params.event?.offsetX || chartDom.clientWidth / 2;
@@ -460,7 +454,7 @@ export function createTreeVisualizationEngine() {
};
const onNodeMouseOut = () => {
if (activeNodeId) {
if (activeNode) {
// Clear existing timer before setting new one
if (hideTimer) {
clearTimeout(hideTimer);
@@ -499,7 +493,6 @@ export function createTreeVisualizationEngine() {
generateEChartsOptions,
setupTraceNodeTooltips,
findNodeAtPoint, // Export for testing
findNodeByName, // Export helper for testing
findNodeByIndex, // Export helper for testing
getHealthColor,
};