Skip to content

Commit bed7188

Browse files
authored
feat(insights): Replace Web Vitals "Score Breakdown" chart with standard widgets (#88339)
Closes [DAIN-111: Replace Web Vitals "Score Breakdown" chart with `TimeSeriesWidgetVisualization`](https://linear.app/getsentry/issue/DAIN-111/replace-web-vitals-score-breakdown-chart-with). Takes the "Score Breakdown" chart and re-implements it using `useMetricsSeries` for data fetching, and `InsightsTimeSeriesWidget` for the chart. Note, this relies on #88329 for _full_ functionality. ## Changes The visible changes are: 1. The "delayed" period is more visually distinct 2. Each score has a legend entry, and can be toggled 3. The X axis shows time 4. No subtitle under the title 5. "Releases" bubbles 6. An "info" tooltip explains the score contributions instead of chart tooltip 7. Chart tooltip values shows the score contribution rather than the original score
1 parent 34f4ff3 commit bed7188

File tree

7 files changed

+199
-96
lines changed

7 files changed

+199
-96
lines changed

static/app/views/insights/browser/webVitals/components/charts/performanceScoreBreakdownChart.spec.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,21 @@ describe('PerformanceScoreBreakdownChart', function () {
4343
},
4444
});
4545

46+
MockApiClient.addMockResponse({
47+
url: `/organizations/${organization.slug}/releases/stats/`,
48+
body: [],
49+
});
50+
4651
eventsStatsMock = MockApiClient.addMockResponse({
4752
url: `/organizations/${organization.slug}/events-stats/`,
48-
body: {},
53+
body: {
54+
'performance_score(measurements.score.lcp)': {
55+
data: [[1743348600, [{count: 0.6106921965623204}]]],
56+
},
57+
'performance_score(measurements.score.fcp)': {
58+
data: [[1743435000, [{count: 0.7397871866098699}]]],
59+
},
60+
},
4961
});
5062
});
5163

Lines changed: 90 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
import {useTheme} from '@emotion/react';
22
import styled from '@emotion/styled';
33

4-
import {DEFAULT_RELATIVE_PERIODS} from 'sentry/constants';
54
import {t} from 'sentry/locale';
6-
import {space} from 'sentry/styles/space';
75
import type {Series} from 'sentry/types/echarts';
8-
import usePageFilters from 'sentry/utils/usePageFilters';
6+
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
97
import {ORDER} from 'sentry/views/insights/browser/webVitals/components/charts/performanceScoreChart';
10-
import {
11-
useProjectWebVitalsScoresTimeseriesQuery,
12-
type WebVitalsScoreBreakdown,
13-
} from 'sentry/views/insights/browser/webVitals/queries/storedScoreQueries/useProjectWebVitalsScoresTimeseriesQuery';
8+
import type {WebVitalsScoreBreakdown} from 'sentry/views/insights/browser/webVitals/queries/storedScoreQueries/useProjectWebVitalsScoresTimeseriesQuery';
149
import type {WebVitals} from 'sentry/views/insights/browser/webVitals/types';
15-
import {applyStaticWeightsToTimeseries} from 'sentry/views/insights/browser/webVitals/utils/applyStaticWeightsToTimeseries';
1610
import {getWeights} from 'sentry/views/insights/browser/webVitals/utils/getWeights';
1711
import type {BrowserType} from 'sentry/views/insights/browser/webVitals/utils/queryParameterDecoders/browserType';
18-
import Chart, {ChartType} from 'sentry/views/insights/common/components/chart';
19-
import ChartPanel from 'sentry/views/insights/common/components/chartPanel';
20-
import type {SubregionCode} from 'sentry/views/insights/types';
12+
import {InsightsTimeSeriesWidget} from 'sentry/views/insights/common/components/insightsTimeSeriesWidget';
13+
import {
14+
type DiscoverSeries,
15+
useMetricsSeries,
16+
} from 'sentry/views/insights/common/queries/useDiscoverSeries';
17+
import {SpanMetricsField, type SubregionCode} from 'sentry/views/insights/types';
18+
19+
import {DEFAULT_QUERY_FILTER} from '../../settings';
20+
21+
import {WebVitalsWeightList} from './webVitalWeightList';
2122

