import type React from 'react'; import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react'; import {browserHistory} from 'react-router'; import styled from '@emotion/styled'; import type {Location} from 'history'; import * as qs from 'query-string'; import ButtonBar from 'sentry/components/buttonBar'; import DiscoverButton from 'sentry/components/discoverButton'; import * as Layout from 'sentry/components/layouts/thirds'; import NoProjectMessage from 'sentry/components/noProjectMessage'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters'; import {t} from 'sentry/locale'; import type {EventTransaction, Organization} from 'sentry/types'; import {trackAnalytics} from 'sentry/utils/analytics'; import EventView from 'sentry/utils/discover/eventView'; import TraceMetaQuery, { type TraceMetaQueryChildrenProps, } from 'sentry/utils/performance/quickTrace/traceMetaQuery'; import type { TraceFullDetailed, TraceSplitResults, } from 'sentry/utils/performance/quickTrace/types'; import {useApiQuery} from 'sentry/utils/queryClient'; import {decodeScalar} from 'sentry/utils/queryString'; import useApi from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; import useOnClickOutside from 'sentry/utils/useOnClickOutside'; import useOrganization from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; import useProjects from 'sentry/utils/useProjects'; import {rovingTabIndexReducer} from 'sentry/views/performance/newTraceDetails/rovingTabIndex'; import { searchInTraceTree, traceSearchReducer, } from 'sentry/views/performance/newTraceDetails/traceSearch'; import {TraceSearchInput} from 'sentry/views/performance/newTraceDetails/traceSearchInput'; import {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/virtualizedViewManager'; import Breadcrumb from '../breadcrumb'; import TraceDrawer from './traceDrawer/traceDrawer'; import {isTraceNode} from './guards'; import Trace from './trace'; import TraceHeader from './traceHeader'; import {TraceTree, type TraceTreeNode} from './traceTree'; import TraceWarnings from './traceWarnings'; import {useTrace} from './useTrace'; const DOCUMENT_TITLE = [t('Trace Details'), t('Performance')].join(' — '); function maybeFocusRow() { const focused_node = document.querySelector(".TraceRow[tabIndex='0']"); if ( focused_node && 'focus' in focused_node && typeof focused_node.focus === 'function' ) { focused_node.focus(); } } export function TraceView() { const location = useLocation(); const organization = useOrganization(); const params = useParams<{traceSlug?: string}>(); const traceSlug = params.traceSlug?.trim() ?? ''; const queryParams = useMemo(() => { const normalizedParams = normalizeDateTimeParams(location.query, { allowAbsolutePageDatetime: true, }); const start = decodeScalar(normalizedParams.start); const end = decodeScalar(normalizedParams.end); const statsPeriod = decodeScalar(normalizedParams.statsPeriod); return {start, end, statsPeriod, useSpans: 1}; }, [location.query]); const traceEventView = useMemo(() => { const {start, end, statsPeriod} = queryParams; return EventView.fromSavedQuery({ id: undefined, name: `Events with Trace ID ${traceSlug}`, fields: ['title', 'event.type', 'project', 'timestamp'], orderby: '-timestamp', query: `trace:${traceSlug}`, projects: [ALL_ACCESS_PROJECTS], version: 2, start, end, range: statsPeriod, }); }, [queryParams, traceSlug]); const trace = useTrace(); return ( {metaResults => ( )} ); } type TraceViewContentProps = { location: Location; metaResults: TraceMetaQueryChildrenProps; organization: Organization; status: 'pending' | 'resolved' | 'error' | 'initial'; trace: TraceSplitResults | null; traceEventView: EventView; traceSlug: string; }; function TraceViewContent(props: TraceViewContentProps) { const api = useApi(); const [activeTab, setActiveTab] = useState<'trace' | 'node'>('trace'); const {projects} = useProjects(); const rootEvent = useRootEvent(props.trace); const viewManager = useMemo(() => { return new VirtualizedViewManager({ list: {width: 0.5}, span_list: {width: 0.5}, }); }, []); const tree = useMemo(() => { if (props.status === 'pending' || rootEvent.status !== 'success') { return TraceTree.Loading({ project_slug: projects?.[0]?.slug ?? '', event_id: props.traceSlug, }); } if (props.trace) { return TraceTree.FromTrace(props.trace, rootEvent.data); } return TraceTree.Empty(); }, [ props.traceSlug, props.trace, props.status, projects, rootEvent.data, rootEvent.status, ]); const traceType = useMemo(() => { if (props.status !== 'resolved' || !tree) { return null; } return TraceTree.GetTraceType(tree.root); }, [props.status, tree]); const [rovingTabIndexState, rovingTabIndexDispatch] = useReducer( rovingTabIndexReducer, { index: null, items: null, node: null, } ); useLayoutEffect(() => { return rovingTabIndexDispatch({ type: 'initialize', items: tree.list.length - 1, index: null, node: null, }); }, [tree.list.length]); const initialQuery = useMemo((): string | undefined => { const query = qs.parse(location.search); if (typeof query.search === 'string') { return query.search; } return undefined; // We only want to decode on load // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const [searchState, searchDispatch] = useReducer(traceSearchReducer, { query: initialQuery, resultIteratorIndex: undefined, resultIndex: undefined, results: undefined, status: undefined, resultsLookup: new Map(), }); const [clickedNode, setClickedNode] = useState[]>( [] ); const onSetClickedNode = useCallback( (node: TraceTreeNode | null) => { setActiveTab(node && !isTraceNode(node ?? null) ? 'node' : 'trace'); setClickedNode(node && !isTraceNode(node) ? [node] : []); maybeFocusRow(); }, [] ); const searchingRaf = useRef<{id: number | null} | null>(null); const onTraceSearch = useCallback( (query: string) => { if (searchingRaf.current?.id) { window.cancelAnimationFrame(searchingRaf.current.id); } searchingRaf.current = searchInTraceTree(query, tree, results => { searchDispatch({ type: 'set results', results: results[0], resultsLookup: results[1], }); }); }, [tree] ); const previousResultIndexRef = useRef(searchState.resultIndex); useLayoutEffect(() => { if (previousResultIndexRef.current === searchState.resultIndex) { return; } if (!viewManager.list) { return; } if (typeof searchState.resultIndex !== 'number') { return; } viewManager.list.scrollToRow(searchState.resultIndex); previousResultIndexRef.current = searchState.resultIndex; }, [searchState.resultIndex, viewManager.list]); const onSearchChange = useCallback( (event: React.ChangeEvent) => { if (!event.currentTarget.value) { searchDispatch({type: 'clear query'}); return; } onTraceSearch(event.currentTarget.value); searchDispatch({type: 'set query', query: event.currentTarget.value}); }, [onTraceSearch] ); const onSearchClear = useCallback(() => { searchDispatch({type: 'clear query'}); }, []); const onSearchKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === 'ArrowDown') { searchDispatch({type: 'go to next match'}); } else { if (event.key === 'ArrowUp') { searchDispatch({type: 'go to previous match'}); } } }, []); const onNextSearchClick = useCallback(() => { searchDispatch({type: 'go to next match'}); }, []); const onPreviousSearchClick = useCallback(() => { searchDispatch({type: 'go to previous match'}); }, []); const breadcrumbTransaction = useMemo(() => { return { project: rootEvent.data?.projectID ?? '', name: rootEvent.data?.title ?? '', }; }, [rootEvent.data]); const trackOpenInDiscover = useCallback(() => { trackAnalytics('performance_views.trace_view.open_in_discover', { organization: props.organization, }); }, [props.organization]); const syncQuery = useMemo(() => { return {search: searchState.query}; }, [searchState.query]); useQueryParamSync(syncQuery); const onOutsideClick = useCallback(() => { const {node: _node, ...queryParamsWithoutNode} = qs.parse(location.search); browserHistory.push({ pathname: location.pathname, query: queryParamsWithoutNode, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const traceContainerRef = useRef(null); useOnClickOutside(traceContainerRef, onOutsideClick); const previouslyFocusedIndexRef = useRef(null); const scrollToNode = useCallback( (node: TraceTreeNode) => { previouslyFocusedIndexRef.current = null; viewManager .scrollToPath(tree, [...node.path], () => void 0, { api, organization: props.organization, }) .then(maybeNode => { if (!maybeNode) { return; } viewManager.onScrollEndOutOfBoundsCheck(); rovingTabIndexDispatch({ type: 'set index', index: maybeNode.index, node: maybeNode.node, }); if (searchState.query) { onTraceSearch(searchState.query); } maybeFocusRow(); }); }, [api, props.organization, tree, viewManager, searchState, onTraceSearch] ); return ( {t('Trace ID: %s', props.traceSlug)} {t('Open in Discover')} {traceType ? : null} (traceContainerRef.current = r)}> ); } function useQueryParamSync(query: Record) { const previousQueryRef = useRef>(query); const syncStateTimeoutRef = useRef(null); useEffect(() => { const keys = Object.keys(query); const previousKeys = Object.keys(previousQueryRef.current); if ( keys.length === previousKeys.length && keys.every(key => { return query[key] === previousQueryRef.current[key]; }) ) { previousQueryRef.current = query; return; } if (syncStateTimeoutRef.current !== null) { window.clearTimeout(syncStateTimeoutRef.current); } previousQueryRef.current = query; syncStateTimeoutRef.current = window.setTimeout(() => { browserHistory.replace({ pathname: location.pathname, query: { ...qs.parse(location.search), ...previousQueryRef.current, }, }); }, 1000); }, [query]); } function useRootEvent(trace: TraceSplitResults | null) { const root = trace?.transactions[0] || trace?.orphan_errors[0]; const organization = useOrganization(); return useApiQuery( [ `/organizations/${organization.slug}/events/${root?.project_slug}:${root?.event_id}/`, { query: { referrer: 'trace-details-summary', }, }, ], { staleTime: 0, enabled: !!trace, } ); } const TraceBody = styled('div')` padding-bottom: 0 !important; padding-left: 24px; padding-right: 24px; padding-top: 24px; background-color: ${p => p.theme.background}; display: flex; flex-direction: column; flex: 1 1 100%; border-bottom: 2px solid red; `; const TraceTop = styled('div')` display: flex; flex-direction: column; `; const TraceLayout = styled('div')` display: flex; flex-direction: column; flex: 1 1 100%; border-bottom: 2px solid green; ~ footer { display: none; } `; const TraceContainer = styled('div')` display: flex; flex: 1 1 100%; box-shadow: 0 0 0 1px ${p => p.theme.border}; border-bottom: 2px solid red; `; const TraceToolbar = styled('div')` flex-grow: 0; position: relative; `; const TraceGrid = styled('div')` display: grid; width: 100%; grid-template-rows: 1fr min-content; grid-template-columns: 100%; `;