spanBar.tsx 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069
  1. import 'intersection-observer'; // this is a polyfill
  2. import {Component, createRef, Fragment} from 'react';
  3. import styled from '@emotion/styled';
  4. import Count from 'sentry/components/count';
  5. import {ROW_HEIGHT} from 'sentry/components/performance/waterfall/constants';
  6. import {MessageRow} from 'sentry/components/performance/waterfall/messageRow';
  7. import {
  8. Row,
  9. RowCell,
  10. RowCellContainer,
  11. } from 'sentry/components/performance/waterfall/row';
  12. import {DurationPill, RowRectangle} from 'sentry/components/performance/waterfall/rowBar';
  13. import {
  14. DividerContainer,
  15. DividerLine,
  16. DividerLineGhostContainer,
  17. EmbeddedTransactionBadge,
  18. ErrorBadge,
  19. } from 'sentry/components/performance/waterfall/rowDivider';
  20. import {
  21. RowTitle,
  22. RowTitleContainer,
  23. RowTitleContent,
  24. } from 'sentry/components/performance/waterfall/rowTitle';
  25. import {
  26. ConnectorBar,
  27. TOGGLE_BORDER_BOX,
  28. TreeConnector,
  29. TreeToggle,
  30. TreeToggleContainer,
  31. TreeToggleIcon,
  32. } from 'sentry/components/performance/waterfall/treeConnector';
  33. import {
  34. getDurationDisplay,
  35. getHumanDuration,
  36. toPercent,
  37. } from 'sentry/components/performance/waterfall/utils';
  38. import Tooltip from 'sentry/components/tooltip';
  39. import {IconWarning} from 'sentry/icons';
  40. import {t} from 'sentry/locale';
  41. import space from 'sentry/styles/space';
  42. import {Organization} from 'sentry/types';
  43. import {EventTransaction} from 'sentry/types/event';
  44. import {defined} from 'sentry/utils';
  45. import {trackAnalyticsEvent} from 'sentry/utils/analytics';
  46. import {generateEventSlug} from 'sentry/utils/discover/urls';
  47. import * as QuickTraceContext from 'sentry/utils/performance/quickTrace/quickTraceContext';
  48. import {QuickTraceContextChildrenProps} from 'sentry/utils/performance/quickTrace/quickTraceContext';
  49. import {QuickTraceEvent, TraceError} from 'sentry/utils/performance/quickTrace/types';
  50. import {isTraceFull} from 'sentry/utils/performance/quickTrace/utils';
  51. import * as AnchorLinkManager from './anchorLinkManager';
  52. import {
  53. MINIMAP_CONTAINER_HEIGHT,
  54. MINIMAP_SPAN_BAR_HEIGHT,
  55. NUM_OF_SPANS_FIT_IN_MINI_MAP,
  56. } from './constants';
  57. import * as DividerHandlerManager from './dividerHandlerManager';
  58. import SpanBarCursorGuide from './spanBarCursorGuide';
  59. import SpanDetail from './spanDetail';
  60. import {MeasurementMarker} from './styles';
  61. import {
  62. FetchEmbeddedChildrenState,
  63. GroupType,
  64. ParsedTraceType,
  65. ProcessedSpanType,
  66. SpanType,
  67. TreeDepthType,
  68. } from './types';
  69. import {
  70. durationlessBrowserOps,
  71. getMeasurementBounds,
  72. getMeasurements,
  73. getSpanID,
  74. getSpanOperation,
  75. isEventFromBrowserJavaScriptSDK,
  76. isGapSpan,
  77. isOrphanSpan,
  78. isOrphanTreeDepth,
  79. SpanBoundsType,
  80. SpanGeneratedBoundsType,
  81. spanTargetHash,
  82. SpanViewBoundsType,
  83. unwrapTreeDepth,
  84. } from './utils';
  85. // TODO: maybe use babel-plugin-preval
  86. // for (let i = 0; i <= 1.0; i += 0.01) {
  87. // INTERSECTION_THRESHOLDS.push(i);
  88. // }
  89. const INTERSECTION_THRESHOLDS: Array<number> = [
  90. 0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.11, 0.12, 0.13, 0.14,
  91. 0.15, 0.16, 0.17, 0.18, 0.19, 0.2, 0.21, 0.22, 0.23, 0.24, 0.25, 0.26, 0.27, 0.28, 0.29,
  92. 0.3, 0.31, 0.32, 0.33, 0.34, 0.35, 0.36, 0.37, 0.38, 0.39, 0.4, 0.41, 0.42, 0.43, 0.44,
  93. 0.45, 0.46, 0.47, 0.48, 0.49, 0.5, 0.51, 0.52, 0.53, 0.54, 0.55, 0.56, 0.57, 0.58, 0.59,
  94. 0.6, 0.61, 0.62, 0.63, 0.64, 0.65, 0.66, 0.67, 0.68, 0.69, 0.7, 0.71, 0.72, 0.73, 0.74,
  95. 0.75, 0.76, 0.77, 0.78, 0.79, 0.8, 0.81, 0.82, 0.83, 0.84, 0.85, 0.86, 0.87, 0.88, 0.89,
  96. 0.9, 0.91, 0.92, 0.93, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99, 1.0,
  97. ];
  98. export const MARGIN_LEFT = 0;
  99. type SpanBarProps = {
  100. continuingTreeDepths: Array<TreeDepthType>;
  101. event: Readonly<EventTransaction>;
  102. fetchEmbeddedChildrenState: FetchEmbeddedChildrenState;
  103. generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
  104. generateContentSpanBarRef: () => (instance: HTMLDivElement | null) => void;
  105. isEmbeddedTransactionTimeAdjusted: boolean;
  106. markSpanInView: (spanId: string, treeDepth: number) => void;
  107. markSpanOutOfView: (spanId: string) => void;
  108. numOfSpanChildren: number;
  109. numOfSpans: number;
  110. onWheel: (deltaX: number) => void;
  111. organization: Organization;
  112. showEmbeddedChildren: boolean;
  113. showSpanTree: boolean;
  114. span: Readonly<ProcessedSpanType>;
  115. spanNumber: number;
  116. storeSpanBar: (spanBar: SpanBar) => void;
  117. toggleEmbeddedChildren:
  118. | ((props: {eventSlug: string; orgSlug: string}) => void)
  119. | undefined;
  120. toggleSpanGroup: (() => void) | undefined;
  121. toggleSpanTree: () => void;
  122. trace: Readonly<ParsedTraceType>;
  123. treeDepth: number;
  124. groupOccurrence?: number;
  125. groupType?: GroupType;
  126. isLast?: boolean;
  127. isRoot?: boolean;
  128. spanBarColor?: string;
  129. spanBarHatch?: boolean;
  130. toggleSiblingSpanGroup?: ((span: SpanType, occurrence: number) => void) | undefined;
  131. };
  132. type SpanBarState = {
  133. showDetail: boolean;
  134. };
  135. class SpanBar extends Component<SpanBarProps, SpanBarState> {
  136. state: SpanBarState = {
  137. showDetail: false,
  138. };
  139. componentDidMount() {
  140. this._mounted = true;
  141. if (this.spanRowDOMRef.current) {
  142. this.props.storeSpanBar(this);
  143. this.connectObservers();
  144. }
  145. if (this.spanTitleRef.current) {
  146. this.spanTitleRef.current.addEventListener('wheel', this.handleWheel, {
  147. passive: false,
  148. });
  149. }
  150. }
  151. componentWillUnmount() {
  152. this._mounted = false;
  153. this.disconnectObservers();
  154. if (this.spanTitleRef.current) {
  155. this.spanTitleRef.current.removeEventListener('wheel', this.handleWheel);
  156. }
  157. const {span} = this.props;
  158. if ('type' in span) {
  159. return;
  160. }
  161. this.props.markSpanOutOfView(span.span_id);
  162. }
  163. spanRowDOMRef = createRef<HTMLDivElement>();
  164. spanTitleRef = createRef<HTMLDivElement>();
  165. intersectionObserver?: IntersectionObserver = void 0;
  166. zoomLevel: number = 1; // assume initial zoomLevel is 100%
  167. _mounted: boolean = false;
  168. handleWheel = (event: WheelEvent) => {
  169. // https://stackoverflow.com/q/57358640
  170. // https://github.com/facebook/react/issues/14856
  171. if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
  172. return;
  173. }
  174. event.preventDefault();
  175. event.stopPropagation();
  176. if (Math.abs(event.deltaY) === Math.abs(event.deltaX)) {
  177. return;
  178. }
  179. const {onWheel} = this.props;
  180. onWheel(event.deltaX);
  181. };
  182. toggleDisplayDetail = () => {
  183. this.setState(state => ({
  184. showDetail: !state.showDetail,
  185. }));
  186. };
  187. scrollIntoView = () => {
  188. const element = this.spanRowDOMRef.current;
  189. if (!element) {
  190. return;
  191. }
  192. const boundingRect = element.getBoundingClientRect();
  193. // The extra 1 pixel is necessary so that the span is recognized as in view by the IntersectionObserver
  194. const offset = boundingRect.top + window.scrollY - MINIMAP_CONTAINER_HEIGHT - 1;
  195. this.setState({showDetail: true}, () => window.scrollTo(0, offset));
  196. };
  197. renderDetail({
  198. isVisible,
  199. transactions,
  200. errors,
  201. }: {
  202. errors: TraceError[] | null;
  203. isVisible: boolean;
  204. transactions: QuickTraceEvent[] | null;
  205. }) {
  206. const {span, organization, isRoot, trace, event} = this.props;
  207. return (
  208. <AnchorLinkManager.Consumer>
  209. {({registerScrollFn, scrollToHash}) => {
  210. if (!isGapSpan(span)) {
  211. registerScrollFn(spanTargetHash(span.span_id), this.scrollIntoView, false);
  212. }
  213. if (!this.state.showDetail || !isVisible) {
  214. return null;
  215. }
  216. return (
  217. <SpanDetail
  218. span={span}
  219. organization={organization}
  220. event={event}
  221. isRoot={!!isRoot}
  222. trace={trace}
  223. childTransactions={transactions}
  224. relatedErrors={errors}
  225. scrollToHash={scrollToHash}
  226. />
  227. );
  228. }}
  229. </AnchorLinkManager.Consumer>
  230. );
  231. }
  232. getBounds(): SpanViewBoundsType {
  233. const {event, span, generateBounds} = this.props;
  234. const bounds = generateBounds({
  235. startTimestamp: span.start_timestamp,
  236. endTimestamp: span.timestamp,
  237. });
  238. const shouldHideSpanWarnings = isEventFromBrowserJavaScriptSDK(event);
  239. switch (bounds.type) {
  240. case 'TRACE_TIMESTAMPS_EQUAL': {
  241. return {
  242. warning: t('Trace times are equal'),
  243. left: void 0,
  244. width: void 0,
  245. isSpanVisibleInView: bounds.isSpanVisibleInView,
  246. };
  247. }
  248. case 'INVALID_VIEW_WINDOW': {
  249. return {
  250. warning: t('Invalid view window'),
  251. left: void 0,
  252. width: void 0,
  253. isSpanVisibleInView: bounds.isSpanVisibleInView,
  254. };
  255. }
  256. case 'TIMESTAMPS_EQUAL': {
  257. const warning =
  258. shouldHideSpanWarnings &&
  259. 'op' in span &&
  260. span.op &&
  261. durationlessBrowserOps.includes(span.op)
  262. ? void 0
  263. : t('Equal start and end times');
  264. return {
  265. warning,
  266. left: bounds.start,
  267. width: 0.00001,
  268. isSpanVisibleInView: bounds.isSpanVisibleInView,
  269. };
  270. }
  271. case 'TIMESTAMPS_REVERSED': {
  272. return {
  273. warning: t('Reversed start and end times'),
  274. left: bounds.start,
  275. width: bounds.end - bounds.start,
  276. isSpanVisibleInView: bounds.isSpanVisibleInView,
  277. };
  278. }
  279. case 'TIMESTAMPS_STABLE': {
  280. return {
  281. warning: void 0,
  282. left: bounds.start,
  283. width: bounds.end - bounds.start,
  284. isSpanVisibleInView: bounds.isSpanVisibleInView,
  285. };
  286. }
  287. default: {
  288. const _exhaustiveCheck: never = bounds;
  289. return _exhaustiveCheck;
  290. }
  291. }
  292. }
  293. renderMeasurements() {
  294. const {event, generateBounds} = this.props;
  295. if (this.state.showDetail) {
  296. return null;
  297. }
  298. const measurements = getMeasurements(event, generateBounds);
  299. return (
  300. <Fragment>
  301. {Array.from(measurements.values()).map(verticalMark => {
  302. const mark = Object.values(verticalMark.marks)[0];
  303. const {timestamp} = mark;
  304. const bounds = getMeasurementBounds(timestamp, generateBounds);
  305. const shouldDisplay = defined(bounds.left) && defined(bounds.width);
  306. if (!shouldDisplay || !bounds.isSpanVisibleInView) {
  307. return null;
  308. }
  309. return (
  310. <MeasurementMarker
  311. key={String(timestamp)}
  312. style={{
  313. left: `clamp(0%, ${toPercent(bounds.left || 0)}, calc(100% - 1px))`,
  314. }}
  315. failedThreshold={verticalMark.failedThreshold}
  316. />
  317. );
  318. })}
  319. </Fragment>
  320. );
  321. }
  322. renderSpanTreeConnector({hasToggler}: {hasToggler: boolean}) {
  323. const {
  324. isLast,
  325. isRoot,
  326. treeDepth: spanTreeDepth,
  327. continuingTreeDepths,
  328. span,
  329. showSpanTree,
  330. } = this.props;
  331. const spanID = getSpanID(span);
  332. if (isRoot) {
  333. if (hasToggler) {
  334. return (
  335. <ConnectorBar
  336. style={{right: '15px', height: '10px', bottom: '-5px', top: 'auto'}}
  337. key={`${spanID}-last`}
  338. orphanBranch={false}
  339. />
  340. );
  341. }
  342. return null;
  343. }
  344. const connectorBars: Array<React.ReactNode> = continuingTreeDepths.map(treeDepth => {
  345. const depth: number = unwrapTreeDepth(treeDepth);
  346. if (depth === 0) {
  347. // do not render a connector bar at depth 0,
  348. // if we did render a connector bar, this bar would be placed at depth -1
  349. // which does not exist.
  350. return null;
  351. }
  352. const left = ((spanTreeDepth - depth) * (TOGGLE_BORDER_BOX / 2) + 2) * -1;
  353. return (
  354. <ConnectorBar
  355. style={{left}}
  356. key={`${spanID}-${depth}`}
  357. orphanBranch={isOrphanTreeDepth(treeDepth)}
  358. />
  359. );
  360. });
  361. if (hasToggler && showSpanTree) {
  362. // if there is a toggle button, we add a connector bar to create an attachment
  363. // between the toggle button and any connector bars below the toggle button
  364. connectorBars.push(
  365. <ConnectorBar
  366. style={{
  367. right: '15px',
  368. height: `${ROW_HEIGHT / 2}px`,
  369. bottom: isLast ? `-${ROW_HEIGHT / 2 + 2}px` : '0',
  370. top: 'auto',
  371. }}
  372. key={`${spanID}-last-bottom`}
  373. orphanBranch={false}
  374. />
  375. );
  376. }
  377. return (
  378. <TreeConnector
  379. isLast={isLast}
  380. hasToggler={hasToggler}
  381. orphanBranch={isOrphanSpan(span)}
  382. >
  383. {connectorBars}
  384. </TreeConnector>
  385. );
  386. }
  387. renderSpanTreeToggler({left, errored}: {errored: boolean; left: number}) {
  388. const {numOfSpanChildren, isRoot, showSpanTree} = this.props;
  389. const chevron = <TreeToggleIcon direction={showSpanTree ? 'up' : 'down'} />;
  390. if (numOfSpanChildren <= 0) {
  391. return (
  392. <TreeToggleContainer style={{left: `${left}px`}}>
  393. {this.renderSpanTreeConnector({hasToggler: false})}
  394. </TreeToggleContainer>
  395. );
  396. }
  397. const chevronElement = !isRoot ? <div>{chevron}</div> : null;
  398. return (
  399. <TreeToggleContainer style={{left: `${left}px`}} hasToggler>
  400. {this.renderSpanTreeConnector({hasToggler: true})}
  401. <TreeToggle
  402. disabled={!!isRoot}
  403. isExpanded={showSpanTree}
  404. errored={errored}
  405. onClick={event => {
  406. event.stopPropagation();
  407. if (isRoot) {
  408. return;
  409. }
  410. this.props.toggleSpanTree();
  411. }}
  412. >
  413. <Count value={numOfSpanChildren} />
  414. {chevronElement}
  415. </TreeToggle>
  416. </TreeToggleContainer>
  417. );
  418. }
  419. renderTitle(errors: TraceError[] | null) {
  420. const {generateContentSpanBarRef} = this.props;
  421. const {
  422. span,
  423. treeDepth,
  424. groupOccurrence,
  425. toggleSpanGroup,
  426. toggleSiblingSpanGroup,
  427. groupType,
  428. } = this.props;
  429. let titleFragments: React.ReactNode[] = [];
  430. if (
  431. typeof toggleSpanGroup === 'function' ||
  432. typeof toggleSiblingSpanGroup === 'function'
  433. ) {
  434. titleFragments.push(
  435. <Regroup
  436. onClick={event => {
  437. event.stopPropagation();
  438. event.preventDefault();
  439. if (groupType === GroupType.SIBLINGS && 'op' in span) {
  440. toggleSiblingSpanGroup?.(span, groupOccurrence ?? 0);
  441. } else {
  442. toggleSpanGroup && toggleSpanGroup();
  443. }
  444. }}
  445. >
  446. <a
  447. href="#regroup"
  448. onClick={event => {
  449. event.preventDefault();
  450. }}
  451. >
  452. {t('Regroup')}
  453. </a>
  454. </Regroup>
  455. );
  456. }
  457. const spanOperationName = getSpanOperation(span);
  458. if (spanOperationName) {
  459. titleFragments.push(spanOperationName);
  460. }
  461. titleFragments = titleFragments.flatMap(current => [current, ' \u2014 ']);
  462. const description = span?.description ?? getSpanID(span);
  463. const left = treeDepth * (TOGGLE_BORDER_BOX / 2) + MARGIN_LEFT;
  464. const errored = Boolean(errors && errors.length > 0);
  465. return (
  466. <RowTitleContainer
  467. data-debug-id="SpanBarTitleContainer"
  468. ref={generateContentSpanBarRef()}
  469. >
  470. {this.renderSpanTreeToggler({left, errored})}
  471. <RowTitle
  472. style={{
  473. left: `${left}px`,
  474. width: '100%',
  475. }}
  476. >
  477. <RowTitleContent errored={errored}>
  478. <strong>{titleFragments}</strong>
  479. {description}
  480. </RowTitleContent>
  481. </RowTitle>
  482. </RowTitleContainer>
  483. );
  484. }
  485. connectObservers() {
  486. if (!this.spanRowDOMRef.current) {
  487. return;
  488. }
  489. this.disconnectObservers();
  490. /**
  491. We track intersections events between the span bar's DOM element
  492. and the viewport's (root) intersection area. the intersection area is sized to
  493. exclude the minimap. See below.
  494. By default, the intersection observer's root intersection is the viewport.
  495. We adjust the margins of this root intersection area to exclude the minimap's
  496. height. The minimap's height is always fixed.
  497. VIEWPORT (ancestor element used for the intersection events)
  498. +--+-------------------------+--+
  499. | | | |
  500. | | MINIMAP | |
  501. | | | |
  502. | +-------------------------+ | ^
  503. | | | | |
  504. | | SPANS | | | ROOT
  505. | | | | | INTERSECTION
  506. | | | | | OBSERVER
  507. | | | | | HEIGHT
  508. | | | | |
  509. | | | | |
  510. | | | | |
  511. | +-------------------------+ | |
  512. | | |
  513. +-------------------------------+ v
  514. */
  515. this.intersectionObserver = new IntersectionObserver(
  516. entries =>
  517. entries.forEach(entry => {
  518. if (!this._mounted) {
  519. return;
  520. }
  521. const shouldMoveMinimap = this.props.numOfSpans > NUM_OF_SPANS_FIT_IN_MINI_MAP;
  522. if (!shouldMoveMinimap) {
  523. return;
  524. }
  525. const spanNumber = this.props.spanNumber;
  526. const minimapSlider = document.getElementById('minimap-background-slider');
  527. if (!minimapSlider) {
  528. return;
  529. }
  530. // NOTE: THIS IS HACKY.
  531. //
  532. // IntersectionObserver.rootMargin is un-affected by the browser's zoom level.
  533. // The margins of the intersection area needs to be adjusted.
  534. // Thus, IntersectionObserverEntry.rootBounds may not be what we expect.
  535. //
  536. // We address this below.
  537. //
  538. // Note that this function was called whenever an intersection event occurred wrt
  539. // the thresholds.
  540. //
  541. if (entry.rootBounds) {
  542. // After we create the IntersectionObserver instance with rootMargin set as:
  543. // -${MINIMAP_CONTAINER_HEIGHT * this.zoomLevel}px 0px 0px 0px
  544. //
  545. // we can introspect the rootBounds to infer the zoomlevel.
  546. //
  547. // we always expect entry.rootBounds.top to equal MINIMAP_CONTAINER_HEIGHT
  548. const actualRootTop = Math.ceil(entry.rootBounds.top);
  549. if (actualRootTop !== MINIMAP_CONTAINER_HEIGHT && actualRootTop > 0) {
  550. // we revert the actualRootTop value by the current zoomLevel factor
  551. const normalizedActualTop = actualRootTop / this.zoomLevel;
  552. const zoomLevel = MINIMAP_CONTAINER_HEIGHT / normalizedActualTop;
  553. this.zoomLevel = zoomLevel;
  554. // we reconnect the observers; the callback functions may be invoked
  555. this.connectObservers();
  556. // NOTE: since we cannot guarantee that the callback function is invoked on
  557. // the newly connected observers, we continue running this function.
  558. }
  559. }
  560. // root refers to the root intersection rectangle used for the IntersectionObserver
  561. const rectRelativeToRoot = entry.boundingClientRect as DOMRect;
  562. const bottomYCoord = rectRelativeToRoot.y + rectRelativeToRoot.height;
  563. // refers to if the rect is out of view from the viewport
  564. const isOutOfViewAbove = rectRelativeToRoot.y < 0 && bottomYCoord < 0;
  565. if (isOutOfViewAbove) {
  566. return;
  567. }
  568. const relativeToMinimap = {
  569. top: rectRelativeToRoot.y - MINIMAP_CONTAINER_HEIGHT,
  570. bottom: bottomYCoord - MINIMAP_CONTAINER_HEIGHT,
  571. };
  572. const rectBelowMinimap =
  573. relativeToMinimap.top > 0 && relativeToMinimap.bottom > 0;
  574. if (rectBelowMinimap) {
  575. const {span, treeDepth} = this.props;
  576. if ('type' in span) {
  577. return;
  578. }
  579. // If isIntersecting is false, this means the span is out of view below the viewport
  580. if (!entry.isIntersecting) {
  581. this.props.markSpanOutOfView(span.span_id);
  582. } else {
  583. this.props.markSpanInView(span.span_id, treeDepth);
  584. }
  585. // if the first span is below the minimap, we scroll the minimap
  586. // to the top. this addresses spurious scrolling to the top of the page
  587. if (spanNumber <= 1) {
  588. minimapSlider.style.top = '0px';
  589. return;
  590. }
  591. return;
  592. }
  593. const inAndAboveMinimap = relativeToMinimap.bottom <= 0;
  594. if (inAndAboveMinimap) {
  595. const {span} = this.props;
  596. if ('type' in span) {
  597. return;
  598. }
  599. this.props.markSpanOutOfView(span.span_id);
  600. return;
  601. }
  602. // invariant: spanNumber >= 1
  603. const numberOfMovedSpans = spanNumber - 1;
  604. const totalHeightOfHiddenSpans = numberOfMovedSpans * MINIMAP_SPAN_BAR_HEIGHT;
  605. const currentSpanHiddenRatio = 1 - entry.intersectionRatio;
  606. const panYPixels =
  607. totalHeightOfHiddenSpans + currentSpanHiddenRatio * MINIMAP_SPAN_BAR_HEIGHT;
  608. // invariant: this.props.numOfSpans - spanNumberToStopMoving + 1 = NUM_OF_SPANS_FIT_IN_MINI_MAP
  609. const spanNumberToStopMoving =
  610. this.props.numOfSpans + 1 - NUM_OF_SPANS_FIT_IN_MINI_MAP;
  611. if (spanNumber > spanNumberToStopMoving) {
  612. // if the last span bar appears on the minimap, we do not want the minimap
  613. // to keep panning upwards
  614. minimapSlider.style.top = `-${
  615. spanNumberToStopMoving * MINIMAP_SPAN_BAR_HEIGHT
  616. }px`;
  617. return;
  618. }
  619. minimapSlider.style.top = `-${panYPixels}px`;
  620. }),
  621. {
  622. threshold: INTERSECTION_THRESHOLDS,
  623. rootMargin: `-${MINIMAP_CONTAINER_HEIGHT * this.zoomLevel}px 0px 0px 0px`,
  624. }
  625. );
  626. this.intersectionObserver.observe(this.spanRowDOMRef.current);
  627. }
  628. disconnectObservers() {
  629. if (this.intersectionObserver) {
  630. this.intersectionObserver.disconnect();
  631. }
  632. }
  633. renderDivider(
  634. dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
  635. ) {
  636. if (this.state.showDetail) {
  637. // Mock component to preserve layout spacing
  638. return (
  639. <DividerLine
  640. showDetail
  641. style={{
  642. position: 'absolute',
  643. }}
  644. />
  645. );
  646. }
  647. const {addDividerLineRef} = dividerHandlerChildrenProps;
  648. return (
  649. <DividerLine
  650. ref={addDividerLineRef()}
  651. style={{
  652. position: 'absolute',
  653. }}
  654. onMouseEnter={() => {
  655. dividerHandlerChildrenProps.setHover(true);
  656. }}
  657. onMouseLeave={() => {
  658. dividerHandlerChildrenProps.setHover(false);
  659. }}
  660. onMouseOver={() => {
  661. dividerHandlerChildrenProps.setHover(true);
  662. }}
  663. onMouseDown={dividerHandlerChildrenProps.onDragStart}
  664. onClick={event => {
  665. // we prevent the propagation of the clicks from this component to prevent
  666. // the span detail from being opened.
  667. event.stopPropagation();
  668. }}
  669. />
  670. );
  671. }
  672. getRelatedErrors(quickTrace: QuickTraceContextChildrenProps): TraceError[] | null {
  673. if (!quickTrace) {
  674. return null;
  675. }
  676. const {span} = this.props;
  677. const {currentEvent} = quickTrace;
  678. if (isGapSpan(span) || !currentEvent || !isTraceFull(currentEvent)) {
  679. return null;
  680. }
  681. return currentEvent.errors.filter(error => error.span === span.span_id);
  682. }
  683. getChildTransactions(
  684. quickTrace: QuickTraceContextChildrenProps
  685. ): QuickTraceEvent[] | null {
  686. if (!quickTrace) {
  687. return null;
  688. }
  689. const {span} = this.props;
  690. const {trace} = quickTrace;
  691. if (isGapSpan(span) || !trace) {
  692. return null;
  693. }
  694. return trace.filter(({parent_span_id}) => parent_span_id === span.span_id);
  695. }
  696. renderErrorBadge(errors: TraceError[] | null): React.ReactNode {
  697. return errors?.length ? <ErrorBadge /> : null;
  698. }
  699. renderEmbeddedTransactionsBadge(
  700. transactions: QuickTraceEvent[] | null
  701. ): React.ReactNode {
  702. const {toggleEmbeddedChildren, organization, showEmbeddedChildren} = this.props;
  703. if (!organization.features.includes('unified-span-view')) {
  704. return null;
  705. }
  706. if (transactions && transactions.length === 1) {
  707. const transaction = transactions[0];
  708. return (
  709. <Tooltip
  710. title={
  711. <span>
  712. {showEmbeddedChildren
  713. ? t('This span is showing a direct child. Remove transaction to hide')
  714. : t('This span has a direct child. Add transaction to view')}
  715. </span>
  716. }
  717. position="top"
  718. containerDisplayMode="block"
  719. >
  720. <EmbeddedTransactionBadge
  721. expanded={showEmbeddedChildren}
  722. onClick={() => {
  723. if (toggleEmbeddedChildren) {
  724. if (showEmbeddedChildren) {
  725. trackAnalyticsEvent({
  726. eventKey: 'span_view.embedded_child.hide',
  727. eventName: 'Span View: Hide Embedded Transaction',
  728. organization_id: parseInt(organization.id, 10),
  729. });
  730. } else {
  731. trackAnalyticsEvent({
  732. eventKey: 'span_view.embedded_child.show',
  733. eventName: 'Span View: Show Embedded Transaction',
  734. organization_id: parseInt(organization.id, 10),
  735. });
  736. }
  737. toggleEmbeddedChildren({
  738. orgSlug: organization.slug,
  739. eventSlug: generateEventSlug({
  740. id: transaction.event_id,
  741. project: transaction.project_slug,
  742. }),
  743. });
  744. }
  745. }}
  746. />
  747. </Tooltip>
  748. );
  749. }
  750. return null;
  751. }
  752. renderWarningText() {
  753. let warningText = this.getBounds().warning;
  754. if (this.props.isEmbeddedTransactionTimeAdjusted) {
  755. const embeddedWarningText = t(
  756. 'All child span timestamps have been adjusted to account for mismatched client and server clocks.'
  757. );
  758. warningText = warningText
  759. ? `${warningText}. ${embeddedWarningText}`
  760. : embeddedWarningText;
  761. }
  762. if (!warningText) {
  763. return null;
  764. }
  765. return (
  766. <Tooltip containerDisplayMode="flex" title={warningText}>
  767. <StyledIconWarning size="xs" />
  768. </Tooltip>
  769. );
  770. }
  771. renderHeader({
  772. dividerHandlerChildrenProps,
  773. errors,
  774. transactions,
  775. }: {
  776. dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps;
  777. errors: TraceError[] | null;
  778. transactions: QuickTraceEvent[] | null;
  779. }) {
  780. const {span, spanBarColor, spanBarHatch, spanNumber} = this.props;
  781. const startTimestamp: number = span.start_timestamp;
  782. const endTimestamp: number = span.timestamp;
  783. const duration = Math.abs(endTimestamp - startTimestamp);
  784. const durationString = getHumanDuration(duration);
  785. const bounds = this.getBounds();
  786. const {dividerPosition, addGhostDividerLineRef} = dividerHandlerChildrenProps;
  787. const displaySpanBar = defined(bounds.left) && defined(bounds.width);
  788. const durationDisplay = getDurationDisplay(bounds);
  789. return (
  790. <RowCellContainer showDetail={this.state.showDetail}>
  791. <RowCell
  792. data-type="span-row-cell"
  793. showDetail={this.state.showDetail}
  794. style={{
  795. width: `calc(${toPercent(dividerPosition)} - 0.5px)`,
  796. paddingTop: 0,
  797. }}
  798. onClick={() => {
  799. this.toggleDisplayDetail();
  800. }}
  801. ref={this.spanTitleRef}
  802. >
  803. {this.renderTitle(errors)}
  804. </RowCell>
  805. <DividerContainer>
  806. {this.renderDivider(dividerHandlerChildrenProps)}
  807. {this.renderErrorBadge(errors)}
  808. {this.renderEmbeddedTransactionsBadge(transactions)}
  809. </DividerContainer>
  810. <RowCell
  811. data-type="span-row-cell"
  812. showDetail={this.state.showDetail}
  813. showStriping={spanNumber % 2 !== 0}
  814. style={{
  815. width: `calc(${toPercent(1 - dividerPosition)} - 0.5px)`,
  816. }}
  817. onClick={() => {
  818. this.toggleDisplayDetail();
  819. }}
  820. >
  821. {displaySpanBar && (
  822. <RowRectangle
  823. spanBarHatch={!!spanBarHatch}
  824. style={{
  825. backgroundColor: spanBarColor,
  826. left: `min(${toPercent(bounds.left || 0)}, calc(100% - 1px))`,
  827. width: toPercent(bounds.width || 0),
  828. }}
  829. >
  830. <DurationPill
  831. durationDisplay={durationDisplay}
  832. showDetail={this.state.showDetail}
  833. spanBarHatch={!!spanBarHatch}
  834. >
  835. {durationString}
  836. {this.renderWarningText()}
  837. </DurationPill>
  838. </RowRectangle>
  839. )}
  840. {this.renderMeasurements()}
  841. <SpanBarCursorGuide />
  842. </RowCell>
  843. {!this.state.showDetail && (
  844. <DividerLineGhostContainer
  845. style={{
  846. width: `calc(${toPercent(dividerPosition)} + 0.5px)`,
  847. display: 'none',
  848. }}
  849. >
  850. <DividerLine
  851. ref={addGhostDividerLineRef()}
  852. style={{
  853. right: 0,
  854. }}
  855. className="hovering"
  856. onClick={event => {
  857. // the ghost divider line should not be interactive.
  858. // we prevent the propagation of the clicks from this component to prevent
  859. // the span detail from being opened.
  860. event.stopPropagation();
  861. }}
  862. />
  863. </DividerLineGhostContainer>
  864. )}
  865. </RowCellContainer>
  866. );
  867. }
  868. renderEmbeddedChildrenState() {
  869. const {fetchEmbeddedChildrenState} = this.props;
  870. switch (fetchEmbeddedChildrenState) {
  871. case 'loading_embedded_transactions': {
  872. return (
  873. <MessageRow>
  874. <span>{t('Loading embedded transaction')}</span>
  875. </MessageRow>
  876. );
  877. }
  878. case 'error_fetching_embedded_transactions': {
  879. return (
  880. <MessageRow>
  881. <span>{t('Error loading embedded transaction')}</span>
  882. </MessageRow>
  883. );
  884. }
  885. default:
  886. return null;
  887. }
  888. }
  889. render() {
  890. const {spanNumber} = this.props;
  891. const bounds = this.getBounds();
  892. const {isSpanVisibleInView} = bounds;
  893. return (
  894. <Fragment>
  895. <Row
  896. ref={this.spanRowDOMRef}
  897. visible={isSpanVisibleInView}
  898. showBorder={this.state.showDetail}
  899. data-test-id={`span-row-${spanNumber}`}
  900. >
  901. <QuickTraceContext.Consumer>
  902. {quickTrace => {
  903. const errors = this.getRelatedErrors(quickTrace);
  904. const transactions = this.getChildTransactions(quickTrace);
  905. return (
  906. <Fragment>
  907. <DividerHandlerManager.Consumer>
  908. {(
  909. dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
  910. ) =>
  911. this.renderHeader({
  912. dividerHandlerChildrenProps,
  913. errors,
  914. transactions,
  915. })
  916. }
  917. </DividerHandlerManager.Consumer>
  918. {this.renderDetail({
  919. isVisible: isSpanVisibleInView,
  920. transactions,
  921. errors,
  922. })}
  923. </Fragment>
  924. );
  925. }}
  926. </QuickTraceContext.Consumer>
  927. </Row>
  928. {this.renderEmbeddedChildrenState()}
  929. </Fragment>
  930. );
  931. }
  932. }
  933. const StyledIconWarning = styled(IconWarning)`
  934. margin-left: ${space(0.25)};
  935. margin-bottom: ${space(0.25)};
  936. `;
  937. const Regroup = styled('span')``;
  938. export default SpanBar;