index.tsx 30 KB

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