Browse Source

feat(insights): Update Web Vitals Page Summary samples table to focus on a single selected vital at a time (#85967)

Adds a `Web Vital` dropdown selector to the samples table in the Web
Vitals Page Summary view. The samples table now focuses on a single
selected `Web Vital` rather than attempting to display multiple vitals
in a single row from the same page load (which often led to many empty
cells if vitals were not found). This also lets us conditionally render
certain columns only when they make sense to certain vitals, such as LCP
Element or Interaction Target.

<img width="910" alt="image"
src="https://github.com/user-attachments/assets/4b19613d-a52b-423d-aa7a-a94abed66052"
/>
edwardgou-sentry 1 week ago
parent
commit
606484a645

+ 41 - 7
static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx

@@ -47,7 +47,7 @@ import {generateReplayLink} from 'sentry/views/performance/transactionSummary/ut
 
 type Column = GridColumnHeader;
 
-const columnOrder: GridColumnOrder[] = [
+const PAGELOADS_COLUMN_ORDER: GridColumnOrder[] = [
   {key: 'id', width: COL_WIDTH_UNDEFINED, name: t('Transaction')},
   {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: t('Replay')},
   {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: t('Profile')},
@@ -55,18 +55,20 @@ const columnOrder: GridColumnOrder[] = [
   {key: 'score', width: COL_WIDTH_UNDEFINED, name: t('Score')},
 ];
 
-const inpColumnOrder: GridColumnOrder[] = [
+const SPANS_SAMPLES_WITHOUT_TRACE_COLUMN_ORDER: GridColumnOrder[] = [
   {
     key: SpanIndexedField.SPAN_DESCRIPTION,
     width: COL_WIDTH_UNDEFINED,
-    name: t('Interaction Target'),
+    name: t('Description'),
   },
   {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: t('Profile')},
   {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: t('Replay')},
-  {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: t('Inp')},
+  {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: t('Web Vital')},
   {key: 'score', width: COL_WIDTH_UNDEFINED, name: t('Score')},
 ];
 
+const NO_VALUE = ' \u2014 ';
+
 const sort: GridColumnSortBy<keyof TransactionSampleRowWithScore> = {
   key: 'totalScore',
   order: 'desc',
@@ -176,6 +178,18 @@ export function PageOverviewWebVitalsDetailPanel({
     if (col.key === 'replayId' || col.key === 'profile.id') {
       return <AlignCenter>{col.name}</AlignCenter>;
     }
+
+    if (col.key === SpanIndexedField.SPAN_DESCRIPTION) {
+      if (webVital === 'lcp') {
+        return <span>{t('LCP Element')}</span>;
+      }
+      if (webVital === 'cls') {
+        return <span>{t('CLS Source')}</span>;
+      }
+      if (webVital === 'inp') {
+        return <span>{t('Interaction Target')}</span>;
+      }
+    }
     return <NoOverflow>{col.name}</NoOverflow>;
   };
 
@@ -361,6 +375,26 @@ export function PageOverviewWebVitalsDetailPanel({
         </AlignCenter>
       );
     }
+
+    if (key === SpanIndexedField.SPAN_DESCRIPTION) {
+      const description =
+        webVital === 'lcp' &&
+        (row as SpanSampleRowWithScore)[SpanIndexedField.SPAN_OP] === 'pageload'
+          ? (row as SpanSampleRowWithScore)[SpanIndexedField.LCP_ELEMENT]
+          : webVital === 'cls' &&
+              (row as SpanSampleRowWithScore)[SpanIndexedField.SPAN_OP] === 'pageload'
+            ? (row as SpanSampleRowWithScore)[SpanIndexedField.CLS_SOURCE]
+            : (row as SpanSampleRowWithScore)[key];
+
+      if (description) {
+        return (
+          <NoOverflow>
+            <Tooltip title={description}>{description}</Tooltip>
+          </NoOverflow>
+        );
+      }
+      return <NoOverflow>{NO_VALUE}</NoOverflow>;
+    }
     if (key === SpanIndexedField.SPAN_DESCRIPTION) {
       return (
         <NoOverflow>
@@ -427,7 +461,7 @@ export function PageOverviewWebVitalsDetailPanel({
             <GridEditable
               data={spansTableData}
               isLoading={isSpansLoading}
-              columnOrder={inpColumnOrder}
+              columnOrder={SPANS_SAMPLES_WITHOUT_TRACE_COLUMN_ORDER}
               columnSortBy={[sort]}
               grid={{
                 renderHeadCell,
@@ -438,7 +472,7 @@ export function PageOverviewWebVitalsDetailPanel({
             <GridEditable
               data={spansTableData}
               isLoading={isSpansLoading}
-              columnOrder={columnOrder}
+              columnOrder={SPANS_SAMPLES_WITHOUT_TRACE_COLUMN_ORDER}
               columnSortBy={[sort]}
               grid={{
                 renderHeadCell,
@@ -449,7 +483,7 @@ export function PageOverviewWebVitalsDetailPanel({
             <GridEditable
               data={transactionsTableData}
               isLoading={isTransactionWebVitalsQueryLoading}
-              columnOrder={columnOrder}
+              columnOrder={PAGELOADS_COLUMN_ORDER}
               columnSortBy={[sort]}
               grid={{
                 renderHeadCell,

+ 195 - 104
static/app/views/insights/browser/webVitals/components/tables/pageSamplePerformanceTable.tsx

@@ -4,6 +4,7 @@ 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 {CompactSelect} from 'sentry/components/compactSelect';
 import type {GridColumnHeader, GridColumnOrder} from 'sentry/components/gridEditable';
 import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
 import SortLink from 'sentry/components/gridEditable/sortLink';
@@ -14,7 +15,7 @@ import {TransactionSearchQueryBuilder} from 'sentry/components/performance/trans
 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 {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {defined} from 'sentry/utils';
 import {trackAnalytics} from 'sentry/utils/analytics';
@@ -43,6 +44,7 @@ import {MODULE_DOC_LINK} from 'sentry/views/insights/browser/webVitals/settings'
 import type {
   SpanSampleRowWithScore,
   TransactionSampleRowWithScore,
+  WebVitals,
 } from 'sentry/views/insights/browser/webVitals/types';
 import {
   DEFAULT_INDEXED_SORT,
@@ -62,7 +64,7 @@ import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceHe
 import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils';
 
 type Column = GridColumnHeader<keyof TransactionSampleRowWithScore>;
-type InteractionsColumn = GridColumnHeader<keyof SpanSampleRowWithScore>;
+type SpansColumn = GridColumnHeader<keyof SpanSampleRowWithScore | 'webVital'>;
 
 const PAGELOADS_COLUMN_ORDER: Array<
   GridColumnOrder<keyof TransactionSampleRowWithScore>
@@ -93,16 +95,27 @@ const INTERACTION_SAMPLES_COLUMN_ORDER: Array<
   {key: 'totalScore', width: COL_WIDTH_UNDEFINED, name: t('Score')},
 ];
 
-const SPANS_SAMPLES_COLUMN_ORDER: Array<GridColumnOrder<keyof SpanSampleRowWithScore>> = [
+const SPANS_SAMPLES_WITHOUT_TRACE_COLUMN_ORDER: Array<
+  GridColumnOrder<keyof SpanSampleRowWithScore | 'webVital'>
+> = [
   {
     key: SpanIndexedField.SPAN_DESCRIPTION,
     width: COL_WIDTH_UNDEFINED,
     name: t('Description'),
   },
   {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: t('User')},
-  {key: SpanIndexedField.LCP, width: COL_WIDTH_UNDEFINED, name: 'LCP'},
-  {key: SpanIndexedField.CLS, width: COL_WIDTH_UNDEFINED, name: 'CLS'},
-  {key: SpanIndexedField.INP, width: COL_WIDTH_UNDEFINED, name: 'INP'},
+  {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: t('Web Vital')},
+  {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 SPANS_SAMPLES_WITHOUT_DESCRIPTION_COLUMN_ORDER: Array<
+  GridColumnOrder<keyof SpanSampleRowWithScore | 'webVital'>
+> = [
+  {key: 'id', width: COL_WIDTH_UNDEFINED, name: t('Trace')},
+  {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: t('User')},
+  {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: t('Web Vital')},
   {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')},
@@ -112,8 +125,21 @@ enum Datatype {
   PAGELOADS = 'pageloads',
   INTERACTIONS = 'interactions',
   SPANS = 'spans',
+  CLS = 'cls',
+  LCP = 'lcp',
+  FCP = 'fcp',
+  TTFB = 'ttfb',
+  INP = 'inp',
 }
 
+const WEB_VITAL_DATATYPES = [
+  Datatype.LCP,
+  Datatype.CLS,
+  Datatype.FCP,
+  Datatype.TTFB,
+  Datatype.INP,
+];
+
 const DATATYPE_KEY = 'type';
 
 const NO_VALUE = ' \u2014 ';
@@ -133,25 +159,29 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
   const navigate = useNavigate();
   const domainViewFilters = useDomainViewFilters();
 
+  const handleStandaloneClsLcp = organization.features.includes(
+    'performance-vitals-standalone-cls-lcp'
+  );
+
   const browserTypes = decodeBrowserTypes(location.query[SpanIndexedField.BROWSER_NAME]);
   const subregions = decodeList(
     location.query[SpanMetricsField.USER_GEO_SUBREGION]
   ) as SubregionCode[];
 
-  let datatype = Datatype.PAGELOADS;
-  switch (decodeScalar(location.query[DATATYPE_KEY], 'pageloads')) {
-    case 'interactions':
-      datatype = Datatype.INTERACTIONS;
-      break;
-    case 'spans':
-      datatype = Datatype.SPANS;
-      break;
-    default:
-      datatype = Datatype.PAGELOADS;
+  const defaultDatatype = handleStandaloneClsLcp ? Datatype.LCP : Datatype.PAGELOADS;
+  let datatype = defaultDatatype;
+  if (
+    Object.values(Datatype).includes(
+      decodeScalar(location.query[DATATYPE_KEY], defaultDatatype) as Datatype
+    )
+  ) {
+    datatype = decodeScalar(location.query[DATATYPE_KEY], defaultDatatype) as Datatype;
   }
 
   const isSpansBasedDatatype =
-    datatype === Datatype.INTERACTIONS || datatype === Datatype.SPANS;
+    datatype === Datatype.INTERACTIONS ||
+    datatype === Datatype.SPANS ||
+    WEB_VITAL_DATATYPES.includes(datatype);
 
   const sortableFields = SORTABLE_INDEXED_FIELDS;
 
@@ -183,6 +213,10 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
     subregions,
   });
 
+  const webVitalFilter = WEB_VITAL_DATATYPES.includes(datatype)
+    ? `measurements.score.weight.${datatype}:>0`
+    : '';
+
   const {
     data: standaloneSpansTableData,
     isFetching: isStandaloneSpansLoading,
@@ -191,9 +225,12 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
     transaction,
     enabled: isSpansBasedDatatype,
     limit,
-    filter: `${new MutableSearch(query ?? '').formatString()} ${datatype === Datatype.INTERACTIONS ? INTERACTION_SPANS_FILTER : SPANS_FILTER}`,
+    filter: `${new MutableSearch(query ?? '').formatString()} ${datatype === Datatype.INTERACTIONS ? INTERACTION_SPANS_FILTER : SPANS_FILTER} ${webVitalFilter}`,
     browserTypes,
     subregions,
+    webVital: WEB_VITAL_DATATYPES.includes(datatype)
+      ? (datatype as WebVitals)
+      : undefined,
   });
 
   const {profileExists} = useProfileExists(
@@ -206,7 +243,7 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
     return getDuration(value, value < 1 ? 0 : 2, true);
   };
 
-  function renderHeadCell(col: Column | InteractionsColumn) {
+  function renderHeadCell(col: Column | SpansColumn) {
     function generateSortLink() {
       const key = ['totalScore', 'inpScore'].includes(col.key)
         ? 'measurements.score.total'
@@ -264,7 +301,11 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
                 isHoverable
                 title={
                   <span>
-                    {t('The overall performance rating of this page.')}
+                    {tct('The [webVital] performance rating of this sample.', {
+                      webVital: handleStandaloneClsLcp
+                        ? datatype.toUpperCase()
+                        : 'overall',
+                    })}
                     <br />
                     <ExternalLink href={`${MODULE_DOC_LINK}#performance-score`}>
                       {t('How is this calculated?')}
@@ -272,7 +313,11 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
                   </span>
                 }
               >
-                <TooltipHeader>{t('Perf Score')}</TooltipHeader>
+                <TooltipHeader>
+                  {tct('[webVital] Score', {
+                    webVital: handleStandaloneClsLcp ? datatype.toUpperCase() : 'Perf',
+                  })}
+                </TooltipHeader>
               </StyledTooltip>
             </AlignCenter>
           }
@@ -290,11 +335,28 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
         </AlignCenter>
       );
     }
+
+    if (col.key === 'webVital') {
+      return <AlignRight>{datatype.toUpperCase()}</AlignRight>;
+    }
+
+    if (col.key === SpanIndexedField.SPAN_DESCRIPTION) {
+      if (datatype === Datatype.LCP) {
+        return <span>{t('LCP Element')}</span>;
+      }
+      if (datatype === Datatype.CLS) {
+        return <span>{t('CLS Source')}</span>;
+      }
+      if (datatype === Datatype.INP) {
+        return <span>{t('Interaction Target')}</span>;
+      }
+    }
+
     return <span>{col.name}</span>;
   }
 
   function renderBodyCell(
-    col: Column | InteractionsColumn,
+    col: Column | SpansColumn,
     row: TransactionSampleRowWithScore | SpanSampleRowWithScore
   ) {
     const {key} = col;
@@ -325,36 +387,40 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
         </NoOverflow>
       );
     }
-    if (
-      [
-        'measurements.fcp',
-        'measurements.lcp',
-        'measurements.ttfb',
-        'measurements.inp',
-        'transaction.duration',
-      ].includes(key)
-    ) {
-      return (
-        <AlignRight>
-          {(row as any)[key] === undefined ? (
-            <NoValue>{NO_VALUE}</NoValue>
-          ) : (
-            getFormattedDuration(((row as any)[key] as number) / 1000)
-          )}
-        </AlignRight>
-      );
-    }
-    if (['measurements.cls', 'opportunity'].includes(key)) {
-      return (
-        <AlignRight>
-          {(row as any)[key] === undefined ? (
-            <NoValue>{NO_VALUE}</NoValue>
-          ) : (
-            Math.round(((row as any)[key] as number) * 100) / 100
-          )}
-        </AlignRight>
-      );
-    }
+    const renderNumber = (numberKey: string) => {
+      if (
+        [
+          'measurements.fcp',
+          'measurements.lcp',
+          'measurements.ttfb',
+          'measurements.inp',
+          'transaction.duration',
+        ].includes(numberKey)
+      ) {
+        return (
+          <AlignRight>
+            {(row as any)[numberKey] === undefined ? (
+              <NoValue>{NO_VALUE}</NoValue>
+            ) : (
+              getFormattedDuration(((row as any)[numberKey] as number) / 1000)
+            )}
+          </AlignRight>
+        );
+      }
+      if (['measurements.cls', 'opportunity'].includes(numberKey)) {
+        return (
+          <AlignRight>
+            {(row as any)[numberKey] === undefined ? (
+              <NoValue>{NO_VALUE}</NoValue>
+            ) : (
+              Math.round(((row as any)[numberKey] as number) * 100) / 100
+            )}
+          </AlignRight>
+        );
+      }
+      return null;
+    };
+
     if (key === 'profile.id') {
       const profileId = String(row[key]);
       const profileTarget =
@@ -436,7 +502,7 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
 
       if (key === 'id' && 'id' in row) {
         return (
-          <Tooltip title={t('View Transaction')}>
+          <Tooltip title={t('View Trace')}>
             <NoOverflow>
               <Link to={traceViewLink}>{getShortEventId(row.trace)}</Link>
             </NoOverflow>
@@ -445,28 +511,37 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
       }
 
       if (key === SpanIndexedField.SPAN_DESCRIPTION) {
-        return (
-          <Tooltip title={(row as any)[key]}>
-            <NoOverflow>
-              {organization.features.includes('performance-vitals-standalone-cls-lcp') &&
-              'span.op' in row &&
-              row['span.op'] === 'pageload' &&
-              traceViewLink ? (
-                <Link to={traceViewLink}>{(row as any)[key]}</Link>
-              ) : (
-                (row as any)[key]
-              )}
-            </NoOverflow>
-          </Tooltip>
-        );
+        const description =
+          datatype === 'lcp' &&
+          (row as SpanSampleRowWithScore)[SpanIndexedField.SPAN_OP] === 'pageload'
+            ? (row as SpanSampleRowWithScore)[SpanIndexedField.LCP_ELEMENT]
+            : datatype === 'cls' &&
+                (row as SpanSampleRowWithScore)[SpanIndexedField.SPAN_OP] === 'pageload'
+              ? (row as SpanSampleRowWithScore)[SpanIndexedField.CLS_SOURCE]
+              : (row as SpanSampleRowWithScore)[key];
+
+        if (description) {
+          return (
+            <Tooltip title={description}>
+              <NoOverflow>{description}</NoOverflow>
+            </Tooltip>
+          );
+        }
+        return <NoOverflow>{NO_VALUE}</NoOverflow>;
       }
     }
 
+    if (key === 'webVital') {
+      return renderNumber(`measurements.${datatype}`);
+    }
+
     return (
-      <NoOverflow>
-        {/* @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message */}
-        {row[key] && row[key] !== '' ? row[key] : <NoValue>{NO_VALUE}</NoValue>}
-      </NoOverflow>
+      renderNumber(key) ?? (
+        <NoOverflow>
+          {/* @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message */}
+          {row[key] && row[key] !== '' ? row[key] : <NoValue>{NO_VALUE}</NoValue>}
+        </NoOverflow>
+      )
     );
   }
 
@@ -484,44 +559,58 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
   return (
     <span>
       <SearchBarContainer>
-        <SegmentedControl
-          size="md"
-          value={datatype}
-          aria-label={t('Data Type')}
-          onChange={newDataSet => {
-            // Reset pagination and sort when switching datatypes
-            trackAnalytics('insight.vital.overview.toggle_data_type', {
-              organization,
-              type: newDataSet,
-            });
-
-            navigate({
-              ...location,
-              query: {
-                ...location.query,
-                sort: undefined,
-                cursor: undefined,
-                [DATATYPE_KEY]: newDataSet,
-              },
-            });
-          }}
-        >
-          <SegmentedControl.Item key={Datatype.PAGELOADS} aria-label={t('Pageloads')}>
-            {t('Pageloads')}
-          </SegmentedControl.Item>
-          {organization.features.includes('performance-vitals-standalone-cls-lcp') ? (
-            <SegmentedControl.Item key={Datatype.SPANS} aria-label={t('Events')}>
-              {t('Events')}
+        {handleStandaloneClsLcp ? (
+          <CompactSelect
+            triggerProps={{prefix: t('Web Vital')}}
+            value={datatype}
+            options={WEB_VITAL_DATATYPES.map(type => ({
+              label: type.toUpperCase(),
+              value: type,
+            }))}
+            onChange={newDataType => {
+              trackAnalytics('insight.vital.overview.toggle_data_type', {
+                organization,
+                type: newDataType.value,
+              });
+              navigate({
+                ...location,
+                query: {...location.query, [DATATYPE_KEY]: newDataType.value},
+              });
+            }}
+          />
+        ) : (
+          <SegmentedControl
+            size="md"
+            value={datatype}
+            aria-label={t('Data Type')}
+            onChange={newDataSet => {
+              // Reset pagination and sort when switching datatypes
+              trackAnalytics('insight.vital.overview.toggle_data_type', {
+                organization,
+                type: newDataSet,
+              });
+              navigate({
+                ...location,
+                query: {
+                  ...location.query,
+                  sort: undefined,
+                  cursor: undefined,
+                  [DATATYPE_KEY]: newDataSet,
+                },
+              });
+            }}
+          >
+            <SegmentedControl.Item key={Datatype.PAGELOADS} aria-label={t('Pageloads')}>
+              {t('Pageloads')}
             </SegmentedControl.Item>
-          ) : (
             <SegmentedControl.Item
               key={Datatype.INTERACTIONS}
               aria-label={t('Interactions')}
             >
               {t('Interactions')}
             </SegmentedControl.Item>
-          )}
-        </SegmentedControl>
+          </SegmentedControl>
+        )}
         <StyledSearchBar>
           <TransactionSearchQueryBuilder
             projects={projectIds}
@@ -535,7 +624,7 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
         <GridEditable
           isLoading={isLoading}
           columnOrder={
-            organization.features.includes('performance-vitals-standalone-cls-lcp')
+            handleStandaloneClsLcp
               ? PAGELOADS_COLUMN_ORDER.filter(
                   col => !['measurements.cls', 'measurements.lcp'].includes(col.key)
                 )
@@ -556,7 +645,9 @@ export function PageSamplePerformanceTable({transaction, search, limit = 9}: Pro
           columnOrder={
             datatype === Datatype.INTERACTIONS
               ? INTERACTION_SAMPLES_COLUMN_ORDER
-              : SPANS_SAMPLES_COLUMN_ORDER
+              : ['cls', 'lcp', 'inp'].includes(datatype)
+                ? SPANS_SAMPLES_WITHOUT_TRACE_COLUMN_ORDER
+                : SPANS_SAMPLES_WITHOUT_DESCRIPTION_COLUMN_ORDER
           }
           columnSortBy={[]}
           data={standaloneSpansTableData}

+ 3 - 0
static/app/views/insights/browser/webVitals/queries/useSpanSamplesCategorizedQuery.tsx

@@ -49,6 +49,7 @@ export function useSpanSamplesCategorizedQuery({
       : undefined,
     browserTypes,
     subregions,
+    webVital: webVital ?? undefined,
   });
   const {data: mehData, isFetching: isMehDataLoading} = useSpanSamplesWebVitalsQuery({
     transaction,
@@ -59,6 +60,7 @@ export function useSpanSamplesCategorizedQuery({
       : undefined,
     browserTypes,
     subregions,
+    webVital: webVital ?? undefined,
   });
   const {data: poorData, isFetching: isBadDataLoading} = useSpanSamplesWebVitalsQuery({
     transaction,
@@ -69,6 +71,7 @@ export function useSpanSamplesCategorizedQuery({
       : undefined,
     browserTypes,
     subregions,
+    webVital: webVital ?? undefined,
   });
 
   const data = [...goodData, ...mehData, ...poorData];

+ 34 - 19
static/app/views/insights/browser/webVitals/queries/useSpanSamplesWebVitalsQuery.tsx

@@ -4,6 +4,7 @@ import {
   SORTABLE_INDEXED_FIELDS,
   SORTABLE_INDEXED_INTERACTION_FIELDS,
   type SpanSampleRowWithScore,
+  type WebVitals,
 } from 'sentry/views/insights/browser/webVitals/types';
 import type {BrowserType} from 'sentry/views/insights/browser/webVitals/utils/queryParameterDecoders/browserType';
 import {useWebVitalsSort} from 'sentry/views/insights/browser/webVitals/utils/useWebVitalsSort';
@@ -20,7 +21,7 @@ export const CLS_SPANS_FILTER =
   'span.op:[ui.webvital.cls,pageload] (!measurements.score.weight.cls:0)';
 
 export const SPANS_FILTER =
-  'span.op:[ui.interaction.click,ui.interaction.hover,ui.interaction.drag,ui.interaction.press,ui.webvital.lcp,ui.webvital.cls,pageload] (!measurements.score.weight.inp:0 OR !measurements.score.weight.lcp:0 OR !measurements.score.weight.cls:0)';
+  'span.op:[ui.interaction.click,ui.interaction.hover,ui.interaction.drag,ui.interaction.press,ui.webvital.lcp,ui.webvital.cls,pageload]';
 
 export function useSpanSamplesWebVitalsQuery({
   transaction,
@@ -30,6 +31,7 @@ export function useSpanSamplesWebVitalsQuery({
   sortName,
   browserTypes,
   subregions,
+  webVital = 'inp',
 }: {
   limit: number;
   browserTypes?: BrowserType[];
@@ -38,6 +40,7 @@ export function useSpanSamplesWebVitalsQuery({
   sortName?: string;
   subregions?: SubregionCode[];
   transaction?: string;
+  webVital?: WebVitals;
 }) {
   const filteredSortableFields = [
     ...SORTABLE_INDEXED_FIELDS,
@@ -66,17 +69,38 @@ export function useSpanSamplesWebVitalsQuery({
     );
   }
 
+  let field: SpanIndexedField | undefined;
+  let ratioField: SpanIndexedField | undefined;
+  switch (webVital) {
+    case 'lcp':
+      field = SpanIndexedField.LCP;
+      ratioField = SpanIndexedField.LCP_SCORE_RATIO;
+      break;
+    case 'cls':
+      field = SpanIndexedField.CLS;
+      ratioField = SpanIndexedField.CLS_SCORE_RATIO;
+      break;
+    case 'fcp':
+      field = SpanIndexedField.FCP;
+      ratioField = SpanIndexedField.FCP_SCORE_RATIO;
+      break;
+    case 'ttfb':
+      field = SpanIndexedField.TTFB;
+      ratioField = SpanIndexedField.TTFB_SCORE_RATIO;
+      break;
+    case 'inp':
+    default:
+      field = SpanIndexedField.INP;
+      ratioField = SpanIndexedField.INP_SCORE_RATIO;
+      break;
+  }
+
   const {data, isPending, ...rest} = useSpansIndexed(
     {
       search: `${mutableSearch.formatString()} ${filter}`,
       sorts: [sort],
       fields: [
-        SpanIndexedField.INP,
-        SpanIndexedField.LCP,
-        SpanIndexedField.CLS,
-        SpanIndexedField.INP_SCORE_RATIO,
-        SpanIndexedField.LCP_SCORE_RATIO,
-        SpanIndexedField.CLS_SCORE_RATIO,
+        ...(field && ratioField ? [field, ratioField] : []),
         SpanIndexedField.TOTAL_SCORE,
         SpanIndexedField.TRACE,
         SpanIndexedField.PROFILE_ID,
@@ -88,6 +112,8 @@ export function useSpanSamplesWebVitalsQuery({
         SpanIndexedField.SPAN_SELF_TIME,
         SpanIndexedField.TRANSACTION,
         SpanIndexedField.SPAN_OP,
+        SpanIndexedField.LCP_ELEMENT,
+        SpanIndexedField.CLS_SOURCE,
       ],
       enabled,
       limit,
@@ -99,18 +125,7 @@ export function useSpanSamplesWebVitalsQuery({
       ? data.map(row => {
           return {
             ...row,
-            'measurements.inp':
-              row[SpanIndexedField.INP_SCORE_RATIO] > 0
-                ? row[SpanIndexedField.INP]
-                : undefined,
-            'measurements.lcp':
-              row[SpanIndexedField.LCP_SCORE_RATIO] > 0
-                ? row[SpanIndexedField.LCP]
-                : undefined,
-            'measurements.cls':
-              row[SpanIndexedField.CLS_SCORE_RATIO] > 0
-                ? row[SpanIndexedField.CLS]
-                : undefined,
+            [`measurements.${webVital}`]: row[ratioField] > 0 ? row[field] : undefined,
             'user.display': row[SpanIndexedField.USER_DISPLAY],
             replayId: row[SpanIndexedField.REPLAY],
             'profile.id': row[SpanIndexedField.PROFILE_ID],

+ 5 - 0
static/app/views/insights/browser/webVitals/types.tsx

@@ -53,6 +53,11 @@ export type SpanSampleRow = {
   [SpanIndexedField.INP]?: number;
   [SpanIndexedField.CLS]?: number;
   [SpanIndexedField.LCP]?: number;
+  [SpanIndexedField.FCP]?: number;
+  [SpanIndexedField.TTFB]?: number;
+  [SpanIndexedField.LCP_ELEMENT]?: string;
+  [SpanIndexedField.SPAN_OP]?: string;
+  [SpanIndexedField.CLS_SOURCE]?: string;
 };
 
 export type SpanSampleRowWithScore = SpanSampleRow & {

+ 6 - 10
static/app/views/insights/browser/webVitals/views/pageOverview.spec.tsx

@@ -154,11 +154,7 @@ describe('PageOverview', function () {
             dataset: 'spansIndexed',
             field: [
               'measurements.inp',
-              'measurements.lcp',
-              'measurements.cls',
               'measurements.score.ratio.inp',
-              'measurements.score.ratio.lcp',
-              'measurements.score.ratio.cls',
               'measurements.score.total',
               'trace',
               'profile_id',
@@ -170,9 +166,11 @@ describe('PageOverview', function () {
               'span.self_time',
               'transaction',
               'span.op',
+              'lcp.element',
+              'cls.source.1',
             ],
             query:
-              'has:message !span.description:<unknown> transaction:/  span.op:[ui.interaction.click,ui.interaction.hover,ui.interaction.drag,ui.interaction.press]',
+              'has:message !span.description:<unknown> transaction:/  span.op:[ui.interaction.click,ui.interaction.hover,ui.interaction.drag,ui.interaction.press] ',
           }),
         })
       )
@@ -204,11 +202,7 @@ describe('PageOverview', function () {
             dataset: 'spansIndexed',
             field: [
               'measurements.inp',
-              'measurements.lcp',
-              'measurements.cls',
               'measurements.score.ratio.inp',
-              'measurements.score.ratio.lcp',
-              'measurements.score.ratio.cls',
               'measurements.score.total',
               'trace',
               'profile_id',
@@ -220,9 +214,11 @@ describe('PageOverview', function () {
               'span.self_time',
               'transaction',
               'span.op',
+              'lcp.element',
+              'cls.source.1',
             ],
             query:
-              'has:message !span.description:<unknown> transaction:"/page-with-a-\\*/"  span.op:[ui.interaction.click,ui.interaction.hover,ui.interaction.drag,ui.interaction.press]',
+              'has:message !span.description:<unknown> transaction:"/page-with-a-\\*/"  span.op:[ui.interaction.click,ui.interaction.hover,ui.interaction.drag,ui.interaction.press] ',
           }),
         })
       )

+ 14 - 0
static/app/views/insights/types.tsx

@@ -280,7 +280,11 @@ export enum SpanIndexedField {
   CLS_SCORE = 'measurements.score.cls',
   CLS_SCORE_RATIO = 'measurements.score.ratio.cls',
   TTFB = 'measurements.ttfb',
+  TTFB_SCORE = 'measurements.score.ttfb',
+  TTFB_SCORE_RATIO = 'measurements.score.ratio.ttfb',
   FCP = 'measurements.fcp',
+  FCP_SCORE = 'measurements.score.fcp',
+  FCP_SCORE_RATIO = 'measurements.score.ratio.fcp',
   TOTAL_SCORE = 'measurements.score.total',
   RESPONSE_CODE = 'span.status_code',
   CACHE_HIT = 'cache.hit',
@@ -293,6 +297,8 @@ export enum SpanIndexedField {
   MESSAGING_MESSAGE_DESTINATION_NAME = 'messaging.destination.name',
   USER_GEO_SUBREGION = 'user.geo.subregion',
   IS_TRANSACTION = 'is_transaction',
+  LCP_ELEMENT = 'lcp.element',
+  CLS_SOURCE = 'cls.source.1',
 }
 
 export type SpanIndexedResponse = {
@@ -363,6 +369,12 @@ export type SpanIndexedResponse = {
   [SpanIndexedField.CLS]: number;
   [SpanIndexedField.CLS_SCORE]: number;
   [SpanIndexedField.CLS_SCORE_RATIO]: number;
+  [SpanIndexedField.TTFB]: number;
+  [SpanIndexedField.TTFB_SCORE]: number;
+  [SpanIndexedField.TTFB_SCORE_RATIO]: number;
+  [SpanIndexedField.FCP]: number;
+  [SpanIndexedField.FCP_SCORE]: number;
+  [SpanIndexedField.FCP_SCORE_RATIO]: number;
   [SpanIndexedField.TOTAL_SCORE]: number;
   [SpanIndexedField.RESPONSE_CODE]: string;
   [SpanIndexedField.CACHE_HIT]: '' | 'true' | 'false';
@@ -374,6 +386,8 @@ export type SpanIndexedResponse = {
   [SpanIndexedField.MESSAGING_MESSAGE_RETRY_COUNT]: number;
   [SpanIndexedField.MESSAGING_MESSAGE_DESTINATION_NAME]: string;
   [SpanIndexedField.USER_GEO_SUBREGION]: string;
+  [SpanIndexedField.LCP_ELEMENT]: string;
+  [SpanIndexedField.CLS_SOURCE]: string;
 };
 
 export type SpanIndexedProperty = keyof SpanIndexedResponse;