From ea38de99a09ef2f8526212f424eb421a3981f38c Mon Sep 17 00:00:00 2001 From: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com> Date: Sat, 25 Apr 2026 19:49:01 +0530 Subject: [PATCH] fix: Incorrect duration values were displayed in logs-details correlation traces tab (#11462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- .../components/traces/FlameGraphView.spec.ts | 20 ++-- web/src/components/traces/FlameGraphView.vue | 60 +++++----- .../TelemetryCorrelationDashboard.spec.ts | 109 +++++++++++++++++- .../TelemetryCorrelationDashboard.vue | 85 ++++++++++---- 4 files changed, 214 insertions(+), 60 deletions(-) diff --git a/web/src/components/traces/FlameGraphView.spec.ts b/web/src/components/traces/FlameGraphView.spec.ts index 755884ec60..aac8027f84 100644 --- a/web/src/components/traces/FlameGraphView.spec.ts +++ b/web/src/components/traces/FlameGraphView.spec.ts @@ -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", () => { diff --git a/web/src/components/traces/FlameGraphView.vue b/web/src/components/traces/FlameGraphView.vue index ca3308fc64..120bc9e63f 100644 --- a/web/src/components/traces/FlameGraphView.vue +++ b/web/src/components/traces/FlameGraphView.vue @@ -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 }; diff --git a/web/src/plugins/correlation/TelemetryCorrelationDashboard.spec.ts b/web/src/plugins/correlation/TelemetryCorrelationDashboard.spec.ts index f28998a9f5..6127c7a71a 100644 --- a/web/src/plugins/correlation/TelemetryCorrelationDashboard.spec.ts +++ b/web/src/plugins/correlation/TelemetryCorrelationDashboard.spec.ts @@ -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(); + }); + }); }); diff --git a/web/src/plugins/correlation/TelemetryCorrelationDashboard.vue b/web/src/plugins/correlation/TelemetryCorrelationDashboard.vue index b730b49c87..b05ad4686d 100644 --- a/web/src/plugins/correlation/TelemetryCorrelationDashboard.vue +++ b/web/src/plugins/correlation/TelemetryCorrelationDashboard.vue @@ -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 => { 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", }, });