content.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  1. import {Component, createRef, Fragment, useEffect} from 'react';
  2. import type {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import connectDotsImg from 'sentry-images/spot/performance-connect-dots.svg';
  5. import {Alert} from 'sentry/components/alert';
  6. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  7. import {Button} from 'sentry/components/button';
  8. import ButtonBar from 'sentry/components/buttonBar';
  9. import DiscoverButton from 'sentry/components/discoverButton';
  10. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  11. import * as Layout from 'sentry/components/layouts/thirds';
  12. import ExternalLink from 'sentry/components/links/externalLink';
  13. import LoadingError from 'sentry/components/loadingError';
  14. import LoadingIndicator from 'sentry/components/loadingIndicator';
  15. import {SidebarPanelKey} from 'sentry/components/sidebar/types';
  16. import TimeSince from 'sentry/components/timeSince';
  17. import {withPerformanceOnboarding} from 'sentry/data/platformCategories';
  18. import {IconClose} from 'sentry/icons';
  19. import {t, tct, tn} from 'sentry/locale';
  20. import SidebarPanelStore from 'sentry/stores/sidebarPanelStore';
  21. import {space} from 'sentry/styles/space';
  22. import type {Organization} from 'sentry/types/organization';
  23. import {defined} from 'sentry/utils';
  24. import {trackAnalytics} from 'sentry/utils/analytics';
  25. import type EventView from 'sentry/utils/discover/eventView';
  26. import type {QueryError} from 'sentry/utils/discover/genericDiscoverQuery';
  27. import getDuration from 'sentry/utils/duration/getDuration';
  28. import type {Fuse} from 'sentry/utils/fuzzySearch';
  29. import {createFuzzySearch} from 'sentry/utils/fuzzySearch';
  30. import getDynamicText from 'sentry/utils/getDynamicText';
  31. import type {
  32. TraceError,
  33. TraceFullDetailed,
  34. TraceMeta,
  35. } from 'sentry/utils/performance/quickTrace/types';
  36. import {filterTrace, reduceTrace} from 'sentry/utils/performance/quickTrace/utils';
  37. import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
  38. import useDismissAlert from 'sentry/utils/useDismissAlert';
  39. import useProjects from 'sentry/utils/useProjects';
  40. import Breadcrumb from 'sentry/views/performance/breadcrumb';
  41. import type {TraceTree} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
  42. import {MetaData} from 'sentry/views/performance/transactionDetails/styles';
  43. import {TraceDetailHeader, TraceSearchBar, TraceSearchContainer} from './styles';
  44. import TraceNotFound from './traceNotFound';
  45. import TraceView from './traceView';
  46. import type {TraceInfo} from './types';
  47. import {getTraceInfo, hasTraceData, isRootTransaction} from './utils';
  48. type IndexedFusedTransaction = {
  49. event: TraceFullDetailed | TraceError;
  50. indexed: string[];
  51. };
  52. type Props = Pick<RouteComponentProps<{traceSlug: string}, {}>, 'params' | 'location'> & {
  53. dateSelected: boolean;
  54. error: QueryError | null;
  55. isLoading: boolean;
  56. meta: TraceMeta | null;
  57. organization: Organization;
  58. traceEventView: EventView;
  59. traceSlug: string;
  60. traces: TraceTree.Transaction[] | null;
  61. handleLimitChange?: (newLimit: number) => void;
  62. orphanErrors?: TraceError[];
  63. };
  64. type State = {
  65. filteredEventIds: Set<string> | undefined;
  66. searchQuery: string | undefined;
  67. };
  68. class TraceDetailsContent extends Component<Props, State> {
  69. state: State = {
  70. searchQuery: undefined,
  71. filteredEventIds: undefined,
  72. };
  73. componentDidMount() {
  74. this.initFuse();
  75. }
  76. componentDidUpdate(prevProps: Props) {
  77. if (
  78. this.props.traces !== prevProps.traces ||
  79. this.props.orphanErrors !== prevProps.orphanErrors
  80. ) {
  81. this.initFuse();
  82. }
  83. }
  84. fuse: Fuse<IndexedFusedTransaction> | null = null;
  85. traceViewRef = createRef<HTMLDivElement>();
  86. virtualScrollbarContainerRef = createRef<HTMLDivElement>();
  87. async initFuse() {
  88. const {traces, orphanErrors} = this.props;
  89. if (!hasTraceData(traces, orphanErrors)) {
  90. return;
  91. }
  92. const transformedEvents: IndexedFusedTransaction[] =
  93. traces?.flatMap(trace =>
  94. reduceTrace<IndexedFusedTransaction[]>(
  95. trace,
  96. (acc, transaction) => {
  97. const indexed: string[] = [
  98. transaction['transaction.op'],
  99. transaction.transaction,
  100. transaction.project_slug,
  101. ];
  102. acc.push({
  103. event: transaction,
  104. indexed,
  105. });
  106. return acc;
  107. },
  108. []
  109. )
  110. ) ?? [];
  111. // Include orphan error titles and project slugs during fuzzy search
  112. orphanErrors?.forEach(orphanError => {
  113. const indexed: string[] = [orphanError.title, orphanError.project_slug, 'Unknown'];
  114. transformedEvents.push({
  115. indexed,
  116. event: orphanError,
  117. });
  118. });
  119. this.fuse = await createFuzzySearch(transformedEvents, {
  120. keys: ['indexed'],
  121. includeMatches: true,
  122. threshold: 0.6,
  123. location: 0,
  124. distance: 100,
  125. maxPatternLength: 32,
  126. });
  127. }
  128. renderTraceLoading() {
  129. return (
  130. <LoadingContainer>
  131. <StyledLoadingIndicator />
  132. {t('Hang in there, as we build your trace view!')}
  133. </LoadingContainer>
  134. );
  135. }
  136. renderTraceRequiresDateRangeSelection() {
  137. return <LoadingError message={t('Trace view requires a date range selection.')} />;
  138. }
  139. handleTransactionFilter = (searchQuery: string) => {
  140. this.setState({searchQuery: searchQuery || undefined}, this.filterTransactions);
  141. };
  142. filterTransactions = () => {
  143. const {traces, orphanErrors} = this.props;
  144. const {filteredEventIds, searchQuery} = this.state;
  145. if (!searchQuery || !hasTraceData(traces, orphanErrors) || !defined(this.fuse)) {
  146. if (filteredEventIds !== undefined) {
  147. this.setState({
  148. filteredEventIds: undefined,
  149. });
  150. }
  151. return;
  152. }
  153. const fuseMatches = this.fuse
  154. .search<IndexedFusedTransaction>(searchQuery)
  155. /**
  156. * Sometimes, there can be matches that don't include any
  157. * indices. These matches are often noise, so exclude them.
  158. */
  159. .filter(({matches}) => matches?.length)
  160. .map(({item}) => item.event.event_id);
  161. /**
  162. * Fuzzy search on ids result in seemingly random results. So switch to
  163. * doing substring matches on ids to provide more meaningful results.
  164. */
  165. const idMatches: string[] = [];
  166. traces
  167. ?.flatMap(trace =>
  168. filterTrace(
  169. trace,
  170. ({event_id, span_id}) =>
  171. event_id.includes(searchQuery) || span_id.includes(searchQuery)
  172. )
  173. )
  174. .forEach(transaction => idMatches.push(transaction.event_id));
  175. // Include orphan error event_ids and span_ids during substring search
  176. orphanErrors?.forEach(orphanError => {
  177. const {event_id, span} = orphanError;
  178. if (event_id.includes(searchQuery) || span.includes(searchQuery)) {
  179. idMatches.push(event_id);
  180. }
  181. });
  182. this.setState({
  183. filteredEventIds: new Set([...fuseMatches, ...idMatches]),
  184. });
  185. };
  186. renderSearchBar() {
  187. return (
  188. <TraceSearchContainer>
  189. <TraceSearchBar
  190. defaultQuery=""
  191. query={this.state.searchQuery || ''}
  192. placeholder={t('Search for events')}
  193. onSearch={this.handleTransactionFilter}
  194. />
  195. </TraceSearchContainer>
  196. );
  197. }
  198. renderTraceHeader(traceInfo: TraceInfo) {
  199. const {meta} = this.props;
  200. const errors = meta?.errors ?? traceInfo.errors.size;
  201. const performanceIssues =
  202. meta?.performance_issues ?? traceInfo.performanceIssues.size;
  203. return (
  204. <TraceDetailHeader>
  205. <GuideAnchor target="trace_view_guide_breakdown">
  206. <MetaData
  207. headingText={t('Event Breakdown')}
  208. tooltipText={t(
  209. 'The number of transactions and issues there are in this trace.'
  210. )}
  211. bodyText={tct('[transactions] | [errors]', {
  212. transactions: tn(
  213. '%s Transaction',
  214. '%s Transactions',
  215. meta?.transactions ?? traceInfo.transactions.size
  216. ),
  217. errors: tn('%s Issue', '%s Issues', errors + performanceIssues),
  218. })}
  219. subtext={tn(
  220. 'Across %s project',
  221. 'Across %s projects',
  222. meta?.projects ?? traceInfo.projects.size
  223. )}
  224. />
  225. </GuideAnchor>
  226. <MetaData
  227. headingText={t('Total Duration')}
  228. tooltipText={t('The time elapsed between the start and end of this trace.')}
  229. bodyText={getDuration(
  230. traceInfo.endTimestamp - traceInfo.startTimestamp,
  231. 2,
  232. true
  233. )}
  234. subtext={getDynamicText({
  235. value: <TimeSince date={(traceInfo.endTimestamp || 0) * 1000} />,
  236. fixed: '5 days ago',
  237. })}
  238. />
  239. </TraceDetailHeader>
  240. );
  241. }
  242. renderTraceWarnings() {
  243. const {traces, orphanErrors} = this.props;
  244. const {roots, orphans} = (traces ?? []).reduce(
  245. (counts, trace) => {
  246. if (isRootTransaction(trace)) {
  247. counts.roots++;
  248. } else {
  249. counts.orphans++;
  250. }
  251. return counts;
  252. },
  253. {roots: 0, orphans: 0}
  254. );
  255. let warning: React.ReactNode = null;
  256. if (roots === 0 && orphans > 0) {
  257. warning = (
  258. <Alert type="info" showIcon>
  259. <ExternalLink href="https://docs.sentry.io/product/performance/trace-view/#orphan-traces-and-broken-subtraces">
  260. {t(
  261. 'A root transaction is missing. Transactions linked by a dashed line have been orphaned and cannot be directly linked to the root.'
  262. )}
  263. </ExternalLink>
  264. </Alert>
  265. );
  266. } else if (roots === 1 && orphans > 0) {
  267. warning = (
  268. <Alert type="info" showIcon>
  269. <ExternalLink href="https://docs.sentry.io/product/performance/trace-view/#orphan-traces-and-broken-subtraces">
  270. {t(
  271. 'This trace has broken subtraces. Transactions linked by a dashed line have been orphaned and cannot be directly linked to the root.'
  272. )}
  273. </ExternalLink>
  274. </Alert>
  275. );
  276. } else if (roots > 1) {
  277. warning = (
  278. <Alert type="info" showIcon>
  279. <ExternalLink href="https://docs.sentry.io/product/sentry-basics/tracing/trace-view/#multiple-roots">
  280. {t('Multiple root transactions have been found with this trace ID.')}
  281. </ExternalLink>
  282. </Alert>
  283. );
  284. } else if (orphanErrors && orphanErrors.length > 0) {
  285. warning = <OnlyOrphanErrorWarnings orphanErrors={orphanErrors} />;
  286. }
  287. return warning;
  288. }
  289. renderContent() {
  290. const {
  291. dateSelected,
  292. isLoading,
  293. error,
  294. organization,
  295. location,
  296. traceEventView,
  297. traceSlug,
  298. traces,
  299. meta,
  300. orphanErrors,
  301. } = this.props;
  302. if (!dateSelected) {
  303. return this.renderTraceRequiresDateRangeSelection();
  304. }
  305. if (isLoading) {
  306. return this.renderTraceLoading();
  307. }
  308. const hasData = hasTraceData(traces, orphanErrors);
  309. if (error !== null || !hasData) {
  310. return (
  311. <TraceNotFound
  312. meta={meta}
  313. traceEventView={traceEventView}
  314. traceSlug={traceSlug}
  315. location={location}
  316. organization={organization}
  317. />
  318. );
  319. }
  320. const traceInfo = traces ? getTraceInfo(traces, orphanErrors) : undefined;
  321. return (
  322. <Fragment>
  323. {this.renderTraceWarnings()}
  324. {traceInfo && this.renderTraceHeader(traceInfo)}
  325. {this.renderSearchBar()}
  326. <Margin>
  327. <VisuallyCompleteWithData id="PerformanceDetails-TraceView" hasData={hasData}>
  328. <TraceView
  329. filteredEventIds={this.state.filteredEventIds}
  330. traceInfo={traceInfo}
  331. location={location}
  332. organization={organization}
  333. traceEventView={traceEventView}
  334. traceSlug={traceSlug}
  335. traces={traces || []}
  336. meta={meta}
  337. orphanErrors={orphanErrors || []}
  338. handleLimitChange={this.props.handleLimitChange}
  339. />
  340. </VisuallyCompleteWithData>
  341. </Margin>
  342. </Fragment>
  343. );
  344. }
  345. render() {
  346. const {organization, location, traceEventView, traceSlug} = this.props;
  347. return (
  348. <Fragment>
  349. <Layout.Header>
  350. <Layout.HeaderContent>
  351. <Breadcrumb
  352. organization={organization}
  353. location={location}
  354. traceSlug={traceSlug}
  355. />
  356. <Layout.Title data-test-id="trace-header">
  357. {t('Trace ID: %s', traceSlug)}
  358. </Layout.Title>
  359. </Layout.HeaderContent>
  360. <Layout.HeaderActions>
  361. <ButtonBar gap={1}>
  362. <DiscoverButton
  363. size="sm"
  364. to={traceEventView.getResultsViewUrlTarget(organization.slug)}
  365. onClick={() => {
  366. trackAnalytics('performance_views.trace_view.open_in_discover', {
  367. organization,
  368. });
  369. }}
  370. >
  371. {t('Open in Discover')}
  372. </DiscoverButton>
  373. </ButtonBar>
  374. </Layout.HeaderActions>
  375. </Layout.Header>
  376. <Layout.Body>
  377. <Layout.Main fullWidth>{this.renderContent()}</Layout.Main>
  378. </Layout.Body>
  379. </Fragment>
  380. );
  381. }
  382. }
  383. type OnlyOrphanErrorWarningsProps = {
  384. orphanErrors: TraceError[];
  385. };
  386. function OnlyOrphanErrorWarnings({orphanErrors}: OnlyOrphanErrorWarningsProps) {
  387. const {projects} = useProjects();
  388. const projectSlug = orphanErrors[0] ? orphanErrors[0].project_slug : '';
  389. const project = projects.find(p => p.slug === projectSlug);
  390. const LOCAL_STORAGE_KEY = `${project?.id}:performance-orphan-error-onboarding-banner-hide`;
  391. const currentPlatform = project?.platform;
  392. const hasPerformanceOnboarding = currentPlatform
  393. ? withPerformanceOnboarding.has(currentPlatform)
  394. : false;
  395. useEffect(() => {
  396. if (hasPerformanceOnboarding && location.hash === '#performance-sidequest') {
  397. SidebarPanelStore.activatePanel(SidebarPanelKey.PERFORMANCE_ONBOARDING);
  398. }
  399. }, [hasPerformanceOnboarding]);
  400. const {dismiss: snooze, isDismissed: isSnoozed} = useDismissAlert({
  401. key: LOCAL_STORAGE_KEY,
  402. expirationDays: 7,
  403. });
  404. const {dismiss, isDismissed} = useDismissAlert({
  405. key: LOCAL_STORAGE_KEY,
  406. expirationDays: 365,
  407. });
  408. if (!orphanErrors.length) {
  409. return null;
  410. }
  411. if (!hasPerformanceOnboarding) {
  412. return (
  413. <Alert type="info" showIcon>
  414. {t(
  415. "The good news is we know these errors are related to each other in the same trace. The bad news is that we can't tell you more than that due to limited sampling."
  416. )}
  417. </Alert>
  418. );
  419. }
  420. if (isDismissed || isSnoozed) {
  421. return null;
  422. }
  423. return (
  424. <BannerWrapper>
  425. <ActionsWrapper>
  426. <BannerTitle>{t('Connect the Dots')}</BannerTitle>
  427. <BannerDescription>
  428. {t(
  429. "If you haven't already, configure performance monitoring to learn more about how your services are interacting with each other. This will provide more clarity about how your errors are linked."
  430. )}
  431. </BannerDescription>
  432. <ButtonsWrapper>
  433. <ActionButton>
  434. <Button
  435. priority="primary"
  436. onClick={event => {
  437. event.preventDefault();
  438. window.location.hash = 'performance-sidequest';
  439. SidebarPanelStore.activatePanel(SidebarPanelKey.PERFORMANCE_ONBOARDING);
  440. }}
  441. >
  442. {t('Configure')}
  443. </Button>
  444. </ActionButton>
  445. <ActionButton>
  446. <Button href="https://docs.sentry.io/product/performance/" external>
  447. {t('Learn More')}
  448. </Button>
  449. </ActionButton>
  450. </ButtonsWrapper>
  451. </ActionsWrapper>
  452. {<Background image={connectDotsImg} />}
  453. <CloseDropdownMenu
  454. position="bottom-end"
  455. triggerProps={{
  456. showChevron: false,
  457. borderless: true,
  458. icon: <IconClose color="subText" />,
  459. }}
  460. size="xs"
  461. items={[
  462. {
  463. key: 'dismiss',
  464. label: t('Dismiss'),
  465. onAction: () => {
  466. dismiss();
  467. },
  468. },
  469. {
  470. key: 'snooze',
  471. label: t('Snooze'),
  472. onAction: () => {
  473. snooze();
  474. },
  475. },
  476. ]}
  477. />
  478. </BannerWrapper>
  479. );
  480. }
  481. const BannerWrapper = styled('div')`
  482. position: relative;
  483. border: 1px solid ${p => p.theme.border};
  484. border-radius: ${p => p.theme.borderRadius};
  485. padding: ${space(2)} ${space(3)};
  486. margin-bottom: ${space(2)};
  487. background: linear-gradient(
  488. 90deg,
  489. ${p => p.theme.backgroundSecondary}00 0%,
  490. ${p => p.theme.backgroundSecondary}FF 70%,
  491. ${p => p.theme.backgroundSecondary}FF 100%
  492. );
  493. min-width: 850px;
  494. `;
  495. const ActionsWrapper = styled('div')`
  496. max-width: 50%;
  497. `;
  498. const ButtonsWrapper = styled('div')`
  499. display: flex;
  500. align-items: center;
  501. gap: ${space(0.5)};
  502. `;
  503. const BannerTitle = styled('div')`
  504. font-size: ${p => p.theme.fontSizeExtraLarge};
  505. margin-bottom: ${space(1)};
  506. font-weight: ${p => p.theme.fontWeightBold};
  507. `;
  508. const BannerDescription = styled('div')`
  509. margin-bottom: ${space(1.5)};
  510. `;
  511. const CloseDropdownMenu = styled(DropdownMenu)`
  512. position: absolute;
  513. display: block;
  514. top: ${space(1)};
  515. right: ${space(1)};
  516. color: ${p => p.theme.white};
  517. cursor: pointer;
  518. z-index: 1;
  519. `;
  520. const Background = styled('div')<{image: any}>`
  521. display: flex;
  522. justify-self: flex-end;
  523. position: absolute;
  524. top: 14px;
  525. right: 15px;
  526. height: 81%;
  527. width: 100%;
  528. max-width: 413px;
  529. background-image: url(${p => p.image});
  530. background-repeat: no-repeat;
  531. background-size: contain;
  532. `;
  533. const ActionButton = styled('div')`
  534. display: flex;
  535. gap: ${space(1)};
  536. `;
  537. const StyledLoadingIndicator = styled(LoadingIndicator)`
  538. margin-bottom: 0;
  539. `;
  540. const LoadingContainer = styled('div')`
  541. font-size: ${p => p.theme.fontSizeLarge};
  542. color: ${p => p.theme.subText};
  543. text-align: center;
  544. `;
  545. const Margin = styled('div')`
  546. margin-top: ${space(2)};
  547. `;
  548. export default TraceDetailsContent;