Files
Eric Allam 187c0476c3 perf(webapp): shrink run trace loader payload and add trace span cap controls (#3906)
## Summary

The run trace page loader serialized every span's raw OTel events (with
full properties) into the response, even though the tree UI only renders
the derived `timelineEvents` and the span detail panel refetches what it
needs. On event-heavy traces that inflated both the loader payload and
the server-side heap copies built per request. This PR keeps raw span
events server-side and pairs that with a few related trace-view
improvements:

- A new optional `TRACE_VIEW_EMERGENCY_SPAN_CAP` env var (unset by
default) clamps the trace summary and detailed trace summary span limits
on both event store paths, including the public run trace endpoint, so
operators can bound trace query sizes in one place without retuning the
per-store limits.
- The TreeView virtualizer resolved every rendered row with a linear
scan over the whole tree (and `getNodeProps` did the same via
`findIndex`); rows now resolve through memoized id lookup maps, which
matters once traces reach tens of thousands of spans.
- The run stream SSE lookup now applies the same organization membership
scoping as the rest of the run page presenters, for consistency.

Behavior is unchanged by default: the trace tree renders from the same
`timelineEvents` it always has, and the new cap only takes effect when
set.
2026-06-11 18:49:05 +01:00

645 lines
17 KiB
TypeScript

