import {Component} from 'react'; import {Theme, withTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Location} from 'history'; import isEqual from 'lodash/isEqual'; import throttle from 'lodash/throttle'; import {Button} from 'sentry/components/button'; import {BarChart, BarChartSeries} from 'sentry/components/charts/barChart'; import BarChartZoom from 'sentry/components/charts/barChartZoom'; import MarkLine from 'sentry/components/charts/components/markLine'; import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask'; import Placeholder from 'sentry/components/placeholder'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {Organization} from 'sentry/types'; import {trackAnalytics} from 'sentry/utils/analytics'; import EventView from 'sentry/utils/discover/eventView'; import {getAggregateAlias} from 'sentry/utils/discover/fields'; import {WebVital} from 'sentry/utils/fields'; import {formatAbbreviatedNumber, formatFloat, getDuration} from 'sentry/utils/formatters'; import getDynamicText from 'sentry/utils/getDynamicText'; import {DataFilter, HistogramData} from 'sentry/utils/performance/histogram/types'; import { computeBuckets, formatHistogramData, } from 'sentry/utils/performance/histogram/utils'; import {Vital} from 'sentry/utils/performance/vitals/types'; import {VitalData} from 'sentry/utils/performance/vitals/vitalsCardsDiscoverQuery'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {EventsDisplayFilterName} from 'sentry/views/performance/transactionSummary/transactionEvents/utils'; import {VitalBar} from '../../landing/vitalsCards'; import { VitalState, vitalStateColors, webVitalMeh, webVitalPoor, } from '../../vitalDetail/utils'; import {NUM_BUCKETS, PERCENTILE} from './constants'; import {Card, CardSectionHeading, CardSummary, Description, StatNumber} from './styles'; import {Rectangle} from './types'; import {asPixelRect, findNearestBucketIndex, getRefRect, mapPoint} from './utils'; type Props = { chartData: HistogramData; colors: [string]; error: boolean; eventView: EventView; isLoading: boolean; location: Location; organization: Organization; summaryData: VitalData | null; theme: Theme; vital: WebVital; vitalDetails: Vital; dataFilter?: DataFilter; max?: number; min?: number; precision?: number; }; type State = { /** * This is a pair of reference points on the graph that we can use to map any * other points to their pixel coordinates on the graph. * * The x values here are the index of the cooresponding bucket and the y value * are the respective counts. * * Invariances: * - refDataRect.point1.x < refDataRect.point2.x * - refDataRect.point1.y < refDataRect.point2.y */ refDataRect: Rectangle | null; /** * This is the cooresponding pixel coordinate of the references points from refDataRect. * * ECharts' pixel coordinates are relative to the top left whereas the axis coordinates * used here are relative to the bottom right. Because of this and the invariances imposed * on refDataRect, these points have the difference invariances. * * Invariances: * - refPixelRect.point1.x < refPixelRect.point2.x * - refPixelRect.point1.y > refPixelRect.point2.y */ refPixelRect: Rectangle | null; }; class VitalCard extends Component { state: State = { refDataRect: null, refPixelRect: null, }; static getDerivedStateFromProps(nextProps: Readonly, prevState: State) { const {isLoading, error, chartData} = nextProps; if (isLoading || error === null) { return {...prevState}; } const refDataRect = getRefRect(chartData); if ( prevState.refDataRect === null || (refDataRect !== null && !isEqual(refDataRect, prevState.refDataRect)) ) { return { ...prevState, refDataRect, }; } return {...prevState}; } trackOpenInDiscoverClicked = () => { const {organization} = this.props; const {vitalDetails: vital} = this.props; trackAnalytics('performance_views.vitals.open_in_discover', { organization, vital: vital.slug, }); }; trackOpenAllEventsClicked = () => { const {organization} = this.props; const {vitalDetails: vital} = this.props; trackAnalytics('performance_views.vitals.open_all_events', { organization, vital: vital.slug, }); }; get summary() { const {summaryData} = this.props; return summaryData?.p75 ?? null; } get failureRate() { const {summaryData} = this.props; const numerator = summaryData?.poor ?? 0; const denominator = summaryData?.total ?? 0; return denominator <= 0 ? 0 : numerator / denominator; } getFormattedStatNumber() { const {vitalDetails: vital} = this.props; const summary = this.summary; const {type} = vital; return summary === null ? '\u2014' : type === 'duration' ? getDuration(summary / 1000, 2, true) : formatFloat(summary, 2); } renderSummary() { const { vitalDetails: vital, eventView, organization, min, max, dataFilter, } = this.props; const {slug, name, description} = vital; const column = `measurements.${slug}`; const newEventView = eventView .withColumns([ {kind: 'field', field: 'transaction'}, { kind: 'function', function: ['percentile', column, PERCENTILE.toString(), undefined], }, {kind: 'function', function: ['count', '', '', undefined]}, ]) .withSorts([ { kind: 'desc', field: getAggregateAlias(`percentile(${column},${PERCENTILE.toString()})`), }, ]); const query = new MutableSearch(newEventView.query ?? ''); query.addFilterValues('has', [column]); // add in any range constraints if any if (min !== undefined || max !== undefined) { if (min !== undefined) { query.addFilterValues(column, [`>=${min}`]); } if (max !== undefined) { query.addFilterValues(column, [`<=${max}`]); } } newEventView.query = query.formatString(); return ( {`${name} (${slug.toUpperCase()})`} {getDynamicText({ value: this.getFormattedStatNumber(), fixed: '\u2014', })} {description}
); } /** * This callback happens everytime ECharts renders. This is NOT when ECharts * finishes rendering, so it can be called quite frequently. The calculations * here can get expensive if done frequently, furthermore, this can trigger a * state change leading to a re-render. So slow down the updates here as they * do not need to be updated every single time. */ handleRendered = throttle( (_, chartRef) => { const {chartData} = this.props; const {refDataRect} = this.state; if (refDataRect === null || chartData.length < 1) { return; } const refPixelRect = refDataRect === null ? null : asPixelRect(chartRef, refDataRect!); if (refPixelRect !== null && !isEqual(refPixelRect, this.state.refPixelRect)) { this.setState({refPixelRect}); } }, 200, {leading: true} ); handleDataZoomCancelled = () => {}; renderHistogram() { const { theme, location, isLoading, chartData, summaryData, error, colors, vital, vitalDetails, precision = 0, } = this.props; const {slug} = vitalDetails; const series = this.getSeries(); const xAxis = { type: 'category' as const, truncate: true, axisTick: { alignWithLabel: true, }, }; const values = series.data.map(point => point.value); const max = values.length ? Math.max(...values) : undefined; const yAxis = { type: 'value' as const, max, axisLabel: { color: theme.chartLabel, formatter: (value: string | number) => formatAbbreviatedNumber(value), }, }; const allSeries = [series]; if (!isLoading && !error) { const baselineSeries = this.getBaselineSeries(); if (baselineSeries !== null) { allSeries.push(baselineSeries); } } const vitalData = !isLoading && !error && summaryData !== null ? {[vital]: summaryData} : {}; return ( {zoomRenderProps => ( {getDynamicText({ value: ( ), fixed: , })} )} ); } bucketWidth() { const {chartData} = this.props; // We can assume that all buckets are of equal width, use the first two // buckets to get the width. The value of each histogram function indicates // the beginning of the bucket. return chartData.length >= 2 ? chartData[1].bin - chartData[0].bin : 0; } getSeries() { const {theme, chartData, precision, vitalDetails, vital} = this.props; const additionalFieldsFn = bucket => { return { itemStyle: {color: theme[this.getVitalsColor(vital, bucket)]}, }; }; const data = formatHistogramData(chartData, { precision: precision === 0 ? undefined : precision, type: vitalDetails.type, additionalFieldsFn, }); return { seriesName: t('Count'), data, }; } getVitalsColor(vital: WebVital, value: number) { const poorThreshold = webVitalPoor[vital]; const mehThreshold = webVitalMeh[vital]; if (value >= poorThreshold) { return vitalStateColors[VitalState.POOR]; } if (value >= mehThreshold) { return vitalStateColors[VitalState.MEH]; } return vitalStateColors[VitalState.GOOD]; } getBaselineSeries(): BarChartSeries | null { const {theme, chartData} = this.props; const summary = this.summary; if (summary === null || this.state.refPixelRect === null) { return null; } const summaryBucket = findNearestBucketIndex(chartData, summary); if (summaryBucket === null || summaryBucket === -1) { return null; } const thresholdPixelBottom = mapPoint( { // subtract 0.5 from the x here to ensure that the threshold lies between buckets x: summaryBucket - 0.5, y: 0, }, this.state.refDataRect!, this.state.refPixelRect! ); if (thresholdPixelBottom === null) { return null; } const thresholdPixelTop = mapPoint( { // subtract 0.5 from the x here to ensure that the threshold lies between buckets x: summaryBucket - 0.5, y: Math.max(...chartData.map(data => data.count)) || 1, }, this.state.refDataRect!, this.state.refPixelRect! ); if (thresholdPixelTop === null) { return null; } const markLine = MarkLine({ animationDuration: 200, data: [[thresholdPixelBottom, thresholdPixelTop] as any], label: { show: false, }, lineStyle: { color: theme.textColor, type: 'solid', }, tooltip: { formatter: () => { return [ '
', '', `${t('p75')}`, '', '
', '
', ].join(''); }, }, }); return { seriesName: t('p75'), data: [], markLine, }; } render() { return ( {this.renderSummary()} {this.renderHistogram()} ); } } const SummaryHeading = styled('div')` display: flex; justify-content: space-between; `; const Container = styled('div')` position: relative; `; const PercentContainer = styled('div')` position: absolute; top: ${space(2)}; right: ${space(3)}; z-index: 2; `; export default withTheme(VitalCard);