index.tsx 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060
  1. import type React from 'react';
  2. import {
  3. useCallback,
  4. useEffect,
  5. useLayoutEffect,
  6. useMemo,
  7. useReducer,
  8. useRef,
  9. } from 'react';
  10. import {browserHistory} from 'react-router';
  11. import styled from '@emotion/styled';
  12. import * as qs from 'query-string';
  13. import Alert from 'sentry/components/alert';
  14. import {Button} from 'sentry/components/button';
  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 {IconClose} from 'sentry/icons';
  25. import {t, tct} from 'sentry/locale';
  26. import {space} from 'sentry/styles/space';
  27. import type {EventTransaction, Organization} from 'sentry/types';
  28. import {trackAnalytics} from 'sentry/utils/analytics';
  29. import EventView from 'sentry/utils/discover/eventView';
  30. import type {
  31. TraceFullDetailed,
  32. TraceMeta,
  33. TraceSplitResults,
  34. } from 'sentry/utils/performance/quickTrace/types';
  35. import {
  36. cancelAnimationTimeout,
  37. requestAnimationTimeout,
  38. } from 'sentry/utils/profiling/hooks/useVirtualizedTree/virtualizedTreeUtils';
  39. import {useApiQuery, type UseApiQueryResult} from 'sentry/utils/queryClient';
  40. import {decodeScalar} from 'sentry/utils/queryString';
  41. import {capitalize} from 'sentry/utils/string/capitalize';
  42. import useApi from 'sentry/utils/useApi';
  43. import {
  44. type DispatchingReducerMiddleware,
  45. useDispatchingReducer,
  46. } from 'sentry/utils/useDispatchingReducer';
  47. import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
  48. import {useLocation} from 'sentry/utils/useLocation';
  49. import useOnClickOutside from 'sentry/utils/useOnClickOutside';
  50. import useOrganization from 'sentry/utils/useOrganization';
  51. import {useParams} from 'sentry/utils/useParams';
  52. import useProjects from 'sentry/utils/useProjects';
  53. import {
  54. rovingTabIndexReducer,
  55. type RovingTabIndexState,
  56. } from 'sentry/views/performance/newTraceDetails/rovingTabIndex';
  57. import {
  58. searchInTraceTree,
  59. traceSearchReducer,
  60. type TraceSearchState,
  61. } from 'sentry/views/performance/newTraceDetails/traceSearch/traceSearch';
  62. import {TraceSearchInput} from 'sentry/views/performance/newTraceDetails/traceSearch/traceSearchInput';
  63. import {
  64. traceTabsReducer,
  65. type TraceTabsReducerState,
  66. } from 'sentry/views/performance/newTraceDetails/traceTabs';
  67. import {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/virtualizedViewManager';
  68. import Breadcrumb from '../breadcrumb';
  69. import {TraceDrawer} from './traceDrawer/traceDrawer';
  70. import {isTraceNode} from './guards';
  71. import {Trace} from './trace';
  72. import {TraceHeader} from './traceHeader';
  73. import {TraceTree, type TraceTreeNode} from './traceTree';
  74. import {useTrace} from './useTrace';
  75. import {useTraceMeta} from './useTraceMeta';
  76. const DOCUMENT_TITLE = [t('Trace')].join(' — ');
  77. function maybeFocusRow() {
  78. const focused_node = document.querySelector(".TraceRow[tabIndex='0']");
  79. if (
  80. focused_node &&
  81. 'focus' in focused_node &&
  82. typeof focused_node.focus === 'function'
  83. ) {
  84. focused_node.focus();
  85. }
  86. }
  87. export function TraceView() {
  88. const organization = useOrganization();
  89. const params = useParams<{traceSlug?: string}>();
  90. const traceSlug = params.traceSlug?.trim() ?? '';
  91. const queryParams = useMemo(() => {
  92. const normalizedParams = normalizeDateTimeParams(qs.parse(location.search), {
  93. allowAbsolutePageDatetime: true,
  94. });
  95. const start = decodeScalar(normalizedParams.start);
  96. const end = decodeScalar(normalizedParams.end);
  97. const statsPeriod = decodeScalar(normalizedParams.statsPeriod);
  98. return {start, end, statsPeriod, useSpans: 1};
  99. }, []);
  100. const traceEventView = useMemo(() => {
  101. const {start, end, statsPeriod} = queryParams;
  102. return EventView.fromSavedQuery({
  103. id: undefined,
  104. name: `Events with Trace ID ${traceSlug}`,
  105. fields: ['title', 'event.type', 'project', 'timestamp'],
  106. orderby: '-timestamp',
  107. query: `trace:${traceSlug}`,
  108. projects: [ALL_ACCESS_PROJECTS],
  109. version: 2,
  110. start,
  111. end,
  112. range: statsPeriod,
  113. });
  114. }, [queryParams, traceSlug]);
  115. const trace = useTrace();
  116. const meta = useTraceMeta();
  117. return (
  118. <SentryDocumentTitle title={DOCUMENT_TITLE} orgSlug={organization.slug}>
  119. <NoProjectMessage organization={organization}>
  120. <TraceViewContent
  121. status={trace.status}
  122. trace={trace.data ?? null}
  123. traceSlug={traceSlug}
  124. organization={organization}
  125. traceEventView={traceEventView}
  126. metaResults={meta}
  127. />
  128. </NoProjectMessage>
  129. </SentryDocumentTitle>
  130. );
  131. }
  132. const TRACE_TAB: TraceTabsReducerState['tabs'][0] = {
  133. node: 'trace',
  134. label: t('Trace'),
  135. };
  136. const VITALS_TAB: TraceTabsReducerState['tabs'][0] = {
  137. node: 'vitals',
  138. label: t('Vitals'),
  139. };
  140. const STATIC_DRAWER_TABS: TraceTabsReducerState['tabs'] = [TRACE_TAB];
  141. type TraceViewContentProps = {
  142. metaResults: UseApiQueryResult<TraceMeta | null, any>;
  143. organization: Organization;
  144. status: UseApiQueryResult<any, any>['status'];
  145. trace: TraceSplitResults<TraceFullDetailed> | null;
  146. traceEventView: EventView;
  147. traceSlug: string;
  148. };
  149. function TraceViewContent(props: TraceViewContentProps) {
  150. const api = useApi();
  151. const {projects} = useProjects();
  152. const rootEvent = useRootEvent(props.trace);
  153. const [tracePreferences, setTracePreferences] = useLocalStorageState<{
  154. drawer: number;
  155. layout: 'drawer right' | 'drawer bottom' | 'drawer left';
  156. list_width: number;
  157. }>('trace_preferences', {
  158. layout: 'drawer bottom',
  159. list_width: 0.66,
  160. drawer: 0,
  161. });
  162. const viewManager = useMemo(() => {
  163. return new VirtualizedViewManager({
  164. list: {width: tracePreferences.list_width},
  165. span_list: {width: 1 - tracePreferences.list_width},
  166. });
  167. // We only care about initial state when we initialize the view manager
  168. // eslint-disable-next-line react-hooks/exhaustive-deps
  169. }, []);
  170. const previouslyFocusedNodeRef = useRef<TraceTreeNode<TraceTree.NodeValue> | null>(
  171. null
  172. );
  173. const previouslyScrolledToNodeRef = useRef<TraceTreeNode<TraceTree.NodeValue> | null>(
  174. null
  175. );
  176. useEffect(() => {
  177. function onDividerResizeEnd(list_width: number) {
  178. setTracePreferences(previousPreferences => {
  179. return {...previousPreferences, list_width};
  180. });
  181. }
  182. viewManager.on('divider resize end', onDividerResizeEnd);
  183. return () => {
  184. viewManager.off('divider resize end', onDividerResizeEnd);
  185. };
  186. }, [viewManager, setTracePreferences]);
  187. const loadingTraceRef = useRef<TraceTree | null>(null);
  188. const tree = useMemo(() => {
  189. if (props.status === 'error') {
  190. const errorTree = TraceTree.Error(
  191. {
  192. project_slug: projects?.[0]?.slug ?? '',
  193. event_id: props.traceSlug,
  194. },
  195. loadingTraceRef.current
  196. );
  197. return errorTree;
  198. }
  199. if (
  200. props.trace?.transactions.length === 0 &&
  201. props.trace?.orphan_errors.length === 0
  202. ) {
  203. return TraceTree.Empty();
  204. }
  205. if (props.status === 'loading') {
  206. const loadingTrace =
  207. loadingTraceRef.current ??
  208. TraceTree.Loading(
  209. {
  210. project_slug: projects?.[0]?.slug ?? '',
  211. event_id: props.traceSlug,
  212. },
  213. loadingTraceRef.current
  214. );
  215. loadingTraceRef.current = loadingTrace;
  216. return loadingTrace;
  217. }
  218. if (props.trace) {
  219. return TraceTree.FromTrace(props.trace);
  220. }
  221. throw new Error('Invalid trace state');
  222. }, [props.traceSlug, props.trace, props.status, projects]);
  223. const [rovingTabIndexState, rovingTabIndexDispatch, rovingTabIndexEventEmitter] =
  224. useDispatchingReducer(rovingTabIndexReducer, {
  225. index: null,
  226. items: null,
  227. node: null,
  228. });
  229. const rovingTabIndexStateRef = useRef<RovingTabIndexState>(rovingTabIndexState);
  230. rovingTabIndexStateRef.current = rovingTabIndexState;
  231. useLayoutEffect(() => {
  232. return rovingTabIndexDispatch({
  233. type: 'initialize',
  234. items: tree.list.length - 1,
  235. index: null,
  236. node: null,
  237. });
  238. }, [tree.list.length, rovingTabIndexDispatch]);
  239. const initialQuery = useMemo((): string | undefined => {
  240. const query = qs.parse(location.search);
  241. if (typeof query.search === 'string') {
  242. return query.search;
  243. }
  244. return undefined;
  245. // We only want to decode on load
  246. // eslint-disable-next-line react-hooks/exhaustive-deps
  247. }, []);
  248. const [searchState, searchDispatch, searchStateEmitter] = useDispatchingReducer(
  249. traceSearchReducer,
  250. {
  251. query: initialQuery,
  252. resultIteratorIndex: null,
  253. resultIndex: null,
  254. node: null,
  255. results: null,
  256. status: undefined,
  257. resultsLookup: new Map(),
  258. }
  259. );
  260. const searchStateRef = useRef<TraceSearchState>(searchState);
  261. searchStateRef.current = searchState;
  262. const [tabs, tabsDispatch] = useReducer(traceTabsReducer, {
  263. tabs: STATIC_DRAWER_TABS,
  264. current: STATIC_DRAWER_TABS[0] ?? null,
  265. last_clicked: null,
  266. });
  267. const tabsStateRef = useRef<TraceTabsReducerState>(tabs);
  268. tabsStateRef.current = tabs;
  269. useLayoutEffect(() => {
  270. if (tree.type !== 'trace') {
  271. return;
  272. }
  273. const newTabs = [TRACE_TAB];
  274. if (tree.vitals.size > 0) {
  275. const types = Array.from(tree.vital_types.values());
  276. const label = types.length > 1 ? t('Vitals') : capitalize(types[0]) + ' Vitals';
  277. newTabs.push({
  278. ...VITALS_TAB,
  279. label,
  280. });
  281. }
  282. tabsDispatch({
  283. type: 'initialize',
  284. payload: {
  285. current: tabs[0] ?? tabsStateRef?.current?.tabs?.[0],
  286. tabs: newTabs,
  287. last_clicked: null,
  288. },
  289. });
  290. // We only want to update the tabs when the tree changes
  291. // eslint-disable-next-line react-hooks/exhaustive-deps
  292. }, [tree]);
  293. const onRowClick = useCallback(
  294. (
  295. node: TraceTreeNode<TraceTree.NodeValue> | null,
  296. event: React.MouseEvent<HTMLElement> | null
  297. ) => {
  298. if (!node) {
  299. tabsDispatch({type: 'clear clicked tab'});
  300. return;
  301. }
  302. if (isTraceNode(node)) {
  303. tabsDispatch({type: 'activate tab', payload: TRACE_TAB.node});
  304. maybeFocusRow();
  305. return;
  306. }
  307. tabsDispatch({type: 'activate tab', payload: node, pin_previous: event?.metaKey});
  308. maybeFocusRow();
  309. },
  310. [tabsDispatch]
  311. );
  312. const searchingRaf = useRef<{id: number | null} | null>(null);
  313. const onTraceSearch = useCallback(
  314. (
  315. traceTree: TraceTree,
  316. query: string,
  317. previouslySelectedNode: TraceTreeNode<TraceTree.NodeValue> | null
  318. ) => {
  319. if (searchingRaf.current?.id) {
  320. window.cancelAnimationFrame(searchingRaf.current.id);
  321. }
  322. searchingRaf.current = searchInTraceTree(
  323. traceTree,
  324. query,
  325. previouslySelectedNode,
  326. ([matches, lookup, previousNodePosition]) => {
  327. // If the user had focused a row, clear it and focus into the search result.
  328. if (rovingTabIndexStateRef.current.index !== null) {
  329. rovingTabIndexDispatch({type: 'clear index'});
  330. }
  331. const resultIteratorIndex: number | undefined =
  332. typeof previousNodePosition?.resultIteratorIndex === 'number'
  333. ? previousNodePosition.resultIteratorIndex
  334. : matches.length > 0
  335. ? 0
  336. : undefined;
  337. const resultIndex: number | undefined =
  338. typeof previousNodePosition?.resultIndex === 'number'
  339. ? previousNodePosition.resultIndex
  340. : matches.length > 0
  341. ? matches[0].index
  342. : undefined;
  343. const node: TraceTreeNode<TraceTree.NodeValue> | null = previousNodePosition
  344. ? previouslySelectedNode
  345. : matches.length > 0
  346. ? matches[0].value
  347. : null;
  348. searchDispatch({
  349. type: 'set results',
  350. results: matches,
  351. resultsLookup: lookup,
  352. resultIteratorIndex: resultIteratorIndex,
  353. resultIndex: resultIndex,
  354. node,
  355. });
  356. }
  357. );
  358. },
  359. [rovingTabIndexDispatch, searchDispatch]
  360. );
  361. const onSearchChange = useCallback(
  362. (event: React.ChangeEvent<HTMLInputElement>) => {
  363. if (!event.currentTarget.value) {
  364. searchDispatch({type: 'clear query'});
  365. return;
  366. }
  367. const previousNode =
  368. rovingTabIndexStateRef.current.node ?? searchStateRef.current.node ?? null;
  369. searchDispatch({type: 'set query', query: event.currentTarget.value});
  370. onTraceSearch(tree, event.currentTarget.value, previousNode);
  371. },
  372. [onTraceSearch, tree, searchDispatch]
  373. );
  374. const onSearchClear = useCallback(() => {
  375. searchDispatch({type: 'clear query'});
  376. }, [searchDispatch]);
  377. const onSearchKeyDown = useCallback(
  378. (event: React.KeyboardEvent<HTMLInputElement>) => {
  379. switch (event.key) {
  380. case 'ArrowDown':
  381. searchDispatch({type: 'go to next match'});
  382. break;
  383. case 'ArrowUp':
  384. searchDispatch({type: 'go to previous match'});
  385. break;
  386. case 'Enter':
  387. searchDispatch({
  388. type: event.shiftKey ? 'go to previous match' : 'go to next match',
  389. });
  390. break;
  391. default:
  392. }
  393. },
  394. [searchDispatch]
  395. );
  396. const onNextSearchClick = useCallback(() => {
  397. searchDispatch({type: 'go to next match'});
  398. }, [searchDispatch]);
  399. const onPreviousSearchClick = useCallback(() => {
  400. searchDispatch({type: 'go to previous match'});
  401. }, [searchDispatch]);
  402. const scrollRowIntoView = useCallback(
  403. (node: TraceTreeNode<TraceTree.NodeValue>, index: number) => {
  404. onRowClick(node, null);
  405. viewManager.scrollToRow(index);
  406. const offset =
  407. node.depth >= (previouslyScrolledToNodeRef.current?.depth ?? 0)
  408. ? viewManager.trace_physical_space.width / 2
  409. : 0;
  410. previouslyScrolledToNodeRef.current = node;
  411. if (viewManager.isOutsideOfViewOnKeyDown(node, offset)) {
  412. viewManager.scrollRowIntoViewHorizontally(node, 0, offset, 'measured');
  413. }
  414. },
  415. [viewManager, onRowClick]
  416. );
  417. useLayoutEffect(() => {
  418. const beforeRovingStateChange: DispatchingReducerMiddleware<
  419. typeof rovingTabIndexReducer
  420. >['before next state'] = (prevState, nextState) => {
  421. const {node, index} = nextState;
  422. if (node && typeof index === 'number' && prevState.node !== node) {
  423. scrollRowIntoView(node, index);
  424. }
  425. };
  426. const beforeSearchStateChange: DispatchingReducerMiddleware<
  427. typeof traceSearchReducer
  428. >['before next state'] = (prevState, nextState) => {
  429. const {node, resultIndex} = nextState;
  430. if (node && typeof resultIndex === 'number' && prevState.node !== node) {
  431. scrollRowIntoView(node, resultIndex);
  432. }
  433. };
  434. rovingTabIndexEventEmitter.on('before next state', beforeRovingStateChange);
  435. searchStateEmitter.on('before next state', beforeSearchStateChange);
  436. return () => {
  437. rovingTabIndexEventEmitter.off('before next state', beforeRovingStateChange);
  438. searchStateEmitter.off('before next state', beforeSearchStateChange);
  439. };
  440. }, [rovingTabIndexEventEmitter, searchStateEmitter, scrollRowIntoView]);
  441. const syncQuery = useMemo(() => {
  442. return {search: searchState.query};
  443. }, [searchState.query]);
  444. useQueryParamSync(syncQuery);
  445. const onOutsideClick = useCallback(() => {
  446. if (tree.type !== 'trace') {
  447. // Dont clear the URL in case the trace is still loading or failed for some reason,
  448. // we want to keep the eventId in the URL so the user can share the URL with support
  449. return;
  450. }
  451. // we will drop eventId such that after users clicks outside and shares the URL,
  452. // we will no longer scroll to the event or node
  453. const {
  454. node: _node,
  455. eventId: _eventId,
  456. ...queryParamsWithoutNode
  457. } = qs.parse(location.search);
  458. browserHistory.push({
  459. pathname: location.pathname,
  460. query: queryParamsWithoutNode,
  461. });
  462. tabsDispatch({type: 'clear clicked tab'});
  463. rovingTabIndexDispatch({type: 'clear index'});
  464. searchDispatch({type: 'clear node'});
  465. // eslint-disable-next-line react-hooks/exhaustive-deps
  466. }, [tree]);
  467. const traceContainerRef = useRef<HTMLElement | null>(null);
  468. useOnClickOutside(traceContainerRef, onOutsideClick);
  469. const scrollToNode = useCallback(
  470. (
  471. node: TraceTreeNode<TraceTree.NodeValue>
  472. ): Promise<{index: number; node: TraceTreeNode<TraceTree.NodeValue>} | null> => {
  473. return viewManager
  474. .scrollToPath(tree, [...node.path], () => void 0, {
  475. api,
  476. organization: props.organization,
  477. })
  478. .then(maybeNode => {
  479. previouslyScrolledToNodeRef.current = maybeNode?.node ?? null;
  480. if (!maybeNode) {
  481. return null;
  482. }
  483. viewManager.onScrollEndOutOfBoundsCheck();
  484. rovingTabIndexDispatch({
  485. type: 'set index',
  486. index: maybeNode.index,
  487. node: maybeNode.node,
  488. });
  489. if (searchState.query) {
  490. const previousNode =
  491. rovingTabIndexStateRef.current.node ?? searchStateRef.current.node ?? null;
  492. onTraceSearch(tree, searchState.query, previousNode);
  493. }
  494. // Re-focus the row if in view as well
  495. maybeFocusRow();
  496. return maybeNode;
  497. });
  498. },
  499. [
  500. api,
  501. props.organization,
  502. tree,
  503. viewManager,
  504. searchState,
  505. onTraceSearch,
  506. rovingTabIndexDispatch,
  507. ]
  508. );
  509. const onLayoutChange = useCallback(
  510. (layout: 'drawer bottom' | 'drawer left' | 'drawer right') => {
  511. setTracePreferences(previousPreferences => {
  512. return {...previousPreferences, layout, drawer: 0};
  513. });
  514. },
  515. [setTracePreferences]
  516. );
  517. const resizeAnimationTimeoutRef = useRef<{id: number} | null>(null);
  518. const onDrawerResize = useCallback(
  519. (size: number) => {
  520. if (resizeAnimationTimeoutRef.current !== null) {
  521. cancelAnimationTimeout(resizeAnimationTimeoutRef.current);
  522. }
  523. resizeAnimationTimeoutRef.current = requestAnimationTimeout(() => {
  524. setTracePreferences(previousPreferences => {
  525. return {
  526. ...previousPreferences,
  527. drawer:
  528. size /
  529. (previousPreferences.layout === 'drawer bottom'
  530. ? window.innerHeight
  531. : window.innerWidth),
  532. };
  533. });
  534. }, 1000);
  535. },
  536. [setTracePreferences]
  537. );
  538. const initialDrawerSize = useMemo(() => {
  539. if (tracePreferences.drawer < 0) {
  540. return 0;
  541. }
  542. const base =
  543. tracePreferences.layout === 'drawer bottom'
  544. ? window.innerHeight
  545. : window.innerWidth;
  546. return tracePreferences.drawer * base;
  547. }, [tracePreferences.drawer, tracePreferences.layout]);
  548. const scrollQueueRef = useRef<{eventId?: string; path?: TraceTree.NodePath[]} | null>(
  549. null
  550. );
  551. const onResetZoom = useCallback(() => {
  552. viewManager.resetZoom();
  553. }, [viewManager]);
  554. const onTabScrollToNode = useCallback(
  555. (node: TraceTreeNode<TraceTree.NodeValue>) => {
  556. scrollToNode(node).then(maybeNode => {
  557. if (!maybeNode) {
  558. return;
  559. }
  560. const offset =
  561. maybeNode.node.depth >= (previouslyScrolledToNodeRef.current?.depth ?? 0)
  562. ? viewManager.trace_physical_space.width / 2
  563. : 0;
  564. previouslyScrolledToNodeRef.current = maybeNode.node;
  565. if (viewManager.isOutsideOfViewOnKeyDown(maybeNode.node, offset)) {
  566. viewManager.scrollRowIntoViewHorizontally(
  567. maybeNode.node,
  568. 0,
  569. offset,
  570. 'measured'
  571. );
  572. }
  573. if (maybeNode.node.space) {
  574. viewManager.animateViewTo(maybeNode.node.space);
  575. }
  576. });
  577. },
  578. [scrollToNode, viewManager]
  579. );
  580. const [forceRender, rerender] = useReducer(x => x + (1 % 2), 0);
  581. return (
  582. <TraceExternalLayout>
  583. <TraceUXChangeAlert />
  584. <TraceMetadataHeader
  585. organization={props.organization}
  586. projectID={rootEvent?.data?.projectID ?? ''}
  587. title={rootEvent?.data?.title ?? ''}
  588. traceSlug={props.traceSlug}
  589. traceEventView={props.traceEventView}
  590. />
  591. <TraceInnerLayout>
  592. <TraceHeader
  593. tree={tree}
  594. rootEventResults={rootEvent}
  595. metaResults={props.metaResults}
  596. organization={props.organization}
  597. traces={props.trace}
  598. traceID={props.traceSlug}
  599. />
  600. <TraceToolbar>
  601. <TraceSearchInput
  602. query={searchState.query}
  603. status={searchState.status}
  604. onChange={onSearchChange}
  605. onSearchClear={onSearchClear}
  606. onKeyDown={onSearchKeyDown}
  607. onNextSearchClick={onNextSearchClick}
  608. onPreviousSearchClick={onPreviousSearchClick}
  609. resultCount={searchState.results?.length}
  610. resultIteratorIndex={searchState.resultIteratorIndex}
  611. />
  612. <Button size="xs" onClick={onResetZoom}>
  613. {t('Reset Zoom')}
  614. </Button>
  615. </TraceToolbar>
  616. <TraceGrid
  617. layout={tracePreferences.layout}
  618. ref={r => (traceContainerRef.current = r)}
  619. >
  620. <Trace
  621. trace={tree}
  622. trace_id={props.traceSlug}
  623. roving_dispatch={rovingTabIndexDispatch}
  624. roving_state={rovingTabIndexState}
  625. search_dispatch={searchDispatch}
  626. search_state={searchState}
  627. onRowClick={onRowClick}
  628. scrollQueueRef={scrollQueueRef}
  629. searchResultsIteratorIndex={searchState.resultIndex}
  630. searchResultsMap={searchState.resultsLookup}
  631. onTraceSearch={onTraceSearch}
  632. previouslyFocusedNodeRef={previouslyFocusedNodeRef}
  633. manager={viewManager}
  634. forceRerender={forceRender}
  635. rerender={rerender}
  636. />
  637. {tree.type === 'error' ? (
  638. <TraceError />
  639. ) : tree.type === 'empty' ? (
  640. <TraceEmpty />
  641. ) : tree.type === 'loading' || scrollQueueRef.current ? (
  642. <TraceLoading />
  643. ) : null}
  644. <TraceDrawer
  645. tabs={tabs}
  646. trace={tree}
  647. manager={viewManager}
  648. scrollToNode={onTabScrollToNode}
  649. tabsDispatch={tabsDispatch}
  650. drawerSize={initialDrawerSize}
  651. layout={tracePreferences.layout}
  652. onLayoutChange={onLayoutChange}
  653. onDrawerResize={onDrawerResize}
  654. rootEventResults={rootEvent}
  655. organization={props.organization}
  656. traces={props.trace}
  657. traceEventView={props.traceEventView}
  658. />
  659. </TraceGrid>
  660. </TraceInnerLayout>
  661. </TraceExternalLayout>
  662. );
  663. }
  664. function useQueryParamSync(query: Record<string, string | undefined>) {
  665. const previousQueryRef = useRef<Record<string, string | undefined>>(query);
  666. const syncStateTimeoutRef = useRef<number | null>(null);
  667. useEffect(() => {
  668. const keys = Object.keys(query);
  669. const previousKeys = Object.keys(previousQueryRef.current);
  670. if (
  671. keys.length === previousKeys.length &&
  672. keys.every(key => {
  673. return query[key] === previousQueryRef.current[key];
  674. })
  675. ) {
  676. previousQueryRef.current = query;
  677. return;
  678. }
  679. if (syncStateTimeoutRef.current !== null) {
  680. window.clearTimeout(syncStateTimeoutRef.current);
  681. }
  682. previousQueryRef.current = query;
  683. syncStateTimeoutRef.current = window.setTimeout(() => {
  684. browserHistory.replace({
  685. pathname: location.pathname,
  686. query: {
  687. ...qs.parse(location.search),
  688. ...previousQueryRef.current,
  689. },
  690. });
  691. }, 1000);
  692. }, [query]);
  693. }
  694. function useRootEvent(trace: TraceSplitResults<TraceFullDetailed> | null) {
  695. const root = trace?.transactions[0] || trace?.orphan_errors[0];
  696. const organization = useOrganization();
  697. return useApiQuery<EventTransaction>(
  698. [
  699. `/organizations/${organization.slug}/events/${root?.project_slug}:${root?.event_id}/`,
  700. {
  701. query: {
  702. referrer: 'trace-details-summary',
  703. },
  704. },
  705. ],
  706. {
  707. staleTime: 0,
  708. enabled: !!trace && !!root,
  709. }
  710. );
  711. }
  712. interface TraceMetadataHeaderProps {
  713. organization: Organization;
  714. projectID: string;
  715. title: string;
  716. traceEventView: EventView;
  717. traceSlug: string;
  718. }
  719. function TraceMetadataHeader(props: TraceMetadataHeaderProps) {
  720. const location = useLocation();
  721. const breadcrumbTransaction = useMemo(() => {
  722. return {
  723. project: props.projectID ?? '',
  724. name: props.title ?? '',
  725. };
  726. }, [props.projectID, props.title]);
  727. const trackOpenInDiscover = useCallback(() => {
  728. trackAnalytics('performance_views.trace_view.open_in_discover', {
  729. organization: props.organization,
  730. });
  731. }, [props.organization]);
  732. return (
  733. <Layout.Header>
  734. <Layout.HeaderContent>
  735. <Breadcrumb
  736. organization={props.organization}
  737. location={location}
  738. transaction={breadcrumbTransaction}
  739. traceSlug={props.traceSlug}
  740. />
  741. </Layout.HeaderContent>
  742. <Layout.HeaderActions>
  743. <ButtonBar gap={1}>
  744. <DiscoverButton
  745. size="sm"
  746. to={props.traceEventView.getResultsViewUrlTarget(props.organization.slug)}
  747. onClick={trackOpenInDiscover}
  748. >
  749. {t('Open in Discover')}
  750. </DiscoverButton>
  751. </ButtonBar>
  752. </Layout.HeaderActions>
  753. </Layout.Header>
  754. );
  755. }
  756. function TraceUXChangeAlert() {
  757. const [dismiss, setDismissed] = useLocalStorageState('trace-view-dismissed', false);
  758. if (dismiss) {
  759. return null;
  760. }
  761. return (
  762. <Alert
  763. type="info"
  764. system
  765. trailingItems={
  766. <Button
  767. aria-label="dismiss"
  768. priority="link"
  769. size="xs"
  770. icon={<IconClose />}
  771. onClick={() => setDismissed(true)}
  772. />
  773. }
  774. >
  775. {tct(
  776. 'Events now provide richer context by linking directly inside traces. Read [why] we are doing this and what it enables.',
  777. {
  778. why: (
  779. <a href="https://docs.sentry.io/product/sentry-basics/concepts/tracing/trace-view/">
  780. {t('why')}
  781. </a>
  782. ),
  783. }
  784. )}
  785. </Alert>
  786. );
  787. }
  788. const TraceExternalLayout = styled('div')`
  789. display: flex;
  790. flex-direction: column;
  791. flex: 1 1 100%;
  792. ~ footer {
  793. display: none;
  794. }
  795. `;
  796. const TraceInnerLayout = styled('div')`
  797. display: flex;
  798. flex-direction: column;
  799. flex: 1 1 100%;
  800. padding: ${space(2)} ${space(2)} 0 ${space(2)};
  801. background-color: ${p => p.theme.background};
  802. `;
  803. const TraceToolbar = styled('div')`
  804. flex-grow: 0;
  805. display: grid;
  806. grid-template-columns: 1fr min-content;
  807. gap: ${space(1)};
  808. `;
  809. const TraceGrid = styled('div')<{
  810. layout: 'drawer bottom' | 'drawer left' | 'drawer right';
  811. }>`
  812. box-shadow: 0 0 0 1px ${p => p.theme.border};
  813. flex: 1 1 100%;
  814. display: grid;
  815. border-top-left-radius: ${p => p.theme.borderRadius};
  816. border-top-right-radius: ${p => p.theme.borderRadius};
  817. overflow: hidden;
  818. position: relative;
  819. /* false positive for grid layout */
  820. /* stylelint-disable */
  821. grid-template-areas: ${p =>
  822. p.layout === 'drawer bottom'
  823. ? `
  824. 'trace'
  825. 'drawer'
  826. `
  827. : p.layout === 'drawer left'
  828. ? `'drawer trace'`
  829. : `'trace drawer'`};
  830. grid-template-columns: ${p =>
  831. p.layout === 'drawer bottom'
  832. ? '1fr'
  833. : p.layout === 'drawer left'
  834. ? 'min-content 1fr'
  835. : '1fr min-content'};
  836. grid-template-rows: 1fr auto;
  837. `;
  838. const LoadingContainer = styled('div')<{animate: boolean; error?: boolean}>`
  839. display: flex;
  840. justify-content: center;
  841. align-items: center;
  842. flex-direction: column;
  843. left: 50%;
  844. top: 50%;
  845. position: absolute;
  846. height: auto;
  847. font-size: ${p => p.theme.fontSizeMedium};
  848. color: ${p => p.theme.gray300};
  849. z-index: 30;
  850. padding: 24px;
  851. background-color: ${p => p.theme.background};
  852. border-radius: ${p => p.theme.borderRadius};
  853. border: 1px solid ${p => p.theme.border};
  854. transform-origin: 50% 50%;
  855. transform: translate(-50%, -50%);
  856. animation: ${p =>
  857. p.animate
  858. ? `${p.error ? 'showLoadingContainerShake' : 'showLoadingContainer'} 300ms cubic-bezier(0.61, 1, 0.88, 1) forwards`
  859. : 'none'};
  860. @keyframes showLoadingContainer {
  861. from {
  862. opacity: 0.6;
  863. transform: scale(0.99) translate(-50%, -50%);
  864. }
  865. to {
  866. opacity: 1;
  867. transform: scale(1) translate(-50%, -50%);
  868. }
  869. }
  870. @keyframes showLoadingContainerShake {
  871. 0% {
  872. transform: translate(-50%, -50%);
  873. }
  874. 25% {
  875. transform: translate(-51%, -50%);
  876. }
  877. 75% {
  878. transform: translate(-49%, -50%);
  879. }
  880. 100% {
  881. transform: translate(-50%, -50%);
  882. }
  883. }
  884. `;
  885. function TraceLoading() {
  886. return (
  887. // Dont flash the animation on load because it's annoying
  888. <LoadingContainer animate={false}>
  889. <NoMarginIndicator size={24}>
  890. <div>{t('Assembling the trace')}</div>
  891. </NoMarginIndicator>
  892. </LoadingContainer>
  893. );
  894. }
  895. function TraceError() {
  896. const linkref = useRef<HTMLAnchorElement>(null);
  897. const feedback = useFeedbackWidget({buttonRef: linkref});
  898. return (
  899. <LoadingContainer animate error>
  900. <div>{t('Ughhhhh, we failed to load your trace...')}</div>
  901. <div>
  902. {t('Seeing this often? Send us ')}
  903. {feedback ? (
  904. <a href="#" ref={linkref}>
  905. {t('feedback')}
  906. </a>
  907. ) : (
  908. <a href="mailto:support@sentry.io?subject=Trace%20fails%20to%20load">
  909. {t('feedback')}
  910. </a>
  911. )}
  912. </div>
  913. </LoadingContainer>
  914. );
  915. }
  916. function TraceEmpty() {
  917. const linkref = useRef<HTMLAnchorElement>(null);
  918. const feedback = useFeedbackWidget({buttonRef: linkref});
  919. return (
  920. <LoadingContainer animate>
  921. <div>{t('This trace does not contain any data?!')}</div>
  922. <div>
  923. {t('Seeing this often? Send us ')}
  924. {feedback ? (
  925. <a href="#" ref={linkref}>
  926. {t('feedback')}
  927. </a>
  928. ) : (
  929. <a href="mailto:support@sentry.io?subject=Trace%20does%20not%20contain%20data">
  930. {t('feedback')}
  931. </a>
  932. )}
  933. </div>
  934. </LoadingContainer>
  935. );
  936. }
  937. const NoMarginIndicator = styled(LoadingIndicator)`
  938. margin: 0;
  939. `;