pageSamplePerformanceTable.tsx 18 KB

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