pageOverviewWebVitalsDetailPanel.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import {useMemo} from 'react';
  2. import {Link} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import GridEditable, {
  5. COL_WIDTH_UNDEFINED,
  6. GridColumnHeader,
  7. GridColumnOrder,
  8. GridColumnSortBy,
  9. } from 'sentry/components/gridEditable';
  10. import {t} from 'sentry/locale';
  11. import {defined} from 'sentry/utils';
  12. import {generateEventSlug} from 'sentry/utils/discover/urls';
  13. import {getShortEventId} from 'sentry/utils/events';
  14. import {getDuration} from 'sentry/utils/formatters';
  15. import {
  16. PageErrorAlert,
  17. PageErrorProvider,
  18. } from 'sentry/utils/performance/contexts/pageError';
  19. import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
  20. import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
  21. import {useLocation} from 'sentry/utils/useLocation';
  22. import useOrganization from 'sentry/utils/useOrganization';
  23. import useProjects from 'sentry/utils/useProjects';
  24. import {useRoutes} from 'sentry/utils/useRoutes';
  25. import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
  26. import {WebVitalDetailHeader} from 'sentry/views/performance/browser/webVitals/components/webVitalDescription';
  27. import {
  28. calculatePerformanceScore,
  29. PERFORMANCE_SCORE_MEDIANS,
  30. PERFORMANCE_SCORE_P90S,
  31. } from 'sentry/views/performance/browser/webVitals/utils/calculatePerformanceScore';
  32. import {
  33. TransactionSampleRowWithScore,
  34. WebVitals,
  35. } from 'sentry/views/performance/browser/webVitals/utils/types';
  36. import {useProjectWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useProjectWebVitalsQuery';
  37. import {useTransactionSamplesWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useTransactionSamplesWebVitalsQuery';
  38. import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils';
  39. import DetailPanel from 'sentry/views/starfish/components/detailPanel';
  40. type Column = GridColumnHeader;
  41. const columnOrder: GridColumnOrder[] = [
  42. {key: 'id', width: COL_WIDTH_UNDEFINED, name: 'Event ID'},
  43. {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: 'Replay'},
  44. {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
  45. {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: 'Web Vital'},
  46. {key: 'score', width: COL_WIDTH_UNDEFINED, name: 'Score'},
  47. ];
  48. const sort: GridColumnSortBy<keyof TransactionSampleRowWithScore> = {
  49. key: 'score',
  50. order: 'desc',
  51. };
  52. export function PageOverviewWebVitalsDetailPanel({
  53. webVital,
  54. onClose,
  55. }: {
  56. onClose: () => void;
  57. webVital: WebVitals | null;
  58. }) {
  59. const location = useLocation();
  60. const {projects} = useProjects();
  61. const organization = useOrganization();
  62. const routes = useRoutes();
  63. const replayLinkGenerator = generateReplayLink(routes);
  64. const project = useMemo(
  65. () => projects.find(p => p.id === String(location.query.project)),
  66. [projects, location.query.project]
  67. );
  68. const transaction = location.query.transaction
  69. ? Array.isArray(location.query.transaction)
  70. ? location.query.transaction[0]
  71. : location.query.transaction
  72. : undefined;
  73. const {data: projectData} = useProjectWebVitalsQuery({transaction});
  74. const projectScore = calculatePerformanceScore({
  75. lcp: projectData?.data[0]['p75(measurements.lcp)'] as number,
  76. fcp: projectData?.data[0]['p75(measurements.fcp)'] as number,
  77. cls: projectData?.data[0]['p75(measurements.cls)'] as number,
  78. ttfb: projectData?.data[0]['p75(measurements.ttfb)'] as number,
  79. fid: projectData?.data[0]['p75(measurements.fid)'] as number,
  80. });
  81. // Do 3 queries filtering on LCP to get a spread of good, meh, and poor events
  82. // We can't query by performance score yet, so we're using LCP as a best estimate
  83. const {data: goodData, isLoading: isGoodTransactionWebVitalsQueryLoading} =
  84. useTransactionSamplesWebVitalsQuery({
  85. limit: 3,
  86. transaction: transaction ?? '',
  87. query: webVital
  88. ? `measurements.${webVital}:<${PERFORMANCE_SCORE_P90S[webVital]}`
  89. : undefined,
  90. enabled: Boolean(webVital),
  91. withProfiles: true,
  92. });
  93. const {data: mehData, isLoading: isMehTransactionWebVitalsQueryLoading} =
  94. useTransactionSamplesWebVitalsQuery({
  95. limit: 3,
  96. transaction: transaction ?? '',
  97. query: webVital
  98. ? `measurements.${webVital}:<${PERFORMANCE_SCORE_MEDIANS[webVital]} measurements.${webVital}:>=${PERFORMANCE_SCORE_P90S[webVital]}`
  99. : undefined,
  100. enabled: Boolean(webVital),
  101. withProfiles: true,
  102. });
  103. const {data: poorData, isLoading: isPoorTransactionWebVitalsQueryLoading} =
  104. useTransactionSamplesWebVitalsQuery({
  105. limit: 3,
  106. transaction: transaction ?? '',
  107. query: webVital
  108. ? `measurements.${webVital}:>=${PERFORMANCE_SCORE_MEDIANS[webVital]}`
  109. : undefined,
  110. enabled: Boolean(webVital),
  111. withProfiles: true,
  112. });
  113. const data = [...goodData, ...mehData, ...poorData];
  114. const isTransactionWebVitalsQueryLoading =
  115. isGoodTransactionWebVitalsQueryLoading ||
  116. isMehTransactionWebVitalsQueryLoading ||
  117. isPoorTransactionWebVitalsQueryLoading;
  118. const tableData: TransactionSampleRowWithScore[] = data.sort(
  119. (a, b) => a[`${webVital}Score`] - b[`${webVital}Score`]
  120. );
  121. const renderHeadCell = (col: Column) => {
  122. if (col.key === 'transaction') {
  123. return <NoOverflow>{col.name}</NoOverflow>;
  124. }
  125. if (col.key === 'webVital') {
  126. return <AlignRight>{`${webVital}`}</AlignRight>;
  127. }
  128. if (col.key === 'score') {
  129. return <AlignCenter>{`${webVital} ${col.name}`}</AlignCenter>;
  130. }
  131. return <NoOverflow>{col.name}</NoOverflow>;
  132. };
  133. const getFormattedDuration = (value: number | null) => {
  134. if (value === null) {
  135. return null;
  136. }
  137. if (value < 1000) {
  138. return getDuration(value / 1000, 0, true);
  139. }
  140. return getDuration(value / 1000, 2, true);
  141. };
  142. const renderBodyCell = (col: Column, row: TransactionSampleRowWithScore) => {
  143. const {key} = col;
  144. if (key === 'score') {
  145. if (row[`measurements.${webVital}`] !== null) {
  146. return (
  147. <AlignCenter>
  148. <PerformanceBadge score={row[`${webVital}Score`]} />
  149. </AlignCenter>
  150. );
  151. }
  152. return null;
  153. }
  154. if (col.key === 'webVital') {
  155. if (row[key] === null) {
  156. return <NoValue>{t('(no value)')}</NoValue>;
  157. }
  158. const value = row[`measurements.${webVital}`];
  159. const formattedValue =
  160. webVital === 'cls' ? value?.toFixed(2) : getFormattedDuration(value);
  161. return <AlignRight>{formattedValue}</AlignRight>;
  162. }
  163. if (key === 'id') {
  164. const eventSlug = generateEventSlug({...row, project: row.projectSlug});
  165. const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug);
  166. return (
  167. <NoOverflow>
  168. <Link to={eventTarget} onClick={onClose}>
  169. {getShortEventId(row.id)}
  170. </Link>
  171. </NoOverflow>
  172. );
  173. }
  174. if (key === 'replayId') {
  175. const replayTarget =
  176. row['transaction.duration'] !== null &&
  177. replayLinkGenerator(
  178. organization,
  179. {
  180. replayId: row.replayId,
  181. id: row.id,
  182. 'transaction.duration': row['transaction.duration'],
  183. timestamp: row.timestamp,
  184. },
  185. undefined
  186. );
  187. return (
  188. <NoOverflow>
  189. {row.replayId && replayTarget && (
  190. <Link to={replayTarget}>{getShortEventId(row.replayId)}</Link>
  191. )}
  192. </NoOverflow>
  193. );
  194. }
  195. if (key === 'profile.id') {
  196. if (!defined(project) || !defined(row['profile.id'])) {
  197. return null;
  198. }
  199. const target = generateProfileFlamechartRoute({
  200. orgSlug: organization.slug,
  201. projectSlug: project.slug,
  202. profileId: String(row['profile.id']),
  203. });
  204. return (
  205. <NoOverflow>
  206. <Link to={target} onClick={onClose}>
  207. {getShortEventId(row['profile.id'])}
  208. </Link>
  209. </NoOverflow>
  210. );
  211. }
  212. return <AlignRight>{row[key]}</AlignRight>;
  213. };
  214. return (
  215. <PageErrorProvider>
  216. <DetailPanel detailKey={webVital ?? undefined} onClose={onClose}>
  217. {webVital && projectData && (
  218. <WebVitalDetailHeader
  219. value={
  220. webVital !== 'cls'
  221. ? getDuration(
  222. (projectData?.data[0][`p75(measurements.${webVital})`] as number) /
  223. 1000,
  224. 2,
  225. true
  226. )
  227. : (
  228. projectData?.data[0][`p75(measurements.${webVital})`] as number
  229. ).toFixed(2)
  230. }
  231. webVital={webVital}
  232. score={projectScore[`${webVital}Score`]}
  233. />
  234. )}
  235. <GridEditable
  236. data={tableData}
  237. isLoading={isTransactionWebVitalsQueryLoading}
  238. columnOrder={columnOrder}
  239. columnSortBy={[sort]}
  240. grid={{
  241. renderHeadCell,
  242. renderBodyCell,
  243. }}
  244. location={location}
  245. />
  246. <PageErrorAlert />
  247. </DetailPanel>
  248. </PageErrorProvider>
  249. );
  250. }
  251. const NoOverflow = styled('span')`
  252. overflow: hidden;
  253. text-overflow: ellipsis;
  254. `;
  255. const AlignRight = styled('span')<{color?: string}>`
  256. text-align: right;
  257. width: 100%;
  258. ${p => (p.color ? `color: ${p.color};` : '')}
  259. `;
  260. const AlignCenter = styled('span')`
  261. text-align: center;
  262. width: 100%;
  263. `;
  264. const NoValue = styled('span')`
  265. color: ${p => p.theme.gray300};
  266. `;