vitalCard.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. import {Component} from 'react';
  2. import type {Theme} from '@emotion/react';
  3. import {withTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import type {Location} from 'history';
  6. import isEqual from 'lodash/isEqual';
  7. import throttle from 'lodash/throttle';
  8. import {LinkButton} from 'sentry/components/button';
  9. import type {BarChartSeries} from 'sentry/components/charts/barChart';
  10. import {BarChart} from 'sentry/components/charts/barChart';
  11. import BarChartZoom from 'sentry/components/charts/barChartZoom';
  12. import MarkLine from 'sentry/components/charts/components/markLine';
  13. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  14. import Placeholder from 'sentry/components/placeholder';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import type {Organization} from 'sentry/types/organization';
  18. import {trackAnalytics} from 'sentry/utils/analytics';
  19. import type EventView from 'sentry/utils/discover/eventView';
  20. import {getAggregateAlias} from 'sentry/utils/discover/fields';
  21. import getDuration from 'sentry/utils/duration/getDuration';
  22. import type {WebVital} from 'sentry/utils/fields';
  23. import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
  24. import getDynamicText from 'sentry/utils/getDynamicText';
  25. import {formatFloat} from 'sentry/utils/number/formatFloat';
  26. import type {DataFilter, HistogramData} from 'sentry/utils/performance/histogram/types';
  27. import {
  28. computeBuckets,
  29. formatHistogramData,
  30. } from 'sentry/utils/performance/histogram/utils';
  31. import type {Vital} from 'sentry/utils/performance/vitals/types';
  32. import type {VitalData} from 'sentry/utils/performance/vitals/vitalsCardsDiscoverQuery';
  33. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  34. import {EventsDisplayFilterName} from 'sentry/views/performance/transactionSummary/transactionEvents/utils';
  35. import {VitalBar} from '../../landing/vitalsCards';
  36. import {
  37. VitalState,
  38. vitalStateColors,
  39. webVitalMeh,
  40. webVitalPoor,
  41. } from '../../vitalDetail/utils';
  42. import {NUM_BUCKETS, PERCENTILE} from './constants';
  43. import {Card, CardSectionHeading, CardSummary, Description, StatNumber} from './styles';
  44. import type {Rectangle} from './types';
  45. import {asPixelRect, findNearestBucketIndex, getRefRect, mapPoint} from './utils';
  46. type Props = {
  47. chartData: HistogramData;
  48. colors: [string];
  49. error: boolean;
  50. eventView: EventView;
  51. isLoading: boolean;
  52. location: Location;
  53. organization: Organization;
  54. summaryData: VitalData | null;
  55. theme: Theme;
  56. vital: WebVital;
  57. vitalDetails: Vital;
  58. dataFilter?: DataFilter;
  59. max?: number;
  60. min?: number;
  61. precision?: number;
  62. };
  63. type State = {
  64. /**
  65. * This is a pair of reference points on the graph that we can use to map any
  66. * other points to their pixel coordinates on the graph.
  67. *
  68. * The x values here are the index of the cooresponding bucket and the y value
  69. * are the respective counts.
  70. *
  71. * Invariances:
  72. * - refDataRect.point1.x < refDataRect.point2.x
  73. * - refDataRect.point1.y < refDataRect.point2.y
  74. */
  75. refDataRect: Rectangle | null;
  76. /**
  77. * This is the cooresponding pixel coordinate of the references points from refDataRect.
  78. *
  79. * ECharts' pixel coordinates are relative to the top left whereas the axis coordinates
  80. * used here are relative to the bottom right. Because of this and the invariances imposed
  81. * on refDataRect, these points have the difference invariances.
  82. *
  83. * Invariances:
  84. * - refPixelRect.point1.x < refPixelRect.point2.x
  85. * - refPixelRect.point1.y > refPixelRect.point2.y
  86. */
  87. refPixelRect: Rectangle | null;
  88. };
  89. class VitalCard extends Component<Props, State> {
  90. state: State = {
  91. refDataRect: null,
  92. refPixelRect: null,
  93. };
  94. static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State) {
  95. const {isLoading, error, chartData} = nextProps;
  96. if (isLoading || error === null) {
  97. return {...prevState};
  98. }
  99. const refDataRect = getRefRect(chartData);
  100. if (
  101. prevState.refDataRect === null ||
  102. (refDataRect !== null && !isEqual(refDataRect, prevState.refDataRect))
  103. ) {
  104. return {
  105. ...prevState,
  106. refDataRect,
  107. };
  108. }
  109. return {...prevState};
  110. }
  111. trackOpenAllEventsClicked = () => {
  112. const {organization} = this.props;
  113. const {vitalDetails: vital} = this.props;
  114. trackAnalytics('performance_views.vitals.open_all_events', {
  115. organization,
  116. vital: vital.slug,
  117. });
  118. };
  119. get summary() {
  120. const {summaryData} = this.props;
  121. return summaryData?.p75 ?? null;
  122. }
  123. get failureRate() {
  124. const {summaryData} = this.props;
  125. const numerator = summaryData?.poor ?? 0;
  126. const denominator = summaryData?.total ?? 0;
  127. return denominator <= 0 ? 0 : numerator / denominator;
  128. }
  129. getFormattedStatNumber() {
  130. const {vitalDetails: vital} = this.props;
  131. const summary = this.summary;
  132. const {type} = vital;
  133. return summary === null
  134. ? '\u2014'
  135. : type === 'duration'
  136. ? getDuration(summary / 1000, 2, true)
  137. : formatFloat(summary, 2);
  138. }
  139. renderSummary() {
  140. const {
  141. vitalDetails: vital,
  142. eventView,
  143. organization,
  144. min,
  145. max,
  146. dataFilter,
  147. } = this.props;
  148. const {slug, name, description} = vital;
  149. const column = `measurements.${slug}`;
  150. const newEventView = eventView
  151. .withColumns([
  152. {kind: 'field', field: 'transaction'},
  153. {
  154. kind: 'function',
  155. function: ['percentile', column, PERCENTILE.toString(), undefined],
  156. },
  157. {kind: 'function', function: ['count', '', '', undefined]},
  158. ])
  159. .withSorts([
  160. {
  161. kind: 'desc',
  162. field: getAggregateAlias(`percentile(${column},${PERCENTILE.toString()})`),
  163. },
  164. ]);
  165. const query = new MutableSearch(newEventView.query ?? '');
  166. query.addFilterValues('has', [column]);
  167. // add in any range constraints if any
  168. if (min !== undefined || max !== undefined) {
  169. if (min !== undefined) {
  170. query.addFilterValues(column, [`>=${min}`]);
  171. }
  172. if (max !== undefined) {
  173. query.addFilterValues(column, [`<=${max}`]);
  174. }
  175. }
  176. newEventView.query = query.formatString();
  177. return (
  178. <CardSummary>
  179. <SummaryHeading>
  180. <CardSectionHeading>{`${name} (${slug.toUpperCase()})`}</CardSectionHeading>
  181. </SummaryHeading>
  182. <StatNumber>
  183. {getDynamicText({
  184. value: this.getFormattedStatNumber(),
  185. fixed: '\u2014',
  186. })}
  187. </StatNumber>
  188. <Description>{description}</Description>
  189. <div>
  190. <LinkButton
  191. size="xs"
  192. to={newEventView
  193. .withColumns([{kind: 'field', field: column}])
  194. .withSorts([{kind: 'desc', field: column}])
  195. .getPerformanceTransactionEventsViewUrlTarget(organization.slug, {
  196. showTransactions:
  197. dataFilter === 'all'
  198. ? EventsDisplayFilterName.P100
  199. : EventsDisplayFilterName.P75,
  200. webVital: column as WebVital,
  201. })}
  202. onClick={this.trackOpenAllEventsClicked}
  203. >
  204. {t('View Sampled Events')}
  205. </LinkButton>
  206. </div>
  207. </CardSummary>
  208. );
  209. }
  210. /**
  211. * This callback happens everytime ECharts renders. This is NOT when ECharts
  212. * finishes rendering, so it can be called quite frequently. The calculations
  213. * here can get expensive if done frequently, furthermore, this can trigger a
  214. * state change leading to a re-render. So slow down the updates here as they
  215. * do not need to be updated every single time.
  216. */
  217. handleRendered = throttle(
  218. (_, chartRef) => {
  219. const {chartData} = this.props;
  220. const {refDataRect} = this.state;
  221. if (refDataRect === null || chartData.length < 1) {
  222. return;
  223. }
  224. const refPixelRect =
  225. refDataRect === null ? null : asPixelRect(chartRef, refDataRect!);
  226. if (refPixelRect !== null && !isEqual(refPixelRect, this.state.refPixelRect)) {
  227. this.setState({refPixelRect});
  228. }
  229. },
  230. 200,
  231. {leading: true}
  232. );
  233. handleDataZoomCancelled = () => {};
  234. renderHistogram() {
  235. const {
  236. theme,
  237. location,
  238. isLoading,
  239. chartData,
  240. summaryData,
  241. error,
  242. colors,
  243. vital,
  244. vitalDetails,
  245. precision = 0,
  246. } = this.props;
  247. const {slug} = vitalDetails;
  248. const series = this.getSeries();
  249. const xAxis = {
  250. type: 'category' as const,
  251. truncate: true,
  252. axisTick: {
  253. alignWithLabel: true,
  254. },
  255. };
  256. const values = series.data.map(point => point.value);
  257. const max = values.length ? Math.max(...values) : undefined;
  258. const yAxis = {
  259. type: 'value' as const,
  260. max,
  261. axisLabel: {
  262. color: theme.chartLabel,
  263. formatter: (value: string | number) => formatAbbreviatedNumber(value),
  264. },
  265. };
  266. const allSeries = [series];
  267. if (!isLoading && !error) {
  268. const baselineSeries = this.getBaselineSeries();
  269. if (baselineSeries !== null) {
  270. allSeries.push(baselineSeries);
  271. }
  272. }
  273. const vitalData =
  274. !isLoading && !error && summaryData !== null ? {[vital]: summaryData} : {};
  275. return (
  276. <BarChartZoom
  277. minZoomWidth={10 ** -precision * NUM_BUCKETS}
  278. location={location}
  279. paramStart={`${slug}Start`}
  280. paramEnd={`${slug}End`}
  281. xAxisIndex={[0]}
  282. buckets={computeBuckets(chartData)}
  283. onDataZoomCancelled={this.handleDataZoomCancelled}
  284. >
  285. {zoomRenderProps => (
  286. <Container>
  287. <TransparentLoadingMask visible={isLoading} />
  288. <PercentContainer>
  289. <VitalBar
  290. isLoading={isLoading}
  291. data={vitalData}
  292. vital={vital}
  293. showBar={false}
  294. showStates={false}
  295. showVitalPercentNames={false}
  296. showVitalThresholds={false}
  297. showDurationDetail={false}
  298. />
  299. </PercentContainer>
  300. {getDynamicText({
  301. value: (
  302. <BarChart
  303. series={allSeries}
  304. xAxis={xAxis}
  305. yAxis={yAxis}
  306. colors={colors}
  307. onRendered={this.handleRendered}
  308. grid={{
  309. left: space(3),
  310. right: space(3),
  311. top: space(3),
  312. bottom: space(1.5),
  313. }}
  314. stacked
  315. {...zoomRenderProps}
  316. />
  317. ),
  318. fixed: <Placeholder testId="skeleton-ui" height="200px" />,
  319. })}
  320. </Container>
  321. )}
  322. </BarChartZoom>
  323. );
  324. }
  325. bucketWidth() {
  326. const {chartData} = this.props;
  327. // We can assume that all buckets are of equal width, use the first two
  328. // buckets to get the width. The value of each histogram function indicates
  329. // the beginning of the bucket.
  330. return chartData.length >= 2 ? chartData[1].bin - chartData[0].bin : 0;
  331. }
  332. getSeries() {
  333. const {theme, chartData, precision, vitalDetails, vital} = this.props;
  334. const additionalFieldsFn = bucket => {
  335. return {
  336. itemStyle: {color: theme[this.getVitalsColor(vital, bucket)]},
  337. };
  338. };
  339. const data = formatHistogramData(chartData, {
  340. precision: precision === 0 ? undefined : precision,
  341. type: vitalDetails.type,
  342. additionalFieldsFn,
  343. });
  344. return {
  345. seriesName: t('Count'),
  346. data,
  347. };
  348. }
  349. getVitalsColor(vital: WebVital, value: number) {
  350. const poorThreshold = webVitalPoor[vital];
  351. const mehThreshold = webVitalMeh[vital];
  352. if (value >= poorThreshold) {
  353. return vitalStateColors[VitalState.POOR];
  354. }
  355. if (value >= mehThreshold) {
  356. return vitalStateColors[VitalState.MEH];
  357. }
  358. return vitalStateColors[VitalState.GOOD];
  359. }
  360. getBaselineSeries(): BarChartSeries | null {
  361. const {theme, chartData} = this.props;
  362. const summary = this.summary;
  363. if (summary === null || this.state.refPixelRect === null) {
  364. return null;
  365. }
  366. const summaryBucket = findNearestBucketIndex(chartData, summary);
  367. if (summaryBucket === null || summaryBucket === -1) {
  368. return null;
  369. }
  370. const thresholdPixelBottom = mapPoint(
  371. {
  372. // subtract 0.5 from the x here to ensure that the threshold lies between buckets
  373. x: summaryBucket - 0.5,
  374. y: 0,
  375. },
  376. this.state.refDataRect!,
  377. this.state.refPixelRect!
  378. );
  379. if (thresholdPixelBottom === null) {
  380. return null;
  381. }
  382. const thresholdPixelTop = mapPoint(
  383. {
  384. // subtract 0.5 from the x here to ensure that the threshold lies between buckets
  385. x: summaryBucket - 0.5,
  386. y: Math.max(...chartData.map(data => data.count)) || 1,
  387. },
  388. this.state.refDataRect!,
  389. this.state.refPixelRect!
  390. );
  391. if (thresholdPixelTop === null) {
  392. return null;
  393. }
  394. const markLine = MarkLine({
  395. animationDuration: 200,
  396. data: [[thresholdPixelBottom, thresholdPixelTop] as any],
  397. label: {
  398. show: false,
  399. },
  400. lineStyle: {
  401. color: theme.textColor,
  402. type: 'solid',
  403. },
  404. tooltip: {
  405. formatter: () => {
  406. return [
  407. '<div class="tooltip-series tooltip-series-solo">',
  408. '<span class="tooltip-label">',
  409. `<strong>${t('p75')}</strong>`,
  410. '</span>',
  411. '</div>',
  412. '<div class="tooltip-arrow"></div>',
  413. ].join('');
  414. },
  415. },
  416. });
  417. return {
  418. seriesName: t('p75'),
  419. data: [],
  420. markLine,
  421. };
  422. }
  423. render() {
  424. return (
  425. <Card>
  426. {this.renderSummary()}
  427. {this.renderHistogram()}
  428. </Card>
  429. );
  430. }
  431. }
  432. const SummaryHeading = styled('div')`
  433. display: flex;
  434. justify-content: space-between;
  435. `;
  436. const Container = styled('div')`
  437. position: relative;
  438. `;
  439. const PercentContainer = styled('div')`
  440. position: absolute;
  441. top: ${space(2)};
  442. right: ${space(3)};
  443. z-index: 2;
  444. `;
  445. export default withTheme(VitalCard);