diff --git a/otel-ui/src/components/TraceVisualization.tsx b/otel-ui/src/components/TraceVisualization.tsx index 7528e42..4e872e3 100644 --- a/otel-ui/src/components/TraceVisualization.tsx +++ b/otel-ui/src/components/TraceVisualization.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useRef, useEffect } from "react"; +import { useMemo, useState, useRef, useEffect, useDeferredValue } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Sparkles, @@ -54,6 +54,8 @@ export type ProcessedTrace = { endpoint: string; }; +const EMPTY_TRACES: TraceData[] = []; + // Flattened item for virtualized tree rendering interface FlattenedSpanItem { span: ProcessedSpan; @@ -96,14 +98,24 @@ const flattenSpanTree = ( collapsedSet: Set, traceMinTime: number, traceTotalRange: number, - depth = 0, + initialDepth = 0, ): FlattenedSpanItem[] => { const result: FlattenedSpanItem[] = []; + const pending: Array<{ span: ProcessedSpan; depth: number }> = []; - for (const span of nodes) { - const hasChildren = Boolean(span.children && span.children.length > 0); + for (let index = nodes.length - 1; index >= 0; index -= 1) { + pending.push({ span: nodes[index], depth: initialDepth }); + } + + // Avoid recursively spreading descendant arrays: deep parent/child chains + // otherwise copy the same nodes once per ancestor. + while (pending.length > 0) { + const current = pending.pop(); + if (!current) break; + const { span, depth } = current; + const children = span.children ?? []; + const hasChildren = children.length > 0; const isCollapsed = collapsedSet.has(span.spanId); - const offsetPercent = traceTotalRange === 0 ? 0 : ((span.startTime - traceMinTime) / traceTotalRange) * 100; const widthPercent = @@ -118,16 +130,10 @@ const flattenSpanTree = ( widthPercent: Math.max(0.5, Math.min(widthPercent, 100 - offsetPercent)), }); - if (hasChildren && !isCollapsed && span.children) { - result.push( - ...flattenSpanTree( - span.children, - collapsedSet, - traceMinTime, - traceTotalRange, - depth + 1, - ), - ); + if (hasChildren && !isCollapsed) { + for (let index = children.length - 1; index >= 0; index -= 1) { + pending.push({ span: children[index], depth: depth + 1 }); + } } } @@ -539,6 +545,7 @@ const VirtualizedSpanWaterfall = ({ const [searchQuery, setSearchQuery] = useState(""); const [errorsOnly, setErrorsOnly] = useState(false); const [minDurationMsFilter, setMinDurationMsFilter] = useState(0); + const deferredSearchQuery = useDeferredValue(searchQuery); const parentRef = useRef(null); // Build tree @@ -563,8 +570,8 @@ const VirtualizedSpanWaterfall = ({ const durMs = item.span.duration / 1_000_000; if (durMs < minDurationMsFilter) return false; } - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase().trim(); + if (deferredSearchQuery.trim()) { + const query = deferredSearchQuery.toLowerCase().trim(); const matchesName = item.span.name.toLowerCase().includes(query); const matchesService = item.span.service.toLowerCase().includes(query); const matchesAttrs = Object.values(item.span.formattedAttributes).some((v) => @@ -574,7 +581,7 @@ const VirtualizedSpanWaterfall = ({ } return true; }); - }, [flattenedItems, filterService, errorsOnly, minDurationMsFilter, searchQuery]); + }, [flattenedItems, filterService, errorsOnly, minDurationMsFilter, deferredSearchQuery]); // Virtualizer @@ -1683,10 +1690,12 @@ export const processTraces = ( ), ); - const uniqueSpans = spanAccumulator.filter( - (span, index, arr) => - arr.findIndex((candidate) => candidate.spanId === span.spanId) === index, - ); + const seenSpanIds = new Set(); + const uniqueSpans = spanAccumulator.filter((span) => { + if (seenSpanIds.has(span.spanId)) return false; + seenSpanIds.add(span.spanId); + return true; + }); const traceGroups = uniqueSpans.reduce>((acc, span) => { if (!acc[span.traceId]) acc[span.traceId] = []; @@ -1739,7 +1748,7 @@ export interface TraceVisualizationProps { } export const TraceVisualization = ({ traces }: TraceVisualizationProps) => { - const safeTraces = traces ?? []; + const safeTraces = traces ?? EMPTY_TRACES; const { logCountBySpanId, activeTraceId, @@ -1756,6 +1765,7 @@ export const TraceVisualization = ({ traces }: TraceVisualizationProps) => { "recent" | "duration-desc" | "duration-asc" | "errors" | "spans" >("recent"); const [groupByEndpoint, setGroupByEndpoint] = useState(false); + const deferredTraceSearchQuery = useDeferredValue(traceSearchQuery); const orderedTraces: ProcessedTrace[] = useMemo( () => processTraces(safeTraces, logCountBySpanId), @@ -1774,8 +1784,8 @@ export const TraceVisualization = ({ traces }: TraceVisualizationProps) => { if (traceFilter === "errors" && !t.hasError) return false; if (traceFilter === "slow" && t.duration / 1_000_000 < 200) return false; if (traceServiceFilter !== "all" && !t.services.includes(traceServiceFilter)) return false; - if (traceSearchQuery.trim()) { - const q = traceSearchQuery.toLowerCase().trim(); + if (deferredTraceSearchQuery.trim()) { + const q = deferredTraceSearchQuery.toLowerCase().trim(); const matchName = t.name.toLowerCase().includes(q); const matchId = t.id.toLowerCase().includes(q); const matchService = t.services.some((s) => s.toLowerCase().includes(q)); @@ -1791,7 +1801,7 @@ export const TraceVisualization = ({ traces }: TraceVisualizationProps) => { if (traceSortBy === "spans") return b.spans.length - a.spans.length; return b.startTime - a.startTime; // recent }); - }, [orderedTraces, traceFilter, traceServiceFilter, traceSearchQuery, traceSortBy]); + }, [orderedTraces, traceFilter, traceServiceFilter, deferredTraceSearchQuery, traceSortBy]); const groupCounts = useMemo(() => { const counts: Record = {};