index.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  1. import type React from 'react';
  2. import {
  3. useCallback,
  4. useEffect,
  5. useLayoutEffect,
  6. useMemo,
  7. useReducer,
  8. useRef,
  9. useState,
  10. } from 'react';
  11. import {browserHistory} from 'react-router';
  12. import styled from '@emotion/styled';
  13. import type {Location} from 'history';
  14. import * as qs from 'query-string';
  15. import ButtonBar from 'sentry/components/buttonBar';
  16. import DiscoverButton from 'sentry/components/discoverButton';
  17. import useFeedbackWidget from 'sentry/components/feedback/widget/useFeedbackWidget';
  18. import * as Layout from 'sentry/components/layouts/thirds';
  19. import LoadingIndicator from 'sentry/components/loadingIndicator';
  20. import NoProjectMessage from 'sentry/components/noProjectMessage';
  21. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  22. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  23. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  24. import {t} from 'sentry/locale';
  25. import {space} from 'sentry/styles/space';
  26. import type {EventTransaction, Organization} from 'sentry/types';
  27. import {trackAnalytics} from 'sentry/utils/analytics';
  28. import EventView from 'sentry/utils/discover/eventView';
  29. import type {
  30. TraceFullDetailed,
  31. TraceMeta,
  32. TraceSplitResults,
  33. } from 'sentry/utils/performance/quickTrace/types';
  34. import {useApiQuery, type UseApiQueryResult} 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 {useTrace} from './useTrace';
  56. import {useTraceMeta} from './useTraceMeta';
  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. const meta = useTraceMeta();
  99. return (
  100. <SentryDocumentTitle title={DOCUMENT_TITLE} orgSlug={organization.slug}>
  101. <NoProjectMessage organization={organization}>
  102. <TraceViewContent
  103. status={trace.status}
  104. trace={trace.data ?? null}
  105. traceSlug={traceSlug}
  106. organization={organization}
  107. location={location}
  108. traceEventView={traceEventView}
  109. metaResults={meta}
  110. />
  111. </NoProjectMessage>
  112. </SentryDocumentTitle>
  113. );
  114. }
  115. type TraceViewContentProps = {
  116. location: Location;
  117. metaResults: UseApiQueryResult<TraceMeta | null, any>;
  118. organization: Organization;
  119. status: UseApiQueryResult<any, any>['status'];
  120. trace: TraceSplitResults<TraceFullDetailed> | null;
  121. traceEventView: EventView;
  122. traceSlug: string;
  123. };
  124. function TraceViewContent(props: TraceViewContentProps) {
  125. const api = useApi();
  126. const [activeTab, setActiveTab] = useState<'trace' | 'node'>('trace');
  127. const {projects} = useProjects();
  128. const rootEvent = useRootEvent(props.trace);
  129. const viewManager = useMemo(() => {
  130. return new VirtualizedViewManager({
  131. list: {width: 0.5},
  132. span_list: {width: 0.5},
  133. });
  134. }, []);
  135. const loadingTraceRef = useRef<TraceTree | null>(null);
  136. const tree = useMemo(() => {
  137. if (props.status === 'error') {
  138. const errorTree = TraceTree.Error(
  139. {
  140. project_slug: projects?.[0]?.slug ?? '',
  141. event_id: props.traceSlug,
  142. },
  143. loadingTraceRef.current
  144. );
  145. return errorTree;
  146. }
  147. if (props.status === 'loading' || rootEvent.status === 'loading') {
  148. const loadingTrace =
  149. loadingTraceRef.current ??
  150. TraceTree.Loading(
  151. {
  152. project_slug: projects?.[0]?.slug ?? '',
  153. event_id: props.traceSlug,
  154. },
  155. loadingTraceRef.current
  156. );
  157. loadingTraceRef.current = loadingTrace;
  158. return loadingTrace;
  159. }
  160. if (props.trace && rootEvent.status === 'success') {
  161. return TraceTree.FromTrace(props.trace, rootEvent.data);
  162. }
  163. return TraceTree.Empty();
  164. }, [
  165. props.traceSlug,
  166. props.trace,
  167. props.status,
  168. projects,
  169. rootEvent.data,
  170. rootEvent.status,
  171. ]);
  172. const [rovingTabIndexState, rovingTabIndexDispatch] = useReducer(
  173. rovingTabIndexReducer,
  174. {
  175. index: null,
  176. items: null,
  177. node: null,
  178. }
  179. );
  180. useLayoutEffect(() => {
  181. return rovingTabIndexDispatch({
  182. type: 'initialize',
  183. items: tree.list.length - 1,
  184. index: null,
  185. node: null,
  186. });
  187. }, [tree.list.length]);
  188. const initialQuery = useMemo((): string | undefined => {
  189. const query = qs.parse(location.search);
  190. if (typeof query.search === 'string') {
  191. return query.search;
  192. }
  193. return undefined;
  194. // We only want to decode on load
  195. // eslint-disable-next-line react-hooks/exhaustive-deps
  196. }, []);
  197. const [searchState, searchDispatch] = useReducer(traceSearchReducer, {
  198. query: initialQuery,
  199. resultIteratorIndex: undefined,
  200. resultIndex: undefined,
  201. results: undefined,
  202. status: undefined,
  203. resultsLookup: new Map(),
  204. });
  205. const [clickedNode, setClickedNode] = useState<TraceTreeNode<TraceTree.NodeValue>[]>(
  206. []
  207. );
  208. const onSetClickedNode = useCallback(
  209. (node: TraceTreeNode<TraceTree.NodeValue> | null) => {
  210. // Clicking on trace node defaults to the trace tab
  211. setActiveTab(node && !isTraceNode(node ?? null) ? 'node' : 'trace');
  212. setClickedNode(node && !isTraceNode(node) ? [node] : []);
  213. maybeFocusRow();
  214. },
  215. []
  216. );
  217. const searchingRaf = useRef<{id: number | null} | null>(null);
  218. const onTraceSearch = useCallback(
  219. (query: string) => {
  220. if (searchingRaf.current?.id) {
  221. window.cancelAnimationFrame(searchingRaf.current.id);
  222. }
  223. searchingRaf.current = searchInTraceTree(query, tree, results => {
  224. searchDispatch({
  225. type: 'set results',
  226. results: results[0],
  227. resultsLookup: results[1],
  228. });
  229. });
  230. },
  231. [tree]
  232. );
  233. const onSearchChange = useCallback(
  234. (event: React.ChangeEvent<HTMLInputElement>) => {
  235. if (!event.currentTarget.value) {
  236. searchDispatch({type: 'clear query'});
  237. return;
  238. }
  239. onTraceSearch(event.currentTarget.value);
  240. searchDispatch({type: 'set query', query: event.currentTarget.value});
  241. },
  242. [onTraceSearch]
  243. );
  244. const onSearchClear = useCallback(() => {
  245. searchDispatch({type: 'clear query'});
  246. }, []);
  247. const onSearchKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
  248. if (event.key === 'ArrowDown') {
  249. searchDispatch({type: 'go to next match'});
  250. } else {
  251. if (event.key === 'ArrowUp') {
  252. searchDispatch({type: 'go to previous match'});
  253. }
  254. }
  255. }, []);
  256. const onNextSearchClick = useCallback(() => {
  257. searchDispatch({type: 'go to next match'});
  258. }, []);
  259. const onPreviousSearchClick = useCallback(() => {
  260. searchDispatch({type: 'go to previous match'});
  261. }, []);
  262. const breadcrumbTransaction = useMemo(() => {
  263. return {
  264. project: rootEvent.data?.projectID ?? '',
  265. name: rootEvent.data?.title ?? '',
  266. };
  267. }, [rootEvent.data]);
  268. const trackOpenInDiscover = useCallback(() => {
  269. trackAnalytics('performance_views.trace_view.open_in_discover', {
  270. organization: props.organization,
  271. });
  272. }, [props.organization]);
  273. const syncQuery = useMemo(() => {
  274. return {search: searchState.query};
  275. }, [searchState.query]);
  276. useQueryParamSync(syncQuery);
  277. const onOutsideClick = useCallback(() => {
  278. // we will drop eventId such that after users clicks outside and shares the URL,
  279. // we will no longer scroll to the event or node
  280. const {
  281. node: _node,
  282. eventId: _eventId,
  283. ...queryParamsWithoutNode
  284. } = qs.parse(location.search);
  285. browserHistory.push({
  286. pathname: location.pathname,
  287. query: queryParamsWithoutNode,
  288. });
  289. // eslint-disable-next-line react-hooks/exhaustive-deps
  290. }, []);
  291. const traceContainerRef = useRef<HTMLElement | null>(null);
  292. useOnClickOutside(traceContainerRef, onOutsideClick);
  293. const previouslyFocusedIndexRef = useRef<number | null>(null);
  294. const scrollToNode = useCallback(
  295. (node: TraceTreeNode<TraceTree.NodeValue>) => {
  296. previouslyFocusedIndexRef.current = null;
  297. viewManager
  298. .scrollToPath(tree, [...node.path], () => void 0, {
  299. api,
  300. organization: props.organization,
  301. })
  302. .then(maybeNode => {
  303. if (!maybeNode) {
  304. return;
  305. }
  306. viewManager.onScrollEndOutOfBoundsCheck();
  307. rovingTabIndexDispatch({
  308. type: 'set index',
  309. index: maybeNode.index,
  310. node: maybeNode.node,
  311. });
  312. if (searchState.query) {
  313. onTraceSearch(searchState.query);
  314. }
  315. // Re-focus the row if in view as well
  316. maybeFocusRow();
  317. });
  318. },
  319. [api, props.organization, tree, viewManager, searchState, onTraceSearch]
  320. );
  321. const scrollQueueRef = useRef<TraceTree.NodePath[] | null>(null);
  322. return (
  323. <TraceExternalLayout>
  324. <Layout.Header>
  325. <Layout.HeaderContent>
  326. <Breadcrumb
  327. organization={props.organization}
  328. location={props.location}
  329. transaction={breadcrumbTransaction}
  330. traceSlug={props.traceSlug}
  331. />
  332. <Layout.Title data-test-id="trace-header">
  333. {t('Trace ID: %s', props.traceSlug)}
  334. </Layout.Title>
  335. </Layout.HeaderContent>
  336. <Layout.HeaderActions>
  337. <ButtonBar gap={1}>
  338. <DiscoverButton
  339. size="sm"
  340. to={props.traceEventView.getResultsViewUrlTarget(props.organization.slug)}
  341. onClick={trackOpenInDiscover}
  342. >
  343. {t('Open in Discover')}
  344. </DiscoverButton>
  345. </ButtonBar>
  346. </Layout.HeaderActions>
  347. </Layout.Header>
  348. <TraceInnerLayout>
  349. <TraceHeader
  350. rootEventResults={rootEvent}
  351. metaResults={props.metaResults}
  352. organization={props.organization}
  353. traces={props.trace}
  354. />
  355. <TraceToolbar>
  356. <TraceSearchInput
  357. query={searchState.query}
  358. status={searchState.status}
  359. onChange={onSearchChange}
  360. onSearchClear={onSearchClear}
  361. onKeyDown={onSearchKeyDown}
  362. onNextSearchClick={onNextSearchClick}
  363. onPreviousSearchClick={onPreviousSearchClick}
  364. resultCount={searchState.results?.length}
  365. resultIteratorIndex={searchState.resultIteratorIndex}
  366. />
  367. </TraceToolbar>
  368. <TraceGrid ref={r => (traceContainerRef.current = r)}>
  369. <Trace
  370. trace={tree}
  371. trace_id={props.traceSlug}
  372. roving_dispatch={rovingTabIndexDispatch}
  373. roving_state={rovingTabIndexState}
  374. search_dispatch={searchDispatch}
  375. search_state={searchState}
  376. setClickedNode={onSetClickedNode}
  377. scrollQueueRef={scrollQueueRef}
  378. searchResultsIteratorIndex={searchState.resultIndex}
  379. searchResultsMap={searchState.resultsLookup}
  380. onTraceSearch={onTraceSearch}
  381. previouslyFocusedIndexRef={previouslyFocusedIndexRef}
  382. manager={viewManager}
  383. />
  384. {tree.type === 'loading' ? (
  385. <TraceLoading />
  386. ) : tree.type === 'error' ? (
  387. <TraceError />
  388. ) : tree.type === 'empty' ? (
  389. <TraceEmpty />
  390. ) : scrollQueueRef.current ? (
  391. <TraceLoading />
  392. ) : null}
  393. <TraceDrawer
  394. trace={tree}
  395. scrollToNode={scrollToNode}
  396. manager={viewManager}
  397. activeTab={activeTab}
  398. setActiveTab={setActiveTab}
  399. nodes={clickedNode}
  400. rootEventResults={rootEvent}
  401. organization={props.organization}
  402. location={props.location}
  403. traces={props.trace}
  404. traceEventView={props.traceEventView}
  405. />
  406. </TraceGrid>
  407. </TraceInnerLayout>
  408. </TraceExternalLayout>
  409. );
  410. }
  411. function useQueryParamSync(query: Record<string, string | undefined>) {
  412. const previousQueryRef = useRef<Record<string, string | undefined>>(query);
  413. const syncStateTimeoutRef = useRef<number | null>(null);
  414. useEffect(() => {
  415. const keys = Object.keys(query);
  416. const previousKeys = Object.keys(previousQueryRef.current);
  417. if (
  418. keys.length === previousKeys.length &&
  419. keys.every(key => {
  420. return query[key] === previousQueryRef.current[key];
  421. })
  422. ) {
  423. previousQueryRef.current = query;
  424. return;
  425. }
  426. if (syncStateTimeoutRef.current !== null) {
  427. window.clearTimeout(syncStateTimeoutRef.current);
  428. }
  429. previousQueryRef.current = query;
  430. syncStateTimeoutRef.current = window.setTimeout(() => {
  431. browserHistory.replace({
  432. pathname: location.pathname,
  433. query: {
  434. ...qs.parse(location.search),
  435. ...previousQueryRef.current,
  436. },
  437. });
  438. }, 1000);
  439. }, [query]);
  440. }
  441. function useRootEvent(trace: TraceSplitResults<TraceFullDetailed> | null) {
  442. const root = trace?.transactions[0] || trace?.orphan_errors[0];
  443. const organization = useOrganization();
  444. return useApiQuery<EventTransaction>(
  445. [
  446. `/organizations/${organization.slug}/events/${root?.project_slug}:${root?.event_id}/`,
  447. {
  448. query: {
  449. referrer: 'trace-details-summary',
  450. },
  451. },
  452. ],
  453. {
  454. staleTime: 0,
  455. enabled: !!trace,
  456. }
  457. );
  458. }
  459. const TraceExternalLayout = styled('div')`
  460. display: flex;
  461. flex-direction: column;
  462. flex: 1 1 100%;
  463. ~ footer {
  464. display: none;
  465. }
  466. `;
  467. const TraceInnerLayout = styled('div')`
  468. display: flex;
  469. flex-direction: column;
  470. flex: 1 1 100%;
  471. padding: ${space(2)} ${space(2)} 0 ${space(2)};
  472. background-color: ${p => p.theme.background};
  473. `;
  474. const TraceToolbar = styled('div')`
  475. flex-grow: 0;
  476. `;
  477. const TraceGrid = styled('div')`
  478. box-shadow: 0 0 0 1px ${p => p.theme.border};
  479. flex: 1 1 100%;
  480. display: grid;
  481. border-top-left-radius: ${p => p.theme.borderRadius};
  482. border-top-right-radius: ${p => p.theme.borderRadius};
  483. overflow: hidden;
  484. position: relative;
  485. grid-template-areas:
  486. 'trace'
  487. 'drawer';
  488. grid-template-rows: 1fr auto;
  489. `;
  490. const LoadingContainer = styled('div')<{animate: boolean; error?: boolean}>`
  491. display: flex;
  492. justify-content: center;
  493. align-items: center;
  494. flex-direction: column;
  495. left: 50%;
  496. top: 50%;
  497. position: absolute;
  498. height: auto;
  499. font-size: ${p => p.theme.fontSizeMedium};
  500. color: ${p => p.theme.gray300};
  501. z-index: 30;
  502. padding: 24px;
  503. background-color: ${p => p.theme.background};
  504. border-radius: ${p => p.theme.borderRadius};
  505. border: 1px solid ${p => p.theme.border};
  506. transform-origin: 50% 50%;
  507. transform: translate(-50%, -50%);
  508. animation: ${p =>
  509. p.animate
  510. ? `${p.error ? 'showLoadingContainerShake' : 'showLoadingContainer'} 300ms cubic-bezier(0.61, 1, 0.88, 1) forwards`
  511. : 'none'};
  512. @keyframes showLoadingContainer {
  513. from {
  514. opacity: 0.6;
  515. transform: scale(0.99) translate(-50%, -50%);
  516. }
  517. to {
  518. opacity: 1;
  519. transform: scale(1) translate(-50%, -50%);
  520. }
  521. }
  522. @keyframes showLoadingContainerShake {
  523. 0% {
  524. transform: translate(-50%, -50%);
  525. }
  526. 25% {
  527. transform: translate(-51%, -50%);
  528. }
  529. 75% {
  530. transform: translate(-49%, -50%);
  531. }
  532. 100% {
  533. transform: translate(-50%, -50%);
  534. }
  535. }
  536. `;
  537. function TraceLoading() {
  538. return (
  539. // Dont flash the animation on load because it's annoying
  540. <LoadingContainer animate={false}>
  541. <NoMarginIndicator size={24}>
  542. <div>{t('Assembling the trace')}</div>
  543. </NoMarginIndicator>
  544. </LoadingContainer>
  545. );
  546. }
  547. function TraceError() {
  548. const linkref = useRef<HTMLAnchorElement>(null);
  549. const feedback = useFeedbackWidget({buttonRef: linkref});
  550. return (
  551. <LoadingContainer animate error>
  552. <div>{t('Ughhhhh, we failed to load your trace...')}</div>
  553. <div>
  554. {t('Seeing this often? Send us ')}
  555. {feedback ? (
  556. <a href="#" ref={linkref}>
  557. {t('feedback')}
  558. </a>
  559. ) : (
  560. <a href="mailto:support@sentry.io?subject=Trace%20fails%20to%20load">
  561. {t('feedback')}
  562. </a>
  563. )}
  564. </div>
  565. </LoadingContainer>
  566. );
  567. }
  568. function TraceEmpty() {
  569. const linkref = useRef<HTMLAnchorElement>(null);
  570. const feedback = useFeedbackWidget({buttonRef: linkref});
  571. return (
  572. <LoadingContainer animate>
  573. <div>{t('This trace does not contain any data?!')}</div>
  574. <div>
  575. {t('Seeing this often? Send us ')}
  576. {feedback ? (
  577. <a href="#" ref={linkref}>
  578. {t('feedback')}
  579. </a>
  580. ) : (
  581. <a href="mailto:support@sentry.io?subject=Trace%20does%20not%20contain%20data">
  582. {t('feedback')}
  583. </a>
  584. )}
  585. </div>
  586. </LoadingContainer>
  587. );
  588. }
  589. const NoMarginIndicator = styled(LoadingIndicator)`
  590. margin: 0;
  591. `;