mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user