fix: Incorrect duration values were displayed in logs-details correlation traces tab (#11462)

#11446 
Traces in the Logs correlation panel were showing wrong durations
because they were read from /latest (cached summaries). Using
/latest_stream delivers the actual trace records with correct duration
data.

#11463 
Updated FlameGraphView.vue so that spans with a computed width below
0.1% of the total trace duration are rendered with a minimum 0.1% width
instead of being dropped — ensuring all spans remain visible regardless
of how short they are.
This commit is contained in:
Omkar Kesarkhane
2026-04-25 14:19:01 +00:00
committed by GitHub
parent cb4c799476
commit ea38de99a0
4 changed files with 214 additions and 60 deletions
@@ -420,11 +420,11 @@ describe("FlameGraphView", () => {
expect(normalSpan.itemStyle.borderWidth).toBe(1);
});
it("should filter out spans below threshold width", async () => {
it("should include spans with tiny duration using minimum 0.1% width", async () => {
const tinySpan = [
createMockSpan({
span_id: "tiny",
durationMs: 0.01, // Very small duration
durationMs: 0.01, // Very small duration: 0.01/10000 * 100 = 0.00001%
startOffsetMs: 0,
}),
];
@@ -441,8 +441,9 @@ describe("FlameGraphView", () => {
const { data } = wrapper.vm.flameGraphDataAndDepth;
// Should be filtered out due to < 0.1% threshold
expect(data.length).toBe(0);
// Tiny spans are included with a minimum 0.1% width so they remain visible
expect(data.length).toBe(1);
expect(data[0].value[2]).toBe(0.1);
});
});
@@ -690,8 +691,9 @@ describe("FlameGraphView", () => {
const { data } = wrapper.vm.flameGraphDataAndDepth;
// Should be filtered out due to 0% width
expect(data.length).toBe(0);
// Zero-duration spans are included with the minimum 0.1% width
expect(data.length).toBe(1);
expect(data[0].value[2]).toBe(0.1);
});
it("should handle negative start offset gracefully", async () => {
@@ -729,8 +731,10 @@ describe("FlameGraphView", () => {
const { data } = wrapper.vm.flameGraphDataAndDepth;
// All spans should be filtered due to tiny percentage
expect(data.length).toBe(0);
// All spans are included; each gets the minimum 0.1% width since their natural
// percentage is far below the threshold against a 1,000,000ms trace duration
expect(data.length).toBe(mockSpans.length);
data.forEach((d: any) => expect(d.value[2]).toBe(0.1));
});
it("should handle undefined selectedSpanId", () => {
+31 -29
View File
@@ -270,38 +270,40 @@ const flameGraphDataAndDepth = computed(() => {
props.spans.forEach((span) => {
const startPercent = (span.startOffsetMs / traceDuration) * 100;
const durationPercent = (span.durationMs / traceDuration) * 100;
let durationPercent = (span.durationMs / traceDuration) * 100;
if (durationPercent > 0.1) {
const visualRow = rowMap.get(span.span_id) ?? span.depth;
const isSelected = span.span_id === props.selectedSpanId;
data.push({
value: [
startPercent, // x position (percentage)
visualRow, // y position (collision-free visual row)
durationPercent, // width (percentage of trace)
span.durationMs, // actual duration in ms
],
// Spans with durationPercent less than 0.1% are invisble in chart
// Added min 0.1% durationPercent
if (durationPercent < 0.1) durationPercent = 0.1;
const visualRow = rowMap.get(span.span_id) ?? span.depth;
const isSelected = span.span_id === props.selectedSpanId;
data.push({
value: [
startPercent, // x position (percentage)
visualRow, // y position (collision-free visual row)
durationPercent, // width (percentage of trace)
span.durationMs, // actual duration in ms
],
itemStyle: {
color: searchObj.meta.serviceColors[span.serviceName] || "#9CA3AF",
borderColor: isSelected
? "#2563EB"
: span.hasError
? "#EF4444"
: "#ffffff",
borderWidth: isSelected ? 3 : span.hasError ? 2 : 1,
},
emphasis: {
itemStyle: {
color: searchObj.meta.serviceColors[span.serviceName] || "#9CA3AF",
borderColor: isSelected
? "#2563EB"
: span.hasError
? "#EF4444"
: "#ffffff",
borderWidth: isSelected ? 3 : span.hasError ? 2 : 1,
borderColor: "#2563EB",
borderWidth: 3,
shadowBlur: 10,
shadowColor: "rgba(37, 99, 235, 0.5)",
},
emphasis: {
itemStyle: {
borderColor: "#2563EB",
borderWidth: 3,
shadowBlur: 10,
shadowColor: "rgba(37, 99, 235, 0.5)",
},
},
spanData: span,
});
}
},
spanData: span,
});
});
return { data, maxRow };
@@ -21,6 +21,16 @@ import TelemetryCorrelationDashboard from "./TelemetryCorrelationDashboard.vue";
import store from "@/test/unit/helpers/store";
import { nextTick } from "vue";
const mockFetchQueryDataWithHttpStream = vi.fn();
const mockCancelStreamQueryBasedOnRequestId = vi.fn();
vi.mock("@/composables/useStreamingSearch", () => ({
default: () => ({
fetchQueryDataWithHttpStream: mockFetchQueryDataWithHttpStream,
cancelStreamQueryBasedOnRequestId: mockCancelStreamQueryBasedOnRequestId,
}),
}));
// Mock composables
vi.mock("@/composables/useNotifications", () => ({
default: vi.fn(() => ({
@@ -80,12 +90,19 @@ vi.mock("@/services/search", () => ({
search: vi.fn(() =>
Promise.resolve({ data: { hits: [], total: 0, took: 0 } })
),
get_traces: vi.fn(() =>
Promise.resolve({ data: { hits: [], total: 0 } })
),
},
}));
vi.mock("@/utils/zincutils", () => ({
b64EncodeUnicode: vi.fn((str: string) => btoa(str)),
}));
vi.mock("@/utils/zincutils", async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
b64EncodeUnicode: vi.fn((str: string) => btoa(str)),
};
});
vi.mock("@/utils/dashboard/constants", () => ({
SELECT_ALL_VALUE: "*",
@@ -557,4 +574,90 @@ describe("TelemetryCorrelationDashboard.vue", () => {
expect(timeObj.__global.end_time).toBeInstanceOf(Date);
});
});
describe("fetchTracesByDimensions", () => {
beforeEach(() => {
mockFetchQueryDataWithHttpStream.mockReset();
mockCancelStreamQueryBasedOnRequestId.mockReset();
});
it("should return empty array when traceStreams is empty", async () => {
wrapper = createWrapper({ traceStreams: [] });
const result = await wrapper.vm.fetchTracesByDimensions();
expect(result).toEqual([]);
});
it("should call fetchQueryDataWithHttpStream with correct stream params", async () => {
mockFetchQueryDataWithHttpStream.mockImplementation((_payload: any, handlers: any) => {
handlers.complete();
});
wrapper = createWrapper({
traceStreams: [{ stream_name: "traces", stream_type: "traces", filters: { service: "api" } }],
});
await wrapper.vm.fetchTracesByDimensions();
expect(mockFetchQueryDataWithHttpStream).toHaveBeenCalledOnce();
const callArg = mockFetchQueryDataWithHttpStream.mock.calls[0][0];
expect(callArg.queryReq.stream_name).toBe("traces");
expect(callArg.queryReq.start_time).toBe(defaultProps.timeRange.startTime);
expect(callArg.queryReq.end_time).toBe(defaultProps.timeRange.endTime);
expect(callArg.type).toBe("traces");
});
it("should build filter string from traceStream filters", async () => {
mockFetchQueryDataWithHttpStream.mockImplementation((_payload: any, handlers: any) => {
handlers.complete();
});
wrapper = createWrapper({
traceStreams: [{ stream_name: "traces", stream_type: "traces", filters: { service: "api", env: "prod" } }],
});
await wrapper.vm.fetchTracesByDimensions();
const callArg = mockFetchQueryDataWithHttpStream.mock.calls[0][0];
expect(callArg.queryReq.filter).toContain("service='api'");
expect(callArg.queryReq.filter).toContain("env='prod'");
});
it("should accumulate and return hits from streaming data callback", async () => {
const mockHit = { traceId: "abc123", trace_id: "abc123" };
mockFetchQueryDataWithHttpStream.mockImplementation((_payload: any, handlers: any) => {
handlers.data(_payload, { content: { results: { hits: [mockHit] } } });
handlers.complete();
});
wrapper = createWrapper({
traceStreams: [{ stream_name: "traces", stream_type: "traces", filters: {} }],
});
const result = await wrapper.vm.fetchTracesByDimensions();
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(1);
});
it("should cancel in-flight stream before starting a new one", async () => {
// First call never completes (simulates in-flight)
mockFetchQueryDataWithHttpStream.mockImplementationOnce(() => {
// no-op: never calls complete/error
});
mockFetchQueryDataWithHttpStream.mockImplementationOnce((_payload: any, handlers: any) => {
handlers.complete();
});
wrapper = createWrapper({
traceStreams: [{ stream_name: "traces", stream_type: "traces", filters: {} }],
});
// First call — sets currentTracesStreamTraceId
wrapper.vm.fetchTracesByDimensions();
// Second call — should cancel first before starting
await wrapper.vm.fetchTracesByDimensions();
expect(mockCancelStreamQueryBasedOnRequestId).toHaveBeenCalledOnce();
});
});
});
@@ -1272,7 +1272,8 @@ import type { StreamInfo } from "@/services/service_streams";
import { SELECT_ALL_VALUE } from "@/utils/dashboard/constants";
import streamService from "@/services/stream";
import searchService from "@/services/search";
import { b64EncodeUnicode } from "@/utils/zincutils";
import { b64EncodeUnicode, getUUID } from "@/utils/zincutils";
import useHttpStreaming from "@/composables/useStreamingSearch";
import LogstashDatasource from "@/components/ingestion/logs/LogstashDatasource.vue";
import DimensionFiltersBar from "./DimensionFiltersBar.vue";
import TraceDetails from "@/plugins/traces/TraceDetails.vue";
@@ -1327,6 +1328,11 @@ const { generateDashboard, generateLogsDashboard } =
useMetricsCorrelationDashboard();
const { semanticGroups, loadSemanticGroups } = useServiceCorrelation();
const { formatTracesMetaData } = useTraces();
const { fetchQueryDataWithHttpStream, cancelStreamQueryBasedOnRequestId } =
useHttpStreaming();
// Track in-flight dimension-based trace stream so it can be cancelled on re-fetch
let currentTracesStreamTraceId: string | null = null;
// Resolved group definitions and their ids (reactive to prop changes)
const groupDefs = computed(
@@ -2544,41 +2550,79 @@ const fetchTraceByTraceId = async (traceId: string) => {
};
/**
* Fetch traces via dimension-based correlation
* Fetch traces via dimension-based correlation using HTTP streaming (/latest_stream)
*/
const fetchTracesByDimensions = async () => {
const fetchTracesByDimensions = (): Promise<any[]> => {
if (!props.traceStreams?.length) {
return [];
return Promise.resolve([]);
}
const traceStreamInfo = props.traceStreams[0];
const streamName = traceStreamInfo.stream_name;
// Build filter using ALL filters from the trace stream's filters
// This uses all the filters coming from the correlation API
const filterParts: string[] = [];
// Use all filters from traceStreamInfo.filters
if (traceStreamInfo.filters) {
for (const [fieldName, value] of Object.entries(traceStreamInfo.filters)) {
filterParts.push(`${fieldName}='${value}'`);
}
}
const filter = filterParts.join(" AND ");
const response = await searchService.get_traces({
org_identifier: currentOrgIdentifier.value,
start_time: props.timeRange.startTime,
end_time: props.timeRange.endTime,
filter: filter,
size: 50,
from: 0,
stream_name: streamName,
});
// Cancel any previous in-flight stream
if (currentTracesStreamTraceId) {
cancelStreamQueryBasedOnRequestId({
trace_id: currentTracesStreamTraceId,
org_id: currentOrgIdentifier.value,
});
currentTracesStreamTraceId = null;
}
// Format traces with service colors and proper structure for TraceBlock
return formatTracesMetaData(response.data?.hits || []);
const traceId = getUUID().replace(/-/g, "");
currentTracesStreamTraceId = traceId;
const accumulated: any[] = [];
return new Promise((resolve, reject) => {
(async () => {
try {
await fetchQueryDataWithHttpStream(
{
queryReq: {
stream_name: streamName,
filter: filter,
start_time: props.timeRange.startTime,
end_time: props.timeRange.endTime,
from: 0,
size: 50,
},
type: "traces",
traceId,
org_id: currentOrgIdentifier.value,
},
{
data: (_: any, response: any) => {
const hits: any[] = response.content?.results?.hits || [];
if (hits.length > 0) {
accumulated.push(...formatTracesMetaData(hits));
}
},
error: (_: any, err: any) => {
currentTracesStreamTraceId = null;
reject(err);
},
complete: () => {
currentTracesStreamTraceId = null;
resolve(accumulated);
},
reset: () => {},
},
);
} catch (e) {
currentTracesStreamTraceId = null;
reject(e);
}
})();
});
};
/**
@@ -2649,6 +2693,7 @@ const openTracesPage = () => {
to: props.timeRange.endTime,
query: b64EncodeUnicode(filterQuery), // Base64 encode the filter query
org_identifier: org,
tab: "traces",
},
});