index.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954
  1. import type React from 'react';
  2. import {
  3. useCallback,
  4. useEffect,
  5. useLayoutEffect,
  6. useMemo,
  7. useReducer,
  8. useRef,
  9. useState,
  10. } from 'react';
  11. import {browserHistory} from 'react-router';
  12. import styled from '@emotion/styled';
  13. import * as Sentry from '@sentry/react';
  14. import * as qs from 'query-string';
  15. import {Button} from 'sentry/components/button';
  16. import useFeedbackWidget from 'sentry/components/feedback/widget/useFeedbackWidget';
  17. import LoadingIndicator from 'sentry/components/loadingIndicator';
  18. import NoProjectMessage from 'sentry/components/noProjectMessage';
  19. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  20. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  21. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  22. import {t} from 'sentry/locale';
  23. import {space} from 'sentry/styles/space';
  24. import type {Organization} from 'sentry/types/organization';
  25. import {trackAnalytics} from 'sentry/utils/analytics';
  26. import EventView from 'sentry/utils/discover/eventView';
  27. import type {
  28. TraceFullDetailed,
  29. TraceMeta,
  30. TraceSplitResults,
  31. } from 'sentry/utils/performance/quickTrace/types';
  32. import {
  33. cancelAnimationTimeout,
  34. requestAnimationTimeout,
  35. } from 'sentry/utils/profiling/hooks/useVirtualizedTree/virtualizedTreeUtils';
  36. import type {UseApiQueryResult} from 'sentry/utils/queryClient';
  37. import {decodeScalar} from 'sentry/utils/queryString';
  38. import {capitalize} from 'sentry/utils/string/capitalize';
  39. import useApi from 'sentry/utils/useApi';
  40. import {
  41. type DispatchingReducerMiddleware,
  42. useDispatchingReducer,
  43. } from 'sentry/utils/useDispatchingReducer';
  44. import useOnClickOutside from 'sentry/utils/useOnClickOutside';
  45. import useOrganization from 'sentry/utils/useOrganization';
  46. import {useParams} from 'sentry/utils/useParams';
  47. import useProjects from 'sentry/utils/useProjects';
  48. import {
  49. type ViewManagerScrollAnchor,
  50. VirtualizedViewManager,
  51. } from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager';
  52. import {TraceShortcuts} from 'sentry/views/performance/newTraceDetails/traceShortcuts';
  53. import {
  54. loadTraceViewPreferences,
  55. storeTraceViewPreferences,
  56. } from 'sentry/views/performance/newTraceDetails/traceState/tracePreferences';
  57. import {useTrace} from './traceApi/useTrace';
  58. import {useTraceMeta} from './traceApi/useTraceMeta';
  59. import {useTraceRootEvent} from './traceApi/useTraceRootEvent';
  60. import {TraceDrawer} from './traceDrawer/traceDrawer';
  61. import {TraceTree, type TraceTreeNode} from './traceModels/traceTree';
  62. import {TraceSearchInput} from './traceSearch/traceSearchInput';
  63. import {searchInTraceTree} from './traceState/traceSearch';
  64. import {isTraceNode} from './guards';
  65. import {Trace} from './trace';
  66. import {TraceHeader} from './traceHeader';
  67. import {TraceMetadataHeader} from './traceMetadataHeader';
  68. import {TraceReducer, type TraceReducerState} from './traceState';
  69. import {TraceUXChangeAlert} from './traceUXChangeBanner';
  70. import {useTraceQueryParamStateSync} from './useTraceQueryParamStateSync';
  71. export function TraceView() {
  72. const params = useParams<{traceSlug?: string}>();
  73. const organization = useOrganization();
  74. const traceSlug = useMemo(() => {
  75. const slug = params.traceSlug?.trim() ?? '';
  76. // null and undefined are not valid trace slugs, but they can be passed
  77. // in the URL and need to check for their string coerced values.
  78. if (!slug || slug === 'null' || slug === 'undefined') {
  79. Sentry.withScope(scope => {
  80. scope.setFingerprint(['trace-null-slug']);
  81. Sentry.captureMessage(`Trace slug is empty`);
  82. });
  83. }
  84. return slug;
  85. }, [params.traceSlug]);
  86. useEffect(() => {
  87. trackAnalytics('performance_views.trace_view_v1_page_load', {
  88. organization,
  89. });
  90. }, [organization]);
  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
  119. title={`${t('Trace')} - ${traceSlug}`}
  120. orgSlug={organization.slug}
  121. >
  122. <NoProjectMessage organization={organization}>
  123. <TraceViewContent
  124. status={trace.status}
  125. trace={trace.data ?? null}
  126. traceSlug={traceSlug}
  127. organization={organization}
  128. traceEventView={traceEventView}
  129. metaResults={meta}
  130. />
  131. </NoProjectMessage>
  132. </SentryDocumentTitle>
  133. );
  134. }
  135. const TRACE_TAB: TraceReducerState['tabs']['tabs'][0] = {
  136. node: 'trace',
  137. label: t('Trace'),
  138. };
  139. const VITALS_TAB: TraceReducerState['tabs']['tabs'][0] = {
  140. node: 'vitals',
  141. label: t('Vitals'),
  142. };
  143. const STATIC_DRAWER_TABS: TraceReducerState['tabs']['tabs'] = [TRACE_TAB];
  144. type TraceViewContentProps = {
  145. metaResults: UseApiQueryResult<TraceMeta | null, any>;
  146. organization: Organization;
  147. status: UseApiQueryResult<any, any>['status'];
  148. trace: TraceSplitResults<TraceFullDetailed> | null;
  149. traceEventView: EventView;
  150. traceSlug: string;
  151. };
  152. function TraceViewContent(props: TraceViewContentProps) {
  153. const api = useApi();
  154. const organization = props.organization;
  155. const {projects} = useProjects();
  156. const rootEvent = useTraceRootEvent(props.trace);
  157. const loadingTraceRef = useRef<TraceTree | null>(null);
  158. const [forceRender, rerender] = useReducer(x => x + (1 % 2), 0);
  159. const scrollQueueRef = useRef<{eventId?: string; path?: TraceTree.NodePath[]} | null>(
  160. null
  161. );
  162. const previouslyFocusedNodeRef = useRef<TraceTreeNode<TraceTree.NodeValue> | null>(
  163. null
  164. );
  165. const previouslyScrolledToNodeRef = useRef<TraceTreeNode<TraceTree.NodeValue> | null>(
  166. null
  167. );
  168. const tree = useMemo(() => {
  169. if (props.status === 'error') {
  170. const errorTree = TraceTree.Error(
  171. {
  172. project_slug: projects?.[0]?.slug ?? '',
  173. event_id: props.traceSlug,
  174. },
  175. loadingTraceRef.current
  176. );
  177. return errorTree;
  178. }
  179. if (
  180. props.trace?.transactions.length === 0 &&
  181. props.trace?.orphan_errors.length === 0
  182. ) {
  183. return TraceTree.Empty();
  184. }
  185. if (props.status === 'loading') {
  186. const loadingTrace =
  187. loadingTraceRef.current ??
  188. TraceTree.Loading(
  189. {
  190. project_slug: projects?.[0]?.slug ?? '',
  191. event_id: props.traceSlug,
  192. },
  193. loadingTraceRef.current
  194. );
  195. loadingTraceRef.current = loadingTrace;
  196. return loadingTrace;
  197. }
  198. if (props.trace) {
  199. return TraceTree.FromTrace(props.trace);
  200. }
  201. throw new Error('Invalid trace state');
  202. }, [props.traceSlug, props.trace, props.status, projects]);
  203. const initialQuery = useMemo((): string | undefined => {
  204. const query = qs.parse(location.search);
  205. if (typeof query.search === 'string') {
  206. return query.search;
  207. }
  208. return undefined;
  209. // We only want to decode on load
  210. // eslint-disable-next-line react-hooks/exhaustive-deps
  211. }, []);
  212. const preferences = useMemo(() => loadTraceViewPreferences(), []);
  213. const [traceState, traceDispatch, traceStateEmitter] = useDispatchingReducer(
  214. TraceReducer,
  215. {
  216. rovingTabIndex: {
  217. index: null,
  218. items: null,
  219. node: null,
  220. },
  221. search: {
  222. node: null,
  223. query: initialQuery,
  224. resultIteratorIndex: null,
  225. resultIndex: null,
  226. results: null,
  227. status: undefined,
  228. resultsLookup: new Map(),
  229. },
  230. preferences,
  231. tabs: {
  232. tabs: STATIC_DRAWER_TABS,
  233. current_tab: STATIC_DRAWER_TABS[0] ?? null,
  234. last_clicked_tab: null,
  235. },
  236. }
  237. );
  238. // Assign the trace state to a ref so we can access it without re-rendering
  239. const traceStateRef = useRef<TraceReducerState>(traceState);
  240. traceStateRef.current = traceState;
  241. // Initialize the view manager right after the state reducer
  242. const viewManager = useMemo(() => {
  243. return new VirtualizedViewManager({
  244. list: {width: traceState.preferences.list.width},
  245. span_list: {width: 1 - traceState.preferences.list.width},
  246. });
  247. // We only care about initial state when we initialize the view manager
  248. // eslint-disable-next-line react-hooks/exhaustive-deps
  249. }, []);
  250. // Initialize the tabs reducer when the tree initializes
  251. useLayoutEffect(() => {
  252. return traceDispatch({
  253. type: 'set roving count',
  254. items: tree.list.length - 1,
  255. });
  256. }, [tree.list.length, traceDispatch]);
  257. // Initialize the tabs reducer when the tree initializes
  258. useLayoutEffect(() => {
  259. if (tree.type !== 'trace') {
  260. return;
  261. }
  262. const newTabs = [TRACE_TAB];
  263. if (tree.vitals.size > 0) {
  264. const types = Array.from(tree.vital_types.values());
  265. const label = types.length > 1 ? t('Vitals') : capitalize(types[0]) + ' Vitals';
  266. newTabs.push({
  267. ...VITALS_TAB,
  268. label,
  269. });
  270. }
  271. traceDispatch({
  272. type: 'initialize tabs reducer',
  273. payload: {
  274. current_tab: traceStateRef?.current?.tabs?.tabs?.[0],
  275. tabs: newTabs,
  276. last_clicked_tab: null,
  277. },
  278. });
  279. // We only want to update the tabs when the tree changes
  280. // eslint-disable-next-line react-hooks/exhaustive-deps
  281. }, [tree]);
  282. const searchingRaf = useRef<{id: number | null} | null>(null);
  283. const onTraceSearch = useCallback(
  284. (
  285. query: string,
  286. activeNode: TraceTreeNode<TraceTree.NodeValue> | null,
  287. behavior: 'track result' | 'persist'
  288. ) => {
  289. if (searchingRaf.current?.id) {
  290. window.cancelAnimationFrame(searchingRaf.current.id);
  291. }
  292. searchingRaf.current = searchInTraceTree(
  293. tree,
  294. query,
  295. activeNode,
  296. ([matches, lookup, activeNodeSearchResult]) => {
  297. // If the previous node is still in the results set, we want to keep it
  298. if (activeNodeSearchResult) {
  299. traceDispatch({
  300. type: 'set results',
  301. results: matches,
  302. resultsLookup: lookup,
  303. resultIteratorIndex: activeNodeSearchResult?.resultIteratorIndex,
  304. resultIndex: activeNodeSearchResult?.resultIndex,
  305. previousNode: activeNodeSearchResult,
  306. node: activeNode,
  307. });
  308. return;
  309. }
  310. if (activeNode && behavior === 'persist') {
  311. traceDispatch({
  312. type: 'set results',
  313. results: matches,
  314. resultsLookup: lookup,
  315. resultIteratorIndex: undefined,
  316. resultIndex: undefined,
  317. previousNode: activeNodeSearchResult,
  318. node: activeNode,
  319. });
  320. return;
  321. }
  322. const resultIndex: number | undefined = matches?.[0]?.index;
  323. const resultIteratorIndex: number | undefined = matches?.[0] ? 0 : undefined;
  324. const node: TraceTreeNode<TraceTree.NodeValue> | null = matches?.[0]?.value;
  325. traceDispatch({
  326. type: 'set results',
  327. results: matches,
  328. resultsLookup: lookup,
  329. resultIteratorIndex: resultIteratorIndex,
  330. resultIndex: resultIndex,
  331. previousNode: activeNodeSearchResult,
  332. node,
  333. });
  334. }
  335. );
  336. },
  337. [traceDispatch, tree]
  338. );
  339. // We need to heavily debounce query string updates because the rest of the app is so slow
  340. // to rerender that it causes the search to drop frames on every keystroke...
  341. const QUERY_STRING_STATE_DEBOUNCE = 300;
  342. const queryStringAnimationTimeoutRef = useRef<{id: number} | null>(null);
  343. const setRowAsFocused = useCallback(
  344. (
  345. node: TraceTreeNode<TraceTree.NodeValue> | null,
  346. event: React.MouseEvent<HTMLElement> | null,
  347. resultsLookup: Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  348. index: number | null,
  349. debounce: number = QUERY_STRING_STATE_DEBOUNCE
  350. ) => {
  351. // sync query string with the clicked node
  352. if (node) {
  353. if (queryStringAnimationTimeoutRef.current) {
  354. cancelAnimationTimeout(queryStringAnimationTimeoutRef.current);
  355. }
  356. queryStringAnimationTimeoutRef.current = requestAnimationTimeout(() => {
  357. const currentQueryStringPath = qs.parse(location.search).node;
  358. const nextNodePath = node.path;
  359. // Updating the query string with the same path is problematic because it causes
  360. // the entire sentry app to rerender, which is enough to cause jank and drop frames
  361. if (JSON.stringify(currentQueryStringPath) === JSON.stringify(nextNodePath)) {
  362. return;
  363. }
  364. const {eventId: _eventId, ...query} = qs.parse(location.search);
  365. browserHistory.replace({
  366. pathname: location.pathname,
  367. query: {
  368. ...query,
  369. node: nextNodePath,
  370. },
  371. });
  372. queryStringAnimationTimeoutRef.current = null;
  373. }, debounce);
  374. if (resultsLookup.has(node) && typeof index === 'number') {
  375. traceDispatch({
  376. type: 'set search iterator index',
  377. resultIndex: index,
  378. resultIteratorIndex: resultsLookup.get(node)!,
  379. });
  380. }
  381. if (isTraceNode(node)) {
  382. traceDispatch({type: 'activate tab', payload: TRACE_TAB.node});
  383. return;
  384. }
  385. traceDispatch({
  386. type: 'activate tab',
  387. payload: node,
  388. pin_previous: event?.metaKey,
  389. });
  390. }
  391. },
  392. [traceDispatch]
  393. );
  394. const onRowClick = useCallback(
  395. (
  396. node: TraceTreeNode<TraceTree.NodeValue>,
  397. event: React.MouseEvent<HTMLElement>,
  398. index: number
  399. ) => {
  400. setRowAsFocused(node, event, traceStateRef.current.search.resultsLookup, null, 0);
  401. if (traceStateRef.current.search.resultsLookup.has(node)) {
  402. const idx = traceStateRef.current.search.resultsLookup.get(node)!;
  403. traceDispatch({
  404. type: 'set search iterator index',
  405. resultIndex: index,
  406. resultIteratorIndex: idx,
  407. });
  408. } else if (traceStateRef.current.search.resultIteratorIndex !== null) {
  409. traceDispatch({type: 'clear search iterator index'});
  410. }
  411. traceDispatch({
  412. type: 'set roving index',
  413. action_source: 'click',
  414. index,
  415. node,
  416. });
  417. },
  418. [setRowAsFocused, traceDispatch]
  419. );
  420. const scrollRowIntoView = useCallback(
  421. (
  422. node: TraceTreeNode<TraceTree.NodeValue>,
  423. index: number,
  424. anchor?: ViewManagerScrollAnchor,
  425. force?: boolean
  426. ) => {
  427. // Last node we scrolled to is the same as the node we want to scroll to
  428. if (previouslyScrolledToNodeRef.current === node && !force) {
  429. return;
  430. }
  431. // Always scroll to the row vertically
  432. viewManager.scrollToRow(index, anchor);
  433. previouslyScrolledToNodeRef.current = node;
  434. // If the row had not yet been measured, then enqueue a listener for when
  435. // the row is rendered and measured. This ensures that horizontal scroll
  436. // accurately narrows zooms to the start of the node as the new width will be updated
  437. if (!viewManager.row_measurer.cache.has(node)) {
  438. viewManager.row_measurer.once('row measure end', () => {
  439. if (!viewManager.isOutsideOfViewOnKeyDown(node)) {
  440. return;
  441. }
  442. viewManager.scrollRowIntoViewHorizontally(node, 0, 48, 'measured');
  443. });
  444. } else {
  445. if (!viewManager.isOutsideOfViewOnKeyDown(node)) {
  446. return;
  447. }
  448. viewManager.scrollRowIntoViewHorizontally(node, 0, 48, 'measured');
  449. }
  450. },
  451. [viewManager]
  452. );
  453. const onTabScrollToNode = useCallback(
  454. (node: TraceTreeNode<TraceTree.NodeValue>) => {
  455. if (node === null) {
  456. return;
  457. }
  458. // We call expandToNode because we want to ensure that the node is
  459. // visible and may not have been collapsed/hidden by the user
  460. TraceTree.ExpandToPath(tree, node.path, rerender, {
  461. api,
  462. organization,
  463. }).then(maybeNode => {
  464. if (maybeNode) {
  465. previouslyFocusedNodeRef.current = null;
  466. scrollRowIntoView(maybeNode.node, maybeNode.index, 'center if outside', true);
  467. traceDispatch({
  468. type: 'set roving index',
  469. node: maybeNode.node,
  470. index: maybeNode.index,
  471. action_source: 'click',
  472. });
  473. setRowAsFocused(
  474. maybeNode.node,
  475. null,
  476. traceStateRef.current.search.resultsLookup,
  477. null,
  478. 0
  479. );
  480. if (traceStateRef.current.search.resultsLookup.has(maybeNode.node)) {
  481. traceDispatch({
  482. type: 'set search iterator index',
  483. resultIndex: maybeNode.index,
  484. resultIteratorIndex: traceStateRef.current.search.resultsLookup.get(
  485. maybeNode.node
  486. )!,
  487. });
  488. } else if (traceStateRef.current.search.resultIteratorIndex !== null) {
  489. traceDispatch({type: 'clear search iterator index'});
  490. }
  491. }
  492. });
  493. },
  494. [api, organization, setRowAsFocused, scrollRowIntoView, tree, traceDispatch]
  495. );
  496. // Callback that is invoked when the trace loads and reaches its initialied state,
  497. // that is when the trace tree data and any data that the trace depends on is loaded,
  498. // but the trace is not yet rendered in the view.
  499. const onTraceLoad = useCallback(
  500. (
  501. _trace: TraceTree,
  502. nodeToScrollTo: TraceTreeNode<TraceTree.NodeValue> | null,
  503. indexOfNodeToScrollTo: number | null
  504. ) => {
  505. if (nodeToScrollTo !== null && indexOfNodeToScrollTo !== null) {
  506. viewManager.scrollToRow(indexOfNodeToScrollTo, 'center');
  507. // At load time, we want to scroll the row into view, but we need to ensure
  508. // that the row had been measured first, else we can exceed the bounds of the container.
  509. scrollRowIntoView(nodeToScrollTo, indexOfNodeToScrollTo, 'center');
  510. setRowAsFocused(
  511. nodeToScrollTo,
  512. null,
  513. traceStateRef.current.search.resultsLookup,
  514. indexOfNodeToScrollTo
  515. );
  516. traceDispatch({
  517. type: 'set roving index',
  518. node: nodeToScrollTo,
  519. index: indexOfNodeToScrollTo,
  520. action_source: 'load',
  521. });
  522. }
  523. if (traceStateRef.current.search.query) {
  524. onTraceSearch(traceStateRef.current.search.query, nodeToScrollTo, 'persist');
  525. }
  526. },
  527. [setRowAsFocused, traceDispatch, onTraceSearch, scrollRowIntoView, viewManager]
  528. );
  529. // Setup the middleware for the trace reducer
  530. useLayoutEffect(() => {
  531. const beforeTraceNextStateDispatch: DispatchingReducerMiddleware<
  532. typeof TraceReducer
  533. >['before next state'] = (prevState, nextState, action) => {
  534. // This effect is responsible fo syncing the keyboard interactions with the search results,
  535. // we observe the changes to the roving tab index and search results and react by syncing the state.
  536. const {node: nextRovingNode, index: nextRovingTabIndex} = nextState.rovingTabIndex;
  537. const {resultIndex: nextSearchResultIndex} = nextState.search;
  538. if (
  539. nextRovingNode &&
  540. action.type === 'set roving index' &&
  541. action.action_source !== 'click' &&
  542. typeof nextRovingTabIndex === 'number' &&
  543. prevState.rovingTabIndex.node !== nextRovingNode
  544. ) {
  545. // When the roving tabIndex updates mark the node as focused and sync search results
  546. setRowAsFocused(
  547. nextRovingNode,
  548. null,
  549. nextState.search.resultsLookup,
  550. nextRovingTabIndex
  551. );
  552. if (action.type === 'set roving index' && action.action_source === 'keyboard') {
  553. scrollRowIntoView(nextRovingNode, nextRovingTabIndex, undefined);
  554. }
  555. if (nextState.search.resultsLookup.has(nextRovingNode)) {
  556. const idx = nextState.search.resultsLookup.get(nextRovingNode)!;
  557. traceDispatch({
  558. type: 'set search iterator index',
  559. resultIndex: nextRovingTabIndex,
  560. resultIteratorIndex: idx,
  561. });
  562. } else if (nextState.search.resultIteratorIndex !== null) {
  563. traceDispatch({type: 'clear search iterator index'});
  564. }
  565. } else if (
  566. typeof nextSearchResultIndex === 'number' &&
  567. prevState.search.resultIndex !== nextSearchResultIndex &&
  568. action.type !== 'set search iterator index'
  569. ) {
  570. // If the search result index changes, mark the node as focused and scroll it into view
  571. const nextNode = tree.list[nextSearchResultIndex];
  572. setRowAsFocused(
  573. nextNode,
  574. null,
  575. nextState.search.resultsLookup,
  576. nextSearchResultIndex
  577. );
  578. scrollRowIntoView(nextNode, nextSearchResultIndex, 'center if outside');
  579. }
  580. };
  581. traceStateEmitter.on('before next state', beforeTraceNextStateDispatch);
  582. return () => {
  583. traceStateEmitter.off('before next state', beforeTraceNextStateDispatch);
  584. };
  585. }, [
  586. tree,
  587. onTraceSearch,
  588. traceStateEmitter,
  589. traceDispatch,
  590. setRowAsFocused,
  591. scrollRowIntoView,
  592. ]);
  593. // Setup the middleware for the view manager and store the list width as a preference
  594. useLayoutEffect(() => {
  595. function onDividerResizeEnd(list_width: number) {
  596. traceDispatch({
  597. type: 'set list width',
  598. payload: list_width,
  599. });
  600. }
  601. viewManager.on('divider resize end', onDividerResizeEnd);
  602. return () => {
  603. viewManager.off('divider resize end', onDividerResizeEnd);
  604. };
  605. }, [viewManager, traceDispatch]);
  606. // Sync part of the state with the URL
  607. const traceQueryStateSync = useMemo(() => {
  608. return {search: traceState.search.query};
  609. }, [traceState.search.query]);
  610. useTraceQueryParamStateSync(traceQueryStateSync);
  611. useLayoutEffect(() => {
  612. storeTraceViewPreferences(traceState.preferences);
  613. }, [traceState.preferences]);
  614. // Setup outside click handler so that we can clear the currently clicked node
  615. const onOutsideTraceContainerClick = useCallback(() => {
  616. if (tree.type !== 'trace') {
  617. // Dont clear the URL in case the trace is still loading or failed for some reason,
  618. // we want to keep the eventId in the URL so the user can share the URL with support
  619. return;
  620. }
  621. // we will drop eventId such that after users clicks outside and shares the URL
  622. const {
  623. node: _node,
  624. eventId: _eventId,
  625. ...queryParamsWithoutNode
  626. } = qs.parse(location.search);
  627. browserHistory.push({
  628. pathname: location.pathname,
  629. query: queryParamsWithoutNode,
  630. });
  631. traceDispatch({type: 'clear'});
  632. }, [tree, traceDispatch]);
  633. const [clickOutsideRef, setClickOutsideRef] = useState<HTMLElement | null>(null);
  634. const [traceGridRef, setTraceGridRef] = useState<HTMLElement | null>(null);
  635. useOnClickOutside(clickOutsideRef, onOutsideTraceContainerClick);
  636. return (
  637. <TraceExternalLayout>
  638. <TraceUXChangeAlert />
  639. <TraceMetadataHeader
  640. organization={props.organization}
  641. projectID={rootEvent?.data?.projectID ?? ''}
  642. title={rootEvent?.data?.title ?? ''}
  643. traceSlug={props.traceSlug}
  644. traceEventView={props.traceEventView}
  645. />
  646. <TraceHeader
  647. tree={tree}
  648. rootEventResults={rootEvent}
  649. metaResults={props.metaResults}
  650. organization={props.organization}
  651. traces={props.trace}
  652. traceID={props.traceSlug}
  653. />
  654. <TraceInnerLayout ref={setClickOutsideRef}>
  655. <TraceToolbar>
  656. <TraceSearchInput
  657. trace_state={traceState}
  658. trace_dispatch={traceDispatch}
  659. onTraceSearch={onTraceSearch}
  660. />
  661. <TraceResetZoomButton viewManager={viewManager} />
  662. <TraceShortcuts />
  663. </TraceToolbar>
  664. <TraceGrid layout={traceState.preferences.layout} ref={setTraceGridRef}>
  665. <Trace
  666. trace={tree}
  667. rerender={rerender}
  668. trace_id={props.traceSlug}
  669. trace_state={traceState}
  670. trace_dispatch={traceDispatch}
  671. scrollQueueRef={scrollQueueRef}
  672. onRowClick={onRowClick}
  673. onTraceLoad={onTraceLoad}
  674. onTraceSearch={onTraceSearch}
  675. previouslyFocusedNodeRef={previouslyFocusedNodeRef}
  676. manager={viewManager}
  677. forceRerender={forceRender}
  678. />
  679. {tree.type === 'error' ? (
  680. <TraceError />
  681. ) : tree.type === 'empty' ? (
  682. <TraceEmpty />
  683. ) : tree.type === 'loading' || scrollQueueRef.current ? (
  684. <TraceLoading />
  685. ) : null}
  686. <TraceDrawer
  687. trace={tree}
  688. traceGridRef={traceGridRef}
  689. traces={props.trace}
  690. manager={viewManager}
  691. trace_state={traceState}
  692. trace_dispatch={traceDispatch}
  693. onTabScrollToNode={onTabScrollToNode}
  694. rootEventResults={rootEvent}
  695. traceEventView={props.traceEventView}
  696. />
  697. </TraceGrid>
  698. </TraceInnerLayout>
  699. </TraceExternalLayout>
  700. );
  701. }
  702. function TraceResetZoomButton(props: {viewManager: VirtualizedViewManager}) {
  703. return (
  704. <Button size="xs" onClick={() => props.viewManager.resetZoom()}>
  705. {t('Reset Zoom')}
  706. </Button>
  707. );
  708. }
  709. const TraceExternalLayout = styled('div')`
  710. display: flex;
  711. flex-direction: column;
  712. flex: 1 1 100%;
  713. ~ footer {
  714. display: none;
  715. }
  716. `;
  717. const TraceInnerLayout = styled('div')`
  718. display: flex;
  719. flex-direction: column;
  720. flex: 1 1 100%;
  721. padding: 0 ${space(2)} 0 ${space(2)};
  722. background-color: ${p => p.theme.background};
  723. --info: ${p => p.theme.purple400};
  724. --warning: ${p => p.theme.yellow300};
  725. --error: ${p => p.theme.error};
  726. --fatal: ${p => p.theme.error};
  727. --default: ${p => p.theme.gray300};
  728. --unknown: ${p => p.theme.gray300};
  729. --profile: ${p => p.theme.purple300};
  730. --autogrouped: ${p => p.theme.blue300};
  731. --performance-issue: ${p => p.theme.blue300};
  732. `;
  733. const TraceToolbar = styled('div')`
  734. flex-grow: 0;
  735. display: grid;
  736. grid-template-columns: 1fr min-content min-content;
  737. gap: ${space(1)};
  738. `;
  739. const TraceGrid = styled('div')<{
  740. layout: 'drawer bottom' | 'drawer left' | 'drawer right';
  741. }>`
  742. box-shadow: 0 0 0 1px ${p => p.theme.border};
  743. flex: 1 1 100%;
  744. display: grid;
  745. border-top-left-radius: ${p => p.theme.borderRadius};
  746. border-top-right-radius: ${p => p.theme.borderRadius};
  747. overflow: hidden;
  748. position: relative;
  749. /* false positive for grid layout */
  750. /* stylelint-disable */
  751. grid-template-areas: ${p =>
  752. p.layout === 'drawer bottom'
  753. ? `
  754. 'trace'
  755. 'drawer'
  756. `
  757. : p.layout === 'drawer left'
  758. ? `'drawer trace'`
  759. : `'trace drawer'`};
  760. grid-template-columns: ${p =>
  761. p.layout === 'drawer bottom'
  762. ? '1fr'
  763. : p.layout === 'drawer left'
  764. ? 'min-content 1fr'
  765. : '1fr min-content'};
  766. grid-template-rows: 1fr auto;
  767. `;
  768. const LoadingContainer = styled('div')<{animate: boolean; error?: boolean}>`
  769. display: flex;
  770. justify-content: center;
  771. align-items: center;
  772. flex-direction: column;
  773. left: 50%;
  774. top: 50%;
  775. position: absolute;
  776. height: auto;
  777. font-size: ${p => p.theme.fontSizeMedium};
  778. color: ${p => p.theme.gray300};
  779. z-index: 30;
  780. padding: 24px;
  781. background-color: ${p => p.theme.background};
  782. border-radius: ${p => p.theme.borderRadius};
  783. border: 1px solid ${p => p.theme.border};
  784. transform-origin: 50% 50%;
  785. transform: translate(-50%, -50%);
  786. animation: ${p =>
  787. p.animate
  788. ? `${p.error ? 'showLoadingContainerShake' : 'showLoadingContainer'} 300ms cubic-bezier(0.61, 1, 0.88, 1) forwards`
  789. : 'none'};
  790. @keyframes showLoadingContainer {
  791. from {
  792. opacity: 0.6;
  793. transform: scale(0.99) translate(-50%, -50%);
  794. }
  795. to {
  796. opacity: 1;
  797. transform: scale(1) translate(-50%, -50%);
  798. }
  799. }
  800. @keyframes showLoadingContainerShake {
  801. 0% {
  802. transform: translate(-50%, -50%);
  803. }
  804. 25% {
  805. transform: translate(-51%, -50%);
  806. }
  807. 75% {
  808. transform: translate(-49%, -50%);
  809. }
  810. 100% {
  811. transform: translate(-50%, -50%);
  812. }
  813. }
  814. `;
  815. function TraceLoading() {
  816. return (
  817. // Dont flash the animation on load because it's annoying
  818. <LoadingContainer animate={false}>
  819. <NoMarginIndicator size={24}>
  820. <div>{t('Assembling the trace')}</div>
  821. </NoMarginIndicator>
  822. </LoadingContainer>
  823. );
  824. }
  825. function TraceError() {
  826. const linkref = useRef<HTMLAnchorElement>(null);
  827. const feedback = useFeedbackWidget({buttonRef: linkref});
  828. return (
  829. <LoadingContainer animate error>
  830. <div>{t('Ughhhhh, we failed to load your trace...')}</div>
  831. <div>
  832. {t('Seeing this often? Send us ')}
  833. {feedback ? (
  834. <a href="#" ref={linkref}>
  835. {t('feedback')}
  836. </a>
  837. ) : (
  838. <a href="mailto:support@sentry.io?subject=Trace%20fails%20to%20load">
  839. {t('feedback')}
  840. </a>
  841. )}
  842. </div>
  843. </LoadingContainer>
  844. );
  845. }
  846. function TraceEmpty() {
  847. const linkref = useRef<HTMLAnchorElement>(null);
  848. const feedback = useFeedbackWidget({buttonRef: linkref});
  849. return (
  850. <LoadingContainer animate>
  851. <div>{t('This trace does not contain any data?!')}</div>
  852. <div>
  853. {t('Seeing this often? Send us ')}
  854. {feedback ? (
  855. <a href="#" ref={linkref}>
  856. {t('feedback')}
  857. </a>
  858. ) : (
  859. <a href="mailto:support@sentry.io?subject=Trace%20does%20not%20contain%20data">
  860. {t('feedback')}
  861. </a>
  862. )}
  863. </div>
  864. </LoadingContainer>
  865. );
  866. }
  867. const NoMarginIndicator = styled(LoadingIndicator)`
  868. margin: 0;
  869. `;