transactionBar.tsx 20 KB

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