transactionBar.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
  1. import {Component, createRef, Fragment} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location} from 'history';
  5. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  6. import Count from 'sentry/components/count';
  7. import * as DividerHandlerManager from 'sentry/components/events/interfaces/spans/dividerHandlerManager';
  8. import * as ScrollbarManager from 'sentry/components/events/interfaces/spans/scrollbarManager';
  9. import {MeasurementMarker} from 'sentry/components/events/interfaces/spans/styles';
  10. import {
  11. getMeasurementBounds,
  12. SpanBoundsType,
  13. SpanGeneratedBoundsType,
  14. transactionTargetHash,
  15. VerticalMark,
  16. } from 'sentry/components/events/interfaces/spans/utils';
  17. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  18. import Link from 'sentry/components/links/link';
  19. import {ROW_HEIGHT, SpanBarType} from 'sentry/components/performance/waterfall/constants';
  20. import {
  21. Row,
  22. RowCell,
  23. RowCellContainer,
  24. RowReplayTimeIndicators,
  25. } from 'sentry/components/performance/waterfall/row';
  26. import {DurationPill, RowRectangle} from 'sentry/components/performance/waterfall/rowBar';
  27. import {
  28. DividerContainer,
  29. DividerLine,
  30. DividerLineGhostContainer,
  31. ErrorBadge,
  32. } from 'sentry/components/performance/waterfall/rowDivider';
  33. import {
  34. RowTitle,
  35. RowTitleContainer,
  36. RowTitleContent,
  37. } from 'sentry/components/performance/waterfall/rowTitle';
  38. import {
  39. ConnectorBar,
  40. TOGGLE_BORDER_BOX,
  41. TreeConnector,
  42. TreeToggle,
  43. TreeToggleContainer,
  44. TreeToggleIcon,
  45. } from 'sentry/components/performance/waterfall/treeConnector';
  46. import {
  47. getDurationDisplay,
  48. getHumanDuration,
  49. } from 'sentry/components/performance/waterfall/utils';
  50. import {generateIssueEventTarget} from 'sentry/components/quickTrace/utils';
  51. import {Tooltip} from 'sentry/components/tooltip';
  52. import {Organization} from 'sentry/types';
  53. import {defined} from 'sentry/utils';
  54. import toPercent from 'sentry/utils/number/toPercent';
  55. import {TraceError, TraceFullDetailed} from 'sentry/utils/performance/quickTrace/types';
  56. import {
  57. isTraceError,
  58. isTraceRoot,
  59. isTraceTransaction,
  60. } from 'sentry/utils/performance/quickTrace/utils';
  61. import Projects from 'sentry/utils/projects';
  62. import {ProjectBadgeContainer} from './styles';
  63. import TransactionDetail from './transactionDetail';
  64. import {TraceInfo, TraceRoot, TreeDepth} from './types';
  65. import {shortenErrorTitle} from './utils';
  66. const MARGIN_LEFT = 0;
  67. type Props = {
  68. addContentSpanBarRef: (instance: HTMLDivElement | null) => void;
  69. continuingDepths: TreeDepth[];
  70. generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
  71. hasGuideAnchor: boolean;
  72. index: number;
  73. isExpanded: boolean;
  74. isLast: boolean;
  75. isOrphan: boolean;
  76. isVisible: boolean;
  77. location: Location;
  78. onWheel: (deltaX: number) => void;
  79. organization: Organization;
  80. removeContentSpanBarRef: (instance: HTMLDivElement | null) => void;
  81. toggleExpandedState: () => void;
  82. traceInfo: TraceInfo;
  83. transaction: TraceRoot | TraceFullDetailed | TraceError;
  84. barColor?: string;
  85. isOrphanError?: boolean;
  86. measurements?: Map<number, VerticalMark>;
  87. numOfOrphanErrors?: number;
  88. onlyOrphanErrors?: boolean;
  89. };
  90. type State = {
  91. showDetail: boolean;
  92. };
  93. class TransactionBar extends Component<Props, State> {
  94. state: State = {
  95. showDetail: false,
  96. };
  97. componentDidMount() {
  98. const {location, transaction} = this.props;
  99. if (
  100. 'event_id' in transaction &&
  101. transactionTargetHash(transaction.event_id) === location.hash
  102. ) {
  103. this.scrollIntoView();
  104. }
  105. if (this.transactionTitleRef.current) {
  106. this.transactionTitleRef.current.addEventListener('wheel', this.handleWheel, {
  107. passive: false,
  108. });
  109. }
  110. }
  111. componentWillUnmount() {
  112. if (this.transactionTitleRef.current) {
  113. this.transactionTitleRef.current.removeEventListener('wheel', this.handleWheel);
  114. }
  115. }
  116. transactionRowDOMRef = createRef<HTMLDivElement>();
  117. transactionTitleRef = createRef<HTMLDivElement>();
  118. spanContentRef: HTMLDivElement | null = null;
  119. handleRowCellClick = () => {
  120. const {transaction, organization} = this.props;
  121. if (isTraceError(transaction)) {
  122. browserHistory.push(generateIssueEventTarget(transaction, organization));
  123. }
  124. if (isTraceTransaction<TraceFullDetailed>(transaction)) {
  125. this.setState(state => ({
  126. showDetail: !state.showDetail,
  127. }));
  128. }
  129. };
  130. getCurrentOffset() {
  131. const {transaction} = this.props;
  132. const {generation} = transaction;
  133. return getOffset(generation);
  134. }
  135. handleWheel = (event: WheelEvent) => {
  136. // https://stackoverflow.com/q/57358640
  137. // https://github.com/facebook/react/issues/14856
  138. if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
  139. return;
  140. }
  141. event.preventDefault();
  142. event.stopPropagation();
  143. if (Math.abs(event.deltaY) === Math.abs(event.deltaX)) {
  144. return;
  145. }
  146. const {onWheel} = this.props;
  147. onWheel(event.deltaX);
  148. };
  149. renderMeasurements() {
  150. const {measurements, generateBounds} = this.props;
  151. if (!measurements) {
  152. return null;
  153. }
  154. return (
  155. <Fragment>
  156. {Array.from(measurements.values()).map(verticalMark => {
  157. const mark = Object.values(verticalMark.marks)[0];
  158. const {timestamp} = mark;
  159. const bounds = getMeasurementBounds(timestamp, generateBounds);
  160. const shouldDisplay = defined(bounds.left) && defined(bounds.width);
  161. if (!shouldDisplay || !bounds.isSpanVisibleInView) {
  162. return null;
  163. }
  164. return (
  165. <MeasurementMarker
  166. key={String(timestamp)}
  167. style={{
  168. left: `clamp(0%, ${toPercent(bounds.left || 0)}, calc(100% - 1px))`,
  169. }}
  170. failedThreshold={verticalMark.failedThreshold}
  171. />
  172. );
  173. })}
  174. </Fragment>
  175. );
  176. }
  177. renderConnector(hasToggle: boolean) {
  178. const {continuingDepths, isExpanded, isOrphan, isLast, transaction} = this.props;
  179. const {generation = 0} = transaction;
  180. const eventId =
  181. isTraceTransaction<TraceFullDetailed>(transaction) || isTraceError(transaction)
  182. ? transaction.event_id
  183. : transaction.traceSlug;
  184. if (generation === 0) {
  185. if (hasToggle) {
  186. return (
  187. <ConnectorBar
  188. style={{right: '15px', height: '10px', bottom: '-5px', top: 'auto'}}
  189. orphanBranch={false}
  190. />
  191. );
  192. }
  193. return null;
  194. }
  195. const connectorBars: Array<React.ReactNode> = continuingDepths.map(
  196. ({depth, isOrphanDepth}) => {
  197. if (generation - depth <= 1) {
  198. // If the difference is less than or equal to 1, then it means that the continued
  199. // bar is from its direct parent. In this case, do not render a connector bar
  200. // because the tree connector below will suffice.
  201. return null;
  202. }
  203. const left = -1 * getOffset(generation - depth - 1) - 2;
  204. return (
  205. <ConnectorBar
  206. style={{left}}
  207. key={`${eventId}-${depth}`}
  208. orphanBranch={isOrphanDepth}
  209. />
  210. );
  211. }
  212. );
  213. if (hasToggle && isExpanded) {
  214. connectorBars.push(
  215. <ConnectorBar
  216. style={{
  217. right: '15px',
  218. height: '10px',
  219. bottom: isLast ? `-${ROW_HEIGHT / 2 + 1}px` : '0',
  220. top: 'auto',
  221. }}
  222. key={`${eventId}-last`}
  223. orphanBranch={false}
  224. />
  225. );
  226. }
  227. return (
  228. <TreeConnector isLast={isLast} hasToggler={hasToggle} orphanBranch={isOrphan}>
  229. {connectorBars}
  230. </TreeConnector>
  231. );
  232. }
  233. renderToggle(errored: boolean) {
  234. const {isExpanded, transaction, toggleExpandedState, numOfOrphanErrors} = this.props;
  235. const left = this.getCurrentOffset();
  236. const hasOrphanErrors = numOfOrphanErrors && numOfOrphanErrors > 0;
  237. const childrenLength =
  238. (!isTraceError(transaction) && transaction.children?.length) || 0;
  239. const generation = transaction.generation || 0;
  240. if (childrenLength <= 0 && !hasOrphanErrors) {
  241. return (
  242. <TreeToggleContainer style={{left: `${left}px`}}>
  243. {this.renderConnector(false)}
  244. </TreeToggleContainer>
  245. );
  246. }
  247. const isRoot = generation === 0;
  248. return (
  249. <TreeToggleContainer style={{left: `${left}px`}} hasToggler>
  250. {this.renderConnector(true)}
  251. <TreeToggle
  252. disabled={isRoot}
  253. isExpanded={isExpanded}
  254. errored={errored}
  255. onClick={event => {
  256. event.stopPropagation();
  257. if (isRoot) {
  258. return;
  259. }
  260. toggleExpandedState();
  261. }}
  262. >
  263. <Count value={childrenLength + (numOfOrphanErrors ?? 0)} />
  264. {!isRoot && (
  265. <div>
  266. <TreeToggleIcon direction={isExpanded ? 'up' : 'down'} />
  267. </div>
  268. )}
  269. </TreeToggle>
  270. </TreeToggleContainer>
  271. );
  272. }
  273. // TODO: Use ScrollbarManager to bring autoscrolling here
  274. renderTitle(_: ScrollbarManager.ScrollbarManagerChildrenProps) {
  275. const {organization, transaction, addContentSpanBarRef, removeContentSpanBarRef} =
  276. this.props;
  277. const left = this.getCurrentOffset();
  278. const errored = isTraceTransaction<TraceFullDetailed>(transaction)
  279. ? transaction.errors &&
  280. transaction.errors.length + transaction.performance_issues.length > 0
  281. : false;
  282. const projectBadge = (isTraceTransaction<TraceFullDetailed>(transaction) ||
  283. isTraceError(transaction)) && (
  284. <Projects orgId={organization.slug} slugs={[transaction.project_slug]}>
  285. {({projects}) => {
  286. const project = projects.find(p => p.slug === transaction.project_slug);
  287. return (
  288. <ProjectBadgeContainer>
  289. <Tooltip title={transaction.project_slug}>
  290. <ProjectBadge
  291. project={project ? project : {slug: transaction.project_slug}}
  292. avatarSize={16}
  293. hideName
  294. />
  295. </Tooltip>
  296. </ProjectBadgeContainer>
  297. );
  298. }}
  299. </Projects>
  300. );
  301. const content = isTraceError(transaction) ? (
  302. <Fragment>
  303. {projectBadge}
  304. <RowTitleContent errored>
  305. <ErrorLink to={generateIssueEventTarget(transaction, organization)}>
  306. <strong>{'Unknown \u2014 '}</strong>
  307. {shortenErrorTitle(transaction.title)}
  308. </ErrorLink>
  309. </RowTitleContent>
  310. </Fragment>
  311. ) : isTraceTransaction<TraceFullDetailed>(transaction) ? (
  312. <Fragment>
  313. {projectBadge}
  314. <RowTitleContent errored={errored}>
  315. <strong>
  316. {transaction['transaction.op']}
  317. {' \u2014 '}
  318. </strong>
  319. {transaction.transaction}
  320. </RowTitleContent>
  321. </Fragment>
  322. ) : (
  323. <RowTitleContent errored={false}>
  324. <strong>{'Trace \u2014 '}</strong>
  325. {transaction.traceSlug}
  326. </RowTitleContent>
  327. );
  328. return (
  329. <RowTitleContainer
  330. ref={ref => {
  331. if (!ref) {
  332. removeContentSpanBarRef(this.spanContentRef);
  333. return;
  334. }
  335. addContentSpanBarRef(ref);
  336. this.spanContentRef = ref;
  337. }}
  338. >
  339. {this.renderToggle(errored)}
  340. <RowTitle
  341. style={{
  342. left: `${left}px`,
  343. width: '100%',
  344. }}
  345. >
  346. {content}
  347. </RowTitle>
  348. </RowTitleContainer>
  349. );
  350. }
  351. renderDivider(
  352. dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
  353. ) {
  354. if (this.state.showDetail) {
  355. // Mock component to preserve layout spacing
  356. return (
  357. <DividerLine
  358. showDetail
  359. style={{
  360. position: 'absolute',
  361. }}
  362. />
  363. );
  364. }
  365. const {addDividerLineRef} = dividerHandlerChildrenProps;
  366. return (
  367. <DividerLine
  368. ref={addDividerLineRef()}
  369. style={{
  370. position: 'absolute',
  371. }}
  372. onMouseEnter={() => {
  373. dividerHandlerChildrenProps.setHover(true);
  374. }}
  375. onMouseLeave={() => {
  376. dividerHandlerChildrenProps.setHover(false);
  377. }}
  378. onMouseOver={() => {
  379. dividerHandlerChildrenProps.setHover(true);
  380. }}
  381. onMouseDown={dividerHandlerChildrenProps.onDragStart}
  382. onClick={event => {
  383. // we prevent the propagation of the clicks from this component to prevent
  384. // the span detail from being opened.
  385. event.stopPropagation();
  386. }}
  387. />
  388. );
  389. }
  390. renderGhostDivider(
  391. dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
  392. ) {
  393. const {dividerPosition, addGhostDividerLineRef} = dividerHandlerChildrenProps;
  394. return (
  395. <DividerLineGhostContainer
  396. style={{
  397. width: `calc(${toPercent(dividerPosition)} + 0.5px)`,
  398. display: 'none',
  399. }}
  400. >
  401. <DividerLine
  402. ref={addGhostDividerLineRef()}
  403. style={{
  404. right: 0,
  405. }}
  406. className="hovering"
  407. onClick={event => {
  408. // the ghost divider line should not be interactive.
  409. // we prevent the propagation of the clicks from this component to prevent
  410. // the span detail from being opened.
  411. event.stopPropagation();
  412. }}
  413. />
  414. </DividerLineGhostContainer>
  415. );
  416. }
  417. renderErrorBadge() {
  418. const {transaction} = this.props;
  419. if (
  420. isTraceRoot(transaction) ||
  421. isTraceError(transaction) ||
  422. !(transaction.errors.length + transaction.performance_issues.length)
  423. ) {
  424. return null;
  425. }
  426. return <ErrorBadge />;
  427. }
  428. renderRectangle() {
  429. const {transaction, traceInfo, barColor} = this.props;
  430. const {showDetail} = this.state;
  431. // Use 1 as the difference in the case that startTimestamp === endTimestamp
  432. const delta = Math.abs(traceInfo.endTimestamp - traceInfo.startTimestamp) || 1;
  433. const start_timestamp = isTraceError(transaction)
  434. ? transaction.timestamp
  435. : transaction.start_timestamp;
  436. if (!(start_timestamp && transaction.timestamp)) {
  437. return null;
  438. }
  439. const startPosition = Math.abs(start_timestamp - traceInfo.startTimestamp);
  440. const startPercentage = startPosition / delta;
  441. const duration = Math.abs(transaction.timestamp - start_timestamp);
  442. const widthPercentage = duration / delta;
  443. return (
  444. <StyledRowRectangle
  445. style={{
  446. backgroundColor: barColor,
  447. left: `min(${toPercent(startPercentage || 0)}, calc(100% - 1px))`,
  448. width: toPercent(widthPercentage || 0),
  449. }}
  450. >
  451. {this.renderPerformanceIssues()}
  452. {isTraceError(transaction) ? (
  453. <ErrorBadge />
  454. ) : (
  455. <Fragment>
  456. {this.renderErrorBadge()}
  457. <DurationPill
  458. durationDisplay={getDurationDisplay({
  459. left: startPercentage,
  460. width: widthPercentage,
  461. })}
  462. showDetail={showDetail}
  463. >
  464. {getHumanDuration(duration)}
  465. </DurationPill>
  466. </Fragment>
  467. )}
  468. </StyledRowRectangle>
  469. );
  470. }
  471. renderPerformanceIssues() {
  472. const {transaction, barColor} = this.props;
  473. if (isTraceError(transaction) || isTraceRoot(transaction)) {
  474. return null;
  475. }
  476. const rows: React.ReactElement[] = [];
  477. // Use 1 as the difference in the case that startTimestamp === endTimestamp
  478. const delta = Math.abs(transaction.timestamp - transaction.start_timestamp) || 1;
  479. for (let i = 0; i < transaction.performance_issues.length; i++) {
  480. const issue = transaction.performance_issues[i];
  481. const startPosition = Math.abs(issue.start - transaction.start_timestamp);
  482. const startPercentage = startPosition / delta;
  483. const duration = Math.abs(issue.end - issue.start);
  484. const widthPercentage = duration / delta;
  485. rows.push(
  486. <RowRectangle
  487. style={{
  488. backgroundColor: barColor,
  489. left: `min(${toPercent(startPercentage || 0)}, calc(100% - 1px))`,
  490. width: toPercent(widthPercentage || 0),
  491. }}
  492. spanBarType={SpanBarType.AFFECTED}
  493. />
  494. );
  495. }
  496. return rows;
  497. }
  498. renderHeader({
  499. dividerHandlerChildrenProps,
  500. scrollbarManagerChildrenProps,
  501. }: {
  502. dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps;
  503. scrollbarManagerChildrenProps: ScrollbarManager.ScrollbarManagerChildrenProps;
  504. }) {
  505. const {hasGuideAnchor, index, transaction, onlyOrphanErrors = false} = this.props;
  506. const {showDetail} = this.state;
  507. const {dividerPosition} = dividerHandlerChildrenProps;
  508. const hideDurationRectangle = isTraceRoot(transaction) && onlyOrphanErrors;
  509. return (
  510. <RowCellContainer showDetail={showDetail}>
  511. <RowCell
  512. data-test-id="transaction-row-title"
  513. data-type="span-row-cell"
  514. style={{
  515. width: `calc(${toPercent(dividerPosition)} - 0.5px)`,
  516. paddingTop: 0,
  517. }}
  518. showDetail={showDetail}
  519. onClick={this.handleRowCellClick}
  520. ref={this.transactionTitleRef}
  521. >
  522. <GuideAnchor target="trace_view_guide_row" disabled={!hasGuideAnchor}>
  523. {this.renderTitle(scrollbarManagerChildrenProps)}
  524. </GuideAnchor>
  525. </RowCell>
  526. <DividerContainer>
  527. {this.renderDivider(dividerHandlerChildrenProps)}
  528. </DividerContainer>
  529. <RowCell
  530. data-test-id="transaction-row-duration"
  531. data-type="span-row-cell"
  532. showStriping={index % 2 !== 0}
  533. style={{
  534. width: `calc(${toPercent(1 - dividerPosition)} - 0.5px)`,
  535. paddingTop: 0,
  536. overflow: 'visible',
  537. }}
  538. showDetail={showDetail}
  539. onClick={this.handleRowCellClick}
  540. >
  541. <RowReplayTimeIndicators />
  542. <GuideAnchor target="trace_view_guide_row_details" disabled={!hasGuideAnchor}>
  543. {!hideDurationRectangle && this.renderRectangle()}
  544. {this.renderMeasurements()}
  545. </GuideAnchor>
  546. </RowCell>
  547. {!showDetail && this.renderGhostDivider(dividerHandlerChildrenProps)}
  548. </RowCellContainer>
  549. );
  550. }
  551. scrollIntoView = () => {
  552. const element = this.transactionRowDOMRef.current;
  553. if (!element) {
  554. return;
  555. }
  556. const boundingRect = element.getBoundingClientRect();
  557. const offset = boundingRect.top + window.scrollY;
  558. this.setState({showDetail: true}, () => window.scrollTo(0, offset));
  559. };
  560. renderDetail() {
  561. const {location, organization, isVisible, transaction} = this.props;
  562. const {showDetail} = this.state;
  563. if (isTraceError(transaction) || isTraceRoot(transaction)) {
  564. return null;
  565. }
  566. if (!isVisible || !showDetail) {
  567. return null;
  568. }
  569. return (
  570. <TransactionDetail
  571. location={location}
  572. organization={organization}
  573. transaction={transaction}
  574. scrollIntoView={this.scrollIntoView}
  575. />
  576. );
  577. }
  578. render() {
  579. const {isVisible, transaction} = this.props;
  580. const {showDetail} = this.state;
  581. return (
  582. <StyledRow
  583. ref={this.transactionRowDOMRef}
  584. visible={isVisible}
  585. showBorder={showDetail}
  586. cursor={
  587. isTraceTransaction<TraceFullDetailed>(transaction) ? 'pointer' : 'default'
  588. }
  589. >
  590. <ScrollbarManager.Consumer>
  591. {scrollbarManagerChildrenProps => (
  592. <DividerHandlerManager.Consumer>
  593. {dividerHandlerChildrenProps =>
  594. this.renderHeader({
  595. dividerHandlerChildrenProps,
  596. scrollbarManagerChildrenProps,
  597. })
  598. }
  599. </DividerHandlerManager.Consumer>
  600. )}
  601. </ScrollbarManager.Consumer>
  602. {this.renderDetail()}
  603. </StyledRow>
  604. );
  605. }
  606. }
  607. function getOffset(generation) {
  608. return generation * (TOGGLE_BORDER_BOX / 2) + MARGIN_LEFT;
  609. }
  610. export default TransactionBar;
  611. const StyledRow = styled(Row)`
  612. &,
  613. ${RowCellContainer} {
  614. overflow: visible;
  615. }
  616. `;
  617. const ErrorLink = styled(Link)`
  618. color: ${p => p.theme.error};
  619. `;
  620. const StyledRowRectangle = styled(RowRectangle)`
  621. display: flex;
  622. align-items: center;
  623. `;