content.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. import * as React from 'react';
  2. import {Params} from 'react-router/lib/Router';
  3. import * as Sentry from '@sentry/react';
  4. import {Location} from 'history';
  5. import Alert from 'app/components/alert';
  6. import GuideAnchor from 'app/components/assistant/guideAnchor';
  7. import ButtonBar from 'app/components/buttonBar';
  8. import DiscoverFeature from 'app/components/discover/discoverFeature';
  9. import DiscoverButton from 'app/components/discoverButton';
  10. import * as AnchorLinkManager from 'app/components/events/interfaces/spans/anchorLinkManager';
  11. import * as DividerHandlerManager from 'app/components/events/interfaces/spans/dividerHandlerManager';
  12. import * as ScrollbarManager from 'app/components/events/interfaces/spans/scrollbarManager';
  13. import * as Layout from 'app/components/layouts/thirds';
  14. import ExternalLink from 'app/components/links/externalLink';
  15. import Link from 'app/components/links/link';
  16. import LoadingError from 'app/components/loadingError';
  17. import LoadingIndicator from 'app/components/loadingIndicator';
  18. import {MessageRow} from 'app/components/performance/waterfall/messageRow';
  19. import {
  20. DividerSpacer,
  21. ScrollbarContainer,
  22. VirtualScrollbar,
  23. VirtualScrollbarGrip,
  24. } from 'app/components/performance/waterfall/miniHeader';
  25. import {pickBarColor, toPercent} from 'app/components/performance/waterfall/utils';
  26. import TimeSince from 'app/components/timeSince';
  27. import {IconInfo} from 'app/icons';
  28. import {t, tct, tn} from 'app/locale';
  29. import {Organization} from 'app/types';
  30. import {createFuzzySearch} from 'app/utils/createFuzzySearch';
  31. import EventView from 'app/utils/discover/eventView';
  32. import {getDuration} from 'app/utils/formatters';
  33. import getDynamicText from 'app/utils/getDynamicText';
  34. import {TraceFullDetailed, TraceMeta} from 'app/utils/performance/quickTrace/types';
  35. import {filterTrace, reduceTrace} from 'app/utils/performance/quickTrace/utils';
  36. import Breadcrumb from 'app/views/performance/breadcrumb';
  37. import {MetaData} from 'app/views/performance/transactionDetails/styles';
  38. import {
  39. SearchContainer,
  40. StyledPanel,
  41. StyledSearchBar,
  42. TraceDetailBody,
  43. TraceDetailHeader,
  44. TraceViewContainer,
  45. TraceViewHeaderContainer,
  46. } from './styles';
  47. import TransactionGroup from './transactionGroup';
  48. import {TraceInfo, TreeDepth} from './types';
  49. import {getTraceInfo, isRootTransaction} from './utils';
  50. type IndexedFusedTransaction = {
  51. transaction: TraceFullDetailed;
  52. indexed: string[];
  53. };
  54. type AccType = {
  55. renderedChildren: React.ReactNode[];
  56. lastIndex: number;
  57. numberOfHiddenTransactionsAbove: number;
  58. };
  59. type Props = {
  60. location: Location;
  61. organization: Organization;
  62. params: Params;
  63. traceSlug: string;
  64. traceEventView: EventView;
  65. dateSelected: boolean;
  66. isLoading: boolean;
  67. error: string | null;
  68. traces: TraceFullDetailed[] | null;
  69. meta: TraceMeta | null;
  70. };
  71. type State = {
  72. searchQuery: string | undefined;
  73. filteredTransactionIds: Set<string> | undefined;
  74. };
  75. class TraceDetailsContent extends React.Component<Props, State> {
  76. state: State = {
  77. searchQuery: undefined,
  78. filteredTransactionIds: undefined,
  79. };
  80. traceViewRef = React.createRef<HTMLDivElement>();
  81. virtualScrollbarContainerRef = React.createRef<HTMLDivElement>();
  82. renderTraceLoading() {
  83. return <LoadingIndicator />;
  84. }
  85. renderTraceRequiresDateRangeSelection() {
  86. return <LoadingError message={t('Trace view requires a date range selection.')} />;
  87. }
  88. renderTraceNotFound() {
  89. return <LoadingError message={t('The trace you are looking for was not found.')} />;
  90. }
  91. handleTransactionFilter = (searchQuery: string) => {
  92. this.setState({searchQuery: searchQuery || undefined}, this.filterTransactions);
  93. };
  94. filterTransactions = async () => {
  95. const {traces} = this.props;
  96. const {filteredTransactionIds, searchQuery} = this.state;
  97. if (!searchQuery || traces === null || traces.length <= 0) {
  98. if (filteredTransactionIds !== undefined) {
  99. this.setState({
  100. filteredTransactionIds: undefined,
  101. });
  102. }
  103. return;
  104. }
  105. const transformed = traces.flatMap(trace =>
  106. reduceTrace<IndexedFusedTransaction[]>(
  107. trace,
  108. (acc, transaction) => {
  109. const indexed: string[] = [
  110. transaction['transaction.op'],
  111. transaction.transaction,
  112. transaction.project_slug,
  113. ];
  114. acc.push({
  115. transaction,
  116. indexed,
  117. });
  118. return acc;
  119. },
  120. []
  121. )
  122. );
  123. const fuse = await createFuzzySearch(transformed, {
  124. keys: ['indexed'],
  125. includeMatches: true,
  126. threshold: 0.6,
  127. location: 0,
  128. distance: 100,
  129. maxPatternLength: 32,
  130. });
  131. const fuseMatches = fuse
  132. .search<IndexedFusedTransaction>(searchQuery)
  133. /**
  134. * Sometimes, there can be matches that don't include any
  135. * indices. These matches are often noise, so exclude them.
  136. */
  137. .filter(({matches}) => matches.length)
  138. .map(({item}) => item.transaction.event_id);
  139. /**
  140. * Fuzzy search on ids result in seemingly random results. So switch to
  141. * doing substring matches on ids to provide more meaningful results.
  142. */
  143. const idMatches = traces
  144. .flatMap(trace =>
  145. filterTrace(
  146. trace,
  147. ({event_id, span_id}) =>
  148. event_id.includes(searchQuery) || span_id.includes(searchQuery)
  149. )
  150. )
  151. .map(transaction => transaction.event_id);
  152. this.setState({
  153. filteredTransactionIds: new Set([...fuseMatches, ...idMatches]),
  154. });
  155. };
  156. renderSearchBar() {
  157. return (
  158. <SearchContainer>
  159. <StyledSearchBar
  160. defaultQuery=""
  161. query={this.state.searchQuery || ''}
  162. placeholder={t('Search for transactions')}
  163. onSearch={this.handleTransactionFilter}
  164. />
  165. </SearchContainer>
  166. );
  167. }
  168. isTransactionVisible = (transaction: TraceFullDetailed): boolean => {
  169. const {filteredTransactionIds} = this.state;
  170. return filteredTransactionIds
  171. ? filteredTransactionIds.has(transaction.event_id)
  172. : true;
  173. };
  174. renderTraceHeader(traceInfo: TraceInfo) {
  175. const {meta} = this.props;
  176. return (
  177. <TraceDetailHeader>
  178. <GuideAnchor target="trace_view_guide_breakdown">
  179. <MetaData
  180. headingText={t('Event Breakdown')}
  181. tooltipText={t(
  182. 'The number of transactions and errors there are in this trace.'
  183. )}
  184. bodyText={tct('[transactions] | [errors]', {
  185. transactions: tn(
  186. '%s Transaction',
  187. '%s Transactions',
  188. meta?.transactions ?? traceInfo.transactions.size
  189. ),
  190. errors: tn('%s Error', '%s Errors', meta?.errors ?? traceInfo.errors.size),
  191. })}
  192. subtext={tn(
  193. 'Across %s project',
  194. 'Across %s projects',
  195. meta?.projects ?? traceInfo.projects.size
  196. )}
  197. />
  198. </GuideAnchor>
  199. <MetaData
  200. headingText={t('Total Duration')}
  201. tooltipText={t('The time elapsed between the start and end of this trace.')}
  202. bodyText={getDuration(
  203. traceInfo.endTimestamp - traceInfo.startTimestamp,
  204. 2,
  205. true
  206. )}
  207. subtext={getDynamicText({
  208. value: <TimeSince date={(traceInfo.endTimestamp || 0) * 1000} />,
  209. fixed: '5 days ago',
  210. })}
  211. />
  212. </TraceDetailHeader>
  213. );
  214. }
  215. renderTraceWarnings() {
  216. const {traces} = this.props;
  217. const {roots, orphans} = (traces ?? []).reduce(
  218. (counts, trace) => {
  219. if (isRootTransaction(trace)) {
  220. counts.roots++;
  221. } else {
  222. counts.orphans++;
  223. }
  224. return counts;
  225. },
  226. {roots: 0, orphans: 0}
  227. );
  228. let warning: React.ReactNode = null;
  229. if (roots === 0 && orphans > 0) {
  230. warning = (
  231. <Alert type="info" icon={<IconInfo size="sm" />}>
  232. <ExternalLink href="https://docs.sentry.io/product/performance/trace-view/#orphan-traces-and-broken-subtraces">
  233. {t(
  234. 'A root transaction is missing. Transactions linked by a dashed line have been orphaned and cannot be directly linked to the root.'
  235. )}
  236. </ExternalLink>
  237. </Alert>
  238. );
  239. } else if (roots === 1 && orphans > 0) {
  240. warning = (
  241. <Alert type="info" icon={<IconInfo size="sm" />}>
  242. <ExternalLink href="https://docs.sentry.io/product/performance/trace-view/#orphan-traces-and-broken-subtraces">
  243. {t(
  244. 'This trace has broken subtraces. Transactions linked by a dashed line have been orphaned and cannot be directly linked to the root.'
  245. )}
  246. </ExternalLink>
  247. </Alert>
  248. );
  249. } else if (roots > 1) {
  250. warning = (
  251. <Alert type="info" icon={<IconInfo size="sm" />}>
  252. <ExternalLink href="https://docs.sentry.io/product/performance/trace-view/#multiple-roots">
  253. {t('Multiple root transactions have been found with this trace ID.')}
  254. </ExternalLink>
  255. </Alert>
  256. );
  257. }
  258. return warning;
  259. }
  260. renderInfoMessage({
  261. isVisible,
  262. numberOfHiddenTransactionsAbove,
  263. }: {
  264. isVisible: boolean;
  265. numberOfHiddenTransactionsAbove: number;
  266. }) {
  267. const messages: React.ReactNode[] = [];
  268. if (isVisible) {
  269. if (numberOfHiddenTransactionsAbove === 1) {
  270. messages.push(
  271. <span key="stuff">
  272. {tct('[numOfTransaction] hidden transaction', {
  273. numOfTransaction: <strong>{numberOfHiddenTransactionsAbove}</strong>,
  274. })}
  275. </span>
  276. );
  277. } else if (numberOfHiddenTransactionsAbove > 1) {
  278. messages.push(
  279. <span key="stuff">
  280. {tct('[numOfTransaction] hidden transactions', {
  281. numOfTransaction: <strong>{numberOfHiddenTransactionsAbove}</strong>,
  282. })}
  283. </span>
  284. );
  285. }
  286. }
  287. if (messages.length <= 0) {
  288. return null;
  289. }
  290. return <MessageRow>{messages}</MessageRow>;
  291. }
  292. renderLimitExceededMessage(traceInfo: TraceInfo) {
  293. const {traceEventView, organization, meta} = this.props;
  294. const count = traceInfo.transactions.size;
  295. const totalTransactions = meta?.transactions ?? count;
  296. if (totalTransactions === null || count >= totalTransactions) {
  297. return null;
  298. }
  299. const target = traceEventView.getResultsViewUrlTarget(organization.slug);
  300. return (
  301. <MessageRow>
  302. {tct(
  303. 'Limited to a view of [count] transactions. To view the full list, [discover].',
  304. {
  305. count,
  306. discover: (
  307. <DiscoverFeature>
  308. {({hasFeature}) => (
  309. <Link disabled={!hasFeature} to={target}>
  310. Open in Discover
  311. </Link>
  312. )}
  313. </DiscoverFeature>
  314. ),
  315. }
  316. )}
  317. </MessageRow>
  318. );
  319. }
  320. renderTransaction(
  321. transaction: TraceFullDetailed,
  322. {
  323. continuingDepths,
  324. isOrphan,
  325. isLast,
  326. index,
  327. numberOfHiddenTransactionsAbove,
  328. traceInfo,
  329. hasGuideAnchor,
  330. }: {
  331. continuingDepths: TreeDepth[];
  332. isOrphan: boolean;
  333. isLast: boolean;
  334. index: number;
  335. numberOfHiddenTransactionsAbove: number;
  336. traceInfo: TraceInfo;
  337. hasGuideAnchor: boolean;
  338. }
  339. ) {
  340. const {location, organization} = this.props;
  341. const {children, event_id: eventId} = transaction;
  342. // Add 1 to the generation to make room for the "root trace"
  343. const generation = transaction.generation + 1;
  344. const isVisible = this.isTransactionVisible(transaction);
  345. const accumulated: AccType = children.reduce(
  346. (acc: AccType, child: TraceFullDetailed, idx: number) => {
  347. const isLastChild = idx === children.length - 1;
  348. const hasChildren = child.children.length > 0;
  349. const result = this.renderTransaction(child, {
  350. continuingDepths:
  351. !isLastChild && hasChildren
  352. ? [...continuingDepths, {depth: generation, isOrphanDepth: isOrphan}]
  353. : continuingDepths,
  354. isOrphan,
  355. isLast: isLastChild,
  356. index: acc.lastIndex + 1,
  357. numberOfHiddenTransactionsAbove: acc.numberOfHiddenTransactionsAbove,
  358. traceInfo,
  359. hasGuideAnchor: false,
  360. });
  361. acc.lastIndex = result.lastIndex;
  362. acc.numberOfHiddenTransactionsAbove = result.numberOfHiddenTransactionsAbove;
  363. acc.renderedChildren.push(result.transactionGroup);
  364. return acc;
  365. },
  366. {
  367. renderedChildren: [],
  368. lastIndex: index,
  369. numberOfHiddenTransactionsAbove: isVisible
  370. ? 0
  371. : numberOfHiddenTransactionsAbove + 1,
  372. }
  373. );
  374. return {
  375. transactionGroup: (
  376. <React.Fragment key={eventId}>
  377. {this.renderInfoMessage({
  378. isVisible,
  379. numberOfHiddenTransactionsAbove,
  380. })}
  381. <TransactionGroup
  382. location={location}
  383. organization={organization}
  384. traceInfo={traceInfo}
  385. transaction={{
  386. ...transaction,
  387. generation,
  388. }}
  389. continuingDepths={continuingDepths}
  390. isOrphan={isOrphan}
  391. isLast={isLast}
  392. index={index}
  393. isVisible={isVisible}
  394. hasGuideAnchor={hasGuideAnchor}
  395. renderedChildren={accumulated.renderedChildren}
  396. barColor={pickBarColor(transaction['transaction.op'])}
  397. />
  398. </React.Fragment>
  399. ),
  400. lastIndex: accumulated.lastIndex,
  401. numberOfHiddenTransactionsAbove: accumulated.numberOfHiddenTransactionsAbove,
  402. };
  403. }
  404. renderTraceView(traceInfo: TraceInfo) {
  405. const sentryTransaction = Sentry.getCurrentHub().getScope()?.getTransaction();
  406. const sentrySpan = sentryTransaction?.startChild({
  407. op: 'trace.render',
  408. description: 'trace-view-content',
  409. });
  410. const {location, organization, traces, traceSlug} = this.props;
  411. if (traces === null || traces.length <= 0) {
  412. return this.renderTraceNotFound();
  413. }
  414. const accumulator: {
  415. index: number;
  416. numberOfHiddenTransactionsAbove: number;
  417. traceInfo: TraceInfo;
  418. transactionGroups: React.ReactNode[];
  419. } = {
  420. index: 1,
  421. numberOfHiddenTransactionsAbove: 0,
  422. traceInfo,
  423. transactionGroups: [],
  424. };
  425. const {transactionGroups, numberOfHiddenTransactionsAbove} = traces.reduce(
  426. (acc, trace, index) => {
  427. const isLastTransaction = index === traces.length - 1;
  428. const hasChildren = trace.children.length > 0;
  429. const isNextChildOrphaned =
  430. !isLastTransaction && traces[index + 1].parent_span_id !== null;
  431. const result = this.renderTransaction(trace, {
  432. ...acc,
  433. // if the root of a subtrace has a parent_span_idk, then it must be an orphan
  434. isOrphan: !isRootTransaction(trace),
  435. isLast: isLastTransaction,
  436. continuingDepths:
  437. !isLastTransaction && hasChildren
  438. ? [{depth: 0, isOrphanDepth: isNextChildOrphaned}]
  439. : [],
  440. hasGuideAnchor: index === 0,
  441. });
  442. acc.index = result.lastIndex + 1;
  443. acc.numberOfHiddenTransactionsAbove = result.numberOfHiddenTransactionsAbove;
  444. acc.transactionGroups.push(result.transactionGroup);
  445. return acc;
  446. },
  447. accumulator
  448. );
  449. const traceView = (
  450. <TraceDetailBody>
  451. <DividerHandlerManager.Provider interactiveLayerRef={this.traceViewRef}>
  452. <DividerHandlerManager.Consumer>
  453. {({dividerPosition}) => (
  454. <ScrollbarManager.Provider
  455. dividerPosition={dividerPosition}
  456. interactiveLayerRef={this.virtualScrollbarContainerRef}
  457. >
  458. <StyledPanel>
  459. <TraceViewHeaderContainer>
  460. <ScrollbarManager.Consumer>
  461. {({
  462. virtualScrollbarRef,
  463. scrollBarAreaRef,
  464. onDragStart,
  465. onScroll,
  466. }) => {
  467. return (
  468. <ScrollbarContainer
  469. ref={this.virtualScrollbarContainerRef}
  470. style={{
  471. // the width of this component is shrunk to compensate for half of the width of the divider line
  472. width: `calc(${toPercent(dividerPosition)} - 0.5px)`,
  473. }}
  474. onScroll={onScroll}
  475. >
  476. <div
  477. style={{
  478. width: 0,
  479. height: '1px',
  480. }}
  481. ref={scrollBarAreaRef}
  482. />
  483. <VirtualScrollbar
  484. data-type="virtual-scrollbar"
  485. ref={virtualScrollbarRef}
  486. onMouseDown={onDragStart}
  487. >
  488. <VirtualScrollbarGrip />
  489. </VirtualScrollbar>
  490. </ScrollbarContainer>
  491. );
  492. }}
  493. </ScrollbarManager.Consumer>
  494. <DividerSpacer />
  495. </TraceViewHeaderContainer>
  496. <TraceViewContainer ref={this.traceViewRef}>
  497. <AnchorLinkManager.Provider>
  498. <TransactionGroup
  499. location={location}
  500. organization={organization}
  501. traceInfo={traceInfo}
  502. transaction={{
  503. traceSlug,
  504. generation: 0,
  505. 'transaction.duration':
  506. traceInfo.endTimestamp - traceInfo.startTimestamp,
  507. children: traces,
  508. start_timestamp: traceInfo.startTimestamp,
  509. timestamp: traceInfo.endTimestamp,
  510. }}
  511. continuingDepths={[]}
  512. isOrphan={false}
  513. isLast={false}
  514. index={0}
  515. isVisible
  516. hasGuideAnchor={false}
  517. renderedChildren={transactionGroups}
  518. barColor={pickBarColor('')}
  519. />
  520. </AnchorLinkManager.Provider>
  521. {this.renderInfoMessage({
  522. isVisible: true,
  523. numberOfHiddenTransactionsAbove,
  524. })}
  525. {this.renderLimitExceededMessage(traceInfo)}
  526. </TraceViewContainer>
  527. </StyledPanel>
  528. </ScrollbarManager.Provider>
  529. )}
  530. </DividerHandlerManager.Consumer>
  531. </DividerHandlerManager.Provider>
  532. </TraceDetailBody>
  533. );
  534. sentrySpan?.finish();
  535. return traceView;
  536. }
  537. renderContent() {
  538. const {dateSelected, isLoading, error, traces} = this.props;
  539. if (!dateSelected) {
  540. return this.renderTraceRequiresDateRangeSelection();
  541. } else if (isLoading) {
  542. return this.renderTraceLoading();
  543. } else if (error !== null || traces === null || traces.length <= 0) {
  544. return this.renderTraceNotFound();
  545. } else {
  546. const traceInfo = getTraceInfo(traces);
  547. return (
  548. <React.Fragment>
  549. {this.renderTraceWarnings()}
  550. {this.renderTraceHeader(traceInfo)}
  551. {this.renderSearchBar()}
  552. {this.renderTraceView(traceInfo)}
  553. </React.Fragment>
  554. );
  555. }
  556. }
  557. render() {
  558. const {organization, location, traceEventView, traceSlug} = this.props;
  559. return (
  560. <React.Fragment>
  561. <Layout.Header>
  562. <Layout.HeaderContent>
  563. <Breadcrumb
  564. organization={organization}
  565. location={location}
  566. traceSlug={traceSlug}
  567. />
  568. <Layout.Title data-test-id="trace-header">
  569. {t('Trace ID: %s', traceSlug)}
  570. </Layout.Title>
  571. </Layout.HeaderContent>
  572. <Layout.HeaderActions>
  573. <ButtonBar gap={1}>
  574. <DiscoverButton
  575. to={traceEventView.getResultsViewUrlTarget(organization.slug)}
  576. >
  577. Open in Discover
  578. </DiscoverButton>
  579. </ButtonBar>
  580. </Layout.HeaderActions>
  581. </Layout.Header>
  582. <Layout.Body>
  583. <Layout.Main fullWidth>{this.renderContent()}</Layout.Main>
  584. </Layout.Body>
  585. </React.Fragment>
  586. );
  587. }
  588. }
  589. export default TraceDetailsContent;