pageOverviewWebVitalsDetailPanel.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  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 {Tooltip} from 'sentry/components/tooltip';
  12. import {t} from 'sentry/locale';
  13. import {defined} from 'sentry/utils';
  14. import {generateEventSlug} from 'sentry/utils/discover/urls';
  15. import {getShortEventId} from 'sentry/utils/events';
  16. import {getDuration} from 'sentry/utils/formatters';
  17. import {PageAlert, PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
  18. import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
  19. import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
  20. import useReplayExists from 'sentry/utils/replayCount/useReplayExists';
  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 {WebVitalStatusLineChart} from 'sentry/views/performance/browser/webVitals/components/webVitalStatusLineChart';
  28. import useProfileExists from 'sentry/views/performance/browser/webVitals/utils/profiling/useProfileExists';
  29. import {calculatePerformanceScoreFromTableDataRow} from 'sentry/views/performance/browser/webVitals/utils/queries/rawWebVitalsQueries/calculatePerformanceScore';
  30. import {useProjectRawWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/rawWebVitalsQueries/useProjectRawWebVitalsQuery';
  31. import {useProjectRawWebVitalsValuesTimeseriesQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/rawWebVitalsQueries/useProjectRawWebVitalsValuesTimeseriesQuery';
  32. import {calculatePerformanceScoreFromStoredTableDataRow} from 'sentry/views/performance/browser/webVitals/utils/queries/storedScoreQueries/calculatePerformanceScoreFromStored';
  33. import {useProjectWebVitalsScoresQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/storedScoreQueries/useProjectWebVitalsScoresQuery';
  34. import {useInteractionsCategorizedSamplesQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/useInteractionsCategorizedSamplesQuery';
  35. import {useTransactionsCategorizedSamplesQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/useTransactionsCategorizedSamplesQuery';
  36. import type {
  37. InteractionSpanSampleRowWithScore,
  38. TransactionSampleRowWithScore,
  39. WebVitals,
  40. } from 'sentry/views/performance/browser/webVitals/utils/types';
  41. import {useStoredScoresSetting} from 'sentry/views/performance/browser/webVitals/utils/useStoredScoresSetting';
  42. import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils';
  43. import DetailPanel from 'sentry/views/starfish/components/detailPanel';
  44. import {SpanIndexedField} from 'sentry/views/starfish/types';
  45. type Column = GridColumnHeader;
  46. const columnOrder: GridColumnOrder[] = [
  47. {key: 'id', width: COL_WIDTH_UNDEFINED, name: t('Transaction')},
  48. {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: t('Replay')},
  49. {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: t('Profile')},
  50. {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: t('Web Vital')},
  51. {key: 'score', width: COL_WIDTH_UNDEFINED, name: t('Score')},
  52. ];
  53. const inpColumnOrder: GridColumnOrder[] = [
  54. {
  55. key: SpanIndexedField.SPAN_DESCRIPTION,
  56. width: COL_WIDTH_UNDEFINED,
  57. name: t('Interaction Target'),
  58. },
  59. {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: t('Profile')},
  60. {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: t('Replay')},
  61. {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: t('Inp')},
  62. {key: 'score', width: COL_WIDTH_UNDEFINED, name: t('Score')},
  63. ];
  64. const sort: GridColumnSortBy<keyof TransactionSampleRowWithScore> = {
  65. key: 'totalScore',
  66. order: 'desc',
  67. };
  68. const inpSort: GridColumnSortBy<keyof InteractionSpanSampleRowWithScore> = {
  69. key: 'inpScore',
  70. order: 'desc',
  71. };
  72. export function PageOverviewWebVitalsDetailPanel({
  73. webVital,
  74. onClose,
  75. }: {
  76. onClose: () => void;
  77. webVital: WebVitals | null;
  78. }) {
  79. const location = useLocation();
  80. const {projects} = useProjects();
  81. const organization = useOrganization();
  82. const routes = useRoutes();
  83. const {replayExists} = useReplayExists();
  84. const shouldUseStoredScores = useStoredScoresSetting();
  85. const isInp = webVital === 'inp';
  86. const replayLinkGenerator = generateReplayLink(routes);
  87. const project = useMemo(
  88. () => projects.find(p => p.id === String(location.query.project)),
  89. [projects, location.query.project]
  90. );
  91. const transaction = location.query.transaction
  92. ? Array.isArray(location.query.transaction)
  93. ? location.query.transaction[0]
  94. : location.query.transaction
  95. : undefined;
  96. const {data: projectData} = useProjectRawWebVitalsQuery({transaction});
  97. const {data: projectScoresData} = useProjectWebVitalsScoresQuery({
  98. enabled: shouldUseStoredScores,
  99. weightWebVital: webVital ?? 'total',
  100. transaction,
  101. });
  102. const projectScore = shouldUseStoredScores
  103. ? calculatePerformanceScoreFromStoredTableDataRow(projectScoresData?.data?.[0])
  104. : calculatePerformanceScoreFromTableDataRow(projectData?.data?.[0]);
  105. const {data: transactionsTableData, isLoading: isTransactionWebVitalsQueryLoading} =
  106. useTransactionsCategorizedSamplesQuery({
  107. transaction: transaction ?? '',
  108. webVital,
  109. enabled: Boolean(webVital) && !isInp,
  110. });
  111. const {data: inpTableData, isLoading: isInteractionsLoading} =
  112. useInteractionsCategorizedSamplesQuery({
  113. transaction: transaction ?? '',
  114. enabled: Boolean(webVital) && isInp,
  115. });
  116. const {profileExists} = useProfileExists(
  117. inpTableData.filter(row => row['profile.id']).map(row => row['profile.id'])
  118. );
  119. const {data: timeseriesData, isLoading: isTimeseriesLoading} =
  120. useProjectRawWebVitalsValuesTimeseriesQuery({transaction});
  121. const webVitalData: LineChartSeries = {
  122. data:
  123. !isTimeseriesLoading && webVital
  124. ? timeseriesData?.[webVital].map(({name, value}) => ({
  125. name,
  126. value,
  127. }))
  128. : [],
  129. seriesName: webVital ?? '',
  130. };
  131. const getProjectSlug = (row: TransactionSampleRowWithScore): string => {
  132. return project && !Array.isArray(location.query.project)
  133. ? project.slug
  134. : row.projectSlug;
  135. };
  136. const renderHeadCell = (col: Column) => {
  137. if (col.key === 'transaction') {
  138. return <NoOverflow>{col.name}</NoOverflow>;
  139. }
  140. if (col.key === 'webVital') {
  141. return <AlignRight>{`${webVital}`}</AlignRight>;
  142. }
  143. if (col.key === 'score' || col.key === 'measurements.score.inp') {
  144. return <AlignCenter>{`${webVital} ${col.name}`}</AlignCenter>;
  145. }
  146. if (col.key === 'replayId' || col.key === 'profile.id') {
  147. return <AlignCenter>{col.name}</AlignCenter>;
  148. }
  149. return <NoOverflow>{col.name}</NoOverflow>;
  150. };
  151. const getFormattedDuration = (value: number) => {
  152. if (value === undefined) {
  153. return null;
  154. }
  155. if (value < 1000) {
  156. return getDuration(value / 1000, 0, true);
  157. }
  158. return getDuration(value / 1000, 2, true);
  159. };
  160. const renderBodyCell = (col: Column, row: TransactionSampleRowWithScore) => {
  161. const {key} = col;
  162. const projectSlug = getProjectSlug(row);
  163. if (key === 'score') {
  164. if (row[`measurements.${webVital}`] !== undefined) {
  165. return (
  166. <AlignCenter>
  167. <PerformanceBadge score={row[`${webVital}Score`]} />
  168. </AlignCenter>
  169. );
  170. }
  171. return null;
  172. }
  173. if (col.key === 'webVital') {
  174. const value = row[`measurements.${webVital}`];
  175. if (value === undefined) {
  176. return (
  177. <AlignRight>
  178. <NoValue>{t('(no value)')}</NoValue>
  179. </AlignRight>
  180. );
  181. }
  182. const formattedValue =
  183. webVital === 'cls' ? value?.toFixed(2) : getFormattedDuration(value);
  184. return <AlignRight>{formattedValue}</AlignRight>;
  185. }
  186. if (key === 'id') {
  187. const eventSlug = generateEventSlug({...row, project: projectSlug});
  188. const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug);
  189. return (
  190. <NoOverflow>
  191. <Link to={eventTarget}>{getShortEventId(row.id)}</Link>
  192. </NoOverflow>
  193. );
  194. }
  195. if (key === 'replayId') {
  196. const replayTarget =
  197. row['transaction.duration'] !== undefined &&
  198. replayLinkGenerator(
  199. organization,
  200. {
  201. replayId: row.replayId,
  202. id: row.id,
  203. 'transaction.duration': row['transaction.duration'],
  204. timestamp: row.timestamp,
  205. },
  206. undefined
  207. );
  208. return row.replayId && replayTarget && replayExists(row[key]) ? (
  209. <AlignCenter>
  210. <Link to={replayTarget}>{getShortEventId(row.replayId)}</Link>
  211. </AlignCenter>
  212. ) : (
  213. <AlignCenter>
  214. <NoValue>{t('(no value)')}</NoValue>
  215. </AlignCenter>
  216. );
  217. }
  218. if (key === 'profile.id') {
  219. if (!defined(project) || !defined(row['profile.id'])) {
  220. return (
  221. <AlignCenter>
  222. <NoValue>{t('(no value)')}</NoValue>
  223. </AlignCenter>
  224. );
  225. }
  226. const target = generateProfileFlamechartRoute({
  227. orgSlug: organization.slug,
  228. projectSlug,
  229. profileId: String(row['profile.id']),
  230. });
  231. return (
  232. <AlignCenter>
  233. <Link to={target}>{getShortEventId(row['profile.id'])}</Link>
  234. </AlignCenter>
  235. );
  236. }
  237. return <AlignRight>{row[key]}</AlignRight>;
  238. };
  239. const renderInpBodyCell = (col: Column, row: InteractionSpanSampleRowWithScore) => {
  240. const {key} = col;
  241. if (key === 'score') {
  242. if (row[`measurements.${webVital}`] !== undefined) {
  243. return (
  244. <AlignCenter>
  245. <PerformanceBadge score={row[`${webVital}Score`]} />
  246. </AlignCenter>
  247. );
  248. }
  249. return null;
  250. }
  251. if (col.key === 'webVital') {
  252. const value = row[`measurements.${webVital}`];
  253. if (value === undefined) {
  254. return (
  255. <AlignRight>
  256. <NoValue>{t('(no value)')}</NoValue>
  257. </AlignRight>
  258. );
  259. }
  260. const formattedValue =
  261. webVital === 'cls' ? value?.toFixed(2) : getFormattedDuration(value);
  262. return <AlignRight>{formattedValue}</AlignRight>;
  263. }
  264. if (key === 'replayId') {
  265. const replayTarget = replayLinkGenerator(
  266. organization,
  267. {
  268. replayId: row.replayId,
  269. id: '', // id doesn't actually matter here. Just to satisfy type.
  270. 'transaction.duration': isInp
  271. ? row[SpanIndexedField.SPAN_SELF_TIME]
  272. : row['transaction.duration'],
  273. timestamp: row.timestamp,
  274. },
  275. undefined
  276. );
  277. return row.replayId && replayTarget && replayExists(row[key]) ? (
  278. <AlignCenter>
  279. <Link to={replayTarget}>{getShortEventId(row.replayId)}</Link>
  280. </AlignCenter>
  281. ) : (
  282. <AlignCenter>
  283. <NoValue>{t('(no value)')}</NoValue>
  284. </AlignCenter>
  285. );
  286. }
  287. if (key === 'profile.id') {
  288. if (
  289. !defined(project) ||
  290. !defined(row['profile.id']) ||
  291. !profileExists(row['profile.id'])
  292. ) {
  293. return (
  294. <AlignCenter>
  295. <NoValue>{t('(no value)')}</NoValue>
  296. </AlignCenter>
  297. );
  298. }
  299. const target = generateProfileFlamechartRoute({
  300. orgSlug: organization.slug,
  301. projectSlug: project.slug,
  302. profileId: String(row['profile.id']),
  303. });
  304. return (
  305. <AlignCenter>
  306. <Link to={target}>{getShortEventId(row['profile.id'])}</Link>
  307. </AlignCenter>
  308. );
  309. }
  310. if (key === SpanIndexedField.SPAN_DESCRIPTION) {
  311. return (
  312. <NoOverflow>
  313. <Tooltip title={row[key]}>{row[key]}</Tooltip>
  314. </NoOverflow>
  315. );
  316. }
  317. return <AlignRight>{row[key]}</AlignRight>;
  318. };
  319. const webVitalScore = projectScore[`${webVital}Score`];
  320. const webVitalValue = projectData?.data[0]?.[`p75(measurements.${webVital})`] as
  321. | number
  322. | undefined;
  323. return (
  324. <PageAlertProvider>
  325. <DetailPanel detailKey={webVital ?? undefined} onClose={onClose}>
  326. {webVital && (
  327. <WebVitalDetailHeader
  328. value={
  329. webVitalValue !== undefined
  330. ? webVital !== 'cls'
  331. ? getDuration(webVitalValue / 1000, 2, true)
  332. : (webVitalValue as number)?.toFixed(2)
  333. : undefined
  334. }
  335. webVital={webVital}
  336. score={webVitalScore}
  337. />
  338. )}
  339. <ChartContainer>
  340. {webVital && <WebVitalStatusLineChart webVitalSeries={webVitalData} />}
  341. </ChartContainer>
  342. <TableContainer>
  343. {isInp ? (
  344. <GridEditable
  345. data={inpTableData}
  346. isLoading={isInteractionsLoading}
  347. columnOrder={inpColumnOrder}
  348. columnSortBy={[inpSort]}
  349. grid={{
  350. renderHeadCell,
  351. renderBodyCell: renderInpBodyCell,
  352. }}
  353. location={location}
  354. />
  355. ) : (
  356. <GridEditable
  357. data={transactionsTableData}
  358. isLoading={isTransactionWebVitalsQueryLoading}
  359. columnOrder={columnOrder}
  360. columnSortBy={[sort]}
  361. grid={{
  362. renderHeadCell,
  363. renderBodyCell,
  364. }}
  365. location={location}
  366. />
  367. )}
  368. </TableContainer>
  369. <PageAlert />
  370. </DetailPanel>
  371. </PageAlertProvider>
  372. );
  373. }
  374. const NoOverflow = styled('span')`
  375. overflow: hidden;
  376. text-overflow: ellipsis;
  377. white-space: nowrap;
  378. `;
  379. const AlignRight = styled('span')<{color?: string}>`
  380. text-align: right;
  381. width: 100%;
  382. ${p => (p.color ? `color: ${p.color};` : '')}
  383. `;
  384. const AlignCenter = styled('span')`
  385. text-align: center;
  386. width: 100%;
  387. `;
  388. const ChartContainer = styled('div')`
  389. position: relative;
  390. flex: 1;
  391. `;
  392. const NoValue = styled('span')`
  393. color: ${p => p.theme.gray300};
  394. `;
  395. const TableContainer = styled('div')`
  396. margin-bottom: 80px;
  397. `;