import {useMemo, useState} 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 {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 {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'; type Column = GridColumnHeader; type InteractionsColumn = GridColumnHeader; const PAGELOADS_COLUMN_ORDER: GridColumnOrder[] = [ {key: 'id', width: COL_WIDTH_UNDEFINED, name: 'Event ID'}, {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: 'User'}, {key: 'measurements.lcp', width: COL_WIDTH_UNDEFINED, name: 'LCP'}, {key: 'measurements.fcp', width: COL_WIDTH_UNDEFINED, name: 'FCP'}, {key: 'measurements.fid', width: COL_WIDTH_UNDEFINED, name: 'FID'}, {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: 'Profile'}, {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: 'Replay'}, {key: 'totalScore', width: COL_WIDTH_UNDEFINED, name: 'Score'}, ]; const INTERACTION_SAMPLES_COLUMN_ORDER: GridColumnOrder< keyof InteractionSpanSampleRowWithScore >[] = [ {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: 'User'}, {key: 'measurements.inp', width: COL_WIDTH_UNDEFINED, name: 'INP'}, {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'}, {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: 'Replay'}, {key: 'totalScore', width: COL_WIDTH_UNDEFINED, name: 'Score'}, ]; const INP_SEARCH_FILTER = 'has:measurements.fid (has:profile.id OR has:replayId)'; enum Dataset { PAGELOADS = 'pageloads', INTERACTIONS = 'interactions', } 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 routes = useRoutes(); const router = useRouter(); const shouldUseStoredScores = useStoredScoresSetting(); const shouldReplaceFidWithInp = useReplaceFidWithInpSetting(); const [dataset, setDataset] = useState(Dataset.PAGELOADS); const samplesColumnOrder = useMemo(() => { if (shouldReplaceFidWithInp) { return PAGELOADS_COLUMN_ORDER.filter(col => col.key !== 'measurements.fid'); } return PAGELOADS_COLUMN_ORDER; }, [shouldReplaceFidWithInp]); const sortableFields = shouldUseStoredScores ? SORTABLE_INDEXED_FIELDS : SORTABLE_INDEXED_FIELDS.filter( field => !SORTABLE_INDEXED_SCORE_FIELDS.includes(field) ); let sort = useWebVitalsSort({ defaultSort: DEFAULT_INDEXED_SORT, sortableFields: sortableFields as unknown as string[], }); // Need to map fid back to inp for rendering if (shouldReplaceFidWithInp && sort.field === 'measurements.fid') { sort = {...sort, field: 'measurements.inp'}; } 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: dataset === Dataset.PAGELOADS, }); const { data: interactionsTableData, isLoading: isInteractionsLoading, pageLinks: interactionsPageLinks, } = useTransactionSamplesWebVitalsQuery({ limit, transaction, query: `${INP_SEARCH_FILTER} ${search ?? ''}`, withProfiles: true, enabled: dataset === Dataset.INTERACTIONS, }); const getFormattedDuration = (value: number) => { return getDuration(value, value < 1 ? 0 : 2, true); }; function renderHeadCell(col: Column | InteractionsColumn) { function generateSortLink() { const key = col.key === 'totalScore' ? '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') { 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') { 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 profileTarget = defined(row.projectSlug) && defined(row['profile.id']) ? generateProfileFlamechartRoute({ orgSlug: organization.slug, projectSlug: row.projectSlug, profileId: String(row['profile.id']), }) : null; return ( {profileTarget && ( )} ); } if (key === 'replayId' && 'id' in row) { const replayTarget = row['transaction.duration'] !== undefined && replayLinkGenerator( organization, { replayId: row.replayId, id: row.id, 'transaction.duration': row['transaction.duration'], timestamp: row.timestamp, }, undefined ); return ( {replayTarget && Object.keys(replayTarget).length > 0 && ( )} ); } if (key === 'id' && 'id' in row) { const eventSlug = generateEventSlug({...row, project: row.projectSlug}); const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug); return ( {getShortEventId(row.id)} ); } return {row[key]}; } return ( {shouldReplaceFidWithInp && ( { // Reset pagination and sort when switching datasets router.replace({ ...location, query: {...location.query, sort: undefined, cursor: undefined}, }); setDataset(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 */} {!(dataset === Dataset.INTERACTIONS ? interactionsPageLinks : pageLinks) && (