import {useMemo} from 'react'; import {Link} from 'react-router'; import styled from '@emotion/styled'; import ProjectAvatar from 'sentry/components/avatar/projectAvatar'; import {Button, LinkButton} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import SearchBar from 'sentry/components/events/searchBar'; import type {GridColumnHeader, GridColumnOrder} from 'sentry/components/gridEditable'; import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable'; import SortLink from 'sentry/components/gridEditable/sortLink'; import ExternalLink from 'sentry/components/links/externalLink'; import Pagination from 'sentry/components/pagination'; import {SegmentedControl} from 'sentry/components/segmentedControl'; import {Tooltip} from 'sentry/components/tooltip'; import {IconChevron, IconPlay, IconProfiling} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {defined} from 'sentry/utils'; import type {Sort} from 'sentry/utils/discover/fields'; import {generateEventSlug} from 'sentry/utils/discover/urls'; import {getShortEventId} from 'sentry/utils/events'; import {getDuration} from 'sentry/utils/formatters'; import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls'; import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes'; import {decodeScalar} from 'sentry/utils/queryString'; import useReplayExists from 'sentry/utils/replayCount/useReplayExists'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; import useRouter from 'sentry/utils/useRouter'; import {useRoutes} from 'sentry/utils/useRoutes'; import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge'; import useProfileExists from 'sentry/views/performance/browser/webVitals/utils/profiling/useProfileExists'; import {useInpSpanSamplesWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/useInpSpanSamplesWebVitalsQuery'; import {useTransactionSamplesWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/useTransactionSamplesWebVitalsQuery'; import type { InteractionSpanSampleRowWithScore, TransactionSampleRowWithScore, } from 'sentry/views/performance/browser/webVitals/utils/types'; import { DEFAULT_INDEXED_SORT, SORTABLE_INDEXED_FIELDS, SORTABLE_INDEXED_SCORE_FIELDS, } from 'sentry/views/performance/browser/webVitals/utils/types'; import {useReplaceFidWithInpSetting} from 'sentry/views/performance/browser/webVitals/utils/useReplaceFidWithInpSetting'; import {useStoredScoresSetting} from 'sentry/views/performance/browser/webVitals/utils/useStoredScoresSetting'; import {useWebVitalsSort} from 'sentry/views/performance/browser/webVitals/utils/useWebVitalsSort'; import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils'; import {SpanIndexedField} from 'sentry/views/starfish/types'; type Column = GridColumnHeader; type InteractionsColumn = GridColumnHeader; const PAGELOADS_COLUMN_ORDER: GridColumnOrder[] = [ {key: 'id', width: COL_WIDTH_UNDEFINED, name: t('Event ID')}, {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: t('User')}, {key: 'measurements.lcp', width: COL_WIDTH_UNDEFINED, name: 'LCP'}, {key: 'measurements.fcp', width: COL_WIDTH_UNDEFINED, name: 'FCP'}, {key: 'measurements.cls', width: COL_WIDTH_UNDEFINED, name: 'CLS'}, {key: 'measurements.ttfb', width: COL_WIDTH_UNDEFINED, name: 'TTFB'}, {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: t('Profile')}, {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: t('Replay')}, {key: 'totalScore', width: COL_WIDTH_UNDEFINED, name: t('Score')}, ]; const INTERACTION_SAMPLES_COLUMN_ORDER: GridColumnOrder< keyof InteractionSpanSampleRowWithScore >[] = [ { key: SpanIndexedField.SPAN_DESCRIPTION, width: COL_WIDTH_UNDEFINED, name: t('Interaction Target'), }, {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: t('User')}, {key: SpanIndexedField.INP, width: COL_WIDTH_UNDEFINED, name: 'INP'}, {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: t('Profile')}, {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: t('Replay')}, {key: 'inpScore', width: COL_WIDTH_UNDEFINED, name: t('Score')}, ]; enum Datatype { PAGELOADS = 'pageloads', INTERACTIONS = 'interactions', } const DATATYPE_KEY = 'type'; type Props = { transaction: string; limit?: number; search?: string; }; export function PageSamplePerformanceTable({transaction, search, limit = 9}: Props) { const location = useLocation(); const {projects} = useProjects(); const organization = useOrganization(); const {replayExists} = useReplayExists(); const routes = useRoutes(); const router = useRouter(); const shouldUseStoredScores = useStoredScoresSetting(); const shouldReplaceFidWithInp = useReplaceFidWithInpSetting(); let datatype = Datatype.PAGELOADS; switch (decodeScalar(location.query[DATATYPE_KEY], 'pageloads')) { case 'interactions': datatype = Datatype.INTERACTIONS; break; default: datatype = Datatype.PAGELOADS; } const sortableFields = shouldUseStoredScores ? SORTABLE_INDEXED_FIELDS : SORTABLE_INDEXED_FIELDS.filter( field => !SORTABLE_INDEXED_SCORE_FIELDS.includes(field) ); const sort = useWebVitalsSort({ defaultSort: DEFAULT_INDEXED_SORT, sortableFields: sortableFields as unknown as string[], }); const replayLinkGenerator = generateReplayLink(routes); const project = useMemo( () => projects.find(p => p.id === String(location.query.project)), [projects, location.query.project] ); const query = decodeScalar(location.query.query); const { data: tableData, isLoading, pageLinks, } = useTransactionSamplesWebVitalsQuery({ limit, transaction, query: search, withProfiles: true, enabled: datatype === Datatype.PAGELOADS, }); const { data: interactionsTableData, isFetching: isInteractionsLoading, pageLinks: interactionsPageLinks, } = useInpSpanSamplesWebVitalsQuery({ transaction, enabled: datatype === Datatype.INTERACTIONS, limit, filters: new MutableSearch(query ?? '').filters, }); const {profileExists} = useProfileExists( interactionsTableData.filter(row => row['profile.id']).map(row => row['profile.id']) ); const getFormattedDuration = (value: number) => { return getDuration(value, value < 1 ? 0 : 2, true); }; function renderHeadCell(col: Column | InteractionsColumn) { function generateSortLink() { const key = col.key === 'inpScore' ? 'measurements.score.total' : col.key; let newSortDirection: Sort['kind'] = 'desc'; if (sort?.field === key) { if (sort.kind === 'desc') { newSortDirection = 'asc'; } } const newSort = `${newSortDirection === 'desc' ? '-' : ''}${key}`; return { ...location, query: {...location.query, sort: newSort}, }; } const canSort = (sortableFields as ReadonlyArray).includes(col.key); if ( [ 'measurements.fcp', 'measurements.lcp', 'measurements.ttfb', 'measurements.fid', 'measurements.cls', 'measurements.inp', 'transaction.duration', ].includes(col.key) ) { if (canSort) { return ( ); } return ( {col.name} ); } if (col.key === 'totalScore' || col.key === 'inpScore') { return ( {t('The overall performance rating of this page.')}
{t('How is this calculated?')} } > {t('Perf Score')}
} direction={sort?.field === col.key ? sort.kind : undefined} canSort={canSort} generateSortLink={generateSortLink} align={undefined} /> ); } if (col.key === 'replayId' || col.key === 'profile.id') { return ( {col.name} ); } return {col.name}; } function renderBodyCell( col: Column | InteractionsColumn, row: TransactionSampleRowWithScore | InteractionSpanSampleRowWithScore ) { const {key} = col; if (key === 'totalScore' || key === 'inpScore') { return ( ); } if (key === 'transaction' && 'transaction' in row) { return ( {project && ( )} {row.transaction} ); } if ( [ 'measurements.fcp', 'measurements.lcp', 'measurements.ttfb', 'measurements.fid', 'measurements.inp', 'transaction.duration', ].includes(key) ) { return ( {row[key] === undefined ? ( {' \u2014 '} ) : ( getFormattedDuration((row[key] as number) / 1000) )} ); } if (['measurements.cls', 'opportunity'].includes(key)) { return ( {row[key] === undefined ? ( {' \u2014 '} ) : ( Math.round((row[key] as number) * 100) / 100 )} ); } if (key === 'profile.id') { const profileId = String(row[key]); const profileTarget = defined(row.projectSlug) && defined(row[key]) ? generateProfileFlamechartRoute({ orgSlug: organization.slug, projectSlug: row.projectSlug, profileId, }) : null; return ( {profileTarget && profileExists(profileId) && ( )} ); } if (key === 'replayId') { const replayTarget = (row['transaction.duration'] !== undefined || row[SpanIndexedField.SPAN_SELF_TIME] !== undefined) && replayLinkGenerator( organization, { replayId: row[key], id: '', // id doesn't get used in replayLinkGenerator. This is just to satisfy the type. 'transaction.duration': datatype === Datatype.INTERACTIONS ? row[SpanIndexedField.SPAN_SELF_TIME] : row['transaction.duration'], timestamp: row.timestamp, }, undefined ); return ( {replayTarget && Object.keys(replayTarget).length > 0 && replayExists(row[key]) && ( )} ); } if (key === 'id' && 'id' in row) { const eventSlug = generateEventSlug({...row, project: row.projectSlug}); const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug); return ( {getShortEventId(row.id)} ); } if (key === SpanIndexedField.SPAN_DESCRIPTION) { return ( {row[key]} ); } return {row[key]}; } return ( {shouldReplaceFidWithInp && ( { // Reset pagination and sort when switching datatypes router.replace({ ...location, query: { ...location.query, sort: undefined, cursor: undefined, [DATATYPE_KEY]: newDataSet, }, }); }} > {t('Pageloads')} {t('Interactions')} )} router.replace({ ...location, query: {...location.query, query: queryString}, }) } /> {/* The Pagination component disappears if pageLinks is not defined, which happens any time the table data is loading. So we render a disabled button bar if pageLinks is not defined to minimize ui shifting */} {!(datatype === Datatype.INTERACTIONS ? interactionsPageLinks : pageLinks) && (