pageSamplePerformanceTable.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. import {useMemo, useState} from 'react';
  2. import {Link} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  5. import {Button, LinkButton} from 'sentry/components/button';
  6. import ButtonBar from 'sentry/components/buttonBar';
  7. import SearchBar from 'sentry/components/events/searchBar';
  8. import type {GridColumnHeader, GridColumnOrder} from 'sentry/components/gridEditable';
  9. import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  10. import SortLink from 'sentry/components/gridEditable/sortLink';
  11. import ExternalLink from 'sentry/components/links/externalLink';
  12. import Pagination from 'sentry/components/pagination';
  13. import {SegmentedControl} from 'sentry/components/segmentedControl';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {IconChevron, IconPlay, IconProfiling} from 'sentry/icons';
  16. import {t} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import {defined} from 'sentry/utils';
  19. import type {Sort} from 'sentry/utils/discover/fields';
  20. import {generateEventSlug} from 'sentry/utils/discover/urls';
  21. import {getShortEventId} from 'sentry/utils/events';
  22. import {getDuration} from 'sentry/utils/formatters';
  23. import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
  24. import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
  25. import {decodeScalar} from 'sentry/utils/queryString';
  26. import {useLocation} from 'sentry/utils/useLocation';
  27. import useOrganization from 'sentry/utils/useOrganization';
  28. import useProjects from 'sentry/utils/useProjects';
  29. import useRouter from 'sentry/utils/useRouter';
  30. import {useRoutes} from 'sentry/utils/useRoutes';
  31. import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
  32. import {useTransactionSamplesWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/useTransactionSamplesWebVitalsQuery';
  33. import type {
  34. InteractionSpanSampleRowWithScore,
  35. TransactionSampleRowWithScore,
  36. } from 'sentry/views/performance/browser/webVitals/utils/types';
  37. import {
  38. DEFAULT_INDEXED_SORT,
  39. SORTABLE_INDEXED_FIELDS,
  40. SORTABLE_INDEXED_SCORE_FIELDS,
  41. } from 'sentry/views/performance/browser/webVitals/utils/types';
  42. import {useReplaceFidWithInpSetting} from 'sentry/views/performance/browser/webVitals/utils/useReplaceFidWithInpSetting';
  43. import {useStoredScoresSetting} from 'sentry/views/performance/browser/webVitals/utils/useStoredScoresSetting';
  44. import {useWebVitalsSort} from 'sentry/views/performance/browser/webVitals/utils/useWebVitalsSort';
  45. import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils';
  46. import {
  47. useIndexedSpans,
  48. useIndexedSpansBetter,
  49. } from 'sentry/views/starfish/queries/useIndexedSpans';
  50. import {SpanIndexedField} from 'sentry/views/starfish/types';
  51. type Column = GridColumnHeader<keyof TransactionSampleRowWithScore>;
  52. type InteractionsColumn = GridColumnHeader<keyof InteractionSpanSampleRowWithScore>;
  53. const PAGELOADS_COLUMN_ORDER: GridColumnOrder<keyof TransactionSampleRowWithScore>[] = [
  54. {key: 'id', width: COL_WIDTH_UNDEFINED, name: 'Event ID'},
  55. {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: 'User'},
  56. {key: 'measurements.lcp', width: COL_WIDTH_UNDEFINED, name: 'LCP'},
  57. {key: 'measurements.fcp', width: COL_WIDTH_UNDEFINED, name: 'FCP'},
  58. {key: 'measurements.fid', width: COL_WIDTH_UNDEFINED, name: 'FID'},
  59. {key: 'measurements.cls', width: COL_WIDTH_UNDEFINED, name: 'CLS'},
  60. {key: 'measurements.ttfb', width: COL_WIDTH_UNDEFINED, name: 'TTFB'},
  61. {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
  62. {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: 'Replay'},
  63. {key: 'totalScore', width: COL_WIDTH_UNDEFINED, name: 'Score'},
  64. ];
  65. const INTERACTION_SAMPLES_COLUMN_ORDER: GridColumnOrder<
  66. keyof InteractionSpanSampleRowWithScore
  67. >[] = [
  68. {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: 'User'},
  69. {key: 'measurements.inp', width: COL_WIDTH_UNDEFINED, name: 'INP'},
  70. {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
  71. {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: 'Replay'},
  72. {key: 'measurements.score.inp', width: COL_WIDTH_UNDEFINED, name: 'Score'},
  73. ];
  74. const INP_SEARCH_FILTER = 'has:measurements.fid (has:profile.id OR has:replayId)';
  75. enum Dataset {
  76. PAGELOADS = 'pageloads',
  77. INTERACTIONS = 'interactions',
  78. }
  79. type Props = {
  80. transaction: string;
  81. limit?: number;
  82. search?: string;
  83. };
  84. export function PageSamplePerformanceTable({transaction, search, limit = 9}: Props) {
  85. const location = useLocation();
  86. const {projects} = useProjects();
  87. const organization = useOrganization();
  88. const routes = useRoutes();
  89. const router = useRouter();
  90. const shouldUseStoredScores = useStoredScoresSetting();
  91. const shouldReplaceFidWithInp = useReplaceFidWithInpSetting();
  92. const [dataset, setDataset] = useState(Dataset.PAGELOADS);
  93. const samplesColumnOrder = useMemo(() => {
  94. if (shouldReplaceFidWithInp) {
  95. return PAGELOADS_COLUMN_ORDER.filter(col => col.key !== 'measurements.fid');
  96. }
  97. return PAGELOADS_COLUMN_ORDER;
  98. }, [shouldReplaceFidWithInp]);
  99. const sortableFields = shouldUseStoredScores
  100. ? SORTABLE_INDEXED_FIELDS
  101. : SORTABLE_INDEXED_FIELDS.filter(
  102. field => !SORTABLE_INDEXED_SCORE_FIELDS.includes(field)
  103. );
  104. let sort = useWebVitalsSort({
  105. defaultSort: DEFAULT_INDEXED_SORT,
  106. sortableFields: sortableFields as unknown as string[],
  107. });
  108. // Need to map fid back to inp for rendering
  109. if (shouldReplaceFidWithInp && sort.field === 'measurements.fid') {
  110. sort = {...sort, field: 'measurements.inp'};
  111. }
  112. const replayLinkGenerator = generateReplayLink(routes);
  113. const project = useMemo(
  114. () => projects.find(p => p.id === String(location.query.project)),
  115. [projects, location.query.project]
  116. );
  117. const query = decodeScalar(location.query.query);
  118. const {
  119. data: tableData,
  120. isLoading,
  121. pageLinks,
  122. } = useTransactionSamplesWebVitalsQuery({
  123. limit,
  124. transaction,
  125. query: search,
  126. withProfiles: true,
  127. enabled: dataset === Dataset.PAGELOADS,
  128. });
  129. const interactionsPageLinks = null;
  130. const {data: interactionsTableData, isFetching: isInteractionsLoading} =
  131. useIndexedSpansBetter({
  132. filters: {'span.op': 'ui.interaction.click', 'measurements.inp': '>0'},
  133. fields: [
  134. 'measurements.inp',
  135. 'measurements.score.inp',
  136. SpanIndexedField.ID,
  137. SpanIndexedField.TIMESTAMP,
  138. ],
  139. });
  140. const getFormattedDuration = (value: number) => {
  141. return getDuration(value, value < 1 ? 0 : 2, true);
  142. };
  143. function renderHeadCell(col: Column | InteractionsColumn) {
  144. function generateSortLink() {
  145. const key =
  146. col.key === 'measurements.score.inp' ? 'measurements.score.total' : col.key;
  147. let newSortDirection: Sort['kind'] = 'desc';
  148. if (sort?.field === key) {
  149. if (sort.kind === 'desc') {
  150. newSortDirection = 'asc';
  151. }
  152. }
  153. const newSort = `${newSortDirection === 'desc' ? '-' : ''}${key}`;
  154. return {
  155. ...location,
  156. query: {...location.query, sort: newSort},
  157. };
  158. }
  159. const canSort = (sortableFields as ReadonlyArray<string>).includes(col.key);
  160. if (
  161. [
  162. 'measurements.fcp',
  163. 'measurements.lcp',
  164. 'measurements.ttfb',
  165. 'measurements.fid',
  166. 'measurements.cls',
  167. 'measurements.inp',
  168. 'transaction.duration',
  169. ].includes(col.key)
  170. ) {
  171. if (canSort) {
  172. return (
  173. <SortLink
  174. align="right"
  175. title={col.name}
  176. direction={sort?.field === col.key ? sort.kind : undefined}
  177. canSort={canSort}
  178. generateSortLink={generateSortLink}
  179. />
  180. );
  181. }
  182. return (
  183. <AlignRight>
  184. <span>{col.name}</span>
  185. </AlignRight>
  186. );
  187. }
  188. if (col.key === 'totalScore') {
  189. return (
  190. <SortLink
  191. title={
  192. <AlignCenter>
  193. <StyledTooltip
  194. isHoverable
  195. title={
  196. <span>
  197. {t('The overall performance rating of this page.')}
  198. <br />
  199. <ExternalLink href="https://docs.sentry.io/product/performance/web-vitals/#performance-score">
  200. {t('How is this calculated?')}
  201. </ExternalLink>
  202. </span>
  203. }
  204. >
  205. <TooltipHeader>{t('Perf Score')}</TooltipHeader>
  206. </StyledTooltip>
  207. </AlignCenter>
  208. }
  209. direction={sort?.field === col.key ? sort.kind : undefined}
  210. canSort={canSort}
  211. generateSortLink={generateSortLink}
  212. align={undefined}
  213. />
  214. );
  215. }
  216. if (col.key === 'replayId' || col.key === 'profile.id') {
  217. return (
  218. <AlignCenter>
  219. <span>{col.name}</span>
  220. </AlignCenter>
  221. );
  222. }
  223. return <span>{col.name}</span>;
  224. }
  225. function renderBodyCell(
  226. col: Column | InteractionsColumn,
  227. row: TransactionSampleRowWithScore | InteractionSpanSampleRowWithScore
  228. ) {
  229. const {key} = col;
  230. if (key === 'measurements.score.inp') {
  231. return (
  232. <AlignCenter>
  233. <PerformanceBadge
  234. score={((row['measurements.score.inp'] ?? 0) * 100).toFixed(0)}
  235. />
  236. </AlignCenter>
  237. );
  238. }
  239. if (key === 'transaction' && 'transaction' in row) {
  240. return (
  241. <NoOverflow>
  242. {project && (
  243. <StyledProjectAvatar
  244. project={project}
  245. direction="left"
  246. size={16}
  247. hasTooltip
  248. tooltip={project.slug}
  249. />
  250. )}
  251. <Link
  252. to={{...location, query: {...location.query, transaction: row.transaction}}}
  253. >
  254. {row.transaction}
  255. </Link>
  256. </NoOverflow>
  257. );
  258. }
  259. if (
  260. [
  261. 'measurements.fcp',
  262. 'measurements.lcp',
  263. 'measurements.ttfb',
  264. 'measurements.fid',
  265. 'measurements.inp',
  266. 'transaction.duration',
  267. ].includes(key)
  268. ) {
  269. return (
  270. <AlignRight>
  271. {row[key] === undefined ? (
  272. <NoValue>{' \u2014 '}</NoValue>
  273. ) : (
  274. getFormattedDuration((row[key] as number) / 1000)
  275. )}
  276. </AlignRight>
  277. );
  278. }
  279. if (['measurements.cls', 'opportunity'].includes(key)) {
  280. return (
  281. <AlignRight>
  282. {row[key] === undefined ? (
  283. <NoValue>{' \u2014 '}</NoValue>
  284. ) : (
  285. Math.round((row[key] as number) * 100) / 100
  286. )}
  287. </AlignRight>
  288. );
  289. }
  290. if (key === 'profile.id') {
  291. const profileTarget =
  292. defined(row.projectSlug) && defined(row['profile.id'])
  293. ? generateProfileFlamechartRoute({
  294. orgSlug: organization.slug,
  295. projectSlug: row.projectSlug,
  296. profileId: String(row['profile.id']),
  297. })
  298. : null;
  299. return (
  300. <NoOverflow>
  301. <AlignCenter>
  302. {profileTarget && (
  303. <Tooltip title={t('View Profile')}>
  304. <LinkButton to={profileTarget} size="xs">
  305. <IconProfiling size="xs" />
  306. </LinkButton>
  307. </Tooltip>
  308. )}
  309. </AlignCenter>
  310. </NoOverflow>
  311. );
  312. }
  313. if (key === 'replayId' && 'id' in row) {
  314. const replayTarget =
  315. row['transaction.duration'] !== undefined &&
  316. replayLinkGenerator(
  317. organization,
  318. {
  319. replayId: row.replayId,
  320. id: row.id,
  321. 'transaction.duration': row['transaction.duration'],
  322. timestamp: row.timestamp,
  323. },
  324. undefined
  325. );
  326. return (
  327. <NoOverflow>
  328. <AlignCenter>
  329. {replayTarget && Object.keys(replayTarget).length > 0 && (
  330. <Tooltip title={t('View Replay')}>
  331. <LinkButton to={replayTarget} size="xs">
  332. <IconPlay size="xs" />
  333. </LinkButton>
  334. </Tooltip>
  335. )}
  336. </AlignCenter>
  337. </NoOverflow>
  338. );
  339. }
  340. if (key === 'id' && 'id' in row) {
  341. const eventSlug = generateEventSlug({...row, project: row.projectSlug});
  342. const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug);
  343. return (
  344. <NoOverflow>
  345. <Tooltip title={t('View Transaction')}>
  346. <Link to={eventTarget}>{getShortEventId(row.id)}</Link>
  347. </Tooltip>
  348. </NoOverflow>
  349. );
  350. }
  351. return <NoOverflow>{row[key]}</NoOverflow>;
  352. }
  353. return (
  354. <span>
  355. <SearchBarContainer>
  356. {shouldReplaceFidWithInp && (
  357. <SegmentedControl
  358. size="md"
  359. value={dataset}
  360. onChange={newDataSet => {
  361. // Reset pagination and sort when switching datasets
  362. router.replace({
  363. ...location,
  364. query: {...location.query, sort: undefined, cursor: undefined},
  365. });
  366. setDataset(newDataSet);
  367. }}
  368. >
  369. <SegmentedControl.Item key={Dataset.PAGELOADS}>
  370. {t('Pageloads')}
  371. </SegmentedControl.Item>
  372. <SegmentedControl.Item key={Dataset.INTERACTIONS}>
  373. {t('Interactions')}
  374. </SegmentedControl.Item>
  375. </SegmentedControl>
  376. )}
  377. <StyledSearchBar
  378. query={query}
  379. organization={organization}
  380. onSearch={queryString =>
  381. router.replace({
  382. ...location,
  383. query: {...location.query, query: queryString},
  384. })
  385. }
  386. />
  387. <StyledPagination
  388. pageLinks={dataset === Dataset.INTERACTIONS ? interactionsPageLinks : pageLinks}
  389. disabled={dataset === Dataset.INTERACTIONS ? isInteractionsLoading : isLoading}
  390. size="md"
  391. />
  392. {/* The Pagination component disappears if pageLinks is not defined,
  393. which happens any time the table data is loading. So we render a
  394. disabled button bar if pageLinks is not defined to minimize ui shifting */}
  395. {!(dataset === Dataset.INTERACTIONS ? interactionsPageLinks : pageLinks) && (
  396. <Wrapper>
  397. <ButtonBar merged>
  398. <Button
  399. icon={<IconChevron direction="left" />}
  400. disabled
  401. aria-label={t('Previous')}
  402. />
  403. <Button
  404. icon={<IconChevron direction="right" />}
  405. disabled
  406. aria-label={t('Next')}
  407. />
  408. </ButtonBar>
  409. </Wrapper>
  410. )}
  411. </SearchBarContainer>
  412. <GridContainer>
  413. {dataset === Dataset.PAGELOADS && (
  414. <GridEditable
  415. isLoading={isLoading}
  416. columnOrder={samplesColumnOrder}
  417. columnSortBy={[]}
  418. data={tableData}
  419. grid={{
  420. renderHeadCell,
  421. renderBodyCell,
  422. }}
  423. location={location}
  424. minimumColWidth={70}
  425. />
  426. )}
  427. {dataset === Dataset.INTERACTIONS && (
  428. <GridEditable
  429. isLoading={isInteractionsLoading}
  430. columnOrder={INTERACTION_SAMPLES_COLUMN_ORDER}
  431. columnSortBy={[]}
  432. data={interactionsTableData as unknown as InteractionSpanSampleRowWithScore[]}
  433. grid={{
  434. renderHeadCell,
  435. renderBodyCell,
  436. }}
  437. location={location}
  438. minimumColWidth={70}
  439. />
  440. )}
  441. </GridContainer>
  442. </span>
  443. );
  444. }
  445. const NoOverflow = styled('span')`
  446. overflow: hidden;
  447. text-overflow: ellipsis;
  448. white-space: nowrap;
  449. `;
  450. const AlignRight = styled('span')<{color?: string}>`
  451. text-align: right;
  452. width: 100%;
  453. ${p => (p.color ? `color: ${p.color};` : '')}
  454. `;
  455. const AlignCenter = styled('div')`
  456. display: block;
  457. margin: auto;
  458. text-align: center;
  459. width: 100%;
  460. `;
  461. const StyledProjectAvatar = styled(ProjectAvatar)`
  462. top: ${space(0.25)};
  463. position: relative;
  464. padding-right: ${space(1)};
  465. `;
  466. // Not pretty but we need to override gridEditable styles since the original
  467. // styles have too much padding for small spaces
  468. const GridContainer = styled('div')`
  469. margin-bottom: ${space(1)};
  470. th {
  471. padding: 0 ${space(1)};
  472. }
  473. th:first-child {
  474. padding-left: ${space(2)};
  475. }
  476. th:last-child {
  477. padding-right: ${space(2)};
  478. }
  479. td {
  480. padding: ${space(1)};
  481. }
  482. td:first-child {
  483. padding-right: ${space(1)};
  484. padding-left: ${space(2)};
  485. }
  486. `;
  487. const NoValue = styled('span')`
  488. color: ${p => p.theme.gray300};
  489. `;
  490. const SearchBarContainer = styled('div')`
  491. display: flex;
  492. margin-top: ${space(2)};
  493. margin-bottom: ${space(1)};
  494. gap: ${space(1)};
  495. `;
  496. const StyledSearchBar = styled(SearchBar)`
  497. flex-grow: 1;
  498. `;
  499. const StyledPagination = styled(Pagination)`
  500. margin: 0;
  501. `;
  502. const Wrapper = styled('div')`
  503. display: flex;
  504. align-items: center;
  505. justify-content: flex-end;
  506. margin: 0;
  507. `;
  508. const TooltipHeader = styled('span')`
  509. ${p => p.theme.tooltipUnderline()};
  510. `;
  511. const StyledTooltip = styled(Tooltip)`
  512. top: 1px;
  513. position: relative;
  514. `;