vitalCard.tsx 14 KB

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