pageOverviewWebVitalsDetailPanel.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. import {useMemo} from 'react';
  2. import {Link} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import type {LineChartSeries} from 'sentry/components/charts/lineChart';
  5. import type {
  6. GridColumnHeader,
  7. GridColumnOrder,
  8. GridColumnSortBy,
  9. } from 'sentry/components/gridEditable';
  10. import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  11. import {t} from 'sentry/locale';
  12. import {defined} from 'sentry/utils';
  13. import {generateEventSlug} from 'sentry/utils/discover/urls';
  14. import {getShortEventId} from 'sentry/utils/events';
  15. import {getDuration} from 'sentry/utils/formatters';
  16. import {PageAlert, PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
  17. import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
  18. import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
  19. import {useLocation} from 'sentry/utils/useLocation';
  20. import useOrganization from 'sentry/utils/useOrganization';
  21. import useProjects from 'sentry/utils/useProjects';
  22. import {useRoutes} from 'sentry/utils/useRoutes';
  23. import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
  24. import {WebVitalDetailHeader} from 'sentry/views/performance/browser/webVitals/components/webVitalDescription';
  25. import {WebVitalStatusLineChart} from 'sentry/views/performance/browser/webVitals/components/webVitalStatusLineChart';
  26. import {
  27. calculatePerformanceScoreFromTableDataRow,
  28. PERFORMANCE_SCORE_MEDIANS,
  29. PERFORMANCE_SCORE_P90S,
  30. } from 'sentry/views/performance/browser/webVitals/utils/queries/rawWebVitalsQueries/calculatePerformanceScore';
  31. import {useProjectRawWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/rawWebVitalsQueries/useProjectRawWebVitalsQuery';
  32. import {useProjectRawWebVitalsValuesTimeseriesQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/rawWebVitalsQueries/useProjectRawWebVitalsValuesTimeseriesQuery';
  33. import {useTransactionSamplesWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/useTransactionSamplesWebVitalsQuery';
  34. import type {
  35. TransactionSampleRowWithScore,
  36. WebVitals,
  37. } from 'sentry/views/performance/browser/webVitals/utils/types';
  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: 'Transaction'},
  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: 'totalScore',
  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} = useProjectRawWebVitalsQuery({transaction});
  74. const projectScore = calculatePerformanceScoreFromTableDataRow(projectData?.data?.[0]);
  75. // Do 3 queries filtering on LCP to get a spread of good, meh, and poor events
  76. // We can't query by performance score yet, so we're using LCP as a best estimate
  77. const {data: goodData, isLoading: isGoodTransactionWebVitalsQueryLoading} =
  78. useTransactionSamplesWebVitalsQuery({
  79. limit: 3,
  80. transaction: transaction ?? '',
  81. query: webVital
  82. ? `measurements.${webVital}:<${PERFORMANCE_SCORE_P90S[webVital]}`
  83. : undefined,
  84. enabled: Boolean(webVital),
  85. withProfiles: true,
  86. sortName: 'webVitalSort',
  87. webVital: webVital ?? undefined,
  88. });
  89. const {data: mehData, isLoading: isMehTransactionWebVitalsQueryLoading} =
  90. useTransactionSamplesWebVitalsQuery({
  91. limit: 3,
  92. transaction: transaction ?? '',
  93. query: webVital
  94. ? `measurements.${webVital}:<${PERFORMANCE_SCORE_MEDIANS[webVital]} measurements.${webVital}:>=${PERFORMANCE_SCORE_P90S[webVital]}`
  95. : undefined,
  96. enabled: Boolean(webVital),
  97. withProfiles: true,
  98. sortName: 'webVitalSort',
  99. webVital: webVital ?? undefined,
  100. });
  101. const {data: poorData, isLoading: isPoorTransactionWebVitalsQueryLoading} =
  102. useTransactionSamplesWebVitalsQuery({
  103. limit: 3,
  104. transaction: transaction ?? '',
  105. query: webVital
  106. ? `measurements.${webVital}:>=${PERFORMANCE_SCORE_MEDIANS[webVital]}`
  107. : undefined,
  108. enabled: Boolean(webVital),
  109. withProfiles: true,
  110. sortName: 'webVitalSort',
  111. webVital: webVital ?? undefined,
  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 {data: timeseriesData, isLoading: isTimeseriesLoading} =
  122. useProjectRawWebVitalsValuesTimeseriesQuery({transaction});
  123. const webVitalData: LineChartSeries = {
  124. data:
  125. !isTimeseriesLoading && webVital
  126. ? timeseriesData?.[webVital].map(({name, value}) => ({
  127. name,
  128. value,
  129. }))
  130. : [],
  131. seriesName: webVital ?? '',
  132. };
  133. const getProjectSlug = (row: TransactionSampleRowWithScore): string => {
  134. return project && !Array.isArray(location.query.project)
  135. ? project.slug
  136. : row.projectSlug;
  137. };
  138. const renderHeadCell = (col: Column) => {
  139. if (col.key === 'transaction') {
  140. return <NoOverflow>{col.name}</NoOverflow>;
  141. }
  142. if (col.key === 'webVital') {
  143. return <AlignRight>{`${webVital}`}</AlignRight>;
  144. }
  145. if (col.key === 'score') {
  146. return <AlignCenter>{`${webVital} ${col.name}`}</AlignCenter>;
  147. }
  148. if (col.key === 'replayId' || col.key === 'profile.id') {
  149. return <AlignCenter>{col.name}</AlignCenter>;
  150. }
  151. return <NoOverflow>{col.name}</NoOverflow>;
  152. };
  153. const getFormattedDuration = (value: number) => {
  154. if (value === undefined) {
  155. return null;
  156. }
  157. if (value < 1000) {
  158. return getDuration(value / 1000, 0, true);
  159. }
  160. return getDuration(value / 1000, 2, true);
  161. };
  162. const renderBodyCell = (col: Column, row: TransactionSampleRowWithScore) => {
  163. const {key} = col;
  164. const projectSlug = getProjectSlug(row);
  165. if (key === 'score') {
  166. if (row[`measurements.${webVital}`] !== undefined) {
  167. return (
  168. <AlignCenter>
  169. <PerformanceBadge score={row[`${webVital}Score`]} />
  170. </AlignCenter>
  171. );
  172. }
  173. return null;
  174. }
  175. if (col.key === 'webVital') {
  176. const value = row[`measurements.${webVital}`];
  177. if (value === undefined) {
  178. return (
  179. <AlignRight>
  180. <NoValue>{t('(no value)')}</NoValue>
  181. </AlignRight>
  182. );
  183. }
  184. const formattedValue =
  185. webVital === 'cls' ? value?.toFixed(2) : getFormattedDuration(value);
  186. return <AlignRight>{formattedValue}</AlignRight>;
  187. }
  188. if (key === 'id') {
  189. const eventSlug = generateEventSlug({...row, project: projectSlug});
  190. const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug);
  191. return (
  192. <NoOverflow>
  193. <Link to={eventTarget}>{getShortEventId(row.id)}</Link>
  194. </NoOverflow>
  195. );
  196. }
  197. if (key === 'replayId') {
  198. const replayTarget =
  199. row['transaction.duration'] !== undefined &&
  200. replayLinkGenerator(
  201. organization,
  202. {
  203. replayId: row.replayId,
  204. id: row.id,
  205. 'transaction.duration': row['transaction.duration'],
  206. timestamp: row.timestamp,
  207. },
  208. undefined
  209. );
  210. return row.replayId && replayTarget ? (
  211. <AlignCenter>
  212. <Link to={replayTarget}>{getShortEventId(row.replayId)}</Link>
  213. </AlignCenter>
  214. ) : (
  215. <AlignCenter>
  216. <NoValue>{t('(no value)')}</NoValue>
  217. </AlignCenter>
  218. );
  219. }
  220. if (key === 'profile.id') {
  221. if (!defined(project) || !defined(row['profile.id'])) {
  222. return (
  223. <AlignCenter>
  224. <NoValue>{t('(no value)')}</NoValue>
  225. </AlignCenter>
  226. );
  227. }
  228. const target = generateProfileFlamechartRoute({
  229. orgSlug: organization.slug,
  230. projectSlug,
  231. profileId: String(row['profile.id']),
  232. });
  233. return (
  234. <AlignCenter>
  235. <Link to={target}>{getShortEventId(row['profile.id'])}</Link>
  236. </AlignCenter>
  237. );
  238. }
  239. return <AlignRight>{row[key]}</AlignRight>;
  240. };
  241. const webVitalScore = projectScore[`${webVital}Score`];
  242. const webVitalValue = projectData?.data[0][`p75(measurements.${webVital})`] as
  243. | number
  244. | undefined;
  245. return (
  246. <PageAlertProvider>
  247. <DetailPanel detailKey={webVital ?? undefined} onClose={onClose}>
  248. {webVital && (
  249. <WebVitalDetailHeader
  250. value={
  251. webVitalValue !== undefined
  252. ? webVital !== 'cls'
  253. ? getDuration(webVitalValue / 1000, 2, true)
  254. : (webVitalValue as number)?.toFixed(2)
  255. : undefined
  256. }
  257. webVital={webVital}
  258. score={webVitalScore}
  259. />
  260. )}
  261. <ChartContainer>
  262. {webVital && <WebVitalStatusLineChart webVitalSeries={webVitalData} />}
  263. </ChartContainer>
  264. <TableContainer>
  265. <GridEditable
  266. data={tableData}
  267. isLoading={isTransactionWebVitalsQueryLoading}
  268. columnOrder={columnOrder}
  269. columnSortBy={[sort]}
  270. grid={{
  271. renderHeadCell,
  272. renderBodyCell,
  273. }}
  274. location={location}
  275. />
  276. </TableContainer>
  277. <PageAlert />
  278. </DetailPanel>
  279. </PageAlertProvider>
  280. );
  281. }
  282. const NoOverflow = styled('span')`
  283. overflow: hidden;
  284. text-overflow: ellipsis;
  285. `;
  286. const AlignRight = styled('span')<{color?: string}>`
  287. text-align: right;
  288. width: 100%;
  289. ${p => (p.color ? `color: ${p.color};` : '')}
  290. `;
  291. const AlignCenter = styled('span')`
  292. text-align: center;
  293. width: 100%;
  294. `;
  295. const ChartContainer = styled('div')`
  296. position: relative;
  297. flex: 1;
  298. `;
  299. const NoValue = styled('span')`
  300. color: ${p => p.theme.gray300};
  301. `;
  302. const TableContainer = styled('div')`
  303. margin-bottom: 80px;
  304. `;