Просмотр исходного кода

feat(trace): add web or mobile vitals tab (#67935)

Renders any web or mobile vitals collected in a trace in a separate tab
![CleanShot 2024-03-29 at 17 55
33@2x](https://github.com/getsentry/sentry/assets/9317857/04bc792a-65b5-4d79-8680-2b92cdfb58dc)
Jonas 11 месяцев назад
Родитель
Сommit
40b415f0fd

+ 2 - 2
static/app/components/events/eventVitals.tsx

@@ -113,10 +113,10 @@ function MobileVitals({event}: Props) {
   );
 }
 
-type EventVitalProps = Props & {
+interface EventVitalProps extends Props {
   name: string;
   vital?: Vital;
-};
+}
 
 function EventVital({event, name, vital}: EventVitalProps) {
   const value = event.measurements?.[name].value ?? null;

+ 38 - 1
static/app/views/performance/newTraceDetails/index.tsx

@@ -62,6 +62,7 @@ import {
   cancelAnimationTimeout,
   requestAnimationTimeout,
 } from '../../../utils/profiling/hooks/useVirtualizedTree/virtualizedTreeUtils';
+import {capitalize} from '../../../utils/string/capitalize';
 import Breadcrumb from '../breadcrumb';
 
 import {TraceDrawer} from './traceDrawer/traceDrawer';
@@ -140,8 +141,15 @@ export function TraceView() {
 }
 
 const TRACE_TAB: TraceTabsReducerState['tabs'][0] = {
-  node: 'Trace',
+  node: 'trace',
+  label: t('Trace'),
 };
+
+const VITALS_TAB: TraceTabsReducerState['tabs'][0] = {
+  node: 'vitals',
+  label: t('Vitals'),
+};
+
 const STATIC_DRAWER_TABS: TraceTabsReducerState['tabs'] = [TRACE_TAB];
 
 type TraceViewContentProps = {
@@ -293,6 +301,35 @@ function TraceViewContent(props: TraceViewContentProps) {
   const tabsStateRef = useRef<TraceTabsReducerState>(tabs);
   tabsStateRef.current = tabs;
 
+  useLayoutEffect(() => {
+    if (tree.type !== 'trace') {
+      return;
+    }
+
+    const newTabs = [TRACE_TAB];
+
+    if (tree.vitals.size > 0) {
+      const types = Array.from(tree.vital_types.values());
+      const label = types.length > 1 ? t('Vitals') : capitalize(types[0]) + ' Vitals';
+
+      newTabs.push({
+        ...VITALS_TAB,
+        label,
+      });
+    }
+
+    tabsDispatch({
+      type: 'initialize',
+      payload: {
+        current: tabs[0],
+        tabs: newTabs,
+        last_clicked: null,
+      },
+    });
+    // We only want to update the tabs when the tree changes
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [tree]);
+
   const onRowClick = useCallback(
     (
       node: TraceTreeNode<TraceTree.NodeValue> | null,

+ 21 - 140
static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace.tsx

@@ -1,12 +1,5 @@
 import {Fragment, useMemo} from 'react';
-import styled from '@emotion/styled';
 
-import {SectionHeading} from 'sentry/components/charts/styles';
-import EventVitals from 'sentry/components/events/eventVitals';
-import Panel from 'sentry/components/panels/panel';
-import Placeholder from 'sentry/components/placeholder';
-import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
 import type {EventTransaction, Organization} from 'sentry/types';
 import {generateQueryWithTag} from 'sentry/utils';
 import type EventView from 'sentry/utils/discover/eventView';
@@ -15,7 +8,6 @@ import type {
   TraceFullDetailed,
   TraceSplitResults,
 } from 'sentry/utils/performance/quickTrace/types';
-import {WEB_VITAL_DETAILS} from 'sentry/utils/performance/vitals/constants';
 import type {UseApiQueryResult} from 'sentry/utils/queryClient';
 import type RequestError from 'sentry/utils/requestError/requestError';
 import {useLocation} from 'sentry/utils/useLocation';
@@ -25,15 +17,7 @@ import {isTraceNode} from '../../guards';
 import type {TraceTree, TraceTreeNode} from '../../traceTree';
 import {IssueList} from '../details/issues/issues';
 
-const WEB_VITALS = [
-  WEB_VITAL_DETAILS['measurements.cls'],
-  WEB_VITAL_DETAILS['measurements.lcp'],
-  WEB_VITAL_DETAILS['measurements.ttfb'],
-  WEB_VITAL_DETAILS['measurements.fcp'],
-  WEB_VITAL_DETAILS['measurements.fid'],
-];
-
-type TraceFooterProps = {
+type TraceDetailsProps = {
   node: TraceTreeNode<TraceTree.NodeValue> | null;
   organization: Organization;
   rootEventResults: UseApiQueryResult<EventTransaction, RequestError>;
@@ -42,51 +26,7 @@ type TraceFooterProps = {
   tree: TraceTree;
 };
 
-function NoWebVitals() {
-  return (
-    <div style={{flex: 1}}>
-      <SectionHeading>{t('WebVitals')}</SectionHeading>
-      <WebVitalsWrapper>
-        {WEB_VITALS.map(detail => (
-          <StyledPanel key={detail.name}>
-            <div>{detail.name}</div>
-            <div>{' \u2014 '}</div>
-          </StyledPanel>
-        ))}
-      </WebVitalsWrapper>
-    </div>
-  );
-}
-
-function TraceDataLoading() {
-  return (
-    <WebVitalsAndTags>
-      <div style={{flex: 1}}>
-        <SectionHeading>{t('WebVitals')}</SectionHeading>
-        <Fragment>
-          <StyledPlaceholderVital key="title-1" />
-          <StyledPlaceholderVital key="title-2" />
-          <StyledPlaceholderVital key="title-3" />
-          <StyledPlaceholderVital key="title-4" />
-          <StyledPlaceholderVital key="title-5" />
-        </Fragment>
-      </div>
-      <div style={{flex: 1}}>
-        <SectionHeading>{t('Tag Summary')}</SectionHeading>
-        <Fragment>
-          <StyledPlaceholderTagTitle key="title-1" />
-          <StyledPlaceholderTag key="bar-1" />
-          <StyledPlaceholderTagTitle key="title-2" />
-          <StyledPlaceholderTag key="bar-2" />
-          <StyledPlaceholderTagTitle key="title-3" />
-          <StyledPlaceholderTag key="bar-3" />
-        </Fragment>
-      </div>
-    </WebVitalsAndTags>
-  );
-}
-
-export function TraceLevelDetails(props: TraceFooterProps) {
+export function TraceDetails(props: TraceDetailsProps) {
   const location = useLocation();
   const issues = useMemo(() => {
     if (!props.node) {
@@ -104,89 +44,30 @@ export function TraceLevelDetails(props: TraceFooterProps) {
     throw new Error('Expected a trace node');
   }
 
-  if (!props.traces) {
-    return <TraceDataLoading />;
-  }
-
   const {data: rootEvent} = props.rootEventResults;
-  const webVitals = Object.keys(rootEvent?.measurements ?? {})
-    .filter(name => Boolean(WEB_VITAL_DETAILS[`measurements.${name}`]))
-    .sort();
 
   return (
-    <Wrapper>
+    <Fragment>
       <IssueList issues={issues} node={props.node} organization={props.organization} />
       {rootEvent ? (
-        <WebVitalsAndTags>
-          {webVitals.length > 0 ? (
-            <div style={{flex: 1}}>
-              <EventVitals event={rootEvent} />
-            </div>
-          ) : (
-            <NoWebVitals />
-          )}
-          <div style={{flex: 1}}>
-            <Tags
-              generateUrl={(key: string, value: string) => {
-                const url = props.traceEventView.getResultsViewUrlTarget(
-                  props.organization.slug,
-                  false
-                );
-                url.query = generateQueryWithTag(url.query, {
-                  key: formatTagKey(key),
-                  value,
-                });
-                return url;
-              }}
-              totalValues={props.tree.eventsCount}
-              eventView={props.traceEventView}
-              organization={props.organization}
-              location={location}
-            />
-          </div>
-        </WebVitalsAndTags>
+        <Tags
+          generateUrl={(key: string, value: string) => {
+            const url = props.traceEventView.getResultsViewUrlTarget(
+              props.organization.slug,
+              false
+            );
+            url.query = generateQueryWithTag(url.query, {
+              key: formatTagKey(key),
+              value,
+            });
+            return url;
+          }}
+          totalValues={props.tree.eventsCount}
+          eventView={props.traceEventView}
+          organization={props.organization}
+          location={location}
+        />
       ) : null}
-    </Wrapper>
+    </Fragment>
   );
 }
-
-const Wrapper = styled('div')`
-  display: flex;
-  flex-direction: column;
-  gap: ${space(2)};
-`;
-
-const WebVitalsAndTags = styled('div')`
-  display: flex;
-  gap: ${space(2)};
-`;
-
-const StyledPlaceholderTag = styled(Placeholder)`
-  border-radius: ${p => p.theme.borderRadius};
-  height: 16px;
-  margin-bottom: ${space(1.5)};
-`;
-
-const StyledPlaceholderTagTitle = styled(Placeholder)`
-  width: 100px;
-  height: 12px;
-  margin-bottom: ${space(0.5)};
-`;
-
-const StyledPlaceholderVital = styled(StyledPlaceholderTagTitle)`
-  width: 100%;
-  height: 50px;
-  margin-bottom: ${space(0.5)};
-`;
-
-const StyledPanel = styled(Panel)`
-  padding: ${space(1)} ${space(1.5)};
-  margin-bottom: ${space(1)};
-  width: 100%;
-`;
-
-const WebVitalsWrapper = styled('div')`
-  display: flex;
-  align-items: center;
-  flex-direction: column;
-`;

+ 145 - 0
static/app/views/performance/newTraceDetails/traceDrawer/tabs/traceVitals.tsx

@@ -0,0 +1,145 @@
+import styled from '@emotion/styled';
+
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Panel from 'sentry/components/panels/panel';
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconFire} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Measurement} from 'sentry/types';
+import {getDuration} from 'sentry/utils/formatters';
+import type {Vital} from 'sentry/utils/performance/vitals/types';
+import type {IconSize} from 'sentry/utils/theme';
+import useProjects from 'sentry/utils/useProjects';
+import {isTransactionNode} from 'sentry/views/performance/newTraceDetails/guards';
+import {TraceDrawerComponents} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/styles';
+import {
+  TRACE_MEASUREMENT_LOOKUP,
+  type TraceTree,
+} from 'sentry/views/performance/newTraceDetails/traceTree';
+
+interface TraceVitalsProps {
+  trace: TraceTree;
+}
+
+export function TraceVitals(props: TraceVitalsProps) {
+  const {projects} = useProjects();
+  const measurements = Array.from(props.trace.vitals.entries());
+
+  return (
+    <TraceDrawerComponents.DetailContainer>
+      {measurements.map(([node, vital]) => {
+        const op = isTransactionNode(node) ? node.value['transaction.op'] : '';
+        const project = projects.find(p => p.slug === node.metadata.project_slug);
+
+        return (
+          <div key="">
+            <TraceDrawerComponents.HeaderContainer>
+              <TraceDrawerComponents.Title>
+                <Tooltip title={node.metadata.project_slug}>
+                  <ProjectBadge
+                    project={project ? project : {slug: node.metadata.project_slug ?? ''}}
+                    avatarSize={30}
+                    hideName
+                  />
+                </Tooltip>
+                <div>
+                  <div>{t('transaction')}</div>
+                  <TraceDrawerComponents.TitleOp> {op}</TraceDrawerComponents.TitleOp>
+                </div>
+              </TraceDrawerComponents.Title>
+            </TraceDrawerComponents.HeaderContainer>
+
+            <VitalsContainer>
+              {vital.map((v, i) => {
+                return <EventVital key={i} vital={v} value={v.measurement} />;
+              })}
+            </VitalsContainer>
+          </div>
+        );
+      })}
+    </TraceDrawerComponents.DetailContainer>
+  );
+}
+
+const VitalsContainer = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: ${space(1)};
+  margin-top: ${space(2)};
+`;
+
+interface EventVitalProps {
+  value: Measurement;
+  vital: TraceTree.CollectedVital;
+}
+
+function formatVitalDuration(vital: Vital, value: number) {
+  if (vital?.type === 'duration') {
+    return getDuration(value / 1000, 2, true);
+  }
+
+  if (vital?.type === 'integer') {
+    return value.toFixed(0);
+  }
+
+  return value.toFixed(2);
+}
+
+function EventVital(props: EventVitalProps) {
+  const vital = TRACE_MEASUREMENT_LOOKUP[props.vital.key];
+
+  if (!vital) {
+    return null;
+  }
+
+  const failedThreshold =
+    vital.poorThreshold !== undefined && props.value.value >= vital.poorThreshold;
+
+  const currentValue = formatVitalDuration(vital, props.value.value);
+  const thresholdValue = formatVitalDuration(vital, vital?.poorThreshold ?? 0);
+
+  return (
+    <StyledPanel failedThreshold={failedThreshold}>
+      <div>{vital.name ?? name}</div>
+      <ValueRow>
+        {failedThreshold ? (
+          <FireIconContainer data-test-id="threshold-failed-warning" size="sm">
+            <Tooltip
+              title={t('Fails threshold at %s.', thresholdValue)}
+              position="top"
+              containerDisplayMode="inline-block"
+            >
+              <IconFire size="sm" />
+            </Tooltip>
+          </FireIconContainer>
+        ) : null}
+        <Value failedThreshold={failedThreshold}>{currentValue}</Value>
+      </ValueRow>
+    </StyledPanel>
+  );
+}
+
+const StyledPanel = styled(Panel)<{failedThreshold: boolean}>`
+  padding: ${space(1)} ${space(1.5)};
+  margin-bottom: ${space(1)};
+  ${p => p.failedThreshold && `border: 1px solid ${p.theme.red300};`}
+`;
+
+const ValueRow = styled('div')`
+  display: flex;
+  align-items: center;
+`;
+
+const FireIconContainer = styled('span')<{size: IconSize | string}>`
+  display: inline-block;
+  height: ${p => p.theme.iconSizes[p.size] ?? p.size};
+  line-height: ${p => p.theme.iconSizes[p.size] ?? p.size};
+  margin-right: ${space(0.5)};
+  color: ${p => p.theme.errorText};
+`;
+
+const Value = styled('span')<{failedThreshold: boolean}>`
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  ${p => p.failedThreshold && `color: ${p.theme.errorText};`}
+`;

+ 33 - 8
static/app/views/performance/newTraceDetails/traceDrawer/traceDrawer.tsx

@@ -18,6 +18,7 @@ import {
   useResizableDrawer,
   type UseResizableDrawerOptions,
 } from 'sentry/utils/useResizableDrawer';
+import {TraceVitals} from 'sentry/views/performance/newTraceDetails/traceDrawer/tabs/traceVitals';
 import {
   getTraceTabTitle,
   type TraceTabsReducerAction,
@@ -27,7 +28,7 @@ import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDeta
 
 import {makeTraceNodeBarColor, type TraceTree, type TraceTreeNode} from '../traceTree';
 
-import {TraceLevelDetails} from './tabs/trace';
+import {TraceDetails} from './tabs/trace';
 import {TraceTreeNodeDetails} from './tabs/traceTreeNodeDetails';
 
 const MIN_TRACE_DRAWER_DIMENSTIONS: [number, number] = [480, 27];
@@ -293,15 +294,17 @@ export function TraceDrawer(props: TraceDrawerProps) {
       <Content layout={props.layout}>
         <ContentWrapper>
           {props.tabs.current ? (
-            props.tabs.current.node === 'Trace' ? (
-              <TraceLevelDetails
-                node={props.trace.root.children[0]}
+            props.tabs.current.node === 'trace' ? (
+              <TraceDetails
                 tree={props.trace}
+                node={props.trace.root.children[0]}
                 rootEventResults={props.rootEventResults}
                 organization={props.organization}
                 traces={props.traces}
                 traceEventView={props.traceEventView}
               />
+            ) : props.tabs.current.node === 'vitals' ? (
+              <TraceVitals trace={props.trace} />
             ) : (
               <TraceTreeNodeDetails
                 node={props.tabs.current.node}
@@ -334,19 +337,22 @@ function TraceDrawerTab(props: TraceDrawerTabProps) {
     const root = props.trace.root.children[0];
     return (
       <Tab
+        className={typeof props.tab.node === 'string' ? 'Static' : ''}
         active={props.tab === props.tabs.current}
         onClick={() => {
-          props.scrollToNode(root);
+          if (props.tab.node !== 'vitals') {
+            props.scrollToNode(root);
+          }
           props.tabsDispatch({type: 'activate tab', payload: props.index});
         }}
       >
         {/* A trace is technically an entry in the list, so it has a color */}
-        {props.tab.node === 'Trace' ? null : (
+        {props.tab.node === 'trace' || props.tab.node === 'vitals' ? null : (
           <TabButtonIndicator
             backgroundColor={makeTraceNodeBarColor(props.theme, root)}
           />
         )}
-        <TabButton>{node}</TabButton>
+        <TabButton>{props.tab.label ?? props.tab.node}</TabButton>
       </Tab>
     );
   }
@@ -435,7 +441,7 @@ const TabsContainer = styled('ul')`
   width: 100%;
   align-items: center;
   justify-content: left;
-  gap: ${space(1)};
+  gap: ${space(0.5)};
   padding-left: 0;
   margin-bottom: 0;
 `;
@@ -463,6 +469,23 @@ const Tab = styled('li')<{active: boolean}>`
   align-items: center;
   border-bottom: 2px solid ${p => (p.active ? p.theme.blue400 : 'transparent')};
   padding: 0 ${space(0.25)};
+  position: relative;
+
+  &:has(+ :not(.Static)) {
+    margin-right: ${space(2)};
+
+    &:after {
+      display: block;
+      content: '';
+      position: absolute;
+      right: -10px;
+      top: 50%;
+      transform: translateY(-50%);
+      height: 72%;
+      width: 1px;
+      background-color: ${p => p.theme.border};
+    }
+  }
 
   &:hover {
     border-bottom: 2px solid ${p => (p.active ? p.theme.blue400 : p.theme.blue200)};
@@ -480,6 +503,7 @@ const TabButtonIndicator = styled('div')<{backgroundColor: string}>`
   height: 12px;
   min-width: 12px;
   border-radius: 2px;
+  margin-right: ${space(0.25)};
   background-color: ${p => p.backgroundColor};
 `;
 
@@ -494,6 +518,7 @@ const TabButton = styled('button')`
 
   border-radius: 0;
   margin: 0;
+  padding: 0 ${space(0.25)};
   font-size: ${p => p.theme.fontSizeSmall};
   color: ${p => p.theme.textColor};
   background: transparent;

+ 7 - 2
static/app/views/performance/newTraceDetails/traceTabs.tsx

@@ -29,7 +29,7 @@ export function getTraceTabTitle(node: TraceTreeNode<TraceTree.NodeValue>) {
   }
 
   if (isAutogroupedNode(node)) {
-    return t('Autogroup');
+    return t('Autogroup') + ' - ' + node.value.autogrouped_by.op;
   }
 
   if (isMissingInstrumentationNode(node)) {
@@ -53,7 +53,8 @@ export function getTraceTabTitle(node: TraceTreeNode<TraceTree.NodeValue>) {
 }
 
 type Tab = {
-  node: TraceTreeNode<TraceTree.NodeValue> | 'Trace';
+  node: TraceTreeNode<TraceTree.NodeValue> | 'trace' | 'vitals';
+  label?: string;
 };
 
 export type TraceTabsReducerState = {
@@ -63,6 +64,7 @@ export type TraceTabsReducerState = {
 };
 
 export type TraceTabsReducerAction =
+  | {payload: TraceTabsReducerState; type: 'initialize'}
   | {
       payload: Tab['node'] | number;
       type: 'activate tab';
@@ -77,6 +79,9 @@ export function traceTabsReducer(
   action: TraceTabsReducerAction
 ): TraceTabsReducerState {
   switch (action.type) {
+    case 'initialize': {
+      return action.payload;
+    }
     case 'activate tab': {
       // If an index was passed, activate the tab at that index
       if (typeof action.payload === 'number') {

+ 71 - 2
static/app/views/performance/newTraceDetails/traceTree.tsx

@@ -16,6 +16,11 @@ import {
   isTraceError,
   isTraceTransaction,
 } from 'sentry/utils/performance/quickTrace/utils';
+import {
+  MOBILE_VITAL_DETAILS,
+  WEB_VITAL_DETAILS,
+} from 'sentry/utils/performance/vitals/constants';
+import type {Vital} from 'sentry/utils/performance/vitals/types';
 
 import {TraceType} from '../traceDetails/newTraceDetailsContent';
 import {isRootTransaction} from '../traceDetails/utils';
@@ -158,6 +163,8 @@ export declare namespace TraceTree {
     start: number;
     type: 'cls' | 'fcp' | 'fp' | 'lcp' | 'ttfb';
   };
+
+  type CollectedVital = {key: string; measurement: Measurement};
 }
 
 function cacheKey(organization: Organization, project_slug: string, event_id: string) {
@@ -266,8 +273,36 @@ const RENDERABLE_MEASUREMENTS = [
   WebVital.LCP,
   MobileVital.TIME_TO_FULL_DISPLAY,
   MobileVital.TIME_TO_INITIAL_DISPLAY,
+]
+  .map(n => n.replace('measurements.', ''))
+  .reduce((acc, curr) => {
+    acc[curr] = true;
+    return acc;
+  }, {});
+
+const WEB_VITALS = [
+  WebVital.TTFB,
+  WebVital.FP,
+  WebVital.FCP,
+  WebVital.LCP,
+  WebVital.CLS,
+  WebVital.FID,
+  WebVital.INP,
+  WebVital.REQUEST_TIME,
 ].map(n => n.replace('measurements.', ''));
 
+const MOBILE_VITALS = [
+  MobileVital.TIME_TO_FULL_DISPLAY,
+  MobileVital.TIME_TO_INITIAL_DISPLAY,
+].map(n => n.replace('measurements.', ''));
+
+const WEB_VITALS_LOOKUP = new Set<string>(WEB_VITALS);
+const MOBILE_VITALS_LOOKUP = new Set<string>(MOBILE_VITALS);
+
+const COLLECTABLE_MEASUREMENTS = [...WEB_VITALS, ...MOBILE_VITALS].map(n =>
+  n.replace('measurements.', '')
+);
+
 const MEASUREMENT_ACRONYM_MAPPING = {
   [MobileVital.TIME_TO_FULL_DISPLAY.replace('measurements.', '')]: 'TTFD',
   [MobileVital.TIME_TO_INITIAL_DISPLAY.replace('measurements.', '')]: 'TTID',
@@ -281,10 +316,20 @@ const MEASUREMENT_THRESHOLDS = {
   [MobileVital.TIME_TO_INITIAL_DISPLAY.replace('measurements.', '')]: 2000,
 };
 
+export const TRACE_MEASUREMENT_LOOKUP: Record<string, Vital> = {};
+for (const key in {...MOBILE_VITAL_DETAILS, ...WEB_VITAL_DETAILS}) {
+  TRACE_MEASUREMENT_LOOKUP[key.replace('measurements.', '')] = {
+    ...MOBILE_VITAL_DETAILS[key],
+    ...WEB_VITAL_DETAILS[key],
+  };
+}
+
 export class TraceTree {
   type: 'loading' | 'empty' | 'error' | 'trace' = 'trace';
   root: TraceTreeNode<null> = TraceTreeNode.Root();
   indicators: TraceTree.Indicator[] = [];
+  vitals: Map<TraceTreeNode<TraceTree.NodeValue>, TraceTree.CollectedVital[]> = new Map();
+  vital_types: Set<'web' | 'mobile'> = new Set();
   eventsCount: number = 0;
 
   private _spanPromises: Map<string, Promise<Event>> = new Map();
@@ -371,8 +416,11 @@ export class TraceTree {
 
       if (value && 'measurements' in value) {
         tree.collectMeasurements(
+          node,
           traceStart,
           value.measurements as Record<string, Measurement>,
+          tree.vitals,
+          tree.vital_types,
           tree.indicators
         );
       }
@@ -835,13 +883,34 @@ export class TraceTree {
   }
 
   collectMeasurements(
+    node: TraceTreeNode<TraceTree.NodeValue>,
     start_timestamp: number,
     measurements: Record<string, Measurement>,
+    vitals: Map<TraceTreeNode<TraceTree.NodeValue>, TraceTree.CollectedVital[]>,
+    vital_types: Set<'web' | 'mobile'>,
     indicators: TraceTree.Indicator[]
   ): void {
-    for (const measurement of RENDERABLE_MEASUREMENTS) {
+    for (const measurement of COLLECTABLE_MEASUREMENTS) {
       const value = measurements[measurement];
-      if (!value) {
+
+      if (!value || typeof value.value !== 'number') {
+        continue;
+      }
+
+      if (!vitals.has(node)) {
+        vitals.set(node, []);
+      }
+
+      WEB_VITALS_LOOKUP.has(measurement) && vital_types.add('web');
+      MOBILE_VITALS_LOOKUP.has(measurement) && vital_types.add('mobile');
+
+      const vital = vitals.get(node)!;
+      vital.push({
+        key: measurement,
+        measurement: value,
+      });
+
+      if (!RENDERABLE_MEASUREMENTS[measurement]) {
         continue;
       }