webVitalsDetailPanel.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. import {useMemo} from 'react';
  2. import {Link} from 'react-router';
  3. import {useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import ChartZoom from 'sentry/components/charts/chartZoom';
  6. import MarkArea from 'sentry/components/charts/components/markArea';
  7. import MarkLine from 'sentry/components/charts/components/markLine';
  8. import {LineChart, LineChartSeries} from 'sentry/components/charts/lineChart';
  9. import GridEditable, {
  10. COL_WIDTH_UNDEFINED,
  11. GridColumnHeader,
  12. GridColumnOrder,
  13. GridColumnSortBy,
  14. } from 'sentry/components/gridEditable';
  15. import {Tooltip} from 'sentry/components/tooltip';
  16. import {t} from 'sentry/locale';
  17. import {getDuration} from 'sentry/utils/formatters';
  18. import {
  19. PageErrorAlert,
  20. PageErrorProvider,
  21. } from 'sentry/utils/performance/contexts/pageError';
  22. import {useLocation} from 'sentry/utils/useLocation';
  23. import usePageFilters from 'sentry/utils/usePageFilters';
  24. import useRouter from 'sentry/utils/useRouter';
  25. import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
  26. import {calculateOpportunity} from 'sentry/views/performance/browser/webVitals/utils/calculateOpportunity';
  27. import {
  28. calculatePerformanceScore,
  29. PERFORMANCE_SCORE_MEDIANS,
  30. PERFORMANCE_SCORE_P90S,
  31. } from 'sentry/views/performance/browser/webVitals/utils/calculatePerformanceScore';
  32. import {
  33. Row,
  34. RowWithScore,
  35. WebVitals,
  36. } from 'sentry/views/performance/browser/webVitals/utils/types';
  37. import {useProjectWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useProjectWebVitalsQuery';
  38. import {useProjectWebVitalsValuesTimeseriesQuery} from 'sentry/views/performance/browser/webVitals/utils/useProjectWebVitalsValuesTimeseriesQuery';
  39. import {useTransactionWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useTransactionWebVitalsQuery';
  40. import {WebVitalDescription} from 'sentry/views/performance/browser/webVitals/webVitalsDescriptions/webVitalDescription';
  41. import DetailPanel from 'sentry/views/starfish/components/detailPanel';
  42. type Column = GridColumnHeader;
  43. const columnOrder: GridColumnOrder[] = [
  44. {key: 'transaction', width: COL_WIDTH_UNDEFINED, name: 'Pages'},
  45. {key: 'count()', width: COL_WIDTH_UNDEFINED, name: 'Pageloads'},
  46. {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: 'Web Vital'},
  47. {key: 'score', width: COL_WIDTH_UNDEFINED, name: 'Score'},
  48. {key: 'opportunity', width: COL_WIDTH_UNDEFINED, name: 'Opportunity'},
  49. ];
  50. const sort: GridColumnSortBy<keyof Row> = {key: 'count()', order: 'desc'};
  51. const MAX_ROWS = 10;
  52. export function WebVitalsDetailPanel({
  53. webVital,
  54. onClose,
  55. }: {
  56. onClose: () => void;
  57. webVital: WebVitals | null;
  58. }) {
  59. const location = useLocation();
  60. const theme = useTheme();
  61. const pageFilters = usePageFilters();
  62. const router = useRouter();
  63. const {period, start, end, utc} = pageFilters.selection.datetime;
  64. const transaction = location.query.transaction
  65. ? Array.isArray(location.query.transaction)
  66. ? location.query.transaction[0]
  67. : location.query.transaction
  68. : undefined;
  69. const {data: projectData} = useProjectWebVitalsQuery({transaction});
  70. const projectScore = calculatePerformanceScore({
  71. lcp: projectData?.data[0]['p75(measurements.lcp)'] as number,
  72. fcp: projectData?.data[0]['p75(measurements.fcp)'] as number,
  73. cls: projectData?.data[0]['p75(measurements.cls)'] as number,
  74. ttfb: projectData?.data[0]['p75(measurements.ttfb)'] as number,
  75. fid: projectData?.data[0]['p75(measurements.fid)'] as number,
  76. });
  77. const {data, isLoading} = useTransactionWebVitalsQuery({
  78. transaction,
  79. orderBy: webVital,
  80. limit: 100,
  81. });
  82. const dataByOpportunity = useMemo(() => {
  83. if (!data) {
  84. return [];
  85. }
  86. const count = projectData?.data[0]['count()'] as number;
  87. return data
  88. .map(row => ({
  89. ...row,
  90. opportunity: calculateOpportunity(
  91. projectScore[`${webVital}Score`],
  92. count,
  93. row[`${webVital}Score`],
  94. row['count()']
  95. ),
  96. }))
  97. .sort((a, b) => b.opportunity - a.opportunity)
  98. .slice(0, MAX_ROWS);
  99. }, [data, projectData?.data, projectScore, webVital]);
  100. const {data: timeseriesData, isLoading: isTimeseriesLoading} =
  101. useProjectWebVitalsValuesTimeseriesQuery({transaction});
  102. const webVitalData: LineChartSeries[] = [
  103. {
  104. data:
  105. !isTimeseriesLoading && webVital
  106. ? timeseriesData?.[webVital].map(({name, value}) => ({
  107. name,
  108. value,
  109. }))
  110. : [],
  111. seriesName: webVital ?? '',
  112. },
  113. ];
  114. const showPoorMarkLine = webVitalData[0].data?.some(
  115. ({value}) => value > PERFORMANCE_SCORE_MEDIANS[webVital ?? '']
  116. );
  117. const showMehMarkLine = webVitalData[0].data?.some(
  118. ({value}) => value >= PERFORMANCE_SCORE_P90S[webVital ?? '']
  119. );
  120. const showGoodMarkLine = webVitalData[0].data?.every(
  121. ({value}) => value < PERFORMANCE_SCORE_P90S[webVital ?? '']
  122. );
  123. const goodMarkArea = MarkArea({
  124. silent: true,
  125. itemStyle: {
  126. color: theme.green300,
  127. opacity: 0.1,
  128. },
  129. data: [
  130. [
  131. {
  132. yAxis: PERFORMANCE_SCORE_P90S[webVital ?? ''],
  133. },
  134. {
  135. yAxis: 0,
  136. },
  137. ],
  138. ],
  139. });
  140. const mehMarkArea = MarkArea({
  141. silent: true,
  142. itemStyle: {
  143. color: theme.yellow300,
  144. opacity: 0.1,
  145. },
  146. data: [
  147. [
  148. {
  149. yAxis: PERFORMANCE_SCORE_MEDIANS[webVital ?? ''],
  150. },
  151. {
  152. yAxis: PERFORMANCE_SCORE_P90S[webVital ?? ''],
  153. },
  154. ],
  155. ],
  156. });
  157. const poorMarkArea = MarkArea({
  158. silent: true,
  159. itemStyle: {
  160. color: theme.red300,
  161. opacity: 0.1,
  162. },
  163. data: [
  164. [
  165. {
  166. yAxis: PERFORMANCE_SCORE_MEDIANS[webVital ?? ''],
  167. },
  168. {
  169. yAxis: Infinity,
  170. },
  171. ],
  172. ],
  173. });
  174. const goodMarkLine = MarkLine({
  175. silent: true,
  176. lineStyle: {
  177. color: theme.green300,
  178. },
  179. label: {
  180. formatter: () => 'Good',
  181. position: 'insideEndBottom',
  182. color: theme.green300,
  183. },
  184. data: showGoodMarkLine
  185. ? [
  186. [
  187. {xAxis: 'min', y: 10},
  188. {xAxis: 'max', y: 10},
  189. ],
  190. ]
  191. : [
  192. {
  193. yAxis: PERFORMANCE_SCORE_P90S[webVital ?? ''],
  194. },
  195. ],
  196. });
  197. const mehMarkLine = MarkLine({
  198. silent: true,
  199. lineStyle: {
  200. color: theme.yellow300,
  201. },
  202. label: {
  203. formatter: () => 'Meh',
  204. position: 'insideEndBottom',
  205. color: theme.yellow300,
  206. },
  207. data:
  208. showMehMarkLine && !showPoorMarkLine
  209. ? [
  210. [
  211. {xAxis: 'min', y: 10},
  212. {xAxis: 'max', y: 10},
  213. ],
  214. ]
  215. : [
  216. {
  217. yAxis: PERFORMANCE_SCORE_MEDIANS[webVital ?? ''],
  218. },
  219. ],
  220. });
  221. const poorMarkLine = MarkLine({
  222. silent: true,
  223. lineStyle: {
  224. color: theme.red300,
  225. },
  226. label: {
  227. formatter: () => 'Poor',
  228. position: 'insideEndBottom',
  229. color: theme.red300,
  230. },
  231. data: [
  232. [
  233. {xAxis: 'min', y: 10},
  234. {xAxis: 'max', y: 10},
  235. ],
  236. ],
  237. });
  238. webVitalData.push({
  239. seriesName: '',
  240. type: 'line',
  241. markArea: goodMarkArea,
  242. data: [],
  243. });
  244. webVitalData.push({
  245. seriesName: '',
  246. type: 'line',
  247. markArea: mehMarkArea,
  248. data: [],
  249. });
  250. webVitalData.push({
  251. seriesName: '',
  252. type: 'line',
  253. markArea: poorMarkArea,
  254. data: [],
  255. });
  256. webVitalData.push({
  257. seriesName: '',
  258. type: 'line',
  259. markLine: goodMarkLine,
  260. data: [],
  261. });
  262. webVitalData.push({
  263. seriesName: '',
  264. type: 'line',
  265. markLine: mehMarkLine,
  266. data: [],
  267. });
  268. if (showPoorMarkLine) {
  269. webVitalData.push({
  270. seriesName: '',
  271. type: 'line',
  272. markLine: poorMarkLine,
  273. data: [],
  274. });
  275. }
  276. const detailKey = webVital;
  277. const renderHeadCell = (col: Column) => {
  278. if (col.key === 'transaction') {
  279. return <NoOverflow>{col.name}</NoOverflow>;
  280. }
  281. if (col.key === 'webVital') {
  282. return <AlignRight>{`${webVital} P75`}</AlignRight>;
  283. }
  284. if (col.key === 'score') {
  285. return <AlignCenter>{`${webVital} ${col.name}`}</AlignCenter>;
  286. }
  287. if (col.key === 'opportunity') {
  288. return (
  289. <Tooltip
  290. title={t(
  291. 'The biggest opportunities to improve your cumulative performance score.'
  292. )}
  293. >
  294. <OpportunityHeader>{col.name}</OpportunityHeader>
  295. </Tooltip>
  296. );
  297. }
  298. return <AlignRight>{col.name}</AlignRight>;
  299. };
  300. const getFormattedDuration = (value: number) => {
  301. if (value < 1000) {
  302. return getDuration(value / 1000, 0, true);
  303. }
  304. return getDuration(value / 1000, 2, true);
  305. };
  306. const renderBodyCell = (col: Column, row: RowWithScore) => {
  307. const {key} = col;
  308. if (key === 'score') {
  309. return (
  310. <AlignCenter>
  311. <PerformanceBadge score={row[`${webVital}Score`]} />
  312. </AlignCenter>
  313. );
  314. }
  315. if (col.key === 'webVital') {
  316. let value: string | number = row[mapWebVitalToColumn(webVital)];
  317. if (webVital && ['lcp', 'fcp', 'ttfb', 'fid'].includes(webVital)) {
  318. value = getFormattedDuration(value);
  319. } else if (webVital === 'cls') {
  320. value = value?.toFixed(2);
  321. }
  322. return <AlignRight>{value}</AlignRight>;
  323. }
  324. if (key === 'transaction') {
  325. return (
  326. <NoOverflow>
  327. <Link
  328. to={{...location, query: {...location.query, transaction: row.transaction}}}
  329. onClick={onClose}
  330. >
  331. {row.transaction}
  332. </Link>
  333. </NoOverflow>
  334. );
  335. }
  336. return <AlignRight>{row[key]}</AlignRight>;
  337. };
  338. return (
  339. <PageErrorProvider>
  340. <DetailPanel detailKey={detailKey ?? undefined} onClose={onClose}>
  341. {webVital && (
  342. <WebVitalDescription
  343. value={
  344. webVital !== 'cls'
  345. ? getDuration(
  346. (projectData?.data[0][mapWebVitalToColumn(webVital)] as number) /
  347. 1000,
  348. 2,
  349. true
  350. )
  351. : (projectData?.data[0][mapWebVitalToColumn(webVital)] as number).toFixed(
  352. 2
  353. )
  354. }
  355. webVital={webVital}
  356. score={projectScore[`${webVital}Score`]}
  357. />
  358. )}
  359. <ChartContainer>
  360. {webVital && (
  361. <ChartZoom router={router} period={period} start={start} end={end} utc={utc}>
  362. {zoomRenderProps => (
  363. <LineChart
  364. {...zoomRenderProps}
  365. height={240}
  366. series={webVitalData}
  367. xAxis={{show: false}}
  368. grid={{
  369. left: 0,
  370. right: 15,
  371. top: 10,
  372. bottom: 0,
  373. }}
  374. yAxis={
  375. webVital === 'cls'
  376. ? {}
  377. : {axisLabel: {formatter: getFormattedDuration}}
  378. }
  379. />
  380. )}
  381. </ChartZoom>
  382. )}
  383. </ChartContainer>
  384. {!transaction && (
  385. <GridEditable
  386. data={dataByOpportunity}
  387. isLoading={isLoading}
  388. columnOrder={columnOrder}
  389. columnSortBy={[sort]}
  390. grid={{
  391. renderHeadCell,
  392. renderBodyCell,
  393. }}
  394. location={location}
  395. />
  396. )}
  397. <PageErrorAlert />
  398. </DetailPanel>
  399. </PageErrorProvider>
  400. );
  401. }
  402. const mapWebVitalToColumn = (webVital?: WebVitals | null) => {
  403. switch (webVital) {
  404. case 'lcp':
  405. return 'p75(measurements.lcp)';
  406. case 'fcp':
  407. return 'p75(measurements.fcp)';
  408. case 'cls':
  409. return 'p75(measurements.cls)';
  410. case 'ttfb':
  411. return 'p75(measurements.ttfb)';
  412. case 'fid':
  413. return 'p75(measurements.fid)';
  414. default:
  415. return 'count()';
  416. }
  417. };
  418. const NoOverflow = styled('span')`
  419. overflow: hidden;
  420. text-overflow: ellipsis;
  421. `;
  422. const AlignRight = styled('span')<{color?: string}>`
  423. text-align: right;
  424. width: 100%;
  425. ${p => (p.color ? `color: ${p.color};` : '')}
  426. `;
  427. const ChartContainer = styled('div')`
  428. position: relative;
  429. flex: 1;
  430. `;
  431. const AlignCenter = styled('span')`
  432. text-align: center;
  433. width: 100%;
  434. `;
  435. const OpportunityHeader = styled('span')`
  436. ${p => p.theme.tooltipUnderline()};
  437. `;