index.tsx 39 KB

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