2223
type Props = {
2324
browserTypes?: BrowserType[];
@@ -52,93 +53,96 @@ export function PerformanceScoreBreakdownChart({
5253
const theme = useTheme();
5354
const segmentColors = theme.chart.getColorPalette(3).slice(0, 5);
5455

55-
const pageFilters = usePageFilters();
56-
57-
const {data: timeseriesData, isLoading: isTimeseriesLoading} =
58-
useProjectWebVitalsScoresTimeseriesQuery({transaction, browserTypes, subregions});
56+
const search = new MutableSearch(
57+
`${DEFAULT_QUERY_FILTER} has:measurements.score.total`
58+
);
5959

60-
const period = pageFilters.selection.datetime.period;
61-
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
62-
const performanceScoreSubtext = (period && DEFAULT_RELATIVE_PERIODS[period]) ?? '';
63-
const chartSeriesOrder = ORDER;
60+
if (transaction) {
61+
search.addFilterValue('transaction', transaction);
62+
}
6463

65-
const weightedTimeseriesData = applyStaticWeightsToTimeseries(timeseriesData);
64+
if (subregions) {
65+
search.addDisjunctionFilterValues(SpanMetricsField.USER_GEO_SUBREGION, subregions);
66+
}
6667

67-
const weightedTimeseries = formatTimeSeriesResultsToChartData(
68-
weightedTimeseriesData,
69-
segmentColors,
70-
chartSeriesOrder
71-
);
68+
if (browserTypes) {
69+
search.addDisjunctionFilterValues(SpanMetricsField.BROWSER_NAME, browserTypes);
70+
}
7271

73-
const timeseries = formatTimeSeriesResultsToChartData(
72+
const {
73+
data: vitalScoresData,
74+
isLoading: areVitalScoresLoading,
75+
error: vitalScoresError,
76+
} = useMetricsSeries(
7477
{
75-
lcp: timeseriesData.lcp,
76-
fcp: timeseriesData.fcp,
77-
cls: timeseriesData.cls,
78-
ttfb: timeseriesData.ttfb,
79-
inp: timeseriesData.inp,
80-
total: timeseriesData.total,
78+
search,
79+
yAxis: [
80+
'performance_score(measurements.score.lcp)',
81+
'performance_score(measurements.score.fcp)',
82+
'performance_score(measurements.score.cls)',
83+
'performance_score(measurements.score.inp)',
84+
'performance_score(measurements.score.ttfb)',
85+
'count()',
86+
],
87+
transformAliasToInputFormat: true,
8188
},
82-
segmentColors,
83-
chartSeriesOrder
89+
'api.performance.browser.web-vitals.timeseries-scores2'
8490
);
8591

86-
const weights = getWeights(
87-
ORDER.filter(webVital => timeseriesData[webVital].some(series => series.value > 0))
88-
);
92+
const webVitalsThatHaveData: WebVitals[] = vitalScoresData
93+
? ORDER.filter(webVital => {
94+
const key = `performance_score(measurements.score.${webVital})` as const;
95+
const series = vitalScoresData[key]!;
8996

90-
return (
91-
<StyledChartPanel title={t('Score Breakdown')}>
92-
<PerformanceScoreSubtext>{performanceScoreSubtext}</PerformanceScoreSubtext>
93-
<Chart
94-
stacked
95-
hideYAxisSplitLine
96-
height={180}
97-
data={isTimeseriesLoading ? [] : weightedTimeseries}
98-
disableXAxis
99-
loading={isTimeseriesLoading}
100-
type={ChartType.AREA}
101-
grid={{
102-
left: 5,
103-
right: 5,
104-
top: 5,
105-
bottom: 0,
106-
}}
107-
dataMax={100}
108-
chartColors={segmentColors}
109-
tooltipFormatterOptions={{
110-
nameFormatter: name => {
111-
// nameFormatter expects a string an will wrap the output in an html string.
112-
// Kind of a hack, but we can inject some html to escape styling for the subLabel.
113-
const subLabel =
114-
weights === undefined
115-
? ''
116-
: // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
117-
` </strong>(${weights[name.toLocaleLowerCase()].toFixed(
118-
0
119-
)}% of Perf Score)<strong>`;
120-
return `${name} Score${subLabel}`;
121-
},
122-
valueFormatter: (_value, _label, seriesParams: any) => {
123-
const timestamp = seriesParams?.data[0];
124-
const value = timeseries
125-
.find(series => series.seriesName === seriesParams?.seriesName)
126-
?.data.find(dataPoint => dataPoint.name === timestamp)?.value;
127-
return `<span class="tooltip-label-value">${value}</span>`;
97+
return series.data.some(datum => datum.value > 0);
98+
})
99+
: [];
100+
101+
const weights = getWeights(webVitalsThatHaveData);
102+
103+
const allSeries: DiscoverSeries[] = vitalScoresData
104+
? ORDER.map((webVital, index) => {
105+
const key = `performance_score(measurements.score.${webVital})` as const;
106+
const series = vitalScoresData[key]!;
107+
108+
const scaledSeries: DiscoverSeries = {
109+
...series,
110+
data: series.data.map(datum => {
111+
return {
112+
...datum,
113+
value: datum.value * weights[webVital],
114+
};
115+
}),
116+
color: segmentColors[index],
117+
meta: {
118+
// TODO: The backend doesn't return these score fields with the "score" type yet. Fill this in manually for now.
119+
fields: {
120+
...series.meta?.fields,
121+
[key]: 'score',
122+
},
123+
units: series.meta?.units,
128124
},
129-
}}
125+
};
126+
127+
return scaledSeries;
128+
})
129+
: [];
130+
131+
return (
132+
<ChartContainer>
133+
<InsightsTimeSeriesWidget
134+
title={t('Score Breakdown')}
135+
height="100%"
136+
visualizationType="area"
137+
isLoading={areVitalScoresLoading}
138+
error={vitalScoresError}
139+
series={allSeries}
140+
description={<WebVitalsWeightList weights={weights} />}
130141
/>
131-
</StyledChartPanel>
142+
</ChartContainer>
132143
);
133144
}
134145

135-
const StyledChartPanel = styled(ChartPanel)`
136-
flex: 1;
137-
`;
138-
139-
const PerformanceScoreSubtext = styled('div')`
140-
width: 100%;
141-
font-size: ${p => p.theme.fontSizeSmall};
142-
color: ${p => p.theme.subText};
143-
margin-bottom: ${space(1)};
146+
const ChartContainer = styled('div')`
147+
flex: 1 1 0%;
144148
`;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {useTheme} from '@emotion/react';
2+
import styled from '@emotion/styled';
3+
4+
import ExternalLink from 'sentry/components/links/externalLink';
5+
import {t} from 'sentry/locale';
6+
7+
import {MODULE_DOC_LINK} from '../../settings';
8+
import type {WebVitals} from '../../types';
9+
import {Dot} from '../webVitalMeters';
10+
11+
import {ORDER} from './performanceScoreChart';
12+
13+
interface WebVitalsWeightListProps {
14+
weights: Record<WebVitals, number>;
15+
}
16+
export function WebVitalsWeightList({weights}: WebVitalsWeightListProps) {
17+
const theme = useTheme();
18+
const segmentColors = theme.chart.getColorPalette(3);
19+
20+
return (
21+
<Content>
22+
<p>
23+
{t('Each Web Vital score contributes a different amount to the total score.')}
24+
<ExternalLink href={`${MODULE_DOC_LINK}#performance-score`}>
25+
{' '}
26+
{t('How is this calculated?')}
27+
</ExternalLink>
28+
</p>
29+
30+
<List>
31+
{ORDER.map((webVital, index) => (
32+
<ListItem key={webVital}>
33+
<Dot color={segmentColors[index]!} />
34+
{t('%s contributes %s%%', webVital.toUpperCase(), weights[webVital])}
35+
</ListItem>
36+
))}
37+
</List>
38+
</Content>
39+
);
40+
}
41+
42+
const Content = styled('div')`
43+
font-size: ${p => p.theme.fontSizeSmall};
44+
`;
45+
46+
const List = styled('ul')`
47+
list-style-type: none;
48+
margin: 0;
49+
padding: 0;
50+
`;
51+
52+
const ListItem = styled('li')``;

static/app/views/insights/browser/webVitals/views/pageOverview.spec.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ describe('PageOverview', function () {
6464
url: `/organizations/${organization.slug}/recent-searches/`,
6565
body: [],
6666
});
67+
MockApiClient.addMockResponse({
68+
url: `/organizations/${organization.slug}/releases/stats/`,
69+
body: [],
70+
});
6771
});
6872

6973
afterEach(function () {

static/app/views/insights/browser/webVitals/views/webVitalsLandingPage.spec.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,22 @@ describe('WebVitalsLandingPage', function () {
6666
data: [],
6767
},
6868
});
69+
70+
MockApiClient.addMockResponse({
71+
url: `/organizations/${organization.slug}/releases/stats/`,
72+
body: [],
73+
});
74+
6975
MockApiClient.addMockResponse({
7076
url: `/organizations/${organization.slug}/events-stats/`,
71-
body: {},
77+
body: {
78+
'performance_score(measurements.score.lcp)': {
79+
data: [[1743348600, [{count: 0.6106921965623204}]]],
80+
},
81+
'performance_score(measurements.score.fcp)': {
82+
data: [[1743435000, [{count: 0.7397871866098699}]]],
83+
},
84+
},
7285
});
7386
});
7487

