transactionBar.tsx 18 KB

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