vitalCard.tsx 14 KB

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