content.tsx 18 KB

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