diff --git a/.changeset/great-pants-tease.md b/.changeset/great-pants-tease.md new file mode 100644 index 000000000..65ab0f152 --- /dev/null +++ b/.changeset/great-pants-tease.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: allow collapsing child spans diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index b12fc334b..cb9c6f575 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -18,8 +18,11 @@ import { Code, Divider, Group, + Kbd, Text, + Tooltip, } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; import { IconChevronDown, IconChevronRight, @@ -364,6 +367,37 @@ export function useEventsAroundFocus({ }; } +export function getDescendantIds(node: { + id?: string; + children?: Array<{ id?: string; children?: any[] }>; +}): string[] { + const ids: string[] = []; + + if (!node.children?.length) { + return ids; + } + + for (const child of node.children) { + if (child.id) { + ids.push(child.id); + } + + ids.push(...getDescendantIds(child)); + } + + return ids; +} + +function CollapseTooltipLabel({ onShown }: { onShown: () => void }) { + useEffect(() => onShown, [onShown]); + + return ( + <> + ⌥/Alt + click to collapse children + + ); +} + // TODO: Optimize with ts lookup tables export function DBTraceWaterfallChartContainer({ traceTableSource, @@ -546,114 +580,136 @@ export function DBTraceWaterfallChartContainer({ .map(row => row.SpanId) ?? [], ); }, [traceRowsData]); - const rootNodes: Node[] = []; - const nodesMap = new Map(); // Maps result.id (or placeholder id) -> Node - const spanIdMap = new Map(); // Maps SpanId -> result.id of FIRST node with that SpanId - - for (const result of rows ?? []) { - const { type, SpanId, ParentSpanId } = result; - // ignore everything without spanId - if (!SpanId) continue; - - // log have duplicate span id, tag it with -log - const nodeSpanId = type === SourceKind.Log ? `${SpanId}-log` : SpanId; // prevent log spanId overwrite trace spanId - const nodeParentSpanId = - type === SourceKind.Log ? SpanId : ParentSpanId || ''; - - const curNode = { - ...result, - children: [], - }; - if (type === SourceKind.Trace) { - // Check if this is the first node with this SpanId - if (!spanIdMap.has(nodeSpanId)) { - // First occurrence - this becomes the canonical node for this SpanId - spanIdMap.set(nodeSpanId, result.id); - - // Check if there's a placeholder parent waiting for this SpanId - const placeholderId = `placeholder-${nodeSpanId}`; - const placeholder = nodesMap.get(placeholderId); - if (placeholder) { - // Inherit children from placeholder - curNode.children = placeholder.children || []; - // Remove placeholder - nodesMap.delete(placeholderId); + const [collapsedIds, setCollapsedIds] = useState>(new Set()); + const [showSpanEvents, setShowSpanEvents] = useState(true); + + const { nodesMap, flattenedNodes } = useMemo(() => { + const rootNodes: Node[] = []; + const nodesMap = new Map(); // Maps result.id (or placeholder id) -> Node + const spanIdMap = new Map(); // Maps SpanId -> result.id of FIRST node with that SpanId + + for (const result of rows ?? []) { + const { type, SpanId, ParentSpanId } = result; + // ignore everything without spanId + if (!SpanId) continue; + + // log have duplicate span id, tag it with -log + const nodeSpanId = type === SourceKind.Log ? `${SpanId}-log` : SpanId; // prevent log spanId overwrite trace spanId + const nodeParentSpanId = + type === SourceKind.Log ? SpanId : ParentSpanId || ''; + + const curNode = { + ...result, + children: [], + }; + + if (type === SourceKind.Trace) { + // Check if this is the first node with this SpanId + if (!spanIdMap.has(nodeSpanId)) { + // First occurrence - this becomes the canonical node for this SpanId + spanIdMap.set(nodeSpanId, result.id); + + // Check if there's a placeholder parent waiting for this SpanId + const placeholderId = `placeholder-${nodeSpanId}`; + const placeholder = nodesMap.get(placeholderId); + if (placeholder) { + // Inherit children from placeholder + curNode.children = placeholder.children || []; + // Remove placeholder + nodesMap.delete(placeholderId); + } } + // Always add to nodesMap with unique result.id + nodesMap.set(result.id, curNode); } - // Always add to nodesMap with unique result.id - nodesMap.set(result.id, curNode); - } - // root if: is trace event, and (has no parent or parent id is not valid) - const isRootNode = - type === SourceKind.Trace && - (!nodeParentSpanId || !validSpanIDs.has(nodeParentSpanId)); + // root if: is trace event, and (has no parent or parent id is not valid) + const isRootNode = + type === SourceKind.Trace && + (!nodeParentSpanId || !validSpanIDs.has(nodeParentSpanId)); + + if (isRootNode) { + rootNodes.push(curNode); + } else { + // Look up parent by SpanId + const parentResultId = spanIdMap.get(nodeParentSpanId); + let parentNode = parentResultId + ? nodesMap.get(parentResultId) + : undefined; - if (isRootNode) { - rootNodes.push(curNode); - } else { - // Look up parent by SpanId - const parentResultId = spanIdMap.get(nodeParentSpanId); - let parentNode = parentResultId - ? nodesMap.get(parentResultId) - : undefined; - - if (!parentNode) { - // Parent doesn't exist yet, create placeholder - const placeholderId = `placeholder-${nodeParentSpanId}`; - parentNode = nodesMap.get(placeholderId); if (!parentNode) { - parentNode = { children: [] } as any; - nodesMap.set(placeholderId, parentNode); + // Parent doesn't exist yet, create placeholder + const placeholderId = `placeholder-${nodeParentSpanId}`; + parentNode = nodesMap.get(placeholderId); + if (!parentNode) { + parentNode = { children: [] } as any; + nodesMap.set(placeholderId, parentNode); + } } + + parentNode.children.push(curNode); } + } - parentNode.children.push(curNode); + type NodeWithLevel = Node & { level: number }; + // flatten the rootnode dag into an array via in-order traversal + const traverse = (node: Node, arr: NodeWithLevel[], level = 0) => { + // Filter out hidden nodes, but still traverse their (non-hidden) descendants + if (!node.__hdx_hidden) { + arr.push({ + level, + ...node, + }); + } + + // Filter out collapsed nodes + if (collapsedIds.has(node.id)) { + return; + } + node?.children?.forEach((child: any) => traverse(child, arr, level + 1)); + }; + + const flattenedNodes: NodeWithLevel[] = []; + if (rootNodes.length > 0) { + rootNodes.forEach(rootNode => traverse(rootNode, flattenedNodes)); } - } - const [collapsedIds, setCollapsedIds] = useState>(new Set()); - const [showSpanEvents, setShowSpanEvents] = useState(true); + return { nodesMap, flattenedNodes }; + }, [collapsedIds, rows, validSpanIDs]); const toggleCollapse = useCallback( - (id: string) => { + (id: string, event: React.MouseEvent) => { + event.stopPropagation(); // prevent collapsing from selecting row + setCollapsedIds(prev => { const newSet = new Set(prev); - if (newSet.has(id)) { + const isCollapsed = newSet.has(id); + + if (isCollapsed) { newSet.delete(id); } else { newSet.add(id); } + + if (event.altKey) { + const node = nodesMap.get(id); + if (node?.children?.length) { + const descendantIds = getDescendantIds(node); + if (isCollapsed) { + descendantIds.forEach(descId => newSet.delete(descId)); + } else { + descendantIds.forEach(descId => newSet.add(descId)); + } + } + } + return newSet; }); }, - [setCollapsedIds], + [nodesMap], ); - type NodeWithLevel = Node & { level: number }; - // flatten the rootnode dag into an array via in-order traversal - const traverse = (node: Node, arr: NodeWithLevel[], level = 0) => { - // Filter out hidden nodes, but still traverse their (non-hidden) descendants - if (!node.__hdx_hidden) { - arr.push({ - level, - ...node, - }); - } - - // Filter out collapsed nodes - if (collapsedIds.has(node.id)) { - return; - } - node?.children?.forEach((child: any) => traverse(child, arr, level + 1)); - }; - - const flattenedNodes: NodeWithLevel[] = []; - if (rootNodes.length > 0) { - rootNodes.forEach(rootNode => traverse(rootNode, flattenedNodes)); - } - const spanCount = flattenedNodes.length; const errorCount = flattenedNodes.filter( node => @@ -675,156 +731,200 @@ export function DBTraceWaterfallChartContainer({ const minOffset = foundMinOffset === Number.MAX_SAFE_INTEGER ? 0 : foundMinOffset; - const timelineRows = flattenedNodes.map((result, i) => { - const tookMs = (result.Duration || 0) * 1000; - const startOffset = new Date(result.Timestamp).getTime(); - const start = startOffset - minOffset; - const end = start + tookMs; - - const { - Body: _body, - ServiceName: serviceName, - id, - type, - aliasWith, - } = result; - let body = `${_body}`; - try { - body = typeof _body === 'string' ? _body : JSON.stringify(_body); - } catch (e) { - console.warn("DBTraceWaterfallChart: Couldn't JSON stringify Body", e); - } + const [collapseTooltipShown, { open: setCollapseTooltipShown }] = + useDisclosure(false); - // Extract HTTP-related logic - const eventAttributes = result.SpanAttributes || {}; - const hasHttpAttributes = - eventAttributes['http.url'] || eventAttributes['http.method']; - const httpUrl = eventAttributes['http.url']; - - const displayText = - hasHttpAttributes && httpUrl ? `${body} ${httpUrl}` : body; - - // Process span events into markers (only if showSpanEvents is enabled) - const markers = - showSpanEvents && result.SpanEvents - ? result.SpanEvents.map(spanEvent => ({ - timestamp: new Date(spanEvent.Timestamp).getTime() - minOffset, - name: spanEvent.Name, - attributes: spanEvent.Attributes || {}, - })) - : []; - - // Extract status logic - // TODO: Legacy schemas will have STATUS_CODE_ERROR - // See: https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/34799/files#diff-1ec84547ed93f2c8bfb21c371ca0b5304f01371e748d4b02bf397313a4b1dfa4L197 - const isError = - result.StatusCode == 'Error' || result.SeverityText === 'error'; - const status = result.StatusCode || result.SeverityText; - const isWarn = result.SeverityText === 'warn'; - const isHighlighted = highlightedRowWhere === id; + const timelineRows = useMemo( + () => + flattenedNodes.map((result, i) => { + const tookMs = (result.Duration || 0) * 1000; + const startOffset = new Date(result.Timestamp).getTime(); + const start = startOffset - minOffset; + const end = start + tookMs; - return { - id, - type, - aliasWith, - label: ( -
{ - onClick?.({ id, type: type ?? '', aliasWith }); - }} - > -
- {Array.from({ length: result.level }).map((_, index) => ( -
- ))} -
0 ? 1 : 0, - }} - onClick={() => { - toggleCollapse(id); - }} - > - {collapsedIds.has(id) ? ( - - ) : ( - - )}{' '} -
- {!isFilterActive && ( - - {result.children.length > 0 - ? `(${result.children.length})` - : ''} - - )} + const { + Body: _body, + ServiceName: serviceName, + id, + type, + aliasWith, + } = result; + let body = `${_body}`; + try { + body = typeof _body === 'string' ? _body : JSON.stringify(_body); + } catch (e) { + console.warn( + "DBTraceWaterfallChart: Couldn't JSON stringify Body", + e, + ); + } - - {type === SourceKind.Log ? ( - - ) : null} - { - // toggleCollapse(id); - // }} - title={`${serviceName}${hasHttpAttributes && httpUrl ? ` | ${displayText}` : ''}`} - role="button" - > - {serviceName ? `${serviceName} | ` : ''} - {displayText} - - -
-
- ), - style: { - // paddingTop: 1, - marginTop: i === 0 ? 32 : 0, - }, - isActive: isHighlighted, - events: [ - { + // Extract HTTP-related logic + const eventAttributes = result.SpanAttributes || {}; + const hasHttpAttributes = + eventAttributes['http.url'] || eventAttributes['http.method']; + const httpUrl = eventAttributes['http.url']; + + const displayText = + hasHttpAttributes && httpUrl ? `${body} ${httpUrl}` : body; + + // Process span events into markers (only if showSpanEvents is enabled) + const markers = + showSpanEvents && result.SpanEvents + ? result.SpanEvents.map(spanEvent => ({ + timestamp: new Date(spanEvent.Timestamp).getTime() - minOffset, + name: spanEvent.Name, + attributes: spanEvent.Attributes || {}, + })) + : []; + + // Extract status logic + // TODO: Legacy schemas will have STATUS_CODE_ERROR + // See: https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/34799/files#diff-1ec84547ed93f2c8bfb21c371ca0b5304f01371e748d4b02bf397313a4b1dfa4L197 + const isError = + result.StatusCode == 'Error' || result.SeverityText === 'error'; + const status = result.StatusCode || result.SeverityText; + const isWarn = result.SeverityText === 'warn'; + const isHighlighted = highlightedRowWhere === id; + + return { id, type, aliasWith, - start, - end, - tooltip: `${displayText} ${tookMs >= 0 ? `took ${tookMs.toFixed(4)}ms` : ''} ${status ? `| Status: ${status}` : ''}${!isNaN(startOffset) ? ` | Started at ${formatTime(new Date(startOffset), { format: 'withMs' })}` : ''}`, - color: 'var(--color-text-inverted)', - backgroundColor: barColor({ isError, isWarn, isHighlighted, type }), - body: {displayText}, - minWidthPerc: 1, - isError, - markers, - showDuration: type !== SourceKind.Log, - }, - ], - }; - }); + label: ( +
{ + onClick?.({ id, type: type ?? '', aliasWith }); + }} + > +
+ {Array.from({ length: result.level }).map((_, index) => ( +
+ ))} + + + } + withArrow + > +
0 ? 1 : 0, + }} + onClick={ + result.children.length > 0 + ? e => { + toggleCollapse(id, e); + } + : undefined + } + > + {collapsedIds.has(id) ? ( + + ) : ( + + )}{' '} +
+
+ + {!isFilterActive && ( + + {result.children.length > 0 + ? `(${result.children.length})` + : ''} + + )} + + + {type === SourceKind.Log ? ( + + ) : null} + + {serviceName ? `${serviceName} | ` : ''} + {displayText} + + +
+
+ ), + style: { + // paddingTop: 1, + marginTop: i === 0 ? 32 : 0, + }, + isActive: isHighlighted, + events: [ + { + id, + type, + aliasWith, + start, + end, + tooltip: `${displayText} ${tookMs >= 0 ? `took ${tookMs.toFixed(4)}ms` : ''} ${status ? `| Status: ${status}` : ''}${!isNaN(startOffset) ? ` | Started at ${formatTime(new Date(startOffset), { format: 'withMs' })}` : ''}`, + color: 'var(--color-text-inverted)', + backgroundColor: barColor({ + isError, + isWarn, + isHighlighted, + type, + }), + body: {displayText}, + minWidthPerc: 1, + isError, + markers, + showDuration: type !== SourceKind.Log, + }, + ], + }; + }), + [ + collapsedIds, + flattenedNodes, + formatTime, + highlightedRowWhere, + isFilterActive, + minOffset, + onClick, + showSpanEvents, + toggleCollapse, + collapseTooltipShown, + setCollapseTooltipShown, + ], + ); // TODO: Highlighting support const initialScrollRowIndex = flattenedNodes.findIndex(v => { return v.id === highlightedRowWhere; diff --git a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx index 012a2711c..c83172d3e 100644 --- a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx @@ -10,6 +10,7 @@ import useRowWhere from '@/hooks/useRowWhere'; import { RowSidePanelContext } from '../DBRowSidePanel'; import { DBTraceWaterfallChartContainer, + getDescendantIds, SpanRow, useEventsAroundFocus, } from '../DBTraceWaterfallChart'; @@ -375,3 +376,62 @@ describe('useEventsAroundFocus', () => { expect(result.rows.length).toBe(0); }); }); + +describe('getDescendantIds', () => { + it('returns empty array for node with no children', () => { + expect(getDescendantIds({ id: 'root' })).toEqual([]); + expect(getDescendantIds({ id: 'root', children: [] })).toEqual([]); + }); + + it('returns empty array for node with undefined or missing children', () => { + expect(getDescendantIds({ id: 'root', children: undefined })).toEqual([]); + }); + + it('returns direct children ids for a single level', () => { + const node = { + id: 'root', + children: [ + { id: 'a', children: [] }, + { id: 'b', children: [] }, + ], + }; + expect(getDescendantIds(node)).toEqual(['a', 'b']); + }); + + it('returns all descendant ids for nested children', () => { + const node = { + id: 'root', + children: [ + { + id: 'a', + children: [ + { id: 'a1', children: [] }, + { id: 'a2', children: [] }, + ], + }, + { id: 'b', children: [] }, + ], + }; + expect(getDescendantIds(node)).toEqual(['a', 'a1', 'a2', 'b']); + }); + + it('skips children without id but still recurses into their descendants', () => { + const node = { + id: 'root', + children: [ + { + children: [{ id: 'grandchild', children: [] }], + }, + ], + }; + expect(getDescendantIds(node)).toEqual(['grandchild']); + }); + + it('returns single descendant for one child', () => { + const node = { + id: 'root', + children: [{ id: 'only', children: [] }], + }; + expect(getDescendantIds(node)).toEqual(['only']); + }); +});