static/app/views/insights/common/components/insightsTimeSeriesWidget.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface InsightsTimeSeriesWidgetProps {
4040
visualizationType: 'line' | 'area' | 'bar';
4141
aliases?: Record<string, string>;
4242
description?: React.ReactNode;
43+
height?: string | number;
4344
legendSelection?: LegendSelection | undefined;
4445
onLegendSelectionChange?: ((selection: LegendSelection) => void) | undefined;
4546
stacked?: boolean;
@@ -80,7 +81,7 @@ export function InsightsTimeSeriesWidget(props: InsightsTimeSeriesWidgetProps) {
8081
// TODO: Instead of using `ChartContainer`, enforce the height from the parent layout
8182
if (props.isLoading) {
8283
return (
83-
<ChartContainer>
84+
<ChartContainer height={props.height}>
8485
<Widget
8586
Title={Title}
8687
Visualization={<TimeSeriesWidgetVisualization.LoadingPlaceholder />}
@@ -91,7 +92,7 @@ export function InsightsTimeSeriesWidget(props: InsightsTimeSeriesWidgetProps) {
9192

9293
if (props.error) {
9394
return (
94-
<ChartContainer>
95+
<ChartContainer height={props.height}>
9596
<Widget
9697
Title={Title}
9798
Visualization={<Widget.WidgetError error={props.error} />}
@@ -102,7 +103,7 @@ export function InsightsTimeSeriesWidget(props: InsightsTimeSeriesWidgetProps) {
102103

103104
if (props.series.filter(Boolean).length === 0) {
104105
return (
105-
<ChartContainer>
106+
<ChartContainer height={props.height}>
106107
<Widget
107108
Title={Title}
108109
Visualization={<Widget.WidgetError error={MISSING_DATA_MESSAGE} />}
@@ -112,7 +113,7 @@ export function InsightsTimeSeriesWidget(props: InsightsTimeSeriesWidgetProps) {
112113
}
113114

114115
return (
115-
<ChartContainer>
116+
<ChartContainer height={props.height}>
116117
<Widget
117118
Title={Title}
118119
Visualization={
@@ -169,8 +170,10 @@ const COMMON_COLORS = (theme: Theme): Record<string, string> => ({
169170
'avg(span.duration)': theme.chart.colors[2][2],
170171
});
171172

172-
const ChartContainer = styled('div')`
173-
height: 220px;
173+
const ChartContainer = styled('div')<{height?: string | number}>`
174+
min-height: 220px;
175+
height: ${p =>
176+
p.height ? (typeof p.height === 'string' ? p.height : `${p.height}px`) : '220px'};
174177
`;
175178

176179
const ModalChartContainer = styled('div')`

0 commit comments

Comments
 (0)