Skip to content

Commit 40294b9

Browse files
feat(insights): Update Web Vital Meter in the landing page with link to performance issues (#93520)
Update to design with link to related perf issues.
1 parent 42a0a89 commit 40294b9

File tree

6 files changed

+478
-6
lines changed

6 files changed

+478
-6
lines changed

static/app/types/group.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ export enum IssueTitle {
210210
REPLAY_HYDRATION_ERROR = 'Hydration Error Detected',
211211
}
212212

213-
const ISSUE_TYPE_TO_ISSUE_TITLE = {
213+
export const ISSUE_TYPE_TO_ISSUE_TITLE = {
214214
error: IssueTitle.ERROR,
215215

216216
performance_consecutive_db_queries: IssueTitle.PERFORMANCE_CONSECUTIVE_DB_QUERIES,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
import {PageFilterStateFixture} from 'sentry-fixture/pageFilters';
3+
4+
import {render, screen} from 'sentry-test/reactTestingLibrary';
5+
6+
import usePageFilters from 'sentry/utils/usePageFilters';
7+
import WebVitalMetersWithIssues, {
8+
type ProjectData,
9+
} from 'sentry/views/insights/browser/webVitals/components/webVitalMetersWithIssues';
10+
import type {ProjectScore} from 'sentry/views/insights/browser/webVitals/types';
11+
12+
jest.mock('sentry/utils/useLocation');
13+
jest.mock('sentry/utils/usePageFilters');
14+
15+
describe('WebVitalMetersWithIssues', function () {
16+
const organization = OrganizationFixture();
17+
const projectScore: ProjectScore = {
18+
lcpScore: 100,
19+
fcpScore: 100,
20+
clsScore: 100,
21+
ttfbScore: 100,
22+
inpScore: 100,
23+
};
24+
const projectData: ProjectData[] = [];
25+
let issuesMock: jest.Mock;
26+
27+
beforeEach(function () {
28+
jest.mocked(usePageFilters).mockReturnValue(PageFilterStateFixture());
29+
issuesMock = MockApiClient.addMockResponse({
30+
method: 'GET',
31+
url: '/organizations/org-slug/issues/',
32+
body: [],
33+
});
34+
});
35+
36+
it('renders web vital meters', async () => {
37+
render(
38+
<WebVitalMetersWithIssues projectData={projectData} projectScore={projectScore} />,
39+
{
40+
organization,
41+
}
42+
);
43+
44+
expect(await screen.findByText('Largest Contentful Paint')).toBeInTheDocument();
45+
expect(screen.getByText('First Contentful Paint')).toBeInTheDocument();
46+
expect(screen.getByText('Cumulative Layout Shift')).toBeInTheDocument();
47+
expect(screen.getByText('Time To First Byte')).toBeInTheDocument();
48+
expect(screen.getByText('Interaction to Next Paint')).toBeInTheDocument();
49+
50+
expect(issuesMock).toHaveBeenCalledWith(
51+
'/organizations/org-slug/issues/',
52+
expect.objectContaining({
53+
query: expect.objectContaining({
54+
query:
55+
'is:unresolved issue.type:[performance_render_blocking_asset_span,performance_uncompressed_assets,performance_http_overhead,performance_consecutive_http,performance_n_plus_one_api_calls,performance_large_http_payload,performance_p95_endpoint_regression]',
56+
}),
57+
})
58+
);
59+
expect(screen.getAllByLabelText('View Performance Issues')).toHaveLength(5);
60+
});
61+
});
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import React, {Fragment} from 'react';
2+
import {useTheme} from '@emotion/react';
3+
import styled from '@emotion/styled';
4+
import * as qs from 'query-string';
5+
6+
import {LinkButton} from 'sentry/components/core/button/linkButton';
7+
import InteractionStateLayer from 'sentry/components/core/interactionStateLayer';
8+
import {Tooltip} from 'sentry/components/core/tooltip';
9+
import ExternalLink from 'sentry/components/links/externalLink';
10+
import QuestionTooltip from 'sentry/components/questionTooltip';
11+
import {IconIssues} from 'sentry/icons';
12+
import {t, tct} from 'sentry/locale';
13+
import {space} from 'sentry/styles/space';
14+
import type {Organization} from 'sentry/types/organization';
15+
import useOrganization from 'sentry/utils/useOrganization';
16+
import {ORDER} from 'sentry/views/insights/browser/webVitals/components/charts/performanceScoreChart';
17+
import {PerformanceBadge} from 'sentry/views/insights/browser/webVitals/components/performanceBadge';
18+
import {VITAL_DESCRIPTIONS} from 'sentry/views/insights/browser/webVitals/components/webVitalDescription';
19+
import {WEB_VITALS_METERS_CONFIG} from 'sentry/views/insights/browser/webVitals/components/webVitalMeters';
20+
import {
21+
getIssueQueryFilter,
22+
useWebVitalsIssuesQuery,
23+
} from 'sentry/views/insights/browser/webVitals/queries/useWebVitalsIssuesQuery';
24+
import {MODULE_DOC_LINK} from 'sentry/views/insights/browser/webVitals/settings';
25+
import {
26+
type ProjectScore,
27+
WEB_VITAL_PERFORMANCE_ISSUES,
28+
type WebVitals,
29+
} from 'sentry/views/insights/browser/webVitals/types';
30+
31+
export type ProjectData = {
32+
'p75(measurements.cls)': number;
33+
'p75(measurements.fcp)': number;
34+
'p75(measurements.inp)': number;
35+
'p75(measurements.lcp)': number;
36+
'p75(measurements.ttfb)': number;
37+
};
38+
39+
type Props = {
40+
onClick?: (webVital: WebVitals) => void;
41+
projectData?: ProjectData[];
42+
projectScore?: ProjectScore;
43+
showTooltip?: boolean;
44+
transaction?: string;
45+
};
46+
47+
export default function WebVitalMetersWithIssues({
48+
onClick,
49+
projectData,
50+
projectScore,
51+
showTooltip = true,
52+
}: Props) {
53+
const theme = useTheme();
54+
if (!projectScore) {
55+
return null;
56+
}
57+
58+
const colors = theme.chart.getColorPalette(3);
59+
60+
const renderVitals = () => {
61+
return ORDER.map((webVital, index) => {
62+
const webVitalKey: keyof ProjectData = `p75(measurements.${webVital})`;
63+
const score = projectScore[`${webVital}Score`];
64+
const meterValue = projectData?.[0]?.[webVitalKey];
65+
66+
if (!score) {
67+
return null;
68+
}
69+
70+
return (
71+
<VitalMeter
72+
key={webVital}
73+
webVital={webVital}
74+
showTooltip={showTooltip}
75+
score={score}
76+
meterValue={meterValue}
77+
color={colors[index]!}
78+
onClick={onClick}
79+
/>
80+
);
81+
});
82+
};
83+
84+
return (
85+
<Container>
86+
<Flex>{renderVitals()}</Flex>
87+
</Container>
88+
);
89+
}
90+
91+
type VitalMeterProps = {
92+
color: string;
93+
meterValue: number | undefined;
94+
score: number | undefined;
95+
showTooltip: boolean;
96+
webVital: WebVitals;
97+
isAggregateMode?: boolean;
98+
onClick?: (webVital: WebVitals) => void;
99+
};
100+
101+
function VitalMeter({
102+
webVital,
103+
score,
104+
meterValue,
105+
onClick,
106+
isAggregateMode = true,
107+
showTooltip = true,
108+
}: VitalMeterProps) {
109+
const organization = useOrganization();
110+
const webVitalExists = score !== undefined;
111+
112+
const formattedMeterValueText =
113+
webVitalExists && meterValue ? (
114+
WEB_VITALS_METERS_CONFIG[webVital].formatter(meterValue)
115+
) : (
116+
<NoValue />
117+
);
118+
119+
const webVitalKey = `measurements.${webVital}`;
120+
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
121+
const {shortDescription} = VITAL_DESCRIPTIONS[webVitalKey];
122+
123+
const headerText = WEB_VITALS_METERS_CONFIG[webVital].name;
124+
const performanceIssues = WEB_VITAL_PERFORMANCE_ISSUES[webVital];
125+
const {data: issues} = useWebVitalsIssuesQuery(performanceIssues);
126+
const hasIssues = issues && issues.length > 0;
127+
const meterBody = (
128+
<Fragment>
129+
<MeterBarBody>
130+
<StyledIssuesButton
131+
to={getIssuesUrl({organization, webVital})}
132+
aria-label={t('View Performance Issues')}
133+
icon={<IconIssues />}
134+
size="xs"
135+
onClick={event => {
136+
event.stopPropagation();
137+
}}
138+
disabled={!hasIssues}
139+
title={
140+
issues &&
141+
issues.length > 0 &&
142+
(issues.length === 1
143+
? tct('There is 1 performance issue potentially affecting [webVital].', {
144+
webVital: webVital.toUpperCase(),
145+
})
146+
: tct(
147+
'There are [count] performance issues potentially affecting [webVital].',
148+
{
149+
count: issues.length > 5 ? '5+' : issues.length,
150+
webVital: webVital.toUpperCase(),
151+
}
152+
))
153+
}
154+
tooltipProps={{
155+
isHoverable: true,
156+
}}
157+
>
158+
{hasIssues ? (issues.length > 5 ? '5+' : issues.length) : '—'}
159+
</StyledIssuesButton>
160+
<MeterHeader>
161+
{headerText}
162+
163+
{showTooltip && (
164+
<StyledQuestionTooltip
165+
isHoverable
166+
size="xs"
167+
title={
168+
<span>
169+
{shortDescription}
170+
<br />
171+
<ExternalLink href={`${MODULE_DOC_LINK}#performance-score`}>
172+
{t('Find out how performance scores are calculated here.')}
173+
</ExternalLink>
174+
</span>
175+
}
176+
/>
177+
)}
178+
</MeterHeader>
179+
<MeterValueText>
180+
{formattedMeterValueText}
181+
{score && <PerformanceBadge score={score} />}
182+
</MeterValueText>
183+
</MeterBarBody>
184+
</Fragment>
185+
);
186+
return (
187+
<VitalContainer
188+
key={webVital}
189+
webVital={webVital}
190+
webVitalExists={webVitalExists}
191+
meterBody={meterBody}
192+
onClick={onClick}
193+
isAggregateMode={isAggregateMode}
194+
/>
195+
);
196+
}
197+
198+
type VitalContainerProps = {
199+
meterBody: React.ReactNode;
200+
webVital: WebVitals;
201+
webVitalExists: boolean;
202+
isAggregateMode?: boolean;
203+
onClick?: (webVital: WebVitals) => void;
204+
};
205+
206+
function VitalContainer({
207+
webVital,
208+
webVitalExists,
209+
meterBody,
210+
onClick,
211+
isAggregateMode = true,
212+
}: VitalContainerProps) {
213+
return (
214+
<MeterBarContainer
215+
key={webVital}
216+
onClick={() => webVitalExists && onClick?.(webVital)}
217+
clickable={webVitalExists}
218+
>
219+
{webVitalExists && <InteractionStateLayer />}
220+
{webVitalExists && meterBody}
221+
{!webVitalExists && (
222+
<StyledTooltip
223+
title={tct('No [webVital] data found in this [selection].', {
224+
webVital: webVital.toUpperCase(),
225+
selection: isAggregateMode ? 'project' : 'trace',
226+
})}
227+
>
228+
{meterBody}
229+
</StyledTooltip>
230+
)}
231+
</MeterBarContainer>
232+
);
233+
}
234+
235+
const getIssuesUrl = ({
236+
organization,
237+
webVital,
238+
}: {
239+
organization: Organization;
240+
webVital: WebVitals;
241+
}) => {
242+
const query = getIssueQueryFilter(WEB_VITAL_PERFORMANCE_ISSUES[webVital]);
243+
return `/organizations/${organization.slug}/issues/?${qs.stringify({
244+
query,
245+
})}`;
246+
};
247+
248+
const Container = styled('div')`
249+
margin-bottom: ${space(1)};
250+
`;
251+
252+
const Flex = styled('div')<{gap?: number}>`
253+
display: flex;
254+
flex-direction: row;
255+
justify-content: center;
256+
width: 100%;
257+
gap: ${p => (p.gap ? `${p.gap}px` : space(1))};
258+
align-items: center;
259+
flex-wrap: wrap;
260+
`;
261+
262+
const StyledIssuesButton = styled(LinkButton)`
263+
position: absolute;
264+
right: ${space(1)};
265+
`;
266+
267+
// This style explicitly hides InteractionStateLayer when the Issues button is hovered
268+
// This is to prevent hover styles displayed on multiple overlapping components simultaneously
269+
const MeterBarContainer = styled('div')<{clickable?: boolean}>`
270+
background-color: ${p => p.theme.background};
271+
flex: 1;
272+
position: relative;
273+
padding: 0;
274+
cursor: ${p => (p.clickable ? 'pointer' : 'default')};
275+
min-width: 140px;
276+
277+
:has(${StyledIssuesButton}:hover) > ${InteractionStateLayer} {
278+
display: none;
279+
}
280+
`;
281+
282+
const MeterBarBody = styled('div')`
283+
border: 1px solid ${p => p.theme.border};
284+
border-radius: ${p => p.theme.borderRadius};
285+
padding: ${space(1)} 0 ${space(0.5)} 0;
286+
`;
287+
288+
const MeterHeader = styled('div')`
289+
font-size: ${p => p.theme.fontSizeSmall};
290+
font-weight: ${p => p.theme.fontWeightBold};
291+
color: ${p => p.theme.textColor};
292+
display: flex;
293+
width: 100%;
294+
padding: 0 ${space(1)};
295+
align-items: center;
296+
`;
297+
298+
const MeterValueText = styled('div')`
299+
display: flex;
300+
align-items: center;
301+
font-size: ${p => p.theme.headerFontSize};
302+
font-weight: ${p => p.theme.fontWeightBold};
303+
color: ${p => p.theme.textColor};
304+
flex: 1;
305+
text-align: center;
306+
padding: 0 ${space(1)};
307+
gap: ${space(1)};
308+
`;
309+
310+
const NoValueContainer = styled('span')`
311+
color: ${p => p.theme.subText};
312+
font-size: ${p => p.theme.headerFontSize};
313+
`;
314+
315+
function NoValue() {
316+
return <NoValueContainer>{' \u2014 '}</NoValueContainer>;
317+
}
318+
319+
const StyledTooltip = styled(Tooltip)`
320+
display: block;
321+
width: 100%;
322+
`;
323+
324+
const StyledQuestionTooltip = styled(QuestionTooltip)`
325+
padding-left: ${space(0.5)};
326+
`;

0 commit comments

Comments
 (0)