diff --git a/static/app/views/performance/newTraceDetails/index.tsx b/static/app/views/performance/newTraceDetails/index.tsx index c4fe7a17c85178..a079083e51e424 100644 --- a/static/app/views/performance/newTraceDetails/index.tsx +++ b/static/app/views/performance/newTraceDetails/index.tsx @@ -67,6 +67,8 @@ import { useTraceStateDispatch, useTraceStateEmitter, } from 'sentry/views/performance/newTraceDetails/traceState/traceStateProvider'; +import {useTraceScrollToEventOnLoad} from 'sentry/views/performance/newTraceDetails/useTraceScrollToEventOnLoad'; +import {useTraceScrollToPath} from 'sentry/views/performance/newTraceDetails/useTraceScrollToPath'; import type {ReplayTrace} from 'sentry/views/replays/detail/trace/useReplayTraces'; import type {ReplayRecord} from 'sentry/views/replays/types'; @@ -93,18 +95,6 @@ import {TraceType} from './traceType'; import TraceTypeWarnings from './traceTypeWarnings'; import {useTraceQueryParamStateSync} from './useTraceQueryParamStateSync'; -function decodeScrollQueue(maybePath: unknown): TraceTree.NodePath[] | null { - if (Array.isArray(maybePath)) { - return maybePath; - } - - if (typeof maybePath === 'string') { - return [maybePath as TraceTree.NodePath]; - } - - return null; -} - function logTraceMetadata( tree: TraceTree, projects: Project[], @@ -303,33 +293,6 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) { }); }, [props.organization, props.source]); - const initializedRef = useRef(false); - const scrollQueueRef = useRef< - TraceViewWaterfallProps['scrollToNode'] | null | undefined - >(undefined); - - if (scrollQueueRef.current === undefined) { - let scrollToNode: TraceViewWaterfallProps['scrollToNode'] = props.scrollToNode; - if (!props.scrollToNode) { - const queryParams = qs.parse(location.search); - scrollToNode = { - eventId: queryParams.eventId as string | undefined, - path: decodeScrollQueue( - queryParams.node - ) as TraceTreeNode['path'], - }; - } - - if (scrollToNode && (scrollToNode.path || scrollToNode.eventId)) { - scrollQueueRef.current = { - eventId: scrollToNode.eventId as string, - path: scrollToNode.path, - }; - } else { - scrollQueueRef.current = null; - } - } - const previouslyFocusedNodeRef = useRef | null>( null ); @@ -826,7 +789,6 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) { nodeToScrollTo: TraceTreeNode | null, indexOfNodeToScrollTo: number | null ) => { - scrollQueueRef.current = null; const query = qs.parse(location.search); if (query.fov && typeof query.fov === 'string') { @@ -960,13 +922,6 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) { }; }, [traceScheduler, traceDispatch]); - // Sync part of the state with the URL - const traceQueryStateSync = useMemo(() => { - return {search: traceState.search.query}; - }, [traceState.search.query]); - - useTraceQueryParamStateSync(traceQueryStateSync); - const [traceGridRef, setTraceGridRef] = useState(null); // Memoized because it requires tree traversal @@ -1004,6 +959,28 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) { }; }, [viewManager, traceScheduler, tree]); + // Sync part of the state with the URL + const traceQueryStateSync = useMemo(() => { + return {search: traceState.search.query}; + }, [traceState.search.query]); + useTraceQueryParamStateSync(traceQueryStateSync); + + const scrollQueueRef = useTraceScrollToPath(props.scrollToNode); + + useTraceScrollToEventOnLoad({ + rerender, + onTraceLoad, + scrollQueueRef, + manager: viewManager, + scheduler: traceScheduler, + trace: tree, + }); + + const isLoading = !!( + tree.type === 'loading' || + tree.type !== 'trace' || + scrollQueueRef.current + ); return ( {tree.type === 'error' ? ( ) : tree.type === 'empty' ? ( - ) : tree.type === 'loading' || - (scrollQueueRef.current && tree.type !== 'trace') ? ( + ) : isLoading ? ( ) : null} diff --git a/static/app/views/performance/newTraceDetails/trace.spec.tsx b/static/app/views/performance/newTraceDetails/trace.spec.tsx index 86de16b1b7515d..19f80688ac831d 100644 --- a/static/app/views/performance/newTraceDetails/trace.spec.tsx +++ b/static/app/views/performance/newTraceDetails/trace.spec.tsx @@ -790,7 +790,12 @@ describe('trace view', () => { await userEvent.keyboard('{arrowright}'); expect(await screen.findByText('special-span')).toBeInTheDocument(); await userEvent.keyboard('{arrowdown}'); - await waitFor(() => expect(rows[2]).toHaveFocus()); + await waitFor(() => { + const updatedRows = virtualizedContainer.querySelectorAll( + VISIBLE_TRACE_ROW_SELECTOR + ); + expect(updatedRows[2]).toHaveFocus(); + }); expect(await screen.findByTestId('trace-drawer-title')).toHaveTextContent( 'special-span' diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 974483e039d2cb..64313d17e2938a 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -10,23 +10,13 @@ import { } from 'react'; import {type Theme, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; -import * as Sentry from '@sentry/react'; -import {PlatformIcon} from 'platformicons'; -import LoadingIndicator from 'sentry/components/loadingIndicator'; -import Placeholder from 'sentry/components/placeholder'; -import {t} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import type {PlatformKey, Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import {formatTraceDuration} from 'sentry/utils/duration/formatTraceDuration'; -import type { - TraceError, - TracePerformanceIssue, -} from 'sentry/utils/performance/quickTrace/types'; -import {clamp} from 'sentry/utils/profiling/colors/utils'; import {replayPlayerTimestampEmitter} from 'sentry/utils/replays/replayPlayerTimestampEmitter'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; @@ -40,34 +30,36 @@ import { type VirtualizedRow, } from 'sentry/views/performance/newTraceDetails/traceRenderers/traceVirtualizedList'; import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager'; +import {TraceAutogroupedRow} from 'sentry/views/performance/newTraceDetails/traceRow/traceAutogroupedRow'; +import {TraceErrorRow} from 'sentry/views/performance/newTraceDetails/traceRow/traceErrorRow'; +import {TraceLoadingRow} from 'sentry/views/performance/newTraceDetails/traceRow/traceLoadingRow'; +import {TraceMissingInstrumentationRow} from 'sentry/views/performance/newTraceDetails/traceRow/traceMissingInstrumentationRow'; +import {TraceRootRow} from 'sentry/views/performance/newTraceDetails/traceRow/traceRootNode'; +import { + TRACE_CHILDREN_COUNT_WRAPPER_CLASSNAME, + TRACE_CHILDREN_COUNT_WRAPPER_ORPHANED_CLASSNAME, + TRACE_RIGHT_COLUMN_EVEN_CLASSNAME, + TRACE_RIGHT_COLUMN_ODD_CLASSNAME, + type TraceRowProps, +} from 'sentry/views/performance/newTraceDetails/traceRow/traceRow'; +import {TraceSpanRow} from 'sentry/views/performance/newTraceDetails/traceRow/traceSpanRow'; +import {TraceTransactionRow} from 'sentry/views/performance/newTraceDetails/traceRow/traceTransactionRow'; import type {TraceReducerState} from 'sentry/views/performance/newTraceDetails/traceState'; import { getRovingIndexActionFromDOMEvent, type RovingTabIndexUserActions, } from 'sentry/views/performance/newTraceDetails/traceState/traceRovingTabIndex'; -import { - makeTraceNodeBarColor, - ParentAutogroupNode, - TraceTree, - type TraceTreeNode, -} from './traceModels/traceTree'; +import type {TraceTree, TraceTreeNode} from './traceModels/traceTree'; import {useTraceState, useTraceStateDispatch} from './traceState/traceStateProvider'; import { isAutogroupedNode, isMissingInstrumentationNode, - isParentAutogroupedNode, isSpanNode, isTraceErrorNode, isTraceNode, isTransactionNode, } from './guards'; -import {TraceIcons} from './icons'; - -const COUNT_FORMATTER = Intl.NumberFormat(undefined, {notation: 'compact'}); -const NO_ERRORS = new Set(); -const NO_PERFORMANCE_ISSUES = new Set(); -const NO_PROFILES = []; function computeNextIndexFromAction( current_index: number, @@ -94,72 +86,16 @@ function computeNextIndexFromAction( } } -function getMaxErrorSeverity(errors: TraceTree.TraceError[]) { - return errors.reduce((acc, error) => { - if (error.level === 'fatal') { - return 'fatal'; - } - if (error.level === 'error') { - return acc === 'fatal' ? 'fatal' : 'error'; - } - if (error.level === 'warning') { - return acc === 'fatal' || acc === 'error' ? acc : 'warning'; - } - return acc; - }, 'default'); -} - -const RIGHT_COLUMN_EVEN_CLASSNAME = `TraceRightColumn`; -const RIGHT_COLUMN_ODD_CLASSNAME = [RIGHT_COLUMN_EVEN_CLASSNAME, 'Odd'].join(' '); -const CHILDREN_COUNT_WRAPPER_CLASSNAME = `TraceChildrenCountWrapper`; -const CHILDREN_COUNT_WRAPPER_ORPHANED_CLASSNAME = [ - CHILDREN_COUNT_WRAPPER_CLASSNAME, - 'Orphaned', -].join(' '); - -const ERROR_LEVEL_LABELS: Record = { - sample: t('Sample'), - info: t('Info'), - warning: t('Warning'), - // Hardcoded legacy color (orange400). We no longer use orange anywhere - // else in the app (except for the chart palette). This needs to be harcoded - // here because existing users may still associate orange with the "error" level. - error: t('Error'), - fatal: t('Fatal'), - default: t('Default'), - unknown: t('Unknown'), -}; - -function maybeFocusRow( - ref: HTMLDivElement | null, - node: TraceTreeNode, - previouslyFocusedNodeRef: React.MutableRefObject | null> -) { - if (!ref) { - return; - } - if (node === previouslyFocusedNodeRef.current) { - return; - } - previouslyFocusedNodeRef.current = node; - ref.focus(); -} - interface TraceProps { forceRerender: number; - initializedRef: React.MutableRefObject; isEmbedded: boolean; + isLoading: boolean; manager: VirtualizedViewManager; onRowClick: ( node: TraceTreeNode, event: React.MouseEvent, index: number ) => void; - onTraceLoad: ( - trace: TraceTree, - node: TraceTreeNode | null, - index: number | null - ) => void; onTraceSearch: ( query: string, node: TraceTreeNode, @@ -168,14 +104,6 @@ interface TraceProps { previouslyFocusedNodeRef: React.MutableRefObject | null>; rerender: () => void; scheduler: TraceScheduler; - scrollQueueRef: React.MutableRefObject< - | { - eventId?: string; - path?: TraceTree.NodePath[]; - } - | null - | undefined - >; trace: TraceTree; trace_id: string | undefined; } @@ -184,16 +112,14 @@ export function Trace({ trace, onRowClick, manager, - scrollQueueRef, previouslyFocusedNodeRef, onTraceSearch, - onTraceLoad, rerender, scheduler, - initializedRef, forceRerender, trace_id, isEmbedded, + isLoading, }: TraceProps) { const theme = useTheme(); const api = useApi(); @@ -256,111 +182,6 @@ export function Trace({ }; }, [manager, scheduler]); - useLayoutEffect(() => { - if (initializedRef.current) { - return; - } - if (trace.type !== 'trace' || !manager) { - return; - } - - initializedRef.current = true; - - if (!scrollQueueRef.current) { - onTraceLoad(trace, null, null); - return; - } - - // Node path has higher specificity than eventId - const promise = scrollQueueRef.current?.path - ? TraceTree.ExpandToPath(trace, scrollQueueRef.current.path, rerenderRef.current, { - api, - organization, - }) - : scrollQueueRef.current.eventId - ? TraceTree.ExpandToEventID( - scrollQueueRef?.current?.eventId, - trace, - rerenderRef.current, - { - api, - organization, - } - ) - : Promise.resolve(null); - - promise - .then(async node => { - if (!scrollQueueRef.current?.path && !scrollQueueRef.current?.eventId) { - return; - } - - if (!node) { - Sentry.captureMessage('Failed to find and scroll to node in tree'); - return; - } - - // When users are coming off an eventID link, we want to fetch the children - // of the node that the eventID points to. This is because the eventID link - // only points to the transaction, but we want to fetch the children of the - // transaction to show the user the list of spans in that transaction - if (scrollQueueRef.current.eventId && node?.canFetch) { - await trace.zoomIn(node, true, {api, organization}).catch(_e => { - Sentry.captureMessage('Failed to fetch children of eventId on mount'); - }); - } - - let index = trace.list.indexOf(node); - // We have found the node, yet it is somehow not in the visible tree. - // This means that the path we were given did not match the current tree. - // This sometimes happens when we receive external links like span-x, txn-y - // however the resulting tree looks like span-x, autogroup, txn-y. In this case, - // we should expand the autogroup node and try to find the node again. - if (node && index === -1) { - let parent_node = node.parent; - while (parent_node) { - // Transactions break autogrouping chains, so we can stop here - if (isTransactionNode(parent_node)) { - break; - } - if (isAutogroupedNode(parent_node)) { - trace.expand(parent_node, true); - // This is very wasteful as it performs O(n^2) search each time we expand a node... - // In most cases though, we should be operating on a tree with sub 10k elements and hopefully - // a low autogrouped node count. - index = node ? trace.list.findIndex(n => n === node) : -1; - if (index !== -1) { - break; - } - } - parent_node = parent_node.parent; - } - } - onTraceLoad(trace, node, index === -1 ? null : index); - }) - .finally(() => { - // Important to set scrollQueueRef.current to null and trigger a rerender - // after the promise resolves as we show a loading state during scroll, - // else the screen could jump around while we fetch span data - scrollQueueRef.current = null; - rerenderRef.current(); - // Allow react to rerender before dispatching the init event - requestAnimationFrame(() => { - scheduler.dispatch('initialize virtualized list'); - }); - }); - }, [ - api, - trace, - manager, - onTraceLoad, - scheduler, - traceDispatch, - scrollQueueRef, - initializedRef, - organization, - ]); - const onNodeZoomIn = useCallback( ( event: React.MouseEvent | React.KeyboardEvent, @@ -472,7 +293,7 @@ export function Trace({ const renderLoadingRow = useCallback( (n: VirtualizedRow) => { return ( - { return ( - { - return trace.type !== 'trace' || scrollQueueRef.current + return trace.type !== 'trace' || isLoading ? r => renderLoadingRow(r) : r => renderVirtualizedRow(r); - }, [renderLoadingRow, renderVirtualizedRow, trace.type, scrollQueueRef]); + }, [isLoading, renderLoadingRow, renderVirtualizedRow, trace.type]); const traceNode = trace.root.children[0]; const traceStartTimestamp = traceNode?.space?.[0]; @@ -557,7 +377,7 @@ export function Trace({ className={` ${trace.root.space[1] === 0 ? 'Empty' : ''} ${trace.indicators.length > 0 ? 'WithIndicators' : ''} - ${trace.type !== 'trace' || scrollQueueRef.current ? 'Loading' : ''} + ${trace.type !== 'trace' ? 'Loading' : ''} ${ConfigStore.get('theme')}`} >
{ - props.manager.registerColumnRef('list', ref, virtualized_index, props.node); + props.manager.registerColumnRef('list', ref, virtualized_index, node); }, - [props.manager, props.node, virtualized_index] + [props.manager, node, virtualized_index] ); const registerSpanColumnRef = useCallback( (ref: HTMLDivElement | null) => { - props.manager.registerColumnRef('span_list', ref, virtualized_index, props.node); + props.manager.registerColumnRef('span_list', ref, virtualized_index, node); }, - [props.manager, props.node, virtualized_index] + [props.manager, node, virtualized_index] ); const registerSpanArrowRef = useCallback( ref => { - props.manager.registerArrowRef(ref, props.node.space!, virtualized_index); + props.manager.registerArrowRef(ref, node.space!, virtualized_index); }, - [props.manager, props.node, virtualized_index] + [props.manager, node, virtualized_index] ); const onRowClickProp = props.onRowClick; const onRowClick = useCallback( (event: React.MouseEvent) => { - onRowClickProp(props.node, event, props.index); + onRowClickProp(node, event, props.index); }, - [props.index, props.node, onRowClickProp] + [props.index, node, onRowClickProp] ); - const onKeyDownProp = props.onRowKeyDown; + const onRowKeyDownProp = props.onRowKeyDown; const onRowKeyDown = useCallback( - event => onKeyDownProp(event, props.index, props.node), - [props.index, props.node, onKeyDownProp] + (event: React.KeyboardEvent) => onRowKeyDownProp(event, props.index, node), + [props.index, node, onRowKeyDownProp] ); const onRowDoubleClick = useCallback( @@ -715,1057 +536,105 @@ function RenderRow(props: { organization: props.organization, }); e.stopPropagation(); - props.manager.onZoomIntoSpace(props.node.space!); + props.manager.onZoomIntoSpace(node.space!); }, - [props.node, props.manager, props.organization] + [node, props.manager, props.organization] ); const onSpanRowArrowClick = useCallback( (_e: React.MouseEvent) => { - props.manager.onBringRowIntoView(props.node.space!); + props.manager.onBringRowIntoView(node.space!); }, - [props.node.space, props.manager] + [node.space, props.manager] ); const onExpandProp = props.onExpand; - const onExpandClick = useCallback( + const onExpand = useCallback( (e: React.MouseEvent) => { - onExpandProp(e, props.node, !props.node.expanded); + onExpandProp(e, node, !node.expanded); }, - [props.node, onExpandProp] + [node, onExpandProp] ); + const onZoomInProp = props.onZoomIn; + const onZoomIn = useCallback( + (e: React.MouseEvent) => { + onZoomInProp(e, node, !node.zoomedIn); + }, + [node, onZoomInProp] + ); const onExpandDoubleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); }, []); const spanColumnClassName = - props.index % 2 === 1 ? RIGHT_COLUMN_ODD_CLASSNAME : RIGHT_COLUMN_EVEN_CLASSNAME; + props.index % 2 === 1 + ? TRACE_RIGHT_COLUMN_ODD_CLASSNAME + : TRACE_RIGHT_COLUMN_EVEN_CLASSNAME; - const listColumnClassName = props.node.isOrphaned - ? CHILDREN_COUNT_WRAPPER_ORPHANED_CLASSNAME - : CHILDREN_COUNT_WRAPPER_CLASSNAME; + const listColumnClassName = node.isOrphaned + ? TRACE_CHILDREN_COUNT_WRAPPER_ORPHANED_CLASSNAME + : TRACE_CHILDREN_COUNT_WRAPPER_CLASSNAME; const listColumnStyle: React.CSSProperties = { - paddingLeft: props.node.depth * props.manager.row_depth_padding, + paddingLeft: node.depth * props.manager.row_depth_padding, }; - if (isAutogroupedNode(props.node)) { - return ( -
- props.tabIndex === 0 && !props.isEmbedded - ? maybeFocusRow(r, props.node, props.previouslyFocusedNodeRef) - : null - } - tabIndex={props.tabIndex} - className={`Autogrouped TraceRow ${rowSearchClassName} ${props.node.has_errors ? props.node.max_severity : ''}`} - onClick={onRowClick} - onKeyDown={onRowKeyDown} - style={props.style} - > -
-
-
- - - } - status={props.node.fetchStatus} - expanded={!props.node.expanded} - onClick={onExpandClick} - onDoubleClick={onExpandDoubleClick} - > - {COUNT_FORMATTER.format(props.node.groupCount)} - -
+ const rowProps: TraceRowProps> = { + onExpand, + onZoomIn, + onRowClick, + onRowKeyDown, + previouslyFocusedNodeRef: props.previouslyFocusedNodeRef, + isEmbedded: props.isEmbedded, + onSpanArrowClick: onSpanRowArrowClick, + manager: props.manager, + index: props.index, + theme: props.theme, + style: props.style, + projects: props.projects, + tabIndex: props.tabIndex, + onRowDoubleClick, + trace_id: props.trace_id, + node: props.node, + virtualized_index, + listColumnStyle, + listColumnClassName, + spanColumnClassName, + onExpandDoubleClick, + rowSearchClassName, + registerListColumnRef, + registerSpanColumnRef, + registerSpanArrowRef, + }; - {t('Autogrouped')} - - {props.node.value.autogrouped_by.op} -
-
-
- - -
-
- ); + if (isTransactionNode(node)) { + return ; } - if (isTransactionNode(props.node)) { - return ( -
- props.tabIndex === 0 && !props.isEmbedded - ? maybeFocusRow(r, props.node, props.previouslyFocusedNodeRef) - : null - } - tabIndex={props.tabIndex} - className={`TraceRow ${rowSearchClassName} ${props.node.has_errors ? props.node.max_severity : ''}`} - onKeyDown={onRowKeyDown} - onClick={onRowClick} - style={props.style} - > -
-
-
- - {props.node.children.length > 0 || props.node.canFetch ? ( - - ) : ( - '+' - ) - ) : ( - - ) - } - status={props.node.fetchStatus} - expanded={props.node.expanded || props.node.zoomedIn} - onDoubleClick={onExpandDoubleClick} - onClick={e => { - props.node.canFetch - ? props.onZoomIn(e, props.node, !props.node.zoomedIn) - : props.onExpand(e, props.node, !props.node.expanded); - }} - > - {props.node.children.length > 0 - ? COUNT_FORMATTER.format(props.node.children.length) - : null} - - ) : null} -
- - {props.node.value['transaction.op']} - - {props.node.value.transaction} -
-
-
- - -
-
- ); + if (isSpanNode(node)) { + return ; } - if (isSpanNode(props.node)) { - return ( -
- props.tabIndex === 0 && !props.isEmbedded - ? maybeFocusRow(r, props.node, props.previouslyFocusedNodeRef) - : null - } - tabIndex={props.tabIndex} - className={`TraceRow ${rowSearchClassName} ${props.node.has_errors ? props.node.max_severity : ''}`} - onClick={onRowClick} - onKeyDown={onRowKeyDown} - style={props.style} - > -
-
-
- - {props.node.children.length > 0 || props.node.canFetch ? ( - - ) - } - status={props.node.fetchStatus} - expanded={props.node.expanded || props.node.zoomedIn} - onDoubleClick={onExpandDoubleClick} - onClick={e => - props.node.canFetch - ? props.onZoomIn(e, props.node, !props.node.zoomedIn) - : props.onExpand(e, props.node, !props.node.expanded) - } - > - {props.node.children.length > 0 - ? COUNT_FORMATTER.format(props.node.children.length) - : null} - - ) : null} -
- {props.node.value.op ?? ''} - - - {!props.node.value.description - ? props.node.value.span_id ?? 'unknown' - : props.node.value.description.length > 100 - ? props.node.value.description.slice(0, 100).trim() + '\u2026' - : props.node.value.description} - -
-
-
- - -
-
- ); + if (isMissingInstrumentationNode(node)) { + return ; } - if (isMissingInstrumentationNode(props.node)) { - return ( -
- props.tabIndex === 0 && !props.isEmbedded - ? maybeFocusRow(r, props.node, props.previouslyFocusedNodeRef) - : null - } - tabIndex={props.tabIndex} - className={`TraceRow ${rowSearchClassName}`} - onClick={onRowClick} - onKeyDown={onRowKeyDown} - style={props.style} - > -
-
-
- -
- {t('Missing instrumentation')} -
-
-
- - -
-
- ); + if (isAutogroupedNode(node)) { + return ; } - if (isTraceNode(props.node)) { - return ( -
- props.tabIndex === 0 && !props.isEmbedded - ? maybeFocusRow(r, props.node, props.previouslyFocusedNodeRef) - : null - } - tabIndex={props.tabIndex} - className={`TraceRow ${rowSearchClassName} ${props.node.has_errors ? props.node.max_severity : ''}`} - onClick={onRowClick} - onKeyDown={onRowKeyDown} - style={props.style} - > -
-
- {' '} -
- - {props.node.children.length > 0 || props.node.canFetch ? ( - void 0} - onDoubleClick={onExpandDoubleClick} - > - {props.node.fetchStatus === 'loading' - ? null - : props.node.children.length > 0 - ? COUNT_FORMATTER.format(props.node.children.length) - : null} - - ) : null} -
- {t('Trace')} - {props.trace_id ? ( - - - {props.trace_id} - - ) : null} -
-
-
- - -
-
- ); + if (isTraceErrorNode(node)) { + return ; } - if (isTraceErrorNode(props.node)) { - return ( -
- props.tabIndex === 0 && !props.isEmbedded - ? maybeFocusRow(r, props.node, props.previouslyFocusedNodeRef) - : null - } - tabIndex={props.tabIndex} - className={`TraceRow ${rowSearchClassName} ${props.node.max_severity}`} - onClick={onRowClick} - onKeyDown={onRowKeyDown} - style={props.style} - > -
-
-
- {' '} -
- - - {ERROR_LEVEL_LABELS[props.node.value.level ?? 'error']} - - - - {props.node.value.message ?? props.node.value.title} - -
-
-
- - {typeof props.node.value.timestamp === 'number' ? ( -
- -
- ) : null} -
-
-
- ); + if (isTraceNode(node)) { + return ; } return null; } -function RenderPlaceholderRow(props: { - index: number; - manager: VirtualizedViewManager; - node: TraceTreeNode; - style: React.CSSProperties; - theme: Theme; -}) { - return ( -
-
-
-
- - {props.node.children.length > 0 || props.node.canFetch ? ( - void 0} - onDoubleClick={() => void 0} - > - {props.node.children.length > 0 - ? COUNT_FORMATTER.format(props.node.children.length) - : null} - - ) : null} -
- -
-
-
- -
-
- ); -} - -function randomBetween(min: number, max: number) { - return Math.floor(Math.random() * (max - min + 1) + min); -} - -function Connectors(props: { - manager: VirtualizedViewManager; - node: TraceTreeNode; -}) { - const hasChildren = - (props.node.expanded || props.node.zoomedIn) && props.node.children.length > 0; - const showVerticalConnector = - hasChildren || (props.node.value && isParentAutogroupedNode(props.node)); - - // If the tail node of the collapsed node has no children, - // we don't want to render the vertical connector as no children - // are being rendered as the chain is entirely collapsed - const hideVerticalConnector = - showVerticalConnector && - props.node.value && - props.node instanceof ParentAutogroupNode && - (!props.node.tail.children.length || - (!props.node.tail.expanded && !props.node.expanded)); - - return ( - - {props.node.connectors.map((c, i) => { - return ( - - ); - })} - {showVerticalConnector && !hideVerticalConnector ? ( - - ) : null} - {props.node.isLastChild ? ( - - ) : ( - - )} - - ); -} - -function ChildrenButton(props: { - children: React.ReactNode; - expanded: boolean; - icon: React.ReactNode; - onClick: (e: React.MouseEvent) => void; - onDoubleClick: (e: React.MouseEvent) => void; - status: TraceTreeNode['fetchStatus'] | undefined; -}) { - return ( - - ); -} - -interface TraceBarProps { - color: string; - errors: TraceTreeNode['errors']; - manager: VirtualizedViewManager; - node_space: [number, number] | null; - performance_issues: TraceTreeNode['performance_issues']; - profiles: TraceTreeNode['profiles']; - virtualized_index: number; -} - -function TraceBar(props: TraceBarProps) { - const duration = props.node_space ? formatTraceDuration(props.node_space[1]) : null; - - const registerSpanBarRef = useCallback( - (ref: HTMLDivElement | null) => { - props.manager.registerSpanBarRef( - ref, - props.node_space!, - props.color, - props.virtualized_index - ); - }, - [props.manager, props.node_space, props.color, props.virtualized_index] - ); - - const registerSpanBarTextRef = useCallback( - (ref: HTMLDivElement | null) => { - props.manager.registerSpanBarTextRef( - ref, - duration!, - props.node_space!, - props.virtualized_index - ); - }, - [props.manager, props.node_space, props.virtualized_index, duration] - ); - - if (!props.node_space) { - return null; - } - - return ( - -
- {props.errors.size > 0 ? ( - - ) : null} - {props.performance_issues.size > 0 ? ( - - ) : null} - {props.performance_issues.size > 0 || - props.errors.size > 0 || - props.profiles.length > 0 ? ( - - ) : null} -
-
- {duration} -
-
- ); -} - -interface MissingInstrumentationTraceBarProps { - color: string; - manager: VirtualizedViewManager; - node_space: [number, number] | null; - virtualized_index: number; -} -function MissingInstrumentationTraceBar(props: MissingInstrumentationTraceBarProps) { - const duration = props.node_space ? formatTraceDuration(props.node_space[1]) : null; - - const registerSpanBarRef = useCallback( - (ref: HTMLDivElement | null) => { - props.manager.registerSpanBarRef( - ref, - props.node_space!, - props.color, - props.virtualized_index - ); - }, - [props.manager, props.node_space, props.color, props.virtualized_index] - ); - - const registerSpanBarTextRef = useCallback( - (ref: HTMLDivElement | null) => { - props.manager.registerSpanBarTextRef( - ref, - duration!, - props.node_space!, - props.virtualized_index - ); - }, - [props.manager, props.node_space, props.virtualized_index, duration] - ); - - return ( - -
-
-
-
-
-
- {duration} -
- - ); -} - -interface InvisibleTraceBarProps { - children: React.ReactNode; - manager: VirtualizedViewManager; - node_space: [number, number] | null; - virtualizedIndex: number; -} - -function InvisibleTraceBar(props: InvisibleTraceBarProps) { - const registerInvisibleBarRef = useCallback( - (ref: HTMLDivElement | null) => { - props.manager.registerInvisibleBarRef( - ref, - props.node_space!, - props.virtualizedIndex - ); - }, - [props.manager, props.node_space, props.virtualizedIndex] - ); - - const onDoubleClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - props.manager.onZoomIntoSpace(props.node_space!); - }, - [props.manager, props.node_space] - ); - - if (!props.node_space || !props.children) { - return null; - } - - return ( -
- {props.children} -
- ); -} - -interface BackgroundPatternsProps { - errors: TraceTreeNode['errors']; - manager: VirtualizedViewManager; - node_space: [number, number] | null; - performance_issues: TraceTreeNode['performance_issues']; -} - -function BackgroundPatterns(props: BackgroundPatternsProps) { - const performance_issues = useMemo(() => { - if (!props.performance_issues.size) { - return []; - } - return [...props.performance_issues]; - }, [props.performance_issues]); - - const errors = useMemo(() => { - if (!props.errors.size) { - return []; - } - return [...props.errors]; - }, [props.errors]); - - const severity = useMemo(() => { - return getMaxErrorSeverity(errors); - }, [errors]); - - if (!props.performance_issues.size && !props.errors.size) { - return null; - } - - // If there is an error, render the error pattern across the entire width. - // Else if there is a performance issue, render the performance issue pattern - // for the duration of the performance issue. If there is a profile, render - // the profile pattern for entire duration (we do not have profile durations here) - return ( - - {errors.length > 0 ? ( -
-
-
- ) : performance_issues.length > 0 ? ( - - {performance_issues.map((issue, i) => { - const timestamp = issue.start * 1e3; - // Clamp the issue timestamp to the span's timestamp - const left = props.manager.computeRelativeLeftPositionFromOrigin( - clamp( - timestamp, - props.node_space![0], - props.node_space![0] + props.node_space![1] - ), - props.node_space! - ); - - return ( -
-
-
- ); - })} - - ) : null} - - ); -} - -interface ErrorIconsProps { - errors: TraceTreeNode['errors']; - manager: VirtualizedViewManager; - node_space: [number, number] | null; -} - -function ErrorIcons(props: ErrorIconsProps) { - const errors = useMemo(() => { - return [...props.errors]; - }, [props.errors]); - - if (!props.errors.size) { - return null; - } - - return ( - - {errors.map((error, i) => { - const timestamp = error.timestamp ? error.timestamp * 1e3 : props.node_space![0]; - // Clamp the error timestamp to the span's timestamp - const left = props.manager.computeRelativeLeftPositionFromOrigin( - clamp( - timestamp, - props.node_space![0], - props.node_space![0] + props.node_space![1] - ), - props.node_space! - ); - - return ( -
- -
- ); - })} -
- ); -} - -interface PerformanceIssueIconsProps { - manager: VirtualizedViewManager; - node_space: [number, number] | null; - performance_issues: TraceTreeNode['performance_issues']; -} - -function PerformanceIssueIcons(props: PerformanceIssueIconsProps) { - const performance_issues = useMemo(() => { - return [...props.performance_issues]; - }, [props.performance_issues]); - - if (!props.performance_issues.size) { - return null; - } - - return ( - - {performance_issues.map((issue, i) => { - const timestamp = issue.timestamp - ? issue.timestamp * 1e3 - : issue.start - ? issue.start * 1e3 - : props.node_space![0]; - // Clamp the issue timestamp to the span's timestamp - const left = props.manager.computeRelativeLeftPositionFromOrigin( - clamp( - timestamp, - props.node_space![0], - props.node_space![0] + props.node_space![1] - ), - props.node_space! - ); - - return ( -
- -
- ); - })} -
- ); -} - -interface AutogroupedTraceBarProps { - color: string; - entire_space: [number, number] | null; - errors: TraceTreeNode['errors']; - manager: VirtualizedViewManager; - node_spaces: [number, number][]; - performance_issues: TraceTreeNode['performance_issues']; - profiles: TraceTreeNode['profiles']; - virtualized_index: number; -} - -function AutogroupedTraceBar(props: AutogroupedTraceBarProps) { - const duration = props.entire_space ? formatTraceDuration(props.entire_space[1]) : null; - - const registerInvisibleBarRef = useCallback( - (ref: HTMLDivElement | null) => { - props.manager.registerInvisibleBarRef( - ref, - props.entire_space!, - props.virtualized_index - ); - }, - [props.manager, props.entire_space, props.virtualized_index] - ); - - const registerAutogroupedSpanBarTextRef = useCallback( - (ref: HTMLDivElement | null) => { - props.manager.registerSpanBarTextRef( - ref, - duration!, - props.entire_space!, - props.virtualized_index - ); - }, - [props.manager, props.entire_space, props.virtualized_index, duration] - ); - - if (props.node_spaces && props.node_spaces.length <= 1) { - return ( - - ); - } - - if (!props.node_spaces || !props.entire_space) { - return null; - } - - return ( - -
- {props.node_spaces.map((node_space, i) => { - const width = node_space[1] / props.entire_space![1]; - const left = props.manager.computeRelativeLeftPositionFromOrigin( - node_space[0], - props.entire_space! - ); - return ( -
- ); - })} - {/* Autogrouped bars only render icons. That is because in the case of multiple bars - with tiny gaps, the background pattern looks broken as it does not repeat nicely */} - {props.errors.size > 0 ? ( - - ) : null} - {props.performance_issues.size > 0 ? ( - - ) : null} -
-
- {duration} -
- - ); -} - function VerticalTimestampIndicators({ viewmanager, traceStartTimestamp, diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceAutogroupedRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceAutogroupedRow.tsx new file mode 100644 index 00000000000000..3e7252d5e4b1b3 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceAutogroupedRow.tsx @@ -0,0 +1,85 @@ +import {t} from 'sentry/locale'; +import {TraceIcons} from 'sentry/views/performance/newTraceDetails/icons'; +import { + makeTraceNodeBarColor, + type ParentAutogroupNode, + type SiblingAutogroupNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import {AutogroupedTraceBar} from 'sentry/views/performance/newTraceDetails/traceRow/traceBar'; +import { + maybeFocusTraceRow, + TRACE_COUNT_FORMATTER, + TraceChildrenButton, + TraceRowConnectors, + type TraceRowProps, +} from 'sentry/views/performance/newTraceDetails/traceRow/traceRow'; + +export function TraceAutogroupedRow( + props: TraceRowProps +) { + return ( +
+ props.tabIndex === 0 && !props.isEmbedded + ? maybeFocusTraceRow(r, props.node, props.previouslyFocusedNodeRef) + : null + } + tabIndex={props.tabIndex} + className={`Autogrouped TraceRow ${props.rowSearchClassName} ${props.node.has_errors ? props.node.max_severity : ''}`} + onClick={props.onRowClick} + onKeyDown={props.onRowKeyDown} + style={props.style} + > +
+
+
+ + + } + status={props.node.fetchStatus} + expanded={!props.node.expanded} + onClick={props.onExpand} + onDoubleClick={props.onExpandDoubleClick} + > + {TRACE_COUNT_FORMATTER.format(props.node.groupCount)} + +
+ + {t('Autogrouped')} + + {props.node.value.autogrouped_by.op} +
+
+
+ + +
+
+ ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceBackgroundPatterns.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceBackgroundPatterns.tsx new file mode 100644 index 00000000000000..e952b9f7a699e6 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceBackgroundPatterns.tsx @@ -0,0 +1,103 @@ +import {Fragment, useMemo} from 'react'; +import clamp from 'lodash/clamp'; + +import type { + TraceTree, + TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager'; + +function getMaxErrorSeverity(errors: TraceTree.TraceError[]) { + return errors.reduce((acc, error) => { + if (error.level === 'fatal') { + return 'fatal'; + } + if (error.level === 'error') { + return acc === 'fatal' ? 'fatal' : 'error'; + } + if (error.level === 'warning') { + return acc === 'fatal' || acc === 'error' ? acc : 'warning'; + } + return acc; + }, 'default'); +} + +interface BackgroundPatternsProps { + errors: TraceTreeNode['errors']; + manager: VirtualizedViewManager; + node_space: [number, number] | null; + performance_issues: TraceTreeNode['performance_issues']; +} + +export function TraceBackgroundPatterns(props: BackgroundPatternsProps) { + const performance_issues = useMemo(() => { + if (!props.performance_issues.size) { + return []; + } + + return [...props.performance_issues]; + }, [props.performance_issues]); + + const errors = useMemo(() => { + if (!props.errors.size) { + return []; + } + return [...props.errors]; + }, [props.errors]); + + const severity = useMemo(() => { + return getMaxErrorSeverity(errors); + }, [errors]); + + if (!props.performance_issues.size && !props.errors.size) { + return null; + } + + // If there is an error, render the error pattern across the entire width. + // Else if there is a performance issue, render the performance issue pattern + // for the duration of the performance issue. If there is a profile, render + // the profile pattern for entire duration (we do not have profile durations here) + return ( + + {errors.length > 0 ? ( +
+
+
+ ) : performance_issues.length > 0 ? ( + + {performance_issues.map((issue, i) => { + const timestamp = issue.start * 1e3; + // Clamp the issue timestamp to the span's timestamp + const left = props.manager.computeRelativeLeftPositionFromOrigin( + clamp( + timestamp, + props.node_space![0], + props.node_space![0] + props.node_space![1] + ), + props.node_space! + ); + + return ( +
+
+
+ ); + })} + + ) : null} + + ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceBar.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceBar.tsx new file mode 100644 index 00000000000000..54ff94bd8b0773 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceBar.tsx @@ -0,0 +1,281 @@ +import {Fragment, useCallback} from 'react'; + +import {formatTraceDuration} from 'sentry/utils/duration/formatTraceDuration'; +import type { + TraceTree, + TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager'; +import {TraceBackgroundPatterns} from 'sentry/views/performance/newTraceDetails/traceRow/traceBackgroundPatterns'; +import { + TraceErrorIcons, + TracePerformanceIssueIcons, +} from 'sentry/views/performance/newTraceDetails/traceRow/traceIcons'; + +interface InvisibleTraceBarProps { + children: React.ReactNode; + manager: VirtualizedViewManager; + node_space: [number, number] | null; + virtualizedIndex: number; +} + +export function InvisibleTraceBar(props: InvisibleTraceBarProps) { + const registerInvisibleBarRef = useCallback( + (ref: HTMLDivElement | null) => { + props.manager.registerInvisibleBarRef( + ref, + props.node_space!, + props.virtualizedIndex + ); + }, + [props.manager, props.node_space, props.virtualizedIndex] + ); + + const onDoubleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + props.manager.onZoomIntoSpace(props.node_space!); + }, + [props.manager, props.node_space] + ); + + if (!props.node_space || !props.children) { + return null; + } + + return ( +
+ {props.children} +
+ ); +} + +interface MissingInstrumentationTraceBarProps { + color: string; + manager: VirtualizedViewManager; + node_space: [number, number] | null; + virtualized_index: number; +} + +export function MissingInstrumentationTraceBar( + props: MissingInstrumentationTraceBarProps +) { + const duration = props.node_space ? formatTraceDuration(props.node_space[1]) : null; + + const registerSpanBarRef = useCallback( + (ref: HTMLDivElement | null) => { + props.manager.registerSpanBarRef( + ref, + props.node_space!, + props.color, + props.virtualized_index + ); + }, + [props.manager, props.node_space, props.color, props.virtualized_index] + ); + + const registerSpanBarTextRef = useCallback( + (ref: HTMLDivElement | null) => { + props.manager.registerSpanBarTextRef( + ref, + duration!, + props.node_space!, + props.virtualized_index + ); + }, + [props.manager, props.node_space, props.virtualized_index, duration] + ); + + return ( + +
+
+
+
+
+
+ {duration} +
+ + ); +} + +interface TraceBarProps { + color: string; + errors: TraceTreeNode['errors']; + manager: VirtualizedViewManager; + node_space: [number, number] | null; + performance_issues: TraceTreeNode['performance_issues']; + profiles: TraceTreeNode['profiles']; + virtualized_index: number; +} + +export function TraceBar(props: TraceBarProps) { + const duration = props.node_space ? formatTraceDuration(props.node_space[1]) : null; + + const registerSpanBarRef = useCallback( + (ref: HTMLDivElement | null) => { + props.manager.registerSpanBarRef( + ref, + props.node_space!, + props.color, + props.virtualized_index + ); + }, + [props.manager, props.node_space, props.color, props.virtualized_index] + ); + + const registerSpanBarTextRef = useCallback( + (ref: HTMLDivElement | null) => { + props.manager.registerSpanBarTextRef( + ref, + duration!, + props.node_space!, + props.virtualized_index + ); + }, + [props.manager, props.node_space, props.virtualized_index, duration] + ); + + if (!props.node_space) { + return null; + } + + return ( + +
+ {props.errors.size > 0 ? ( + + ) : null} + {props.performance_issues.size > 0 ? ( + + ) : null} + {props.performance_issues.size > 0 || + props.errors.size > 0 || + props.profiles.length > 0 ? ( + + ) : null} +
+
+ {duration} +
+
+ ); +} + +interface AutogroupedTraceBarProps { + color: string; + entire_space: [number, number] | null; + errors: TraceTreeNode['errors']; + manager: VirtualizedViewManager; + node_spaces: [number, number][]; + performance_issues: TraceTreeNode['performance_issues']; + profiles: TraceTreeNode['profiles']; + virtualized_index: number; +} + +export function AutogroupedTraceBar(props: AutogroupedTraceBarProps) { + const duration = props.entire_space ? formatTraceDuration(props.entire_space[1]) : null; + + const registerInvisibleBarRef = useCallback( + (ref: HTMLDivElement | null) => { + props.manager.registerInvisibleBarRef( + ref, + props.entire_space!, + props.virtualized_index + ); + }, + [props.manager, props.entire_space, props.virtualized_index] + ); + + const registerAutogroupedSpanBarTextRef = useCallback( + (ref: HTMLDivElement | null) => { + props.manager.registerSpanBarTextRef( + ref, + duration!, + props.entire_space!, + props.virtualized_index + ); + }, + [props.manager, props.entire_space, props.virtualized_index, duration] + ); + + if (props.node_spaces && props.node_spaces.length <= 1) { + return ( + + ); + } + + if (!props.node_spaces || !props.entire_space) { + return null; + } + + return ( + +
+ {props.node_spaces.map((node_space, i) => { + const width = node_space[1] / props.entire_space![1]; + const left = props.manager.computeRelativeLeftPositionFromOrigin( + node_space[0], + props.entire_space! + ); + return ( +
+ ); + })} + {/* Autogrouped bars only render icons. That is because in the case of multiple bars + with tiny gaps, the background pattern looks broken as it does not repeat nicely */} + {props.errors.size > 0 ? ( + + ) : null} + {props.performance_issues.size > 0 ? ( + + ) : null} +
+
+ {duration} +
+ + ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceErrorRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceErrorRow.tsx new file mode 100644 index 00000000000000..37dd4be42bd2f2 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceErrorRow.tsx @@ -0,0 +1,85 @@ +import type {Theme} from '@emotion/react'; +import {PlatformIcon} from 'platformicons'; + +import {t} from 'sentry/locale'; +import {TraceIcons} from 'sentry/views/performance/newTraceDetails/icons'; +import type { + TraceTree, + TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import {InvisibleTraceBar} from 'sentry/views/performance/newTraceDetails/traceRow/traceBar'; +import { + maybeFocusTraceRow, + TraceRowConnectors, + type TraceRowProps, +} from 'sentry/views/performance/newTraceDetails/traceRow/traceRow'; + +const ERROR_LEVEL_LABELS: Record = { + sample: t('Sample'), + info: t('Info'), + warning: t('Warning'), + // Hardcoded legacy color (orange400). We no longer use orange anywhere + // else in the app (except for the chart palette). This needs to be harcoded + // here because existing users may still associate orange with the "error" level. + error: t('Error'), + fatal: t('Fatal'), + default: t('Default'), + unknown: t('Unknown'), +}; + +export function TraceErrorRow(props: TraceRowProps>) { + return ( +
+ props.tabIndex === 0 && !props.isEmbedded + ? maybeFocusTraceRow(r, props.node, props.previouslyFocusedNodeRef) + : null + } + tabIndex={props.tabIndex} + className={`TraceRow ${props.rowSearchClassName} ${props.node.max_severity}`} + onClick={props.onRowClick} + onKeyDown={props.onRowKeyDown} + style={props.style} + > +
+
+
+ {' '} +
+ + + {ERROR_LEVEL_LABELS[props.node.value.level ?? 'error']} + + + + {props.node.value.message ?? props.node.value.title} + +
+
+
+ + {typeof props.node.value.timestamp === 'number' ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceIcons.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceIcons.tsx new file mode 100644 index 00000000000000..e9ce12304ddea3 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceIcons.tsx @@ -0,0 +1,99 @@ +import {Fragment, useMemo} from 'react'; +import clamp from 'lodash/clamp'; + +import {TraceIcons} from 'sentry/views/performance/newTraceDetails/icons'; +import type { + TraceTree, + TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager'; + +interface ErrorIconsProps { + errors: TraceTreeNode['errors']; + manager: VirtualizedViewManager; + node_space: [number, number] | null; +} + +export function TraceErrorIcons(props: ErrorIconsProps) { + const errors = useMemo(() => { + return [...props.errors]; + }, [props.errors]); + + if (!props.errors.size) { + return null; + } + + return ( + + {errors.map((error, i) => { + const timestamp = error.timestamp ? error.timestamp * 1e3 : props.node_space![0]; + // Clamp the error timestamp to the span's timestamp + const left = props.manager.computeRelativeLeftPositionFromOrigin( + clamp( + timestamp, + props.node_space![0], + props.node_space![0] + props.node_space![1] + ), + props.node_space! + ); + + return ( +
+ +
+ ); + })} +
+ ); +} + +interface TracePerformanceIssueIconsProps { + manager: VirtualizedViewManager; + node_space: [number, number] | null; + performance_issues: TraceTreeNode['performance_issues']; +} + +export function TracePerformanceIssueIcons(props: TracePerformanceIssueIconsProps) { + const performance_issues = useMemo(() => { + return [...props.performance_issues]; + }, [props.performance_issues]); + + if (!props.performance_issues.size) { + return null; + } + + return ( + + {performance_issues.map((issue, i) => { + const timestamp = issue.timestamp + ? issue.timestamp * 1e3 + : issue.start + ? issue.start * 1e3 + : props.node_space![0]; + // Clamp the issue timestamp to the span's timestamp + const left = props.manager.computeRelativeLeftPositionFromOrigin( + clamp( + timestamp, + props.node_space![0], + props.node_space![0] + props.node_space![1] + ), + props.node_space! + ); + + return ( +
+ +
+ ); + })} +
+ ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceLoadingRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceLoadingRow.tsx new file mode 100644 index 00000000000000..475e9806b1c867 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceLoadingRow.tsx @@ -0,0 +1,101 @@ +import type {Theme} from '@emotion/react'; + +import Placeholder from 'sentry/components/placeholder'; +import {isTraceNode} from 'sentry/views/performance/newTraceDetails/guards'; +import type { + TraceTree, + TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager'; +import { + TRACE_COUNT_FORMATTER, + TRACE_RIGHT_COLUMN_EVEN_CLASSNAME, + TRACE_RIGHT_COLUMN_ODD_CLASSNAME, + TraceChildrenButton, + TraceRowConnectors, +} from 'sentry/views/performance/newTraceDetails/traceRow/traceRow'; + +function randomBetween(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +export function TraceLoadingRow(props: { + index: number; + manager: VirtualizedViewManager; + node: TraceTreeNode; + style: React.CSSProperties; + theme: Theme; +}) { + return ( +
+
+
+
+ + {props.node.children.length > 0 || props.node.canFetch ? ( + void 0} + onDoubleClick={() => void 0} + > + {props.node.children.length > 0 + ? TRACE_COUNT_FORMATTER.format(props.node.children.length) + : null} + + ) : null} +
+ +
+
+
+ +
+
+ ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceMissingInstrumentationRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceMissingInstrumentationRow.tsx new file mode 100644 index 00000000000000..2153dc87663f8e --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceMissingInstrumentationRow.tsx @@ -0,0 +1,65 @@ +import {t} from 'sentry/locale'; +import {TraceIcons} from 'sentry/views/performance/newTraceDetails/icons'; +import { + makeTraceNodeBarColor, + type TraceTree, + type TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import {MissingInstrumentationTraceBar} from 'sentry/views/performance/newTraceDetails/traceRow/traceBar'; +import { + maybeFocusTraceRow, + TraceRowConnectors, + type TraceRowProps, +} from 'sentry/views/performance/newTraceDetails/traceRow/traceRow'; + +export function TraceMissingInstrumentationRow( + props: TraceRowProps> +) { + return ( +
+ props.tabIndex === 0 && !props.isEmbedded + ? maybeFocusTraceRow(r, props.node, props.previouslyFocusedNodeRef) + : null + } + tabIndex={props.tabIndex} + className={`TraceRow ${props.rowSearchClassName}`} + onClick={props.onRowClick} + onKeyDown={props.onRowKeyDown} + style={props.style} + > +
+
+
+ +
+ {t('Missing instrumentation')} +
+
+
+ + +
+
+ ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceRootNode.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceRootNode.tsx new file mode 100644 index 00000000000000..40fb2e1789a0f4 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceRootNode.tsx @@ -0,0 +1,101 @@ +import {Fragment} from 'react'; + +import {t} from 'sentry/locale'; +import {isTraceNode} from 'sentry/views/performance/newTraceDetails/guards'; +import {TraceIcons} from 'sentry/views/performance/newTraceDetails/icons'; +import { + makeTraceNodeBarColor, + type TraceTree, + type TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import {TraceBar} from 'sentry/views/performance/newTraceDetails/traceRow/traceBar'; +import { + maybeFocusTraceRow, + TRACE_COUNT_FORMATTER, + TraceChildrenButton, + TraceRowConnectors, + type TraceRowProps, +} from 'sentry/views/performance/newTraceDetails/traceRow/traceRow'; + +const NO_ERRORS = new Set(); +const NO_PERFORMANCE_ISSUES = new Set(); +const NO_PROFILES = []; + +export function TraceRootRow(props: TraceRowProps>) { + if (!isTraceNode(props.node)) { + throw new Error('Trace row rendered called on row that is not root'); + } + + return ( +
+ props.tabIndex === 0 && !props.isEmbedded + ? maybeFocusTraceRow(r, props.node, props.previouslyFocusedNodeRef) + : null + } + tabIndex={props.tabIndex} + className={`TraceRow ${props.rowSearchClassName} ${props.node.has_errors ? props.node.max_severity : ''}`} + onClick={props.onRowClick} + onKeyDown={props.onRowKeyDown} + style={props.style} + > +
+
+ {' '} +
+ + {props.node.children.length > 0 || props.node.canFetch ? ( + void 0} + onDoubleClick={props.onExpandDoubleClick} + > + {props.node.fetchStatus === 'loading' + ? null + : props.node.children.length > 0 + ? TRACE_COUNT_FORMATTER.format(props.node.children.length) + : null} + + ) : null} +
+ {t('Trace')} + {props.trace_id ? ( + + + {props.trace_id} + + ) : null} +
+
+
+ + +
+
+ ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceRow.tsx new file mode 100644 index 00000000000000..fa268f35686734 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceRow.tsx @@ -0,0 +1,141 @@ +import {Fragment} from 'react'; +import type {Theme} from '@emotion/react'; + +import LoadingIndicator from 'sentry/components/loadingIndicator'; +import type {PlatformKey} from 'sentry/types/project'; +import {isParentAutogroupedNode} from 'sentry/views/performance/newTraceDetails/guards'; +import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager'; + +import { + ParentAutogroupNode, + type TraceTree, + type TraceTreeNode, +} from '../traceModels/traceTree'; + +export const TRACE_COUNT_FORMATTER = Intl.NumberFormat(undefined, {notation: 'compact'}); + +export const TRACE_RIGHT_COLUMN_EVEN_CLASSNAME = `TraceRightColumn`; +export const TRACE_RIGHT_COLUMN_ODD_CLASSNAME = [ + TRACE_RIGHT_COLUMN_EVEN_CLASSNAME, + 'Odd', +].join(' '); +export const TRACE_CHILDREN_COUNT_WRAPPER_CLASSNAME = `TraceChildrenCountWrapper`; +export const TRACE_CHILDREN_COUNT_WRAPPER_ORPHANED_CLASSNAME = [ + TRACE_CHILDREN_COUNT_WRAPPER_CLASSNAME, + 'Orphaned', +].join(' '); + +export interface TraceRowProps { + index: number; + isEmbedded: boolean; + listColumnClassName: string; + listColumnStyle: React.CSSProperties; + manager: VirtualizedViewManager; + node: T; + onExpand: (e: React.MouseEvent) => void; + onExpandDoubleClick: (e: React.MouseEvent) => void; + onRowClick: (e: React.MouseEvent) => void; + onRowDoubleClick: (e: React.MouseEvent) => void; + onRowKeyDown: (e: React.KeyboardEvent) => void; + onSpanArrowClick: (e: React.MouseEvent) => void; + onZoomIn: (e: React.MouseEvent) => void; + previouslyFocusedNodeRef: React.MutableRefObject | null>; + projects: Record; + registerListColumnRef: (e: HTMLDivElement | null) => void; + registerSpanArrowRef: (e: HTMLButtonElement | null) => void; + registerSpanColumnRef: (e: HTMLDivElement | null) => void; + rowSearchClassName: string; + spanColumnClassName: string; + style: React.CSSProperties; + tabIndex: number; + theme: Theme; + trace_id: string | undefined; + virtualized_index: number; +} + +export function maybeFocusTraceRow( + ref: HTMLDivElement | null, + node: TraceTreeNode, + previouslyFocusedNodeRef: React.MutableRefObject | null> +) { + if (!ref) { + return; + } + if (node === previouslyFocusedNodeRef.current) { + return; + } + + previouslyFocusedNodeRef.current = node; + ref.focus(); +} + +export function TraceRowConnectors(props: { + manager: VirtualizedViewManager; + node: TraceTreeNode; +}) { + const hasChildren = + (props.node.expanded || props.node.zoomedIn) && props.node.children.length > 0; + const showVerticalConnector = + hasChildren || (props.node.value && isParentAutogroupedNode(props.node)); + + // If the tail node of the collapsed node has no children, + // we don't want to render the vertical connector as no children + // are being rendered as the chain is entirely collapsed + const hideVerticalConnector = + showVerticalConnector && + props.node.value && + props.node instanceof ParentAutogroupNode && + (!props.node.tail.children.length || + (!props.node.tail.expanded && !props.node.expanded)); + + return ( + + {props.node.connectors.map((c, i) => { + return ( + + ); + })} + {showVerticalConnector && !hideVerticalConnector ? ( + + ) : null} + {props.node.isLastChild ? ( + + ) : ( + + )} + + ); +} + +export function TraceChildrenButton(props: { + children: React.ReactNode; + expanded: boolean; + icon: React.ReactNode; + onClick: (e: React.MouseEvent) => void; + onDoubleClick: (e: React.MouseEvent) => void; + status: TraceTreeNode['fetchStatus'] | undefined; +}) { + return ( + + ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceSpanRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceSpanRow.tsx new file mode 100644 index 00000000000000..5bcc1bc7d1d7d7 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceSpanRow.tsx @@ -0,0 +1,98 @@ +import {TraceIcons} from 'sentry/views/performance/newTraceDetails/icons'; +import { + makeTraceNodeBarColor, + type TraceTree, + type TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import {TraceBar} from 'sentry/views/performance/newTraceDetails/traceRow/traceBar'; +import { + maybeFocusTraceRow, + TRACE_COUNT_FORMATTER, + TraceChildrenButton, + TraceRowConnectors, + type TraceRowProps, +} from 'sentry/views/performance/newTraceDetails/traceRow/traceRow'; + +const NO_PROFILES = []; + +export function TraceSpanRow(props: TraceRowProps>) { + return ( +
+ props.tabIndex === 0 && !props.isEmbedded + ? maybeFocusTraceRow(r, props.node, props.previouslyFocusedNodeRef) + : null + } + tabIndex={props.tabIndex} + className={`TraceRow ${props.rowSearchClassName} ${props.node.has_errors ? props.node.max_severity : ''}`} + onClick={props.onRowClick} + onKeyDown={props.onRowKeyDown} + style={props.style} + > +
+
+
+ + {props.node.children.length > 0 || props.node.canFetch ? ( + + ) + } + status={props.node.fetchStatus} + expanded={props.node.expanded || props.node.zoomedIn} + onDoubleClick={props.onExpandDoubleClick} + onClick={e => + props.node.canFetch ? props.onZoomIn(e) : props.onExpand(e) + } + > + {props.node.children.length > 0 + ? TRACE_COUNT_FORMATTER.format(props.node.children.length) + : null} + + ) : null} +
+ {props.node.value.op ?? ''} + + + {!props.node.value.description + ? props.node.value.span_id ?? 'unknown' + : props.node.value.description.length > 100 + ? props.node.value.description.slice(0, 100).trim() + '\u2026' + : props.node.value.description} + +
+
+
+ + +
+
+ ); +} diff --git a/static/app/views/performance/newTraceDetails/traceRow/traceTransactionRow.tsx b/static/app/views/performance/newTraceDetails/traceRow/traceTransactionRow.tsx new file mode 100644 index 00000000000000..0ecb176536fd55 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/traceRow/traceTransactionRow.tsx @@ -0,0 +1,103 @@ +import {PlatformIcon} from 'platformicons'; + +import {TraceIcons} from 'sentry/views/performance/newTraceDetails/icons'; +import { + makeTraceNodeBarColor, + type TraceTree, + type TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import {TraceBar} from 'sentry/views/performance/newTraceDetails/traceRow/traceBar'; +import { + maybeFocusTraceRow, + TRACE_COUNT_FORMATTER, + TraceChildrenButton, + TraceRowConnectors, + type TraceRowProps, +} from 'sentry/views/performance/newTraceDetails/traceRow/traceRow'; + +export function TraceTransactionRow( + props: TraceRowProps> +) { + return ( +
+ props.tabIndex === 0 && !props.isEmbedded + ? maybeFocusTraceRow(r, props.node, props.previouslyFocusedNodeRef) + : null + } + tabIndex={props.tabIndex} + className={`TraceRow ${props.rowSearchClassName} ${props.node.has_errors ? props.node.max_severity : ''}`} + onKeyDown={props.onRowKeyDown} + onClick={props.onRowClick} + style={props.style} + > +
+
+
+ + {props.node.children.length > 0 || props.node.canFetch ? ( + + ) : ( + '+' + ) + ) : ( + + ) + } + status={props.node.fetchStatus} + expanded={props.node.expanded || props.node.zoomedIn} + onDoubleClick={props.onExpandDoubleClick} + onClick={e => { + props.node.canFetch ? props.onZoomIn(e) : props.onExpand(e); + }} + > + {props.node.children.length > 0 + ? TRACE_COUNT_FORMATTER.format(props.node.children.length) + : null} + + ) : null} +
+ + {props.node.value['transaction.op']} + + {props.node.value.transaction} +
+
+
+ + +
+
+ ); +} diff --git a/static/app/views/performance/newTraceDetails/useTraceScrollToEventOnLoad.tsx b/static/app/views/performance/newTraceDetails/useTraceScrollToEventOnLoad.tsx new file mode 100644 index 00000000000000..72bc60611578df --- /dev/null +++ b/static/app/views/performance/newTraceDetails/useTraceScrollToEventOnLoad.tsx @@ -0,0 +1,136 @@ +import {useLayoutEffect, useRef} from 'react'; +import Sentry from '@sentry/react'; + +import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; +import { + isAutogroupedNode, + isTransactionNode, +} from 'sentry/views/performance/newTraceDetails/guards'; +import { + TraceTree, + type TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; +import type {TraceScheduler} from 'sentry/views/performance/newTraceDetails/traceRenderers/traceScheduler'; +import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager'; +import type {useTraceScrollToPath} from 'sentry/views/performance/newTraceDetails/useTraceScrollToPath'; + +type UseTraceScrollToEventOnLoadProps = { + manager: VirtualizedViewManager; + onTraceLoad: ( + trace: TraceTree, + node: TraceTreeNode | null, + index: number | null + ) => void; + rerender: () => void; + scheduler: TraceScheduler; + scrollQueueRef: ReturnType; + trace: TraceTree; +}; + +export function useTraceScrollToEventOnLoad(options: UseTraceScrollToEventOnLoadProps) { + const api = useApi(); + const organization = useOrganization(); + const initializedRef = useRef(false); + const {trace, manager, onTraceLoad, scheduler, scrollQueueRef, rerender} = options; + + useLayoutEffect(() => { + if (initializedRef.current) { + return; + } + if (trace.type !== 'trace' || !manager) { + return; + } + + initializedRef.current = true; + + if (!scrollQueueRef.current) { + onTraceLoad(trace, null, null); + return; + } + + // Node path has higher specificity than eventId + const promise = scrollQueueRef.current?.path + ? TraceTree.ExpandToPath(trace, scrollQueueRef.current.path, rerender, { + api, + organization, + }) + : scrollQueueRef.current.eventId + ? TraceTree.ExpandToEventID(scrollQueueRef?.current?.eventId, trace, rerender, { + api, + organization, + }) + : Promise.resolve(null); + + promise + .then(async node => { + if (!scrollQueueRef.current?.path && !scrollQueueRef.current?.eventId) { + return; + } + + if (!node) { + Sentry.captureMessage('Failed to find and scroll to node in tree'); + return; + } + + // When users are coming off an eventID link, we want to fetch the children + // of the node that the eventID points to. This is because the eventID link + // only points to the transaction, but we want to fetch the children of the + // transaction to show the user the list of spans in that transaction + if (scrollQueueRef.current.eventId && node?.canFetch) { + await trace.zoomIn(node, true, {api, organization}).catch(_e => { + Sentry.captureMessage('Failed to fetch children of eventId on mount'); + }); + } + + let index = trace.list.indexOf(node); + // We have found the node, yet it is somehow not in the visible tree. + // This means that the path we were given did not match the current tree. + // This sometimes happens when we receive external links like span-x, txn-y + // however the resulting tree looks like span-x, autogroup, txn-y. In this case, + // we should expand the autogroup node and try to find the node again. + if (node && index === -1) { + let parent_node = node.parent; + while (parent_node) { + // Transactions break autogrouping chains, so we can stop here + if (isTransactionNode(parent_node)) { + break; + } + if (isAutogroupedNode(parent_node)) { + trace.expand(parent_node, true); + // This is very wasteful as it performs O(n^2) search each time we expand a node... + // In most cases though, we should be operating on a tree with sub 10k elements and hopefully + // a low autogrouped node count. + index = node ? trace.list.findIndex(n => n === node) : -1; + if (index !== -1) { + break; + } + } + parent_node = parent_node.parent; + } + } + onTraceLoad(trace, node, index === -1 ? null : index); + }) + .finally(() => { + // Important to set scrollQueueRef.current to null and trigger a rerender + // after the promise resolves as we show a loading state during scroll, + // else the screen could jump around while we fetch span data + scrollQueueRef.current = null; + rerender(); + // Allow react to rerender before dispatching the init event + requestAnimationFrame(() => { + scheduler.dispatch('initialize virtualized list'); + }); + }); + }, [ + api, + trace, + manager, + onTraceLoad, + scheduler, + scrollQueueRef, + rerender, + initializedRef, + organization, + ]); +} diff --git a/static/app/views/performance/newTraceDetails/useTraceScrollToPath.tsx b/static/app/views/performance/newTraceDetails/useTraceScrollToPath.tsx new file mode 100644 index 00000000000000..c373112cb5da90 --- /dev/null +++ b/static/app/views/performance/newTraceDetails/useTraceScrollToPath.tsx @@ -0,0 +1,59 @@ +import {useRef} from 'react'; +import * as qs from 'query-string'; + +import type { + TraceTree, + TraceTreeNode, +} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; + +function decodeScrollQueue(maybePath: unknown): TraceTree.NodePath[] | null { + if (Array.isArray(maybePath)) { + return maybePath; + } + + if (typeof maybePath === 'string') { + return [maybePath as TraceTree.NodePath]; + } + + return null; +} + +type UseTraceScrollToPath = + | {eventId?: string; path?: TraceTree.NodePath[]} + | null + | undefined; + +export function useTraceScrollToPath( + path: UseTraceScrollToPath +): React.MutableRefObject { + const scrollQueueRef = useRef< + {eventId?: string; path?: TraceTree.NodePath[]} | null | undefined + >(undefined); + + // If we havent decoded anything yet, then decode the path + if (scrollQueueRef.current === undefined) { + let scrollToNode: UseTraceScrollToPath = path; + + if (!path) { + const queryParams = qs.parse(location.search); + + scrollToNode = { + eventId: queryParams.eventId as string | undefined, + path: decodeScrollQueue( + queryParams.node + ) as TraceTreeNode['path'], + }; + } + + if (scrollToNode && (scrollToNode.path || scrollToNode.eventId)) { + scrollQueueRef.current = { + eventId: scrollToNode.eventId as string, + path: scrollToNode.path, + }; + } else { + scrollQueueRef.current = null; + } + } + + return scrollQueueRef; +}