import {useEffect, useMemo} from 'react'; import styled from '@emotion/styled'; import type {LineChartSeries} from 'sentry/components/charts/lineChart'; import type { GridColumnHeader, GridColumnOrder, GridColumnSortBy, } from 'sentry/components/gridEditable'; import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable'; import ExternalLink from 'sentry/components/links/externalLink'; import Link from 'sentry/components/links/link'; import {Tooltip} from 'sentry/components/tooltip'; import {t, tct} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; import getDuration from 'sentry/utils/duration/getDuration'; import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; import {PageAlert, PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert'; import {decodeList} from 'sentry/utils/queryString'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {WebVitalStatusLineChart} from 'sentry/views/insights/browser/webVitals/components/charts/webVitalStatusLineChart'; import {PerformanceBadge} from 'sentry/views/insights/browser/webVitals/components/performanceBadge'; import {WebVitalDescription} from 'sentry/views/insights/browser/webVitals/components/webVitalDescription'; import {useProjectRawWebVitalsQuery} from 'sentry/views/insights/browser/webVitals/queries/rawWebVitalsQueries/useProjectRawWebVitalsQuery'; import {useProjectRawWebVitalsValuesTimeseriesQuery} from 'sentry/views/insights/browser/webVitals/queries/rawWebVitalsQueries/useProjectRawWebVitalsValuesTimeseriesQuery'; import {calculatePerformanceScoreFromStoredTableDataRow} from 'sentry/views/insights/browser/webVitals/queries/storedScoreQueries/calculatePerformanceScoreFromStored'; import {useProjectWebVitalsScoresQuery} from 'sentry/views/insights/browser/webVitals/queries/storedScoreQueries/useProjectWebVitalsScoresQuery'; import {useTransactionWebVitalsScoresQuery} from 'sentry/views/insights/browser/webVitals/queries/storedScoreQueries/useTransactionWebVitalsScoresQuery'; import {MODULE_DOC_LINK} from 'sentry/views/insights/browser/webVitals/settings'; import type { Row, RowWithScoreAndOpportunity, WebVitals, } from 'sentry/views/insights/browser/webVitals/types'; import decodeBrowserTypes from 'sentry/views/insights/browser/webVitals/utils/queryParameterDecoders/browserType'; import DetailPanel from 'sentry/views/insights/common/components/detailPanel'; import {SpanIndexedField, type SubregionCode} from 'sentry/views/insights/types'; type Column = GridColumnHeader; const columnOrder: GridColumnOrder[] = [ {key: 'transaction', width: COL_WIDTH_UNDEFINED, name: 'Pages'}, {key: 'count', width: COL_WIDTH_UNDEFINED, name: 'Pageloads'}, {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: 'Web Vital'}, {key: 'score', width: COL_WIDTH_UNDEFINED, name: 'Score'}, {key: 'opportunity', width: COL_WIDTH_UNDEFINED, name: 'Opportunity'}, ]; const sort: GridColumnSortBy = {key: 'count()', order: 'desc'}; const MAX_ROWS = 10; export function WebVitalsDetailPanel({ webVital, onClose, }: { onClose: () => void; webVital: WebVitals | null; }) { const location = useLocation(); const organization = useOrganization(); const browserTypes = decodeBrowserTypes(location.query[SpanIndexedField.BROWSER_NAME]); const subregions = decodeList( location.query[SpanIndexedField.USER_GEO_SUBREGION] ) as SubregionCode[]; const {data: projectData} = useProjectRawWebVitalsQuery({browserTypes, subregions}); const {data: projectScoresData} = useProjectWebVitalsScoresQuery({ weightWebVital: webVital ?? 'total', browserTypes, subregions, }); const projectScore = calculatePerformanceScoreFromStoredTableDataRow( projectScoresData?.data?.[0] ); const {data, isPending} = useTransactionWebVitalsScoresQuery({ limit: 100, webVital: webVital ?? 'total', ...(webVital ? { query: `count_scores(measurements.score.${webVital}):>0`, defaultSort: { field: `opportunity_score(measurements.score.${webVital})`, kind: 'desc', }, } : {}), enabled: webVital !== null, sortName: 'webVitalsDetailPanelSort', browserTypes, subregions, }); const dataByOpportunity = useMemo(() => { if (!data) { return []; } const sumWeights = projectScoresData?.data?.[0]?.[ `sum(measurements.score.weight.${webVital})` ] as number; return data .map(row => ({ ...row, opportunity: Math.round( (((row as RowWithScoreAndOpportunity).opportunity ?? 0) * 100 * 100) / sumWeights ) / 100, })) .sort((a, b) => { if (a.opportunity === undefined) { return 1; } if (b.opportunity === undefined) { return -1; } return b.opportunity - a.opportunity; }) .slice(0, MAX_ROWS); }, [data, projectScoresData?.data, webVital]); const {data: timeseriesData, isLoading: isTimeseriesLoading} = useProjectRawWebVitalsValuesTimeseriesQuery({browserTypes, subregions}); const webVitalData: LineChartSeries = { data: !isTimeseriesLoading && webVital ? timeseriesData?.[webVital].map(({name, value}) => ({ name, value, })) : [], seriesName: webVital ?? '', }; const detailKey = webVital; useEffect(() => { if (webVital !== null) { trackAnalytics('insight.vital.vital_sidebar_opened', { organization, vital: webVital, }); } }, [organization, webVital]); const renderHeadCell = (col: Column) => { if (col.key === 'transaction') { return {col.name}; } if (col.key === 'webVital') { return {`${webVital} P75`}; } if (col.key === 'score') { return {`${webVital} ${col.name}`}; } if (col.key === 'opportunity') { return ( {tct( "A number rating how impactful a performance improvement on this page would be to your application's [webVital] Performance Score.", {webVital: webVital?.toUpperCase() ?? ''} )}
{t('How is this calculated?')} } > {col.name}
); } if (col.key === 'count') { if (webVital === 'inp') { return {t('Interactions')}; } } return {col.name}; }; const getFormattedDuration = (value: number) => { if (value < 1000) { return getDuration(value / 1000, 0, true); } return getDuration(value / 1000, 2, true); }; const renderBodyCell = (col: Column, row: RowWithScoreAndOpportunity) => { const {key} = col; if (key === 'score') { return ( ); } if (col.key === 'webVital') { let value: string | number = row[mapWebVitalToColumn(webVital)]; if (webVital && ['lcp', 'fcp', 'ttfb', 'inp'].includes(webVital)) { value = getFormattedDuration(value); } else if (webVital === 'cls') { value = value?.toFixed(2); } return {value}; } if (key === 'transaction') { return ( {row.transaction} ); } if (key === 'count') { const count = webVital === 'inp' ? row['count_scores(measurements.score.inp)'] : row['count()']; return {formatAbbreviatedNumber(count)}; } return {row[key]}; }; const webVitalScore = projectScore[`${webVital}Score`]; const webVitalValue = projectData?.data?.[0]?.[mapWebVitalToColumn(webVital)] as | number | undefined; return ( {webVital && ( )} {webVital && } ); } const mapWebVitalToColumn = (webVital?: WebVitals | null) => { switch (webVital) { case 'lcp': return 'p75(measurements.lcp)'; case 'fcp': return 'p75(measurements.fcp)'; case 'cls': return 'p75(measurements.cls)'; case 'ttfb': return 'p75(measurements.ttfb)'; case 'inp': return 'p75(measurements.inp)'; default: return 'count()'; } }; const NoOverflow = styled('span')` overflow: hidden; text-overflow: ellipsis; `; const AlignRight = styled('span')<{color?: string}>` text-align: right; width: 100%; ${p => (p.color ? `color: ${p.color};` : '')} `; const ChartContainer = styled('div')` position: relative; flex: 1; `; const AlignCenter = styled('span')` text-align: center; width: 100%; `; const OpportunityHeader = styled('span')` ${p => p.theme.tooltipUnderline()}; `; const TableContainer = styled('div')` margin-bottom: 80px; `;