content.tsx 18 KB

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