vitalCard.tsx 14 KB

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