index.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. import type React from 'react';
  2. import {
  3. Fragment,
  4. useCallback,
  5. useEffect,
  6. useLayoutEffect,
  7. useMemo,
  8. useReducer,
  9. useRef,
  10. useState,
  11. } from 'react';
  12. import {browserHistory} from 'react-router';
  13. import styled from '@emotion/styled';
  14. import type {Location} from 'history';
  15. import * as qs from 'query-string';
  16. import ButtonBar from 'sentry/components/buttonBar';
  17. import DiscoverButton from 'sentry/components/discoverButton';
  18. import * as Layout from 'sentry/components/layouts/thirds';
  19. import NoProjectMessage from 'sentry/components/noProjectMessage';
  20. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  21. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  22. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  23. import {t} from 'sentry/locale';
  24. import type {EventTransaction, Organization} from 'sentry/types';
  25. import {trackAnalytics} from 'sentry/utils/analytics';
  26. import EventView from 'sentry/utils/discover/eventView';
  27. import TraceMetaQuery, {
  28. type TraceMetaQueryChildrenProps,
  29. } from 'sentry/utils/performance/quickTrace/traceMetaQuery';
  30. import type {
  31. TraceFullDetailed,
  32. TraceSplitResults,
  33. } from 'sentry/utils/performance/quickTrace/types';
  34. import {useApiQuery} from 'sentry/utils/queryClient';
  35. import {decodeScalar} from 'sentry/utils/queryString';
  36. import useApi from 'sentry/utils/useApi';
  37. import {useLocation} from 'sentry/utils/useLocation';
  38. import useOnClickOutside from 'sentry/utils/useOnClickOutside';
  39. import useOrganization from 'sentry/utils/useOrganization';
  40. import {useParams} from 'sentry/utils/useParams';
  41. import useProjects from 'sentry/utils/useProjects';
  42. import {rovingTabIndexReducer} from 'sentry/views/performance/newTraceDetails/rovingTabIndex';
  43. import {
  44. searchInTraceTree,
  45. traceSearchReducer,
  46. } from 'sentry/views/performance/newTraceDetails/traceSearch';
  47. import {TraceSearchInput} from 'sentry/views/performance/newTraceDetails/traceSearchInput';
  48. import {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/virtualizedViewManager';
  49. import Breadcrumb from '../breadcrumb';
  50. import TraceDrawer from './traceDrawer/traceDrawer';
  51. import {isTraceNode} from './guards';
  52. import Trace from './trace';
  53. import TraceHeader from './traceHeader';
  54. import {TraceTree, type TraceTreeNode} from './traceTree';
  55. import TraceWarnings from './traceWarnings';
  56. import {useTrace} from './useTrace';
  57. const DOCUMENT_TITLE = [t('Trace Details'), t('Performance')].join(' — ');
  58. function maybeFocusRow() {
  59. const focused_node = document.querySelector(".TraceRow[tabIndex='0']");
  60. if (
  61. focused_node &&
  62. 'focus' in focused_node &&
  63. typeof focused_node.focus === 'function'
  64. ) {
  65. focused_node.focus();
  66. }
  67. }
  68. export function TraceView() {
  69. const location = useLocation();
  70. const organization = useOrganization();
  71. const params = useParams<{traceSlug?: string}>();
  72. const traceSlug = params.traceSlug?.trim() ?? '';
  73. const queryParams = useMemo(() => {
  74. const normalizedParams = normalizeDateTimeParams(location.query, {
  75. allowAbsolutePageDatetime: true,
  76. });
  77. const start = decodeScalar(normalizedParams.start);
  78. const end = decodeScalar(normalizedParams.end);
  79. const statsPeriod = decodeScalar(normalizedParams.statsPeriod);
  80. return {start, end, statsPeriod, useSpans: 1};
  81. }, [location.query]);
  82. const traceEventView = useMemo(() => {
  83. const {start, end, statsPeriod} = queryParams;
  84. return EventView.fromSavedQuery({
  85. id: undefined,
  86. name: `Events with Trace ID ${traceSlug}`,
  87. fields: ['title', 'event.type', 'project', 'timestamp'],
  88. orderby: '-timestamp',
  89. query: `trace:${traceSlug}`,
  90. projects: [ALL_ACCESS_PROJECTS],
  91. version: 2,
  92. start,
  93. end,
  94. range: statsPeriod,
  95. });
  96. }, [queryParams, traceSlug]);
  97. const trace = useTrace();
  98. return (
  99. <SentryDocumentTitle title={DOCUMENT_TITLE} orgSlug={organization.slug}>
  100. <NoProjectMessage organization={organization}>
  101. <TraceMetaQuery
  102. location={location}
  103. orgSlug={organization.slug}
  104. traceId={traceSlug}
  105. start={queryParams.start}
  106. end={queryParams.end}
  107. statsPeriod={queryParams.statsPeriod}
  108. >
  109. {metaResults => (
  110. <TraceViewContent
  111. status={trace.status}
  112. trace={trace.data}
  113. traceSlug={traceSlug}
  114. organization={organization}
  115. location={location}
  116. traceEventView={traceEventView}
  117. metaResults={metaResults}
  118. />
  119. )}
  120. </TraceMetaQuery>
  121. </NoProjectMessage>
  122. </SentryDocumentTitle>
  123. );
  124. }
  125. type TraceViewContentProps = {
  126. location: Location;
  127. metaResults: TraceMetaQueryChildrenProps;
  128. organization: Organization;
  129. status: 'pending' | 'resolved' | 'error' | 'initial';
  130. trace: TraceSplitResults<TraceFullDetailed> | null;
  131. traceEventView: EventView;
  132. traceSlug: string;
  133. };
  134. function TraceViewContent(props: TraceViewContentProps) {
  135. const api = useApi();
  136. const [activeTab, setActiveTab] = useState<'trace' | 'node'>('trace');
  137. const {projects} = useProjects();
  138. const rootEvent = useRootEvent(props.trace);
  139. const viewManager = useMemo(() => {
  140. return new VirtualizedViewManager({
  141. list: {width: 0.5},
  142. span_list: {width: 0.5},
  143. });
  144. }, []);
  145. const tree = useMemo(() => {
  146. if (props.status === 'pending' || rootEvent.status !== 'success') {
  147. return TraceTree.Loading({
  148. project_slug: projects?.[0]?.slug ?? '',
  149. event_id: props.traceSlug,
  150. });
  151. }
  152. if (props.trace) {
  153. return TraceTree.FromTrace(props.trace, rootEvent.data);
  154. }
  155. return TraceTree.Empty();
  156. }, [
  157. props.traceSlug,
  158. props.trace,
  159. props.status,
  160. projects,
  161. rootEvent.data,
  162. rootEvent.status,
  163. ]);
  164. const traceType = useMemo(() => {
  165. if (props.status !== 'resolved' || !tree) {
  166. return null;
  167. }
  168. return TraceTree.GetTraceType(tree.root);
  169. }, [props.status, tree]);
  170. const [rovingTabIndexState, rovingTabIndexDispatch] = useReducer(
  171. rovingTabIndexReducer,
  172. {
  173. index: null,
  174. items: null,
  175. node: null,
  176. }
  177. );
  178. useLayoutEffect(() => {
  179. return rovingTabIndexDispatch({
  180. type: 'initialize',
  181. items: tree.list.length - 1,
  182. index: null,
  183. node: null,
  184. });
  185. }, [tree.list.length]);
  186. const initialQuery = useMemo((): string | undefined => {
  187. const query = qs.parse(location.search);
  188. if (typeof query.search === 'string') {
  189. return query.search;
  190. }
  191. return undefined;
  192. // We only want to decode on load
  193. // eslint-disable-next-line react-hooks/exhaustive-deps
  194. }, []);
  195. const [searchState, searchDispatch] = useReducer(traceSearchReducer, {
  196. query: initialQuery,
  197. resultIteratorIndex: undefined,
  198. resultIndex: undefined,
  199. results: undefined,
  200. status: undefined,
  201. resultsLookup: new Map(),
  202. });
  203. const [clickedNode, setClickedNode] = useState<TraceTreeNode<TraceTree.NodeValue>[]>(
  204. []
  205. );
  206. const onSetClickedNode = useCallback(
  207. (node: TraceTreeNode<TraceTree.NodeValue> | null) => {
  208. setActiveTab(node && !isTraceNode(node ?? null) ? 'node' : 'trace');
  209. setClickedNode(node && !isTraceNode(node) ? [node] : []);
  210. maybeFocusRow();
  211. },
  212. []
  213. );
  214. const searchingRaf = useRef<{id: number | null} | null>(null);
  215. const onTraceSearch = useCallback(
  216. (query: string) => {
  217. if (searchingRaf.current?.id) {
  218. window.cancelAnimationFrame(searchingRaf.current.id);
  219. }
  220. searchingRaf.current = searchInTraceTree(query, tree, results => {
  221. searchDispatch({
  222. type: 'set results',
  223. results: results[0],
  224. resultsLookup: results[1],
  225. });
  226. });
  227. },
  228. [tree]
  229. );
  230. const previousResultIndexRef = useRef<number | undefined>(searchState.resultIndex);
  231. useLayoutEffect(() => {
  232. if (previousResultIndexRef.current === searchState.resultIndex) {
  233. return;
  234. }
  235. if (!viewManager.list) {
  236. return;
  237. }
  238. if (typeof searchState.resultIndex !== 'number') {
  239. return;
  240. }
  241. viewManager.list.scrollToRow(searchState.resultIndex);
  242. previousResultIndexRef.current = searchState.resultIndex;
  243. }, [searchState.resultIndex, viewManager.list]);
  244. const onSearchChange = useCallback(
  245. (event: React.ChangeEvent<HTMLInputElement>) => {
  246. if (!event.currentTarget.value) {
  247. searchDispatch({type: 'clear query'});
  248. return;
  249. }
  250. onTraceSearch(event.currentTarget.value);
  251. searchDispatch({type: 'set query', query: event.currentTarget.value});
  252. },
  253. [onTraceSearch]
  254. );
  255. const onSearchClear = useCallback(() => {
  256. searchDispatch({type: 'clear query'});
  257. }, []);
  258. const onSearchKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
  259. if (event.key === 'ArrowDown') {
  260. searchDispatch({type: 'go to next match'});
  261. } else {
  262. if (event.key === 'ArrowUp') {
  263. searchDispatch({type: 'go to previous match'});
  264. }
  265. }
  266. }, []);
  267. const onNextSearchClick = useCallback(() => {
  268. searchDispatch({type: 'go to next match'});
  269. }, []);
  270. const onPreviousSearchClick = useCallback(() => {
  271. searchDispatch({type: 'go to previous match'});
  272. }, []);
  273. const breadcrumbTransaction = useMemo(() => {
  274. return {
  275. project: rootEvent.data?.projectID ?? '',
  276. name: rootEvent.data?.title ?? '',
  277. };
  278. }, [rootEvent.data]);
  279. const trackOpenInDiscover = useCallback(() => {
  280. trackAnalytics('performance_views.trace_view.open_in_discover', {
  281. organization: props.organization,
  282. });
  283. }, [props.organization]);
  284. const syncQuery = useMemo(() => {
  285. return {search: searchState.query};
  286. }, [searchState.query]);
  287. useQueryParamSync(syncQuery);
  288. const onOutsideClick = useCallback(() => {
  289. const {node: _node, ...queryParamsWithoutNode} = qs.parse(location.search);
  290. browserHistory.push({
  291. pathname: location.pathname,
  292. query: queryParamsWithoutNode,
  293. });
  294. // eslint-disable-next-line react-hooks/exhaustive-deps
  295. }, []);
  296. const traceContainerRef = useRef<HTMLElement | null>(null);
  297. useOnClickOutside(traceContainerRef, onOutsideClick);
  298. const previouslyFocusedIndexRef = useRef<number | null>(null);
  299. const scrollToNode = useCallback(
  300. (node: TraceTreeNode<TraceTree.NodeValue>) => {
  301. previouslyFocusedIndexRef.current = null;
  302. viewManager
  303. .scrollToPath(tree, [...node.path], () => void 0, {
  304. api,
  305. organization: props.organization,
  306. })
  307. .then(maybeNode => {
  308. if (!maybeNode) {
  309. return;
  310. }
  311. viewManager.onScrollEndOutOfBoundsCheck();
  312. rovingTabIndexDispatch({
  313. type: 'set index',
  314. index: maybeNode.index,
  315. node: maybeNode.node,
  316. });
  317. if (searchState.query) {
  318. onTraceSearch(searchState.query);
  319. }
  320. maybeFocusRow();
  321. });
  322. },
  323. [api, props.organization, tree, viewManager, searchState, onTraceSearch]
  324. );
  325. return (
  326. <Fragment>
  327. <Layout.Header>
  328. <Layout.HeaderContent>
  329. <Breadcrumb
  330. organization={props.organization}
  331. location={props.location}
  332. transaction={breadcrumbTransaction}
  333. traceSlug={props.traceSlug}
  334. />
  335. <Layout.Title data-test-id="trace-header">
  336. {t('Trace ID: %s', props.traceSlug)}
  337. </Layout.Title>
  338. </Layout.HeaderContent>
  339. <Layout.HeaderActions>
  340. <ButtonBar gap={1}>
  341. <DiscoverButton
  342. size="sm"
  343. to={props.traceEventView.getResultsViewUrlTarget(props.organization.slug)}
  344. onClick={trackOpenInDiscover}
  345. >
  346. {t('Open in Discover')}
  347. </DiscoverButton>
  348. </ButtonBar>
  349. </Layout.HeaderActions>
  350. </Layout.Header>
  351. <TraceLayout>
  352. {traceType ? <TraceWarnings type={traceType} /> : null}
  353. <TraceTop>
  354. <TraceHeader
  355. rootEventResults={rootEvent}
  356. metaResults={props.metaResults}
  357. organization={props.organization}
  358. traces={props.trace}
  359. />
  360. <TraceToolbar>
  361. <TraceSearchInput
  362. query={searchState.query}
  363. status={searchState.status}
  364. onChange={onSearchChange}
  365. onSearchClear={onSearchClear}
  366. onKeyDown={onSearchKeyDown}
  367. onNextSearchClick={onNextSearchClick}
  368. onPreviousSearchClick={onPreviousSearchClick}
  369. resultCount={searchState.results?.length}
  370. resultIteratorIndex={searchState.resultIteratorIndex}
  371. />
  372. </TraceToolbar>
  373. </TraceTop>
  374. <TraceContainer ref={r => (traceContainerRef.current = r)}>
  375. <TraceGrid>
  376. <Trace
  377. trace={tree}
  378. trace_id={props.traceSlug}
  379. roving_dispatch={rovingTabIndexDispatch}
  380. roving_state={rovingTabIndexState}
  381. search_dispatch={searchDispatch}
  382. search_state={searchState}
  383. setClickedNode={onSetClickedNode}
  384. searchResultsIteratorIndex={searchState.resultIndex}
  385. searchResultsMap={searchState.resultsLookup}
  386. onTraceSearch={onTraceSearch}
  387. previouslyFocusedIndexRef={previouslyFocusedIndexRef}
  388. manager={viewManager}
  389. />
  390. <TraceDrawer
  391. scrollToNode={scrollToNode}
  392. manager={viewManager}
  393. activeTab={activeTab}
  394. setActiveTab={setActiveTab}
  395. nodes={clickedNode}
  396. rootEventResults={rootEvent}
  397. organization={props.organization}
  398. location={props.location}
  399. traces={props.trace}
  400. traceEventView={props.traceEventView}
  401. />
  402. </TraceGrid>
  403. </TraceContainer>
  404. </TraceLayout>
  405. </Fragment>
  406. );
  407. }
  408. function useQueryParamSync(query: Record<string, string | undefined>) {
  409. const previousQueryRef = useRef<Record<string, string | undefined>>(query);
  410. const syncStateTimeoutRef = useRef<number | null>(null);
  411. useEffect(() => {
  412. const keys = Object.keys(query);
  413. const previousKeys = Object.keys(previousQueryRef.current);
  414. if (
  415. keys.length === previousKeys.length &&
  416. keys.every(key => {
  417. return query[key] === previousQueryRef.current[key];
  418. })
  419. ) {
  420. previousQueryRef.current = query;
  421. return;
  422. }
  423. if (syncStateTimeoutRef.current !== null) {
  424. window.clearTimeout(syncStateTimeoutRef.current);
  425. }
  426. previousQueryRef.current = query;
  427. syncStateTimeoutRef.current = window.setTimeout(() => {
  428. browserHistory.replace({
  429. pathname: location.pathname,
  430. query: {
  431. ...qs.parse(location.search),
  432. ...previousQueryRef.current,
  433. },
  434. });
  435. }, 1000);
  436. }, [query]);
  437. }
  438. function useRootEvent(trace: TraceSplitResults<TraceFullDetailed> | null) {
  439. const root = trace?.transactions[0] || trace?.orphan_errors[0];
  440. const organization = useOrganization();
  441. return useApiQuery<EventTransaction>(
  442. [
  443. `/organizations/${organization.slug}/events/${root?.project_slug}:${root?.event_id}/`,
  444. {
  445. query: {
  446. referrer: 'trace-details-summary',
  447. },
  448. },
  449. ],
  450. {
  451. staleTime: 0,
  452. enabled: !!trace,
  453. }
  454. );
  455. }
  456. const TraceBody = styled('div')`
  457. padding-bottom: 0 !important;
  458. padding-left: 24px;
  459. padding-right: 24px;
  460. padding-top: 24px;
  461. background-color: ${p => p.theme.background};
  462. display: flex;
  463. flex-direction: column;
  464. flex: 1 1 100%;
  465. border-bottom: 2px solid red;
  466. `;
  467. const TraceTop = styled('div')`
  468. display: flex;
  469. flex-direction: column;
  470. `;
  471. const TraceLayout = styled('div')`
  472. display: flex;
  473. flex-direction: column;
  474. flex: 1 1 100%;
  475. border-bottom: 2px solid green;
  476. ~ footer {
  477. display: none;
  478. }
  479. `;
  480. const TraceContainer = styled('div')`
  481. display: flex;
  482. flex: 1 1 100%;
  483. box-shadow: 0 0 0 1px ${p => p.theme.border};
  484. border-bottom: 2px solid red;
  485. `;
  486. const TraceToolbar = styled('div')`
  487. flex-grow: 0;
  488. position: relative;
  489. `;
  490. const TraceGrid = styled('div')`
  491. display: grid;
  492. width: 100%;
  493. grid-template-rows: 1fr min-content;
  494. grid-template-columns: 100%;
  495. `;