import { VirtualItem, Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import { motion } from "framer-motion";
import {
MutableRefObject,
RefObject,
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
} from "react";
import { cn } from "~/utils/cn";
import { NodeState, NodesState, reducer } from "./reducer";
import { concreteStateFromInput, selectedIdFromState } from "./utils";
export type TreeViewProps<TData> = {
tree: FlatTree<TData>;
parentClassName?: string;
renderNode: (params: {
node: FlatTreeItem<TData>;
state: NodeState;
index: number;
virtualizer: Virtualizer<HTMLElement, Element>;
virtualItem: VirtualItem;
}) => React.ReactNode;
nodes: UseTreeStateOutput["nodes"];
autoFocus?: boolean;
virtualizer: Virtualizer<HTMLElement, Element>;
parentRef?: MutableRefObject<HTMLElement | null>;
scrollRef?: MutableRefObject<HTMLElement | null>;
onScroll?: (scrollTop: number) => void;
} & Pick<UseTreeStateOutput, "getTreeProps" | "getNodeProps">;
export type GetTreePropsFn = UseTreeStateOutput["getTreeProps"];
export type GetNodePropsFn = UseTreeStateOutput["getNodeProps"];
export function TreeView<TData>({
tree,
renderNode,
nodes,
autoFocus = false,
getTreeProps,
getNodeProps,
parentClassName,
virtualizer,
parentRef,
scrollRef,
onScroll,
}: TreeViewProps<TData>) {
useEffect(() => {
if (autoFocus) {
parentRef?.current?.focus();
}
}, [autoFocus, parentRef?.current]);
const virtualItems = virtualizer.getVirtualItems();
// id -> node lookup so each virtual row resolves in O(1) instead of
// scanning the whole tree per row.
const nodesById = useMemo(() => {
const map = new Map<string, FlatTreeItem<TData>>();
for (const node of tree) {
map.set(node.id, node);
}
return map;
}, [tree]);
const scrollCallback = useCallback(
(event: Event) => {
if (!onScroll) return;
const target = event.target as HTMLElement;
onScroll?.(target.scrollTop);
},
[onScroll]
);
useEffect(() => {
//subscribe to scrollRef scroll event
if (!scrollRef?.current || onScroll === undefined) return;
scrollRef.current.addEventListener("scroll", scrollCallback);
return () => scrollRef.current?.removeEventListener("scroll", scrollCallback);
}, [scrollRef?.current]);
return (
<motion.div
ref={(element) => {
if (parentRef) {
parentRef.current = element;
}
if (scrollRef) {
scrollRef.current = element;
}
}}
className={cn(
"w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-within:outline-none",
parentClassName
)}
layoutScroll
{...getTreeProps()}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
overflowY: "visible",
}}
>
<div
style={{
position: "absolute",
overflowY: "visible",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItems.at(0)?.start ?? 0}px)`,
}}
>
{virtualItems.map((virtualItem) => {
const node = nodesById.get(virtualItem.key as string);
if (!node) return null;
const state = nodes[node.id];
if (!state) return null;
if (!state.visible) return null;
return (
<div
key={node.id}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="overflow-clip"
{...getNodeProps(node.id)}
>
{renderNode({
node,
state,
index: virtualItem.index,
virtualizer: virtualizer,
virtualItem,
})}
</div>
);
})}
</div>
</div>
</motion.div>
);
}
export type Filter<TData, TFilterValue> = {
value?: TFilterValue;
fn: (value: TFilterValue, node: FlatTreeItem<TData>) => boolean;
};
type TreeStateHookProps<TData, TFilterValue> = {
tree: FlatTree<TData>;
selectedId?: string;
collapsedIds?: string[];
onSelectedIdChanged?: (selectedId: string | undefined) => void;
estimatedRowHeight: (params: {
node: FlatTreeItem<TData>;
state: NodeState;
index: number;
}) => number;
parentRef: RefObject<any>;
filter?: Filter<TData, TFilterValue>;
};
//this is so Framer Motion can be used to render the components
type HTMLAttributes = Omit<
React.HTMLAttributes<HTMLElement>,
"onAnimationStart" | "onDragStart" | "onDragEnd" | "onDrag"
>;
export type UseTreeStateOutput = {
selected: string | undefined;
nodes: NodesState;
virtualizer: Virtualizer<HTMLElement, Element>;
getTreeProps: () => HTMLAttributes;
getNodeProps: (id: string) => HTMLAttributes;
selectNode: (id: string, scrollToNode?: boolean) => void;
deselectNode: (id: string) => void;
deselectAllNodes: () => void;
toggleNodeSelection: (id: string, scrollToNode?: boolean) => void;
expandNode: (id: string, scrollToNode?: boolean) => void;
collapseNode: (id: string) => void;
toggleExpandNode: (id: string, scrollToNode?: boolean) => void;
expandAllBelowDepth: (depth: number) => void;
collapseAllBelowDepth: (depth: number) => void;
expandLevel: (level: number) => void;
collapseLevel: (level: number) => void;
toggleExpandLevel: (level: number) => void;
selectFirstVisibleNode: (scrollToNode?: boolean) => void;
selectLastVisibleNode: (scrollToNode?: boolean) => void;
selectNextVisibleNode: (scrollToNode?: boolean) => void;
selectPreviousVisibleNode: (scrollToNode?: boolean) => void;
selectParentNode: (scrollToNode?: boolean) => void;
scrollToNode: (id: string) => void;
};
export function useTree<TData, TFilterValue>({
tree,
selectedId,
collapsedIds,
onSelectedIdChanged,
parentRef,
estimatedRowHeight,
filter,
}: TreeStateHookProps<TData, TFilterValue>): UseTreeStateOutput {
const previousNodeCount = useRef(tree.length);
const previousSelectedId = useRef<string | undefined>(selectedId);
const [state, dispatch] = useReducer(
reducer,
concreteStateFromInput({ tree, selectedId, collapsedIds, filter })
);
// id -> index lookup so getNodeProps resolves in O(1) instead of scanning
// the whole tree per rendered row.
const treeIndexById = useMemo(() => {
const map = new Map<string, number>();
tree.forEach((node, index) => {
map.set(node.id, index);
});
return map;
}, [tree]);
//sync external selectedId prop into internal state
useEffect(() => {
const internalSelectedId = selectedIdFromState(state.nodes);
if (selectedId !== internalSelectedId) {
if (selectedId === undefined) {
dispatch({ type: "DESELECT_ALL_NODES" });
} else {
dispatch({ type: "SELECT_NODE", payload: { id: selectedId, scrollToNode: false, scrollToNodeFn } });
}
}
}, [selectedId]);
//fire onSelectedIdChanged()
useEffect(() => {
const selectedId = selectedIdFromState(state.nodes);
if (selectedId !== previousSelectedId.current) {
previousSelectedId.current = selectedId;
onSelectedIdChanged?.(selectedId);
}
}, [state.changes.selectedId]);
//update tree when the number of nodes changes
useEffect(() => {
if (tree.length !== previousNodeCount.current) {
previousNodeCount.current = tree.length;
dispatch({ type: "UPDATE_TREE", payload: { tree } });
}
}, [previousNodeCount.current, tree.length]);
//update the filter, if it's changed
const previousFilter = useRef(filter);
useEffect(() => {
//check if the value (not reference) of the filter is the same
const previousValue = previousFilter.current
? JSON.stringify(previousFilter.current.value)
: undefined;
const newValue = filter ? JSON.stringify(filter.value) : undefined;
previousFilter.current = filter;
if (previousValue !== newValue) {
dispatch({ type: "UPDATE_FILTER", payload: { filter } });
}
}, [filter?.value]);
const virtualizer = useVirtualizer({
count: state.visibleNodeIds.length,
getItemKey: (index) => state.visibleNodeIds[index],
getScrollElement: () => parentRef.current,
estimateSize: (index: number) => {
const treeItem = tree[index];
if (!treeItem) return 0;
return estimatedRowHeight({
node: treeItem,
state: state.nodes[treeItem.id],
index,
});
},
overscan: 50,
});
const scrollToNodeFn = useCallback(
(id: string) => {
const itemIndex = state.visibleNodeIds.findIndex((n) => n === id);
if (itemIndex !== -1) {
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
}
},
[state]
);
const selectNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "SELECT_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const deselectNode = useCallback(
(id: string) => {
dispatch({ type: "DESELECT_NODE", payload: { id } });
},
[state]
);
const deselectAllNodes = useCallback(() => {
dispatch({ type: "DESELECT_ALL_NODES" });
}, [state]);
const toggleNodeSelection = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_NODE_SELECTION", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const expandNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const collapseNode = useCallback(
(id: string) => {
dispatch({ type: "COLLAPSE_NODE", payload: { id } });
},
[state]
);
const toggleExpandNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const selectFirstVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_FIRST_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[tree, state]
);
const selectLastVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_LAST_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[tree, state]
);
const selectNextVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_NEXT_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const selectPreviousVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_PREVIOUS_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const selectParentNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_PARENT_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const expandAllBelowDepth = useCallback(
(depth: number) => {
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } });
},
[state]
);
const collapseAllBelowDepth = useCallback(
(depth: number) => {
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } });
},
[state]
);
const expandLevel = useCallback(
(level: number) => {
dispatch({ type: "EXPAND_LEVEL", payload: { level } });
},
[state]
);
const collapseLevel = useCallback(
(level: number) => {
dispatch({ type: "COLLAPSE_LEVEL", payload: { level } });
},
[state]
);
const toggleExpandLevel = useCallback(
(level: number) => {
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } });
},
[state]
);
const getTreeProps = useCallback(() => {
return {
role: "tree",
"aria-multiselectable": true,
tabIndex: -1,
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
if (e.defaultPrevented) {
return; // Do nothing if the event was already processed
}
switch (e.key) {
case "Home": {
selectFirstVisibleNode(true);
e.preventDefault();
break;
}
case "End": {
selectLastVisibleNode(true);
e.preventDefault();
break;
}
case "Down":
case "ArrowDown": {
selectNextVisibleNode(true);
e.preventDefault();
break;
}
case "Up":
case "ArrowUp": {
selectPreviousVisibleNode(true);
e.preventDefault();
break;
}
case "Left":
case "ArrowLeft": {
if (e.metaKey) {
return;
}
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
if (selected) {
const treeNode = tree.find((node) => node.id === selected);
if (e.altKey) {
if (treeNode && treeNode.hasChildren) {
collapseLevel(treeNode.level);
}
break;
}
const shouldCollapse =
treeNode && treeNode.hasChildren && state.nodes[selected].expanded;
if (shouldCollapse) {
collapseNode(selected);
} else {
selectParentNode(true);
}
}
break;
}
case "Right":
case "ArrowRight": {
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
if (selected) {
const treeNode = tree.find((node) => node.id === selected);
if (e.altKey) {
if (treeNode && treeNode.hasChildren) {
expandLevel(treeNode.level);
}
break;
}
expandNode(selected, true);
}
break;
}
case "Escape": {
deselectAllNodes();
e.preventDefault();
break;
}
}
},
};
}, [state]);
const getNodeProps = useCallback(
(id: string) => {
const node = state.nodes[id];
if (!node) return {};
const treeItemIndex = treeIndexById.get(id) ?? -1;
const treeItem = tree[treeItemIndex];
return {
"aria-expanded": node.expanded,
"aria-level": treeItem.level + 1,
role: "treeitem",
tabIndex: node.selected ? -1 : undefined,
};
},
[state, treeIndexById]
);
return {
selected: selectedIdFromState(state.nodes),
nodes: state.nodes,
getTreeProps,
getNodeProps,
selectNode,
deselectNode,
deselectAllNodes,
toggleNodeSelection,
expandNode,
collapseNode,
toggleExpandNode,
expandAllBelowDepth,
collapseAllBelowDepth,
expandLevel,
collapseLevel,
toggleExpandLevel,
selectFirstVisibleNode,
selectLastVisibleNode,
selectNextVisibleNode,
selectPreviousVisibleNode,
selectParentNode,
scrollToNode: scrollToNodeFn,
virtualizer,
};
}
/** An actual tree structure with custom data */
export type Tree<TData> = {
id: string;
runId?: string;
children?: Tree<TData>[];
data: TData;
};
/** A tree but flattened so it can easily be used for DOM elements */
export type FlatTreeItem<TData> = {
id: string;
parentId?: string | undefined;
runId?: string;
children: string[];
hasChildren: boolean;
/** The indentation level, the root is 0 */
level: number;
data: TData;
};
export type FlatTree<TData> = FlatTreeItem<TData>[];
export function flattenTree<TData>(tree: Tree<TData>): FlatTree<TData> {
const flatTree: FlatTree<TData> = [];
function flattenNode(node: Tree<TData>, parentId: string | undefined, level: number) {
const children = node.children?.map((child) => child.id) ?? [];
flatTree.push({
id: node.id,
parentId,
runId: node.runId,
children,
hasChildren: children.length > 0,
level,
data: node.data,
});
node.children?.forEach((child) => {
flattenNode(child, node.id, level + 1);
});
}
flattenNode(tree, undefined, 0);
return flatTree;
}
type FlatTreeWithoutChildren<TData> = {
id: string;
parentId: string | undefined;
runId?: string;
data: TData;
};
export function createTreeFromFlatItems<TData>(
withoutChildren: FlatTreeWithoutChildren<TData>[],
rootId: string
): Tree<TData> | undefined {
// Index items by id
const indexedItems: { [id: string]: Tree<TData> } = withoutChildren.reduce((acc, item) => {
acc[item.id] = { id: item.id, runId: item.runId, data: item.data, children: [] };
return acc;
}, {} as { [id: string]: Tree<TData> });
// Add items to parent's children array
withoutChildren.forEach((item) => {
const indexedItem = indexedItems[item.id];
if (item.parentId !== undefined) {
const parentItem = indexedItems[item.parentId];
if (parentItem) {
// If parent ID doesn't exist, this is also a root item
parentItem.children?.push(indexedItem);
}
}
});
return indexedItems[rootId];
}