diff --git a/static/app/utils/useHoverOverlay.tsx b/static/app/utils/useHoverOverlay.tsx index 95f2d8ff821ed4..42904bbf7b2214 100644 --- a/static/app/utils/useHoverOverlay.tsx +++ b/static/app/utils/useHoverOverlay.tsx @@ -140,7 +140,7 @@ interface UseHoverOverlayProps { underlineColor?: ColorOrAlias; } -function isOverflown(el: Element): boolean { +export function isOverflown(el: Element): boolean { // Safari seems to calculate scrollWidth incorrectly, causing isOverflown to always return true in some cases. // Adding a 2 pixel tolerance seems to account for this discrepancy. const tolerance = diff --git a/static/app/views/insights/pages/agents/components/tracesTable.tsx b/static/app/views/insights/pages/agents/components/tracesTable.tsx index fb23ea909dc2fc..558fe3f4d679e8 100644 --- a/static/app/views/insights/pages/agents/components/tracesTable.tsx +++ b/static/app/views/insights/pages/agents/components/tracesTable.tsx @@ -1,9 +1,18 @@ -import {Fragment, memo, useCallback, useMemo} from 'react'; +import {Fragment, memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; +import {parseAsString, useQueryState} from 'nuqs'; + +import {Tag} from '@sentry/scraps/badge/tag'; +import {Container} from '@sentry/scraps/layout'; +import {Flex} from '@sentry/scraps/layout/flex'; +import {Link} from '@sentry/scraps/link'; import {Button} from 'sentry/components/core/button'; +import {Text} from 'sentry/components/core/text'; import {Tooltip} from 'sentry/components/core/tooltip'; import Pagination from 'sentry/components/pagination'; +import Placeholder from 'sentry/components/placeholder'; +import {MutableSearch} from 'sentry/components/searchSyntax/mutableSearch'; import GridEditable, { COL_WIDTH_UNDEFINED, type GridColumnHeader, @@ -13,15 +22,14 @@ import useStateBasedColumnResize from 'sentry/components/tables/gridEditable/use import TimeSince from 'sentry/components/timeSince'; import {IconArrow} from 'sentry/icons'; import {t} from 'sentry/locale'; +import {isOverflown} from 'sentry/utils/useHoverOverlay'; +import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; import {useTraces} from 'sentry/views/explore/hooks/useTraces'; import {getExploreUrl} from 'sentry/views/explore/utils'; -import { - OverflowEllipsisTextContainer, - TextAlignRight, -} from 'sentry/views/insights/common/components/textAlign'; +import {TextAlignRight} from 'sentry/views/insights/common/components/textAlign'; import {useSpans} from 'sentry/views/insights/common/queries/useDiscover'; import {useTraceViewDrawer} from 'sentry/views/insights/pages/agents/components/drawer'; import {LLMCosts} from 'sentry/views/insights/pages/agents/components/llmCosts'; @@ -40,6 +48,7 @@ import {DurationCell} from 'sentry/views/insights/pages/platform/shared/table/Du import {NumberCell} from 'sentry/views/insights/pages/platform/shared/table/NumberCell'; interface TableData { + agents: string[]; duration: number; errors: number; llmCalls: number; @@ -49,6 +58,7 @@ interface TableData { totalTokens: number; traceId: string; transaction: string; + isAgentDataLoading?: boolean; isSpanDataLoading?: boolean; } @@ -56,7 +66,7 @@ const EMPTY_ARRAY: never[] = []; const defaultColumnOrder: Array> = [ {key: 'traceId', name: t('Trace ID'), width: 110}, - {key: 'transaction', name: t('Trace Root'), width: COL_WIDTH_UNDEFINED}, + {key: 'agents', name: t('Agents / Trace Root'), width: COL_WIDTH_UNDEFINED}, {key: 'duration', name: t('Root Duration'), width: 130}, {key: 'errors', name: t('Errors'), width: 100}, {key: 'llmCalls', name: t('LLM Calls'), width: 110}, @@ -112,9 +122,31 @@ export function TracesTable() { Referrer.TRACES_TABLE ); + const agentsRequest = useSpans( + { + search: `span.op:gen_ai.invoke_agent has:gen_ai.agent.name trace:[${tracesRequest.data?.data.map(span => `"${span.trace}"`).join(',')}]`, + fields: ['trace', 'gen_ai.agent.name', 'timestamp'], + sorts: [{field: 'timestamp', kind: 'asc'}], + samplingMode: SAMPLING_MODE.HIGH_ACCURACY, + enabled: Boolean(tracesRequest.data && tracesRequest.data.data.length > 0), + }, + Referrer.TRACES_TABLE + ); + + const traceAgents = useMemo>>(() => { + if (!agentsRequest.data) { + return new Map(); + } + return agentsRequest.data.reduce((acc, span) => { + const agentsSet = acc.get(span.trace) ?? new Set(); + agentsSet.add(span['gen_ai.agent.name']); + acc.set(span.trace, agentsSet); + return acc; + }, new Map>()); + }, [agentsRequest.data]); + const traceErrorRequest = useSpans( { - // Get all spans with error status search: `span.status:internal_error trace:[${tracesRequest.data?.data.map(span => span.trace).join(',')}]`, fields: ['trace', 'count(span.duration)'], limit: tracesRequest.data?.data.length ?? 0, @@ -175,6 +207,8 @@ export function TracesTable() { totalTokens: spanDataMap[span.trace]?.totalTokens ?? 0, totalCost: spanDataMap[span.trace]?.totalCost ?? null, timestamp: span.start, + agents: Array.from(traceAgents.get(span.trace) ?? []), + isAgentDataLoading: agentsRequest.isLoading, isSpanDataLoading: spansRequest.isLoading || traceErrorRequest.isLoading, })); }, [ @@ -182,6 +216,8 @@ export function TracesTable() { spanDataMap, spansRequest.isLoading, traceErrorRequest.isLoading, + traceAgents, + agentsRequest.isLoading, ]); const renderHeadCell = useCallback((column: GridColumnHeader) => { @@ -189,7 +225,7 @@ export function TracesTable() { {column.name} {column.key === 'timestamp' && } - {column.key === 'transaction' && } + {column.key === 'agents' && } ); }, []); @@ -208,9 +244,9 @@ export function TracesTable() { isLoading={tracesRequest.isPending} error={tracesRequest.error} data={tableData} + stickyHeader columnOrder={columnOrder} columnSortBy={EMPTY_ARRAY} - stickyHeader grid={{ renderBodyCell, renderHeadCell, @@ -251,13 +287,23 @@ const BodyCell = memo(function BodyCell({ ); - case 'transaction': - return ( - - - {dataRow.transaction} - - + case 'agents': + if (dataRow.isAgentDataLoading) { + return ; + } + return dataRow.agents.length > 0 ? ( + + ) : ( + + + {dataRow.transaction} + + ); case 'duration': return ; @@ -301,6 +347,90 @@ const BodyCell = memo(function BodyCell({ } }); +function AgentTags({agents}: {agents: string[]}) { + const [showAll, setShowAll] = useState(false); + const location = useLocation(); + const [searchQuery] = useQueryState('query', parseAsString.withDefault('')); + const [showToggle, setShowToggle] = useState(false); + const resizeObserverRef = useRef(null); + const containerRef = useRef(null); + + const handleShowAll = useCallback(() => { + setShowAll(!showAll); + + if (!containerRef.current) return; + // While the all tags are visible, observe the container to see if it displays more than one line (22px) + // so we can reset the show all state accordingly + const observer = new ResizeObserver(entries => { + const containerElement = entries[0]?.target; + if (!containerElement || containerElement.clientHeight > 22) return; + setShowToggle(false); + setShowAll(false); + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + }); + resizeObserverRef.current = observer; + observer.observe(containerRef.current); + }, [showAll]); + + // Cleanup the resize observer when the component unmounts + useEffect(() => { + return () => { + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + }; + }, []); + + return ( + { + setShowToggle(isOverflown(event.currentTarget)); + }} + onMouseLeave={() => setShowToggle(false)} + > + {agents.map(agent => ( + + + + {agent} + + + + ))} + {/* Placeholder for floating button */} + + + + + + ); +} + const GridEditableContainer = styled('div')` position: relative; margin-bottom: ${p => p.theme.space.md};