index.tsx 30 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043
  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';
  53. import {TraceSearchInput} from 'sentry/views/performance/newTraceDetails/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],
  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. return (
  567. <TraceExternalLayout>
  568. <TraceUXChangeAlert />
  569. <TraceMetadataHeader
  570. organization={props.organization}
  571. projectID={rootEvent?.data?.projectID ?? ''}
  572. title={rootEvent?.data?.title ?? ''}
  573. traceSlug={props.traceSlug}
  574. traceEventView={props.traceEventView}
  575. />
  576. <TraceInnerLayout>
  577. <TraceHeader
  578. tree={tree}
  579. rootEventResults={rootEvent}
  580. metaResults={props.metaResults}
  581. organization={props.organization}
  582. traces={props.trace}
  583. traceID={props.traceSlug}
  584. />
  585. <TraceToolbar>
  586. <TraceSearchInput
  587. query={searchState.query}
  588. status={searchState.status}
  589. onChange={onSearchChange}
  590. onSearchClear={onSearchClear}
  591. onKeyDown={onSearchKeyDown}
  592. onNextSearchClick={onNextSearchClick}
  593. onPreviousSearchClick={onPreviousSearchClick}
  594. resultCount={searchState.results?.length}
  595. resultIteratorIndex={searchState.resultIteratorIndex}
  596. />
  597. <Button size="xs" onClick={onResetZoom}>
  598. {t('Reset Zoom')}
  599. </Button>
  600. </TraceToolbar>
  601. <TraceGrid
  602. layout={tracePreferences.layout}
  603. ref={r => (traceContainerRef.current = r)}
  604. >
  605. <Trace
  606. trace={tree}
  607. trace_id={props.traceSlug}
  608. roving_dispatch={rovingTabIndexDispatch}
  609. roving_state={rovingTabIndexState}
  610. search_dispatch={searchDispatch}
  611. search_state={searchState}
  612. onRowClick={onRowClick}
  613. scrollQueueRef={scrollQueueRef}
  614. searchResultsIteratorIndex={searchState.resultIndex}
  615. searchResultsMap={searchState.resultsLookup}
  616. onTraceSearch={onTraceSearch}
  617. previouslyFocusedNodeRef={previouslyFocusedNodeRef}
  618. manager={viewManager}
  619. />
  620. {tree.type === 'loading' ? (
  621. <TraceLoading />
  622. ) : tree.type === 'error' ? (
  623. <TraceError />
  624. ) : tree.type === 'empty' ? (
  625. <TraceEmpty />
  626. ) : scrollQueueRef.current ? (
  627. <TraceLoading />
  628. ) : null}
  629. <TraceDrawer
  630. tabs={tabs}
  631. trace={tree}
  632. manager={viewManager}
  633. scrollToNode={onTabScrollToNode}
  634. tabsDispatch={tabsDispatch}
  635. drawerSize={initialDrawerSize}
  636. layout={tracePreferences.layout}
  637. onLayoutChange={onLayoutChange}
  638. onDrawerResize={onDrawerResize}
  639. rootEventResults={rootEvent}
  640. organization={props.organization}
  641. traces={props.trace}
  642. traceEventView={props.traceEventView}
  643. />
  644. </TraceGrid>
  645. </TraceInnerLayout>
  646. </TraceExternalLayout>
  647. );
  648. }
  649. function useQueryParamSync(query: Record<string, string | undefined>) {
  650. const previousQueryRef = useRef<Record<string, string | undefined>>(query);
  651. const syncStateTimeoutRef = useRef<number | null>(null);
  652. useEffect(() => {
  653. const keys = Object.keys(query);
  654. const previousKeys = Object.keys(previousQueryRef.current);
  655. if (
  656. keys.length === previousKeys.length &&
  657. keys.every(key => {
  658. return query[key] === previousQueryRef.current[key];
  659. })
  660. ) {
  661. previousQueryRef.current = query;
  662. return;
  663. }
  664. if (syncStateTimeoutRef.current !== null) {
  665. window.clearTimeout(syncStateTimeoutRef.current);
  666. }
  667. previousQueryRef.current = query;
  668. syncStateTimeoutRef.current = window.setTimeout(() => {
  669. browserHistory.replace({
  670. pathname: location.pathname,
  671. query: {
  672. ...qs.parse(location.search),
  673. ...previousQueryRef.current,
  674. },
  675. });
  676. }, 1000);
  677. }, [query]);
  678. }
  679. function useRootEvent(trace: TraceSplitResults<TraceFullDetailed> | null) {
  680. const root = trace?.transactions[0] || trace?.orphan_errors[0];
  681. const organization = useOrganization();
  682. return useApiQuery<EventTransaction>(
  683. [
  684. `/organizations/${organization.slug}/events/${root?.project_slug}:${root?.event_id}/`,
  685. {
  686. query: {
  687. referrer: 'trace-details-summary',
  688. },
  689. },
  690. ],
  691. {
  692. staleTime: 0,
  693. enabled: !!trace && !!root,
  694. }
  695. );
  696. }
  697. interface TraceMetadataHeaderProps {
  698. organization: Organization;
  699. projectID: string;
  700. title: string;
  701. traceEventView: EventView;
  702. traceSlug: string;
  703. }
  704. function TraceMetadataHeader(props: TraceMetadataHeaderProps) {
  705. const location = useLocation();
  706. const breadcrumbTransaction = useMemo(() => {
  707. return {
  708. project: props.projectID ?? '',
  709. name: props.title ?? '',
  710. };
  711. }, [props.projectID, props.title]);
  712. const trackOpenInDiscover = useCallback(() => {
  713. trackAnalytics('performance_views.trace_view.open_in_discover', {
  714. organization: props.organization,
  715. });
  716. }, [props.organization]);
  717. return (
  718. <Layout.Header>
  719. <Layout.HeaderContent>
  720. <Breadcrumb
  721. organization={props.organization}
  722. location={location}
  723. transaction={breadcrumbTransaction}
  724. traceSlug={props.traceSlug}
  725. />
  726. </Layout.HeaderContent>
  727. <Layout.HeaderActions>
  728. <ButtonBar gap={1}>
  729. <DiscoverButton
  730. size="sm"
  731. to={props.traceEventView.getResultsViewUrlTarget(props.organization.slug)}
  732. onClick={trackOpenInDiscover}
  733. >
  734. {t('Open in Discover')}
  735. </DiscoverButton>
  736. </ButtonBar>
  737. </Layout.HeaderActions>
  738. </Layout.Header>
  739. );
  740. }
  741. function TraceUXChangeAlert() {
  742. const [dismiss, setDismissed] = useLocalStorageState('trace-view-dismissed', false);
  743. if (dismiss) {
  744. return null;
  745. }
  746. return (
  747. <Alert
  748. type="info"
  749. system
  750. trailingItems={
  751. <Button
  752. aria-label="dismiss"
  753. priority="link"
  754. size="xs"
  755. icon={<IconClose />}
  756. onClick={() => setDismissed(true)}
  757. />
  758. }
  759. >
  760. {tct(
  761. 'Events now provide richer context by linking directly inside traces. Read [why] we are doing this and what it enables.',
  762. {
  763. why: (
  764. <a href="https://docs.sentry.io/product/sentry-basics/concepts/tracing/trace-view/">
  765. {t('why')}
  766. </a>
  767. ),
  768. }
  769. )}
  770. </Alert>
  771. );
  772. }
  773. const TraceExternalLayout = styled('div')`
  774. display: flex;
  775. flex-direction: column;
  776. flex: 1 1 100%;
  777. ~ footer {
  778. display: none;
  779. }
  780. `;
  781. const TraceInnerLayout = styled('div')`
  782. display: flex;
  783. flex-direction: column;
  784. flex: 1 1 100%;
  785. padding: ${space(2)} ${space(2)} 0 ${space(2)};
  786. background-color: ${p => p.theme.background};
  787. `;
  788. const TraceToolbar = styled('div')`
  789. flex-grow: 0;
  790. display: grid;
  791. grid-template-columns: 1fr min-content;
  792. gap: ${space(1)};
  793. `;
  794. const TraceGrid = styled('div')<{
  795. layout: 'drawer bottom' | 'drawer left' | 'drawer right';
  796. }>`
  797. box-shadow: 0 0 0 1px ${p => p.theme.border};
  798. flex: 1 1 100%;
  799. display: grid;
  800. border-top-left-radius: ${p => p.theme.borderRadius};
  801. border-top-right-radius: ${p => p.theme.borderRadius};
  802. overflow: hidden;
  803. position: relative;
  804. /* false positive for grid layout */
  805. /* stylelint-disable */
  806. grid-template-areas: ${p =>
  807. p.layout === 'drawer bottom'
  808. ? `
  809. 'trace'
  810. 'drawer'
  811. `
  812. : p.layout === 'drawer left'
  813. ? `'drawer trace'`
  814. : `'trace drawer'`};
  815. grid-template-columns: ${p =>
  816. p.layout === 'drawer bottom'
  817. ? '1fr'
  818. : p.layout === 'drawer left'
  819. ? 'min-content 1fr'
  820. : '1fr min-content'};
  821. grid-template-rows: 1fr auto;
  822. `;
  823. const LoadingContainer = styled('div')<{animate: boolean; error?: boolean}>`
  824. display: flex;
  825. justify-content: center;
  826. align-items: center;
  827. flex-direction: column;
  828. left: 50%;
  829. top: 50%;
  830. position: absolute;
  831. height: auto;
  832. font-size: ${p => p.theme.fontSizeMedium};
  833. color: ${p => p.theme.gray300};
  834. z-index: 30;
  835. padding: 24px;
  836. background-color: ${p => p.theme.background};
  837. border-radius: ${p => p.theme.borderRadius};
  838. border: 1px solid ${p => p.theme.border};
  839. transform-origin: 50% 50%;
  840. transform: translate(-50%, -50%);
  841. animation: ${p =>
  842. p.animate
  843. ? `${p.error ? 'showLoadingContainerShake' : 'showLoadingContainer'} 300ms cubic-bezier(0.61, 1, 0.88, 1) forwards`
  844. : 'none'};
  845. @keyframes showLoadingContainer {
  846. from {
  847. opacity: 0.6;
  848. transform: scale(0.99) translate(-50%, -50%);
  849. }
  850. to {
  851. opacity: 1;
  852. transform: scale(1) translate(-50%, -50%);
  853. }
  854. }
  855. @keyframes showLoadingContainerShake {
  856. 0% {
  857. transform: translate(-50%, -50%);
  858. }
  859. 25% {
  860. transform: translate(-51%, -50%);
  861. }
  862. 75% {
  863. transform: translate(-49%, -50%);
  864. }
  865. 100% {
  866. transform: translate(-50%, -50%);
  867. }
  868. }
  869. `;
  870. function TraceLoading() {
  871. return (
  872. // Dont flash the animation on load because it's annoying
  873. <LoadingContainer animate={false}>
  874. <NoMarginIndicator size={24}>
  875. <div>{t('Assembling the trace')}</div>
  876. </NoMarginIndicator>
  877. </LoadingContainer>
  878. );
  879. }
  880. function TraceError() {
  881. const linkref = useRef<HTMLAnchorElement>(null);
  882. const feedback = useFeedbackWidget({buttonRef: linkref});
  883. return (
  884. <LoadingContainer animate error>
  885. <div>{t('Ughhhhh, we failed to load your trace...')}</div>
  886. <div>
  887. {t('Seeing this often? Send us ')}
  888. {feedback ? (
  889. <a href="#" ref={linkref}>
  890. {t('feedback')}
  891. </a>
  892. ) : (
  893. <a href="mailto:support@sentry.io?subject=Trace%20fails%20to%20load">
  894. {t('feedback')}
  895. </a>
  896. )}
  897. </div>
  898. </LoadingContainer>
  899. );
  900. }
  901. function TraceEmpty() {
  902. const linkref = useRef<HTMLAnchorElement>(null);
  903. const feedback = useFeedbackWidget({buttonRef: linkref});
  904. return (
  905. <LoadingContainer animate>
  906. <div>{t('This trace does not contain any data?!')}</div>
  907. <div>
  908. {t('Seeing this often? Send us ')}
  909. {feedback ? (
  910. <a href="#" ref={linkref}>
  911. {t('feedback')}
  912. </a>
  913. ) : (
  914. <a href="mailto:support@sentry.io?subject=Trace%20does%20not%20contain%20data">
  915. {t('feedback')}
  916. </a>
  917. )}
  918. </div>
  919. </LoadingContainer>
  920. );
  921. }
  922. const NoMarginIndicator = styled(LoadingIndicator)`
  923. margin: 0;
  924. `;