index.tsx 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218
  1. import type React from 'react';
  2. import {
  3. Fragment,
  4. useCallback,
  5. useEffect,
  6. useLayoutEffect,
  7. useMemo,
  8. useReducer,
  9. useRef,
  10. useState,
  11. } from 'react';
  12. import {flushSync} from 'react-dom';
  13. import styled from '@emotion/styled';
  14. import * as Sentry from '@sentry/react';
  15. import * as qs from 'query-string';
  16. import {Button} from 'sentry/components/button';
  17. import useFeedbackWidget from 'sentry/components/feedback/widget/useFeedbackWidget';
  18. import LoadingIndicator from 'sentry/components/loadingIndicator';
  19. import NoProjectMessage from 'sentry/components/noProjectMessage';
  20. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  21. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  22. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  23. import {t} from 'sentry/locale';
  24. import {space} from 'sentry/styles/space';
  25. import type {EventTransaction} from 'sentry/types/event';
  26. import type {Organization} from 'sentry/types/organization';
  27. import type {Project} from 'sentry/types/project';
  28. import {trackAnalytics} from 'sentry/utils/analytics';
  29. import {browserHistory} from 'sentry/utils/browserHistory';
  30. import EventView from 'sentry/utils/discover/eventView';
  31. import type {TraceSplitResults} 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 type RequestError from 'sentry/utils/requestError/requestError';
  39. import {capitalize} from 'sentry/utils/string/capitalize';
  40. import useApi from 'sentry/utils/useApi';
  41. import type {DispatchingReducerMiddleware} from 'sentry/utils/useDispatchingReducer';
  42. import useOrganization from 'sentry/utils/useOrganization';
  43. import usePageFilters from 'sentry/utils/usePageFilters';
  44. import {useParams} from 'sentry/utils/useParams';
  45. import useProjects from 'sentry/utils/useProjects';
  46. import {traceAnalytics} from 'sentry/views/performance/newTraceDetails/traceAnalytics';
  47. import {
  48. TraceEventPriority,
  49. type TraceEvents,
  50. TraceScheduler,
  51. } from 'sentry/views/performance/newTraceDetails/traceRenderers/traceScheduler';
  52. import {TraceView as TraceViewModel} from 'sentry/views/performance/newTraceDetails/traceRenderers/traceView';
  53. import {
  54. type ViewManagerScrollAnchor,
  55. VirtualizedViewManager,
  56. } from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager';
  57. import {
  58. searchInTraceTreeText,
  59. searchInTraceTreeTokens,
  60. } from 'sentry/views/performance/newTraceDetails/traceSearch/traceSearchEvaluator';
  61. import {parseTraceSearch} from 'sentry/views/performance/newTraceDetails/traceSearch/traceTokenConverter';
  62. import {TraceShortcuts} from 'sentry/views/performance/newTraceDetails/traceShortcutsModal';
  63. import {
  64. TraceStateProvider,
  65. useTraceState,
  66. useTraceStateDispatch,
  67. useTraceStateEmitter,
  68. } from 'sentry/views/performance/newTraceDetails/traceState/traceStateProvider';
  69. import type {ReplayTrace} from 'sentry/views/replays/detail/trace/useReplayTraces';
  70. import type {ReplayRecord} from 'sentry/views/replays/types';
  71. import {useTrace} from './traceApi/useTrace';
  72. import {type TraceMetaQueryResults, useTraceMeta} from './traceApi/useTraceMeta';
  73. import {useTraceRootEvent} from './traceApi/useTraceRootEvent';
  74. import {TraceDrawer} from './traceDrawer/traceDrawer';
  75. import {
  76. traceNodeAdjacentAnalyticsProperties,
  77. traceNodeAnalyticsName,
  78. TraceTree,
  79. type TraceTreeNode,
  80. } from './traceModels/traceTree';
  81. import {TraceSearchInput} from './traceSearch/traceSearchInput';
  82. import {
  83. DEFAULT_TRACE_VIEW_PREFERENCES,
  84. loadTraceViewPreferences,
  85. } from './traceState/tracePreferences';
  86. import {PerformanceSetupWarning} from './traceWarnings/performanceSetupWarning';
  87. import {isTraceNode} from './guards';
  88. import {Trace} from './trace';
  89. import {TraceMetadataHeader} from './traceMetadataHeader';
  90. import type {TraceReducer, TraceReducerState} from './traceState';
  91. import {TraceType} from './traceType';
  92. import {useTraceQueryParamStateSync} from './useTraceQueryParamStateSync';
  93. function decodeScrollQueue(maybePath: unknown): TraceTree.NodePath[] | null {
  94. if (Array.isArray(maybePath)) {
  95. return maybePath;
  96. }
  97. if (typeof maybePath === 'string') {
  98. return [maybePath as TraceTree.NodePath];
  99. }
  100. return null;
  101. }
  102. function logTraceMetadata(
  103. tree: TraceTree,
  104. projects: Project[],
  105. organization: Organization
  106. ) {
  107. switch (tree.shape) {
  108. case TraceType.BROKEN_SUBTRACES:
  109. case TraceType.EMPTY_TRACE:
  110. case TraceType.MULTIPLE_ROOTS:
  111. case TraceType.ONE_ROOT:
  112. case TraceType.NO_ROOT:
  113. case TraceType.ONLY_ERRORS:
  114. case TraceType.BROWSER_MULTIPLE_ROOTS:
  115. traceAnalytics.trackTraceMetadata(tree, projects, organization);
  116. break;
  117. default: {
  118. Sentry.captureMessage('Unknown trace type');
  119. }
  120. }
  121. }
  122. export function TraceView() {
  123. const params = useParams<{traceSlug?: string}>();
  124. const organization = useOrganization();
  125. const traceSlug = useMemo(() => {
  126. const slug = params.traceSlug?.trim() ?? '';
  127. // null and undefined are not valid trace slugs, but they can be passed
  128. // in the URL and need to check for their string coerced values.
  129. if (!slug || slug === 'null' || slug === 'undefined') {
  130. Sentry.withScope(scope => {
  131. scope.setFingerprint(['trace-null-slug']);
  132. Sentry.captureMessage(`Trace slug is empty`);
  133. });
  134. }
  135. return slug;
  136. }, [params.traceSlug]);
  137. const queryParams = useMemo(() => {
  138. const normalizedParams = normalizeDateTimeParams(qs.parse(location.search), {
  139. allowAbsolutePageDatetime: true,
  140. });
  141. const start = decodeScalar(normalizedParams.start);
  142. const timestamp: string | undefined = decodeScalar(normalizedParams.timestamp);
  143. const end = decodeScalar(normalizedParams.end);
  144. const statsPeriod = decodeScalar(normalizedParams.statsPeriod);
  145. const numberTimestamp = timestamp ? Number(timestamp) : undefined;
  146. return {start, end, statsPeriod, timestamp: numberTimestamp, useSpans: 1};
  147. }, []);
  148. const traceEventView = useMemo(() => {
  149. const {start, end, statsPeriod, timestamp} = queryParams;
  150. let startTimeStamp = start;
  151. let endTimeStamp = end;
  152. // If timestamp exists in the query params, we want to use it to set the start and end time
  153. // with a buffer of 1.5 days, for retrieving events belonging to the trace.
  154. if (typeof timestamp === 'number') {
  155. const buffer = 36 * 60 * 60 * 1000; // 1.5 days in milliseconds
  156. const dateFromTimestamp = new Date(timestamp * 1000);
  157. startTimeStamp = new Date(dateFromTimestamp.getTime() - buffer).toISOString();
  158. endTimeStamp = new Date(dateFromTimestamp.getTime() + buffer).toISOString();
  159. }
  160. return EventView.fromSavedQuery({
  161. id: undefined,
  162. name: `Events with Trace ID ${traceSlug}`,
  163. fields: ['title', 'event.type', 'project', 'timestamp'],
  164. orderby: '-timestamp',
  165. query: `trace:${traceSlug}`,
  166. projects: [ALL_ACCESS_PROJECTS],
  167. version: 2,
  168. start: startTimeStamp,
  169. end: endTimeStamp,
  170. range: !(startTimeStamp || endTimeStamp) ? statsPeriod : undefined,
  171. });
  172. }, [queryParams, traceSlug]);
  173. const meta = useTraceMeta([traceSlug]);
  174. const preferences = useMemo(
  175. () =>
  176. loadTraceViewPreferences('trace-view-preferences') ||
  177. DEFAULT_TRACE_VIEW_PREFERENCES,
  178. []
  179. );
  180. const trace = useTrace({traceSlug, timestamp: queryParams.timestamp});
  181. const rootEvent = useTraceRootEvent(trace.data ?? null);
  182. return (
  183. <SentryDocumentTitle
  184. title={`${t('Trace Details')} - ${traceSlug}`}
  185. orgSlug={organization.slug}
  186. >
  187. <TraceStateProvider
  188. initialPreferences={preferences}
  189. preferencesStorageKey="trace-view-preferences"
  190. >
  191. <NoProjectMessage organization={organization}>
  192. <TraceExternalLayout>
  193. <TraceMetadataHeader
  194. organization={organization}
  195. traceSlug={traceSlug}
  196. traceEventView={traceEventView}
  197. />
  198. <TraceInnerLayout>
  199. <TraceViewWaterfall
  200. traceSlug={traceSlug}
  201. trace={trace.data ?? null}
  202. status={trace.status}
  203. organization={organization}
  204. rootEvent={rootEvent}
  205. traceEventView={traceEventView}
  206. metaResults={meta}
  207. replayRecord={null}
  208. source="performance"
  209. />
  210. </TraceInnerLayout>
  211. </TraceExternalLayout>
  212. </NoProjectMessage>
  213. </TraceStateProvider>
  214. </SentryDocumentTitle>
  215. );
  216. }
  217. const TRACE_TAB: TraceReducerState['tabs']['tabs'][0] = {
  218. node: 'trace',
  219. label: t('Trace'),
  220. };
  221. const VITALS_TAB: TraceReducerState['tabs']['tabs'][0] = {
  222. node: 'vitals',
  223. label: t('Vitals'),
  224. };
  225. type TraceViewWaterfallProps = {
  226. metaResults: TraceMetaQueryResults;
  227. organization: Organization;
  228. replayRecord: ReplayRecord | null;
  229. rootEvent: UseApiQueryResult<EventTransaction, RequestError>;
  230. source: string;
  231. status: UseApiQueryResult<any, any>['status'];
  232. trace: TraceSplitResults<TraceTree.Transaction> | null;
  233. traceEventView: EventView;
  234. traceSlug: string | undefined;
  235. replayTraces?: ReplayTrace[];
  236. };
  237. export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
  238. const api = useApi();
  239. const {projects} = useProjects();
  240. const organization = useOrganization();
  241. const loadingTraceRef = useRef<TraceTree | null>(null);
  242. const [forceRender, rerender] = useReducer(x => (x + 1) % Number.MAX_SAFE_INTEGER, 0);
  243. const traceState = useTraceState();
  244. const traceDispatch = useTraceStateDispatch();
  245. const traceStateEmitter = useTraceStateEmitter();
  246. const filters = usePageFilters();
  247. const traceScheduler = useMemo(() => new TraceScheduler(), []);
  248. const traceView = useMemo(() => new TraceViewModel(), []);
  249. const forceRerender = useCallback(() => {
  250. flushSync(rerender);
  251. }, []);
  252. useEffect(() => {
  253. trackAnalytics('performance_views.trace_view_v1_page_load', {
  254. organization: props.organization,
  255. source: props.source,
  256. });
  257. }, [props.organization, props.source]);
  258. const initializedRef = useRef(false);
  259. const scrollQueueRef = useRef<
  260. {eventId?: string; path?: TraceTree.NodePath[]} | null | undefined
  261. >(undefined);
  262. if (scrollQueueRef.current === undefined) {
  263. const queryParams = qs.parse(location.search);
  264. const maybeQueue = decodeScrollQueue(queryParams.node);
  265. if (maybeQueue || queryParams.eventId) {
  266. scrollQueueRef.current = {
  267. eventId: queryParams.eventId as string,
  268. path: maybeQueue as TraceTreeNode<TraceTree.NodeValue>['path'],
  269. };
  270. } else {
  271. scrollQueueRef.current = null;
  272. }
  273. }
  274. const previouslyFocusedNodeRef = useRef<TraceTreeNode<TraceTree.NodeValue> | null>(
  275. null
  276. );
  277. const previouslyScrolledToNodeRef = useRef<TraceTreeNode<TraceTree.NodeValue> | null>(
  278. null
  279. );
  280. const [tree, setTree] = useState<TraceTree>(TraceTree.Empty());
  281. useEffect(() => {
  282. if (props.status === 'error') {
  283. const errorTree = TraceTree.Error(
  284. {
  285. project_slug: projects?.[0]?.slug ?? '',
  286. event_id: props.traceSlug,
  287. },
  288. loadingTraceRef.current
  289. );
  290. setTree(errorTree);
  291. }
  292. if (
  293. props.trace?.transactions.length === 0 &&
  294. props.trace?.orphan_errors.length === 0
  295. ) {
  296. setTree(TraceTree.Empty());
  297. return;
  298. }
  299. if (props.status === 'loading') {
  300. const loadingTrace =
  301. loadingTraceRef.current ??
  302. TraceTree.Loading(
  303. {
  304. project_slug: projects?.[0]?.slug ?? '',
  305. event_id: props.traceSlug,
  306. },
  307. loadingTraceRef.current
  308. );
  309. loadingTraceRef.current = loadingTrace;
  310. setTree(loadingTrace);
  311. }
  312. if (props.trace) {
  313. const trace = TraceTree.FromTrace(props.trace, props.replayRecord);
  314. // Root frame + 2 nodes
  315. const promises: Promise<void>[] = [];
  316. if (trace.list.length < 4) {
  317. for (const c of trace.list) {
  318. if (c.canFetch) {
  319. promises.push(trace.zoomIn(c, true, {api, organization}).then(rerender));
  320. }
  321. }
  322. }
  323. Promise.allSettled(promises).finally(() => {
  324. setTree(trace);
  325. });
  326. }
  327. }, [
  328. props.traceSlug,
  329. props.trace,
  330. props.status,
  331. props.replayRecord,
  332. projects,
  333. api,
  334. organization,
  335. ]);
  336. useEffect(() => {
  337. if (!props.replayTraces?.length || tree.type !== 'trace') {
  338. return undefined;
  339. }
  340. const cleanup = tree.fetchAdditionalTraces({
  341. api,
  342. filters,
  343. replayTraces: props.replayTraces,
  344. organization: props.organization,
  345. urlParams: qs.parse(location.search),
  346. rerender: forceRerender,
  347. });
  348. return () => cleanup();
  349. // eslint-disable-next-line react-hooks/exhaustive-deps
  350. }, [tree, props.replayTraces]);
  351. // Assign the trace state to a ref so we can access it without re-rendering
  352. const traceStateRef = useRef<TraceReducerState>(traceState);
  353. traceStateRef.current = traceState;
  354. // Initialize the view manager right after the state reducer
  355. const viewManager = useMemo(() => {
  356. return new VirtualizedViewManager(
  357. {
  358. list: {width: traceState.preferences.list.width},
  359. span_list: {width: 1 - traceState.preferences.list.width},
  360. },
  361. traceScheduler,
  362. traceView
  363. );
  364. // We only care about initial state when we initialize the view manager
  365. // eslint-disable-next-line react-hooks/exhaustive-deps
  366. }, []);
  367. useLayoutEffect(() => {
  368. const onTraceViewChange: TraceEvents['set trace view'] = view => {
  369. traceView.setTraceView(view);
  370. viewManager.enqueueFOVQueryParamSync(traceView);
  371. };
  372. const onPhysicalSpaceChange: TraceEvents['set container physical space'] =
  373. container => {
  374. traceView.setTracePhysicalSpace(container, [
  375. 0,
  376. 0,
  377. container[2] * viewManager.columns.span_list.width,
  378. container[3],
  379. ]);
  380. };
  381. const onTraceSpaceChange: TraceEvents['initialize trace space'] = view => {
  382. traceView.setTraceSpace(view);
  383. };
  384. // These handlers have high priority because they are responsible for
  385. // updating the view coordinates. If we update them first, then any components downstream
  386. // that rely on the view coordinates will be in sync with the view.
  387. traceScheduler.on('set trace view', onTraceViewChange, TraceEventPriority.HIGH);
  388. traceScheduler.on('set trace space', onTraceSpaceChange, TraceEventPriority.HIGH);
  389. traceScheduler.on(
  390. 'set container physical space',
  391. onPhysicalSpaceChange,
  392. TraceEventPriority.HIGH
  393. );
  394. traceScheduler.on(
  395. 'initialize trace space',
  396. onTraceSpaceChange,
  397. TraceEventPriority.HIGH
  398. );
  399. return () => {
  400. traceScheduler.off('set trace view', onTraceViewChange);
  401. traceScheduler.off('set trace space', onTraceSpaceChange);
  402. traceScheduler.off('set container physical space', onPhysicalSpaceChange);
  403. traceScheduler.off('initialize trace space', onTraceSpaceChange);
  404. };
  405. }, [traceScheduler, traceView, viewManager]);
  406. // Initialize the tabs reducer when the tree initializes
  407. useLayoutEffect(() => {
  408. return traceDispatch({
  409. type: 'set roving count',
  410. items: tree.list.length - 1,
  411. });
  412. }, [tree.list.length, traceDispatch]);
  413. // Initialize the tabs reducer when the tree initializes
  414. useLayoutEffect(() => {
  415. if (tree.type !== 'trace') {
  416. return;
  417. }
  418. const newTabs = [TRACE_TAB];
  419. if (tree.vitals.size > 0) {
  420. const types = Array.from(tree.vital_types.values());
  421. const label = types.length > 1 ? t('Vitals') : capitalize(types[0]) + ' Vitals';
  422. newTabs.push({
  423. ...VITALS_TAB,
  424. label,
  425. });
  426. }
  427. if (tree.profiled_events.size > 0) {
  428. newTabs.push({
  429. node: 'profiles',
  430. label: 'Profiles',
  431. });
  432. }
  433. traceDispatch({
  434. type: 'initialize tabs reducer',
  435. payload: {
  436. current_tab: traceStateRef?.current?.tabs?.tabs?.[0],
  437. tabs: newTabs,
  438. last_clicked_tab: null,
  439. },
  440. });
  441. // We only want to update the tabs when the tree changes
  442. // eslint-disable-next-line react-hooks/exhaustive-deps
  443. }, [tree]);
  444. const searchingRaf = useRef<{id: number | null} | null>(null);
  445. const onTraceSearch = useCallback(
  446. (
  447. query: string,
  448. activeNode: TraceTreeNode<TraceTree.NodeValue> | null,
  449. behavior: 'track result' | 'persist'
  450. ) => {
  451. if (searchingRaf.current?.id) {
  452. window.cancelAnimationFrame(searchingRaf.current.id);
  453. }
  454. function done([matches, lookup, activeNodeSearchResult]) {
  455. // If the previous node is still in the results set, we want to keep it
  456. if (activeNodeSearchResult) {
  457. traceDispatch({
  458. type: 'set results',
  459. results: matches,
  460. resultsLookup: lookup,
  461. resultIteratorIndex: activeNodeSearchResult?.resultIteratorIndex,
  462. resultIndex: activeNodeSearchResult?.resultIndex,
  463. previousNode: activeNodeSearchResult,
  464. node: activeNode,
  465. });
  466. return;
  467. }
  468. if (activeNode && behavior === 'persist') {
  469. traceDispatch({
  470. type: 'set results',
  471. results: matches,
  472. resultsLookup: lookup,
  473. resultIteratorIndex: undefined,
  474. resultIndex: undefined,
  475. previousNode: activeNodeSearchResult,
  476. node: activeNode,
  477. });
  478. return;
  479. }
  480. const resultIndex: number | undefined = matches?.[0]?.index;
  481. const resultIteratorIndex: number | undefined = matches?.[0] ? 0 : undefined;
  482. const node: TraceTreeNode<TraceTree.NodeValue> | null = matches?.[0]?.value;
  483. traceDispatch({
  484. type: 'set results',
  485. results: matches,
  486. resultsLookup: lookup,
  487. resultIteratorIndex: resultIteratorIndex,
  488. resultIndex: resultIndex,
  489. previousNode: activeNodeSearchResult,
  490. node,
  491. });
  492. }
  493. const tokens = parseTraceSearch(query);
  494. if (tokens) {
  495. searchingRaf.current = searchInTraceTreeTokens(tree, tokens, activeNode, done);
  496. } else {
  497. searchingRaf.current = searchInTraceTreeText(tree, query, activeNode, done);
  498. }
  499. },
  500. [traceDispatch, tree]
  501. );
  502. // We need to heavily debounce query string updates because the rest of the app is so slow
  503. // to rerender that it causes the search to drop frames on every keystroke...
  504. const QUERY_STRING_STATE_DEBOUNCE = 300;
  505. const queryStringAnimationTimeoutRef = useRef<{id: number} | null>(null);
  506. const setRowAsFocused = useCallback(
  507. (
  508. node: TraceTreeNode<TraceTree.NodeValue> | null,
  509. event: React.MouseEvent<HTMLElement> | null,
  510. resultsLookup: Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  511. index: number | null,
  512. debounce: number = QUERY_STRING_STATE_DEBOUNCE
  513. ) => {
  514. // sync query string with the clicked node
  515. if (node) {
  516. if (queryStringAnimationTimeoutRef.current) {
  517. cancelAnimationTimeout(queryStringAnimationTimeoutRef.current);
  518. }
  519. queryStringAnimationTimeoutRef.current = requestAnimationTimeout(() => {
  520. const currentQueryStringPath = qs.parse(location.search).node;
  521. const nextNodePath = node.path;
  522. // Updating the query string with the same path is problematic because it causes
  523. // the entire sentry app to rerender, which is enough to cause jank and drop frames
  524. if (JSON.stringify(currentQueryStringPath) === JSON.stringify(nextNodePath)) {
  525. return;
  526. }
  527. const {eventId: _eventId, ...query} = qs.parse(location.search);
  528. browserHistory.replace({
  529. pathname: location.pathname,
  530. query: {
  531. ...query,
  532. node: nextNodePath,
  533. },
  534. });
  535. queryStringAnimationTimeoutRef.current = null;
  536. }, debounce);
  537. if (resultsLookup.has(node) && typeof index === 'number') {
  538. traceDispatch({
  539. type: 'set search iterator index',
  540. resultIndex: index,
  541. resultIteratorIndex: resultsLookup.get(node)!,
  542. });
  543. }
  544. if (isTraceNode(node)) {
  545. traceDispatch({type: 'activate tab', payload: TRACE_TAB.node});
  546. return;
  547. }
  548. traceDispatch({
  549. type: 'activate tab',
  550. payload: node,
  551. pin_previous: event?.metaKey,
  552. });
  553. }
  554. },
  555. [traceDispatch]
  556. );
  557. const onRowClick = useCallback(
  558. (
  559. node: TraceTreeNode<TraceTree.NodeValue>,
  560. event: React.MouseEvent<HTMLElement>,
  561. index: number
  562. ) => {
  563. trackAnalytics('trace.trace_layout.span_row_click', {
  564. organization,
  565. num_children: node.children.length,
  566. type: traceNodeAnalyticsName(node),
  567. project_platform:
  568. projects.find(p => p.slug === node.metadata.project_slug)?.platform || 'other',
  569. ...traceNodeAdjacentAnalyticsProperties(node),
  570. });
  571. if (traceStateRef.current.preferences.drawer.minimized) {
  572. traceDispatch({type: 'minimize drawer', payload: false});
  573. }
  574. setRowAsFocused(node, event, traceStateRef.current.search.resultsLookup, null, 0);
  575. if (traceStateRef.current.search.resultsLookup.has(node)) {
  576. const idx = traceStateRef.current.search.resultsLookup.get(node)!;
  577. traceDispatch({
  578. type: 'set search iterator index',
  579. resultIndex: index,
  580. resultIteratorIndex: idx,
  581. });
  582. } else if (traceStateRef.current.search.resultIteratorIndex !== null) {
  583. traceDispatch({type: 'clear search iterator index'});
  584. }
  585. traceDispatch({
  586. type: 'set roving index',
  587. action_source: 'click',
  588. index,
  589. node,
  590. });
  591. },
  592. [setRowAsFocused, traceDispatch, organization, projects]
  593. );
  594. const scrollRowIntoView = useCallback(
  595. (
  596. node: TraceTreeNode<TraceTree.NodeValue>,
  597. index: number,
  598. anchor?: ViewManagerScrollAnchor,
  599. force?: boolean
  600. ) => {
  601. // Last node we scrolled to is the same as the node we want to scroll to
  602. if (previouslyScrolledToNodeRef.current === node && !force) {
  603. return;
  604. }
  605. // Always scroll to the row vertically
  606. viewManager.scrollToRow(index, anchor);
  607. if (viewManager.isOutsideOfView(node)) {
  608. viewManager.scrollRowIntoViewHorizontally(node, 0, 48, 'measured');
  609. }
  610. previouslyScrolledToNodeRef.current = node;
  611. },
  612. [viewManager]
  613. );
  614. const onTabScrollToNode = useCallback(
  615. (node: TraceTreeNode<TraceTree.NodeValue>) => {
  616. if (node === null) {
  617. return;
  618. }
  619. // We call expandToNode because we want to ensure that the node is
  620. // visible and may not have been collapsed/hidden by the user
  621. TraceTree.ExpandToPath(tree, node.path, forceRerender, {
  622. api,
  623. organization: props.organization,
  624. }).then(maybeNode => {
  625. if (maybeNode) {
  626. previouslyFocusedNodeRef.current = null;
  627. scrollRowIntoView(maybeNode.node, maybeNode.index, 'center if outside', true);
  628. traceDispatch({
  629. type: 'set roving index',
  630. node: maybeNode.node,
  631. index: maybeNode.index,
  632. action_source: 'click',
  633. });
  634. setRowAsFocused(
  635. maybeNode.node,
  636. null,
  637. traceStateRef.current.search.resultsLookup,
  638. null,
  639. 0
  640. );
  641. if (traceStateRef.current.search.resultsLookup.has(maybeNode.node)) {
  642. traceDispatch({
  643. type: 'set search iterator index',
  644. resultIndex: maybeNode.index,
  645. resultIteratorIndex: traceStateRef.current.search.resultsLookup.get(
  646. maybeNode.node
  647. )!,
  648. });
  649. } else if (traceStateRef.current.search.resultIteratorIndex !== null) {
  650. traceDispatch({type: 'clear search iterator index'});
  651. }
  652. }
  653. });
  654. },
  655. [
  656. api,
  657. props.organization,
  658. setRowAsFocused,
  659. scrollRowIntoView,
  660. tree,
  661. traceDispatch,
  662. forceRerender,
  663. ]
  664. );
  665. // Unlike onTabScrollToNode, this function does not set the node as the current
  666. // focused node, but rather scrolls the node into view and sets the roving index to the node.
  667. const onScrollToNode = useCallback(
  668. (node: TraceTreeNode<TraceTree.NodeValue>) => {
  669. TraceTree.ExpandToPath(tree, node.path, forceRerender, {
  670. api,
  671. organization: props.organization,
  672. }).then(maybeNode => {
  673. if (maybeNode) {
  674. previouslyFocusedNodeRef.current = null;
  675. scrollRowIntoView(maybeNode.node, maybeNode.index, 'center if outside', true);
  676. traceDispatch({
  677. type: 'set roving index',
  678. node: maybeNode.node,
  679. index: maybeNode.index,
  680. action_source: 'click',
  681. });
  682. if (traceStateRef.current.search.resultsLookup.has(maybeNode.node)) {
  683. traceDispatch({
  684. type: 'set search iterator index',
  685. resultIndex: maybeNode.index,
  686. resultIteratorIndex: traceStateRef.current.search.resultsLookup.get(
  687. maybeNode.node
  688. )!,
  689. });
  690. } else if (traceStateRef.current.search.resultIteratorIndex !== null) {
  691. traceDispatch({type: 'clear search iterator index'});
  692. }
  693. }
  694. });
  695. },
  696. [api, props.organization, scrollRowIntoView, tree, traceDispatch, forceRerender]
  697. );
  698. // Callback that is invoked when the trace loads and reaches its initialied state,
  699. // that is when the trace tree data and any data that the trace depends on is loaded,
  700. // but the trace is not yet rendered in the view.
  701. const onTraceLoad = useCallback(
  702. (
  703. _trace: TraceTree,
  704. nodeToScrollTo: TraceTreeNode<TraceTree.NodeValue> | null,
  705. indexOfNodeToScrollTo: number | null
  706. ) => {
  707. scrollQueueRef.current = null;
  708. const query = qs.parse(location.search);
  709. if (query.fov && typeof query.fov === 'string') {
  710. viewManager.maybeInitializeTraceViewFromQS(query.fov);
  711. }
  712. if (nodeToScrollTo !== null && indexOfNodeToScrollTo !== null) {
  713. // At load time, we want to scroll the row into view, but we need to wait for the view
  714. // to initialize before we can do that. We listen for the 'initialize virtualized list' and scroll
  715. // to the row in the view.
  716. traceScheduler.once('initialize virtualized list', () => {
  717. function onTargetRowMeasure() {
  718. if (!nodeToScrollTo || !viewManager.row_measurer.cache.has(nodeToScrollTo)) {
  719. return;
  720. }
  721. viewManager.row_measurer.off('row measure end', onTargetRowMeasure);
  722. if (viewManager.isOutsideOfView(nodeToScrollTo)) {
  723. viewManager.scrollRowIntoViewHorizontally(
  724. nodeToScrollTo!,
  725. 0,
  726. 48,
  727. 'measured'
  728. );
  729. }
  730. }
  731. viewManager.scrollToRow(indexOfNodeToScrollTo, 'center');
  732. viewManager.row_measurer.on('row measure end', onTargetRowMeasure);
  733. previouslyScrolledToNodeRef.current = nodeToScrollTo;
  734. setRowAsFocused(
  735. nodeToScrollTo,
  736. null,
  737. traceStateRef.current.search.resultsLookup,
  738. indexOfNodeToScrollTo
  739. );
  740. traceDispatch({
  741. type: 'set roving index',
  742. node: nodeToScrollTo,
  743. index: indexOfNodeToScrollTo,
  744. action_source: 'load',
  745. });
  746. });
  747. }
  748. if (traceStateRef.current.search.query) {
  749. onTraceSearch(traceStateRef.current.search.query, nodeToScrollTo, 'persist');
  750. }
  751. },
  752. [setRowAsFocused, traceDispatch, onTraceSearch, viewManager, traceScheduler]
  753. );
  754. // Setup the middleware for the trace reducer
  755. useLayoutEffect(() => {
  756. const beforeTraceNextStateDispatch: DispatchingReducerMiddleware<
  757. typeof TraceReducer
  758. >['before next state'] = (prevState, nextState, action) => {
  759. // This effect is responsible fo syncing the keyboard interactions with the search results,
  760. // we observe the changes to the roving tab index and search results and react by syncing the state.
  761. const {node: nextRovingNode, index: nextRovingTabIndex} = nextState.rovingTabIndex;
  762. const {resultIndex: nextSearchResultIndex} = nextState.search;
  763. if (
  764. nextRovingNode &&
  765. action.type === 'set roving index' &&
  766. action.action_source !== 'click' &&
  767. typeof nextRovingTabIndex === 'number' &&
  768. prevState.rovingTabIndex.node !== nextRovingNode
  769. ) {
  770. // When the roving tabIndex updates mark the node as focused and sync search results
  771. setRowAsFocused(
  772. nextRovingNode,
  773. null,
  774. nextState.search.resultsLookup,
  775. nextRovingTabIndex
  776. );
  777. if (action.type === 'set roving index' && action.action_source === 'keyboard') {
  778. scrollRowIntoView(nextRovingNode, nextRovingTabIndex, undefined);
  779. }
  780. if (nextState.search.resultsLookup.has(nextRovingNode)) {
  781. const idx = nextState.search.resultsLookup.get(nextRovingNode)!;
  782. traceDispatch({
  783. type: 'set search iterator index',
  784. resultIndex: nextRovingTabIndex,
  785. resultIteratorIndex: idx,
  786. });
  787. } else if (nextState.search.resultIteratorIndex !== null) {
  788. traceDispatch({type: 'clear search iterator index'});
  789. }
  790. } else if (
  791. typeof nextSearchResultIndex === 'number' &&
  792. prevState.search.resultIndex !== nextSearchResultIndex &&
  793. action.type !== 'set search iterator index'
  794. ) {
  795. // If the search result index changes, mark the node as focused and scroll it into view
  796. const nextNode = tree.list[nextSearchResultIndex];
  797. setRowAsFocused(
  798. nextNode,
  799. null,
  800. nextState.search.resultsLookup,
  801. nextSearchResultIndex
  802. );
  803. scrollRowIntoView(nextNode, nextSearchResultIndex, 'center if outside');
  804. }
  805. };
  806. traceStateEmitter.on('before next state', beforeTraceNextStateDispatch);
  807. return () => {
  808. traceStateEmitter.off('before next state', beforeTraceNextStateDispatch);
  809. };
  810. }, [
  811. tree,
  812. onTraceSearch,
  813. traceStateEmitter,
  814. traceDispatch,
  815. setRowAsFocused,
  816. scrollRowIntoView,
  817. ]);
  818. // Setup the middleware for the view manager and store the list width as a preference
  819. useLayoutEffect(() => {
  820. function onDividerResizeEnd(list_width: number) {
  821. traceDispatch({
  822. type: 'set list width',
  823. payload: list_width,
  824. });
  825. }
  826. traceScheduler.on('divider resize end', onDividerResizeEnd);
  827. return () => {
  828. traceScheduler.off('divider resize end', onDividerResizeEnd);
  829. };
  830. }, [traceScheduler, traceDispatch]);
  831. // Sync part of the state with the URL
  832. const traceQueryStateSync = useMemo(() => {
  833. return {search: traceState.search.query};
  834. }, [traceState.search.query]);
  835. useTraceQueryParamStateSync(traceQueryStateSync);
  836. const [traceGridRef, setTraceGridRef] = useState<HTMLElement | null>(null);
  837. // Memoized because it requires tree traversal
  838. const shape = useMemo(() => tree.shape, [tree]);
  839. useEffect(() => {
  840. if (tree.type !== 'trace') {
  841. return;
  842. }
  843. logTraceMetadata(tree, projects, props.organization);
  844. }, [tree, projects, props.organization]);
  845. useLayoutEffect(() => {
  846. if (tree.type !== 'trace') {
  847. return undefined;
  848. }
  849. traceScheduler.dispatch('initialize trace space', [
  850. tree.root.space[0],
  851. 0,
  852. tree.root.space[1],
  853. 1,
  854. ]);
  855. // Whenever the timeline changes, update the trace space and trigger a redraw
  856. const onTraceTimelineChange = (s: [number, number]) => {
  857. traceScheduler.dispatch('set trace space', [s[0], 0, s[1], 1]);
  858. };
  859. tree.on('trace timeline change', onTraceTimelineChange);
  860. return () => {
  861. tree.off('trace timeline change', onTraceTimelineChange);
  862. };
  863. }, [viewManager, traceScheduler, tree]);
  864. return (
  865. <Fragment>
  866. <PerformanceSetupWarning
  867. tree={tree}
  868. traceSlug={props.traceSlug}
  869. organization={organization}
  870. />
  871. <TraceToolbar>
  872. <TraceSearchInput onTraceSearch={onTraceSearch} organization={organization} />
  873. <TraceResetZoomButton
  874. viewManager={viewManager}
  875. organization={props.organization}
  876. />
  877. <TraceShortcuts />
  878. </TraceToolbar>
  879. <TraceGrid layout={traceState.preferences.layout} ref={setTraceGridRef}>
  880. <Trace
  881. trace={tree}
  882. rerender={rerender}
  883. trace_id={props.traceSlug}
  884. scrollQueueRef={scrollQueueRef}
  885. initializedRef={initializedRef}
  886. onRowClick={onRowClick}
  887. onTraceLoad={onTraceLoad}
  888. onTraceSearch={onTraceSearch}
  889. previouslyFocusedNodeRef={previouslyFocusedNodeRef}
  890. manager={viewManager}
  891. scheduler={traceScheduler}
  892. forceRerender={forceRender}
  893. />
  894. {tree.type === 'error' ? (
  895. <TraceError />
  896. ) : tree.type === 'empty' ? (
  897. <TraceEmpty />
  898. ) : tree.type === 'loading' ||
  899. (scrollQueueRef.current && tree.type !== 'trace') ? (
  900. <TraceLoading />
  901. ) : null}
  902. <TraceDrawer
  903. replayRecord={props.replayRecord}
  904. metaResults={props.metaResults}
  905. traceType={shape}
  906. trace={tree}
  907. traceGridRef={traceGridRef}
  908. traces={props.trace ?? null}
  909. manager={viewManager}
  910. scheduler={traceScheduler}
  911. onTabScrollToNode={onTabScrollToNode}
  912. onScrollToNode={onScrollToNode}
  913. rootEventResults={props.rootEvent}
  914. traceEventView={props.traceEventView}
  915. />
  916. </TraceGrid>
  917. </Fragment>
  918. );
  919. }
  920. function TraceResetZoomButton(props: {
  921. organization: Organization;
  922. viewManager: VirtualizedViewManager;
  923. }) {
  924. const onResetZoom = useCallback(() => {
  925. traceAnalytics.trackResetZoom(props.organization);
  926. props.viewManager.resetZoom();
  927. }, [props.viewManager, props.organization]);
  928. return (
  929. <ResetZoomButton
  930. size="xs"
  931. onClick={onResetZoom}
  932. ref={props.viewManager.registerResetZoomRef}
  933. >
  934. {t('Reset Zoom')}
  935. </ResetZoomButton>
  936. );
  937. }
  938. const ResetZoomButton = styled(Button)`
  939. transition: opacity 0.2s 0.5s ease-in-out;
  940. &[disabled] {
  941. cursor: not-allowed;
  942. opacity: 0.65;
  943. }
  944. `;
  945. const TraceExternalLayout = styled('div')`
  946. display: flex;
  947. flex-direction: column;
  948. flex: 1 1 100%;
  949. ~ footer {
  950. display: none;
  951. }
  952. `;
  953. const TraceInnerLayout = styled('div')`
  954. display: flex;
  955. flex-direction: column;
  956. flex: 1 1 100%;
  957. padding: ${space(2)};
  958. background-color: ${p => p.theme.background};
  959. `;
  960. const TraceToolbar = styled('div')`
  961. flex-grow: 0;
  962. display: grid;
  963. grid-template-columns: 1fr min-content min-content;
  964. gap: ${space(1)};
  965. `;
  966. const TraceGrid = styled('div')<{
  967. layout: 'drawer bottom' | 'drawer left' | 'drawer right';
  968. }>`
  969. border: 1px solid ${p => p.theme.border};
  970. flex: 1 1 100%;
  971. display: grid;
  972. border-radius: ${p => p.theme.borderRadius};
  973. overflow: hidden;
  974. position: relative;
  975. /* false positive for grid layout */
  976. /* stylelint-disable */
  977. grid-template-areas: ${p =>
  978. p.layout === 'drawer bottom'
  979. ? `
  980. 'trace'
  981. 'drawer'
  982. `
  983. : p.layout === 'drawer left'
  984. ? `'drawer trace'`
  985. : `'trace drawer'`};
  986. grid-template-columns: ${p =>
  987. p.layout === 'drawer bottom'
  988. ? '1fr'
  989. : p.layout === 'drawer left'
  990. ? 'min-content 1fr'
  991. : '1fr min-content'};
  992. grid-template-rows: 1fr auto;
  993. `;
  994. const LoadingContainer = styled('div')<{animate: boolean; error?: boolean}>`
  995. display: flex;
  996. justify-content: center;
  997. align-items: center;
  998. flex-direction: column;
  999. left: 50%;
  1000. top: 50%;
  1001. position: absolute;
  1002. height: auto;
  1003. font-size: ${p => p.theme.fontSizeMedium};
  1004. color: ${p => p.theme.gray300};
  1005. z-index: 30;
  1006. padding: 24px;
  1007. background-color: ${p => p.theme.background};
  1008. border-radius: ${p => p.theme.borderRadius};
  1009. border: 1px solid ${p => p.theme.border};
  1010. transform-origin: 50% 50%;
  1011. transform: translate(-50%, -50%);
  1012. animation: ${p =>
  1013. p.animate
  1014. ? `${p.error ? 'showLoadingContainerShake' : 'showLoadingContainer'} 300ms cubic-bezier(0.61, 1, 0.88, 1) forwards`
  1015. : 'none'};
  1016. @keyframes showLoadingContainer {
  1017. from {
  1018. opacity: 0.6;
  1019. transform: scale(0.99) translate(-50%, -50%);
  1020. }
  1021. to {
  1022. opacity: 1;
  1023. transform: scale(1) translate(-50%, -50%);
  1024. }
  1025. }
  1026. @keyframes showLoadingContainerShake {
  1027. 0% {
  1028. transform: translate(-50%, -50%);
  1029. }
  1030. 25% {
  1031. transform: translate(-51%, -50%);
  1032. }
  1033. 75% {
  1034. transform: translate(-49%, -50%);
  1035. }
  1036. 100% {
  1037. transform: translate(-50%, -50%);
  1038. }
  1039. }
  1040. `;
  1041. function TraceLoading() {
  1042. return (
  1043. // Dont flash the animation on load because it's annoying
  1044. <LoadingContainer animate={false}>
  1045. <NoMarginIndicator size={24}>
  1046. <div>{t('Assembling the trace')}</div>
  1047. </NoMarginIndicator>
  1048. </LoadingContainer>
  1049. );
  1050. }
  1051. function TraceError() {
  1052. const linkref = useRef<HTMLAnchorElement>(null);
  1053. const feedback = useFeedbackWidget({buttonRef: linkref});
  1054. useEffect(() => {
  1055. traceAnalytics.trackFailedToFetchTraceState();
  1056. }, []);
  1057. return (
  1058. <LoadingContainer animate error>
  1059. <div>{t('Ughhhhh, we failed to load your trace...')}</div>
  1060. <div>
  1061. {t('Seeing this often? Send us ')}
  1062. {feedback ? (
  1063. <a href="#" ref={linkref}>
  1064. {t('feedback')}
  1065. </a>
  1066. ) : (
  1067. <a href="mailto:support@sentry.io?subject=Trace%20fails%20to%20load">
  1068. {t('feedback')}
  1069. </a>
  1070. )}
  1071. </div>
  1072. </LoadingContainer>
  1073. );
  1074. }
  1075. function TraceEmpty() {
  1076. const linkref = useRef<HTMLAnchorElement>(null);
  1077. const feedback = useFeedbackWidget({buttonRef: linkref});
  1078. useEffect(() => {
  1079. traceAnalytics.trackEmptyTraceState();
  1080. }, []);
  1081. return (
  1082. <LoadingContainer animate>
  1083. <div>{t('This trace does not contain any data?!')}</div>
  1084. <div>
  1085. {t('Seeing this often? Send us ')}
  1086. {feedback ? (
  1087. <a href="#" ref={linkref}>
  1088. {t('feedback')}
  1089. </a>
  1090. ) : (
  1091. <a href="mailto:support@sentry.io?subject=Trace%20does%20not%20contain%20data">
  1092. {t('feedback')}
  1093. </a>
  1094. )}
  1095. </div>
  1096. </LoadingContainer>
  1097. );
  1098. }
  1099. const NoMarginIndicator = styled(LoadingIndicator)`
  1100. margin: 0;
  1101. `;