From 414deb61f8b07e8014a6b42847360c4790265c87 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 22 May 2026 18:17:25 +0200 Subject: [PATCH] feat(frontend): add key-spans visibility filter to trace tree Collapse the trace tree to the spans that carry signal (LLM, tool, agent, retrieval, output parsers, and errors) and hide structural wrappers such as RunnableSequence and RunnableLambda. Key spans is the default view, with an "All spans" option in the tree settings popover and a notice showing how many spans were hidden. The filter rules live in spanVisibility.ts as editable span-type and name-pattern definitions, so the definition of a key span can change without touching the traversal logic. Also fixes a pre-existing layout bug where the trace drawer splitter dividers stopped short of the bottom on tall screens. --- .../components/SessionTree/index.tsx | 17 ++- .../components/TraceContent/index.tsx | 2 +- .../components/TraceDrawerContent.tsx | 9 +- .../TraceTree/assets/spanVisibility.ts | 123 ++++++++++++++++++ .../components/TraceTree/index.tsx | 80 +++++++++--- .../components/TraceTreeSettings/index.tsx | 112 ++++++++++------ .../components/TraceTreeSettings/types.ts | 17 ++- 7 files changed, 294 insertions(+), 66 deletions(-) create mode 100644 web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTree/assets/spanVisibility.ts diff --git a/web/oss/src/components/SharedDrawers/SessionDrawer/components/SessionTree/index.tsx b/web/oss/src/components/SharedDrawers/SessionDrawer/components/SessionTree/index.tsx index eb9b11ca86..d5ce96d82d 100644 --- a/web/oss/src/components/SharedDrawers/SessionDrawer/components/SessionTree/index.tsx +++ b/web/oss/src/components/SharedDrawers/SessionDrawer/components/SessionTree/index.tsx @@ -12,6 +12,7 @@ import {TraceSpanNode} from "@/oss/services/tracing/types" import {TreeContent} from "../../../TraceDrawer/components/TraceTree" import TraceTreeSettings from "../../../TraceDrawer/components/TraceTreeSettings" +import {TraceTreeSettingsState} from "../../../TraceDrawer/components/TraceTreeSettings/types" import {openTraceDrawerAtom} from "../../../TraceDrawer/store/traceDrawerStore" import {useSessionDrawer} from "../../hooks/useSessionDrawer" @@ -21,11 +22,17 @@ const SessionTree = ({selected, setSelected}: SessionTreeProps) => { const [searchValue, setSearchValue] = useState("") const openTraceDrawer = useSetAtom(openTraceDrawerAtom) - const [traceTreeSettings, setTraceTreeSettings] = useLocalStorage("traceTreeSettings", { - latency: true, - cost: true, - tokens: true, - }) + // Shares the "traceTreeSettings" key with the trace drawer. SessionTree does + // not filter spans, so it renders the settings popover without the visibility + // section and never reads `visibility`. + const [traceTreeSettings, setTraceTreeSettings] = useLocalStorage( + "traceTreeSettings", + { + latency: true, + cost: true, + tokens: true, + }, + ) const {sessionTraces, aggregatedStats} = useSessionDrawer() const turnsTree: TraceSpanNode[] = useMemo(() => { diff --git a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceContent/index.tsx b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceContent/index.tsx index 093948c731..7493e43344 100644 --- a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceContent/index.tsx +++ b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceContent/index.tsx @@ -136,7 +136,7 @@ const TraceContent = ({ traces={traces} /> - +
- +
- + = new Set([ + SpanCategory.AGENT, + SpanCategory.LLM, + SpanCategory.CHAT, + SpanCategory.COMPLETION, + SpanCategory.TOOL, + SpanCategory.EMBEDDING, + SpanCategory.QUERY, + SpanCategory.RERANK, +]) + +/** + * Span name patterns that mark a span as meaningful even when its type is a + * generic wrapper (e.g. LangChain reports output parsers as `chain` spans, but + * a tool/agent output parser carries the model's tool-call decision). + * + * This is the second main knob: add a pattern to surface a span the type-based + * rule would otherwise hide. + */ +export const KEY_SPAN_NAME_PATTERNS: readonly RegExp[] = [/OutputParser/i] + +/** + * A rule that decides whether a single span is relevant on its own merits. + * + * A span survives the "Key spans" filter when ANY rule matches it, when it is the + * trace root, or when one of its descendants survives (so the path to a key span + * stays intact). Add, remove, or reorder rules here to evolve the definition of a + * key span over time — the filter logic below does not need to change. + */ +export interface KeySpanRule { + id: string + description: string + test: (node: TraceSpanNode) => boolean +} + +export const keySpanRules: KeySpanRule[] = [ + { + id: "work-span-type", + description: "Model, tool, agent, and retrieval spans", + test: (node) => !!node.span_type && KEY_SPAN_TYPES.has(node.span_type), + }, + { + id: "key-name", + description: "Spans whose name marks them as meaningful (e.g. output parsers)", + test: (node) => + !!node.span_name && KEY_SPAN_NAME_PATTERNS.some((re) => re.test(node.span_name!)), + }, + { + id: "errored-span", + description: "Spans that ended in an error", + test: (node) => node.status_code === StatusCode.STATUS_CODE_ERROR, + }, +] + +export const isKeySpan = (node: TraceSpanNode): boolean => + keySpanRules.some((rule) => rule.test(node)) + +export interface SpanFilterResult { + /** Pruned tree, or null when there is no input tree. */ + tree: TraceSpanNode | null + /** Number of spans removed from view by the filter. */ + hiddenCount: number +} + +/** + * Prune a span tree down to its key spans. + * + * The root is always kept so the trace stays anchored. Non-key spans are removed + * and their surviving descendants are promoted to the nearest kept ancestor — so + * wrapper chains (RunnableSequence, RunnableMap, ...) disappear and the key spans + * they contain attach directly to the root, rather than being kept as scaffolding. + */ +export const filterKeySpans = (root?: TraceSpanNode): SpanFilterResult => { + if (!root) return {tree: null, hiddenCount: 0} + + let hiddenCount = 0 + + const collect = (node: TraceSpanNode, isRoot: boolean): TraceSpanNode[] => { + const keptChildren = ((node.children as TraceSpanNode[] | undefined) || []).flatMap( + (child) => collect(child, false), + ) + + if (isRoot || isKeySpan(node)) { + return [{...node, children: keptChildren}] + } + + hiddenCount += 1 + return keptChildren + } + + const [tree] = collect(root, true) + return {tree: tree ?? null, hiddenCount} +} diff --git a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTree/index.tsx b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTree/index.tsx index 8009787052..27197b9265 100644 --- a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTree/index.tsx +++ b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTree/index.tsx @@ -1,6 +1,13 @@ import {useCallback, useMemo, useState} from "react" -import {Coins, MagnifyingGlass, PlusCircle, SlidersHorizontal, Timer} from "@phosphor-icons/react" +import { + Coins, + Info, + MagnifyingGlass, + PlusCircle, + SlidersHorizontal, + Timer, +} from "@phosphor-icons/react" import {Button, Divider, Input, Popover, Space, Tooltip, Typography} from "antd" import clsx from "clsx" import {useAtomValue} from "jotai" @@ -18,7 +25,9 @@ import { import useTraceDrawer from "../../hooks/useTraceDrawer" import TraceTreeSettings from "../TraceTreeSettings" +import {TraceTreeSettingsState} from "../TraceTreeSettings/types" +import {filterKeySpans} from "./assets/spanVisibility" import {useStyles} from "./assets/styles" import {TraceTreeProps} from "./assets/types" @@ -96,11 +105,15 @@ const TraceTree = ({activeTrace: active, activeTraceId, selected, setSelected}: const classes = useStyles() const [searchValue, setSearchValue] = useState("") - const [traceTreeSettings, setTraceTreeSettings] = useLocalStorage("traceTreeSettings", { - latency: true, - cost: true, - tokens: true, - }) + const [traceTreeSettings, setTraceTreeSettings] = useLocalStorage( + "traceTreeSettings", + { + latency: true, + cost: true, + tokens: true, + visibility: "key", + }, + ) const {getTraceById, traces: allTraces} = useTraceDrawer() const activeTrace = active || getTraceById(activeTraceId) @@ -132,12 +145,22 @@ const TraceTree = ({activeTrace: active, activeTraceId, selected, setSelected}: return nodes[0] || activeTrace }, [activeTrace, allTraces]) - const filteredTree = useMemo(() => { + // Tree after the text search filter (the original behaviour). + const searchedTree = useMemo(() => { if (!searchValue.trim()) return treeRoot as any const result = filterTree(treeRoot as any, searchValue) return result || {...treeRoot, children: []} }, [searchValue, treeRoot]) + // Apply the span visibility filter on top of the searched tree. + const {displayTree, hiddenCount} = useMemo(() => { + if ((traceTreeSettings.visibility ?? "key") !== "key" || !searchedTree) { + return {displayTree: searchedTree, hiddenCount: 0} + } + const {tree, hiddenCount: count} = filterKeySpans(searchedTree as TraceSpanNode) + return {displayTree: (tree as any) || {...searchedTree, children: []}, hiddenCount: count} + }, [searchedTree, traceTreeSettings.visibility]) + const renderTraceLabel = useCallback( (node: TraceSpanNode) => , [traceTreeSettings], @@ -171,10 +194,11 @@ const TraceTree = ({activeTrace: active, activeTraceId, selected, setSelected}: } placement="bottomRight" - classNames={{body: "!p-0 w-[200px]"}} + classNames={{body: "!p-0 w-[240px]"}} arrow={false} >
- node.span_id} - getChildren={(node) => node.children as TraceSpanNode[] | undefined} - renderLabel={renderTraceLabel} - selectedKey={selected} - onSelect={(key) => setSelected(key)} - defaultExpanded - /> +
+ node.span_id} + getChildren={(node) => node.children as TraceSpanNode[] | undefined} + renderLabel={renderTraceLabel} + selectedKey={selected} + onSelect={(key) => setSelected(key)} + defaultExpanded + /> + + {hiddenCount > 0 && ( +
+ + + {hiddenCount}{" "} + {hiddenCount === 1 ? "span" : "spans"} hidden by key spans + + +
+ )} +
) } diff --git a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTreeSettings/index.tsx b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTreeSettings/index.tsx index b78cf68654..8dd952b999 100644 --- a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTreeSettings/index.tsx +++ b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTreeSettings/index.tsx @@ -1,51 +1,89 @@ -import React from "react" +import {ReactNode} from "react" -import {Switch, Typography} from "antd" +import {Check} from "@phosphor-icons/react" +import {Divider, Switch, Typography} from "antd" import clsx from "clsx" +import {SPAN_VISIBILITY_OPTIONS, SpanVisibilityMode} from "../TraceTree/assets/spanVisibility" + import {TraceTreeSettingsProps} from "./types" -const TraceTreeSettings = ({settings, setSettings}: TraceTreeSettingsProps) => { - const handleSwitchChange = (key: keyof typeof settings, checked: boolean) => { - setSettings((prev) => ({ - ...prev, - [key]: checked, - })) +const DISPLAY_TOGGLES = [ + {key: "latency", label: "Show latency"}, + {key: "cost", label: "Show cost"}, + {key: "tokens", label: "Show tokens"}, +] as const + +const SectionLabel = ({children}: {children: ReactNode}) => ( + + {children} + +) + +const TraceTreeSettings = ({ + settings, + setSettings, + showVisibility = false, +}: TraceTreeSettingsProps) => { + const handleSwitchChange = (key: (typeof DISPLAY_TOGGLES)[number]["key"], checked: boolean) => { + setSettings((prev) => ({...prev, [key]: checked})) + } + + const visibility = settings.visibility ?? "key" + const setVisibility = (mode: SpanVisibilityMode) => { + setSettings((prev) => ({...prev, visibility: mode})) } return ( -
-
- Settings -
- -
-
- Show Latency - handleSwitchChange("latency", checked)} - /> -
-
- Show Cost - handleSwitchChange("cost", checked)} - /> -
-
- Show Tokens +
+ Display + {DISPLAY_TOGGLES.map(({key, label}) => ( +
+ {label} handleSwitchChange("tokens", checked)} + size="small" + checked={settings[key]} + onChange={(checked) => handleSwitchChange(key, checked)} />
-
+ ))} + + {showVisibility && ( + <> + + Visibility + {SPAN_VISIBILITY_OPTIONS.map((option) => ( +
setVisibility(option.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setVisibility(option.value) + } + }} + > +
+ {option.label} + + {option.hint} + +
+ +
+ ))} + + )}
) } diff --git a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTreeSettings/types.ts b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTreeSettings/types.ts index af9303bd9f..6d3884cb15 100644 --- a/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTreeSettings/types.ts +++ b/web/oss/src/components/SharedDrawers/TraceDrawer/components/TraceTreeSettings/types.ts @@ -1,8 +1,17 @@ import React from "react" +import {SpanVisibilityMode} from "../TraceTree/assets/spanVisibility" + +export interface TraceTreeSettingsState { + latency: boolean + cost: boolean + tokens: boolean + visibility?: SpanVisibilityMode +} + export interface TraceTreeSettingsProps { - settings: {latency: boolean; cost: boolean; tokens: boolean} - setSettings: React.Dispatch< - React.SetStateAction<{latency: boolean; cost: boolean; tokens: boolean}> - > + settings: TraceTreeSettingsState + setSettings: React.Dispatch> + /** Render the span visibility section (key spans vs all spans). */ + showVisibility?: boolean }