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

feat(web-vitals): Add vertical line markers for web vitals on span view (#21336)

Alberto Leal 4 лет назад
Родитель
Сommit
0af8de2541

+ 55 - 0
src/sentry/static/sentry/app/components/events/interfaces/spans/measurementsManager.tsx

@@ -0,0 +1,55 @@
+import React from 'react';
+
+export type MeasurementsManagerChildrenProps = {
+  hoveringMeasurement: (measurementName: string) => void;
+  notHovering: () => void;
+  currentHoveredMeasurement: string | undefined;
+};
+
+const MeasurementsManagerContext = React.createContext<MeasurementsManagerChildrenProps>({
+  hoveringMeasurement: () => {},
+  notHovering: () => {},
+  currentHoveredMeasurement: undefined,
+});
+
+type Props = {
+  children: React.ReactNode;
+};
+
+type State = {
+  currentHoveredMeasurement: string | undefined;
+};
+
+export class Provider extends React.Component<Props, State> {
+  state: State = {
+    currentHoveredMeasurement: undefined,
+  };
+
+  hoveringMeasurement = (measurementName: string) => {
+    this.setState({
+      currentHoveredMeasurement: measurementName,
+    });
+  };
+
+  notHovering = () => {
+    this.setState({
+      currentHoveredMeasurement: undefined,
+    });
+  };
+
+  render() {
+    const childrenProps = {
+      hoveringMeasurement: this.hoveringMeasurement,
+      notHovering: this.notHovering,
+      currentHoveredMeasurement: this.state.currentHoveredMeasurement,
+    };
+
+    return (
+      <MeasurementsManagerContext.Provider value={childrenProps}>
+        {this.props.children}
+      </MeasurementsManagerContext.Provider>
+    );
+  }
+}
+
+export const Consumer = MeasurementsManagerContext.Consumer;

+ 168 - 0
src/sentry/static/sentry/app/components/events/interfaces/spans/measurementsPanel.tsx

@@ -0,0 +1,168 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import {SentryTransactionEvent} from 'app/types';
+import {defined} from 'app/utils';
+import {
+  WEB_VITAL_ACRONYMS,
+  LONG_WEB_VITAL_NAMES,
+} from 'app/views/performance/transactionVitals/constants';
+import Tooltip from 'app/components/tooltip';
+
+import {
+  getMeasurements,
+  toPercent,
+  getMeasurementBounds,
+  SpanBoundsType,
+  SpanGeneratedBoundsType,
+} from './utils';
+import * as MeasurementsManager from './measurementsManager';
+
+type Props = {
+  event: SentryTransactionEvent;
+  generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
+  dividerPosition: number;
+};
+class MeasurementsPanel extends React.PureComponent<Props> {
+  render() {
+    const {event, generateBounds, dividerPosition} = this.props;
+
+    const measurements = getMeasurements(event);
+
+    return (
+      <Container
+        style={{
+          // the width of this component is shrunk to compensate for half of the width of the divider line
+          width: `calc(${toPercent(1 - dividerPosition)} - 0.5px)`,
+        }}
+      >
+        {Array.from(measurements).map(([timestamp, names]) => {
+          const bounds = getMeasurementBounds(timestamp, generateBounds);
+
+          const shouldDisplay = defined(bounds.left) && defined(bounds.width);
+
+          if (!shouldDisplay) {
+            return null;
+          }
+
+          const hoverMeasurementName = names.join('');
+
+          // generate vertical marker label
+          const acronyms = names.map(name => WEB_VITAL_ACRONYMS[name]);
+          const lastAcronym = acronyms.pop() as string;
+          const label = acronyms.length
+            ? `${acronyms.join(', ')} & ${lastAcronym}`
+            : lastAcronym;
+
+          // generate tooltip labe;l
+          const longNames = names.map(name => LONG_WEB_VITAL_NAMES[name]);
+          const lastName = longNames.pop() as string;
+          const tooltipLabel = longNames.length
+            ? `${longNames.join(', ')} & ${lastName}`
+            : lastName;
+
+          return (
+            <MeasurementsManager.Consumer key={String(timestamp)}>
+              {({hoveringMeasurement, notHovering}) => {
+                return (
+                  <LabelContainer
+                    key={label}
+                    label={label}
+                    tooltipLabel={tooltipLabel}
+                    left={toPercent(bounds.left || 0)}
+                    onMouseLeave={() => {
+                      notHovering();
+                    }}
+                    onMouseOver={() => {
+                      hoveringMeasurement(hoverMeasurementName);
+                    }}
+                  />
+                );
+              }}
+            </MeasurementsManager.Consumer>
+          );
+        })}
+      </Container>
+    );
+  }
+}
+
+const Container = styled('div')`
+  position: relative;
+  overflow: hidden;
+
+  height: 20px;
+`;
+
+const StyledLabelContainer = styled('div')`
+  position: absolute;
+  top: 0;
+  height: 100%;
+  user-select: none;
+`;
+
+const Label = styled('div')`
+  transform: translateX(-50%);
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+`;
+
+export default MeasurementsPanel;
+
+type LabelContainerProps = {
+  left: string;
+  label: string;
+  tooltipLabel: string;
+  onMouseLeave: () => void;
+  onMouseOver: () => void;
+};
+
+type LabelContainerState = {
+  width: number;
+};
+
+class LabelContainer extends React.Component<LabelContainerProps> {
+  state: LabelContainerState = {
+    width: 1,
+  };
+
+  componentDidMount() {
+    const {current} = this.elementDOMRef;
+    if (current) {
+      // eslint-disable-next-line react/no-did-mount-set-state
+      this.setState({
+        width: current.clientWidth,
+      });
+    }
+  }
+
+  elementDOMRef = React.createRef<HTMLDivElement>();
+
+  render() {
+    const {left, onMouseLeave, onMouseOver, label, tooltipLabel} = this.props;
+
+    return (
+      <StyledLabelContainer
+        ref={this.elementDOMRef}
+        style={{
+          left: `clamp(calc(0.5 * ${this.state.width}px), ${left}, calc(100% - 0.5 * ${this.state.width}px))`,
+        }}
+        onMouseLeave={() => {
+          onMouseLeave();
+        }}
+        onMouseOver={() => {
+          onMouseOver();
+        }}
+      >
+        <Label>
+          <Tooltip
+            title={tooltipLabel}
+            position="top"
+            containerDisplayMode="inline-block"
+          >
+            {label}
+          </Tooltip>
+        </Label>
+      </StyledLabelContainer>
+    );
+  }
+}

+ 93 - 12
src/sentry/static/sentry/app/components/events/interfaces/spans/spanBar.tsx

@@ -16,6 +16,7 @@ import {
   toPercent,
   SpanBoundsType,
   SpanGeneratedBoundsType,
+  SpanViewBoundsType,
   getHumanDuration,
   getSpanID,
   getSpanOperation,
@@ -24,6 +25,8 @@ import {
   isOrphanTreeDepth,
   isEventFromBrowserJavaScriptSDK,
   durationlessBrowserOps,
+  getMeasurements,
+  getMeasurementBounds,
 } from './utils';
 import {ParsedTraceType, ProcessedSpanType, TreeDepthType} from './types';
 import {
@@ -31,9 +34,16 @@ import {
   MINIMAP_SPAN_BAR_HEIGHT,
   NUM_OF_SPANS_FIT_IN_MINI_MAP,
 } from './header';
-import {SPAN_ROW_HEIGHT, SpanRow, zIndex, getHatchPattern} from './styles';
+import {
+  SPAN_ROW_HEIGHT,
+  SPAN_ROW_PADDING,
+  SpanRow,
+  zIndex,
+  getHatchPattern,
+} from './styles';
 import * as DividerHandlerManager from './dividerHandlerManager';
 import * as CursorGuideHandler from './cursorGuideHandler';
+import * as MeasurementsManager from './measurementsManager';
 import SpanDetail from './spanDetail';
 
 // TODO: maybe use babel-plugin-preval
@@ -275,12 +285,7 @@ class SpanBar extends React.Component<SpanBarProps, SpanBarState> {
     );
   }
 
-  getBounds(): {
-    warning: undefined | string;
-    left: undefined | number;
-    width: undefined | number;
-    isSpanVisibleInView: boolean;
-  } {
+  getBounds(): SpanViewBoundsType {
     const {event, span, generateBounds} = this.props;
 
     const bounds = generateBounds({
@@ -345,6 +350,56 @@ class SpanBar extends React.Component<SpanBarProps, SpanBarState> {
     }
   }
 
+  renderMeasurements() {
+    const {organization, event, generateBounds} = this.props;
+
+    if (!organization.features.includes('measurements') || this.state.showDetail) {
+      return null;
+    }
+
+    const measurements = getMeasurements(event);
+
+    return (
+      <React.Fragment>
+        {Array.from(measurements).map(([timestamp, names]) => {
+          const bounds = getMeasurementBounds(timestamp, generateBounds);
+
+          const shouldDisplay = defined(bounds.left) && defined(bounds.width);
+
+          if (!shouldDisplay) {
+            return null;
+          }
+
+          const measurementName = names.join('');
+
+          return (
+            <MeasurementsManager.Consumer key={String(timestamp)}>
+              {({hoveringMeasurement, notHovering, currentHoveredMeasurement}) => {
+                return (
+                  <MeasurementMarker
+                    hovering={currentHoveredMeasurement === measurementName}
+                    style={{
+                      left: `clamp(0%, ${toPercent(bounds.left || 0)}, calc(100% - 1px))`,
+                    }}
+                    onMouseEnter={() => {
+                      hoveringMeasurement(measurementName);
+                    }}
+                    onMouseLeave={() => {
+                      notHovering();
+                    }}
+                    onMouseOver={() => {
+                      hoveringMeasurement(measurementName);
+                    }}
+                  />
+                );
+              }}
+            </MeasurementsManager.Consumer>
+          );
+        })}
+      </React.Fragment>
+    );
+  }
+
   renderSpanTreeConnector({hasToggler}: {hasToggler: boolean}) {
     const {
       isLast,
@@ -788,7 +843,7 @@ class SpanBar extends React.Component<SpanBarProps, SpanBarState> {
               spanBarHatch={!!spanBarHatch}
               style={{
                 backgroundColor: spanBarColour,
-                left: toPercent(bounds.left || 0),
+                left: `clamp(0%, ${toPercent(bounds.left || 0)}, calc(100% - 1px))`,
                 width: toPercent(bounds.width || 0),
               }}
             >
@@ -802,6 +857,7 @@ class SpanBar extends React.Component<SpanBarProps, SpanBarState> {
               </DurationPill>
             </SpanBarRectangle>
           )}
+          {this.renderMeasurements()}
           {this.renderCursorGuide()}
         </SpanRowCell>
         {!this.state.showDetail && (
@@ -862,7 +918,6 @@ type SpanRowCellProps = OmitHtmlDivProps<{
 
 export const SpanRowCell = styled('div')<SpanRowCellProps>`
   position: relative;
-  padding: ${space(0.5)} 1px;
   height: 100%;
   overflow: hidden;
   background-color: ${p => getBackgroundColor(p)};
@@ -1101,15 +1156,41 @@ const DurationPill = styled('div')<{
 `;
 
 export const SpanBarRectangle = styled('div')<{spanBarHatch: boolean}>`
-  position: relative;
-  height: 100%;
+  position: absolute;
+  height: ${SPAN_ROW_HEIGHT - 2 * SPAN_ROW_PADDING}px;
+  top: ${SPAN_ROW_PADDING}px;
+  left: 0;
   min-width: 1px;
   user-select: none;
   transition: border-color 0.15s ease-in-out;
-  border-right: 1px solid rgba(0, 0, 0, 0);
   ${p => getHatchPattern(p, '#dedae3', '#f4f2f7')}
 `;
 
+const MeasurementMarker = styled('div')<{hovering: boolean}>`
+  position: absolute;
+  top: 0;
+  height: ${SPAN_ROW_HEIGHT}px;
+  width: 1px;
+  user-select: none;
+  background-color: ${p => p.theme.gray800};
+
+  transition: opacity 125ms ease-in-out;
+  z-index: ${zIndex.dividerLine};
+
+  /* enhanced hit-box */
+  &:after {
+    content: '';
+    z-index: -1;
+    position: absolute;
+    left: -2px;
+    top: 0;
+    width: 9px;
+    height: 100%;
+  }
+
+  opacity: ${({hovering}) => (hovering ? '1' : '0.25')};
+`;
+
 const StyledIconWarning = styled(IconWarning)`
   margin-left: ${space(0.25)};
   margin-bottom: ${space(0.25)};

+ 73 - 8
src/sentry/static/sentry/app/components/events/interfaces/spans/spanTree.tsx

@@ -26,6 +26,7 @@ import {
   isGapSpan,
   isOrphanSpan,
   isEventFromBrowserJavaScriptSDK,
+  toPercent,
 } from './utils';
 import {DragManagerChildrenProps} from './dragManager';
 import SpanGroup from './spanGroup';
@@ -33,6 +34,8 @@ import {SpanRowMessage} from './styles';
 import * as DividerHandlerManager from './dividerHandlerManager';
 import {FilterSpans} from './traceView';
 import {ActiveOperationFilter} from './filter';
+import MeasurementsPanel from './measurementsPanel';
+import * as MeasurementsManager from './measurementsManager';
 
 type RenderedSpanTree = {
   spanTree: JSX.Element | null;
@@ -331,17 +334,23 @@ class SpanTree extends React.Component<PropType> {
     };
   };
 
-  renderRootSpan = (): RenderedSpanTree => {
+  generateBounds() {
     const {dragProps, trace} = this.props;
 
-    const rootSpan: RawSpanType = generateRootSpan(trace);
-
-    const generateBounds = boundsGenerator({
+    return boundsGenerator({
       traceStartTimestamp: trace.traceStartTimestamp,
       traceEndTimestamp: trace.traceEndTimestamp,
       viewStart: dragProps.viewWindowStart,
       viewEnd: dragProps.viewWindowEnd,
     });
+  }
+
+  renderRootSpan = (): RenderedSpanTree => {
+    const {trace} = this.props;
+
+    const rootSpan: RawSpanType = generateRootSpan(trace);
+
+    const generateBounds = this.generateBounds();
 
     return this.renderSpan({
       isRoot: true,
@@ -358,6 +367,48 @@ class SpanTree extends React.Component<PropType> {
     });
   };
 
+  renderSecondaryPanel() {
+    const {organization, event} = this.props;
+
+    if (!organization.features.includes('measurements')) {
+      return null;
+    }
+
+    const hasMeasurements = Object.keys(event.measurements ?? {}).length > 0;
+
+    // only display the secondary header if there are any measurements
+    if (!hasMeasurements) {
+      return null;
+    }
+
+    return (
+      <DividerHandlerManager.Consumer>
+        {(
+          dividerHandlerChildrenProps: DividerHandlerManager.DividerHandlerManagerChildrenProps
+        ) => {
+          const {dividerPosition} = dividerHandlerChildrenProps;
+
+          return (
+            <SecondaryHeader>
+              <div
+                style={{
+                  // the width of this component is shrunk to compensate for half of the width of the divider line
+                  width: `calc(${toPercent(dividerPosition)} - 0.5px)`,
+                }}
+              />
+              <DividerSpacer />
+              <MeasurementsPanel
+                event={event}
+                generateBounds={this.generateBounds()}
+                dividerPosition={dividerPosition}
+              />
+            </SecondaryHeader>
+          );
+        }}
+      </DividerHandlerManager.Consumer>
+    );
+  }
+
   render() {
     const {
       spanTree,
@@ -374,10 +425,13 @@ class SpanTree extends React.Component<PropType> {
 
     return (
       <DividerHandlerManager.Provider interactiveLayerRef={this.traceViewRef}>
-        <TraceViewContainer ref={this.traceViewRef}>
-          {spanTree}
-          {infoMessage}
-        </TraceViewContainer>
+        <MeasurementsManager.Provider>
+          {this.renderSecondaryPanel()}
+          <TraceViewContainer ref={this.traceViewRef}>
+            {spanTree}
+            {infoMessage}
+          </TraceViewContainer>
+        </MeasurementsManager.Provider>
       </DividerHandlerManager.Provider>
     );
   }
@@ -389,4 +443,15 @@ const TraceViewContainer = styled('div')`
   border-bottom-right-radius: 3px;
 `;
 
+const SecondaryHeader = styled('div')`
+  background-color: ${p => p.theme.gray100};
+  display: flex;
+
+  border-bottom: 1px solid ${p => p.theme.gray400};
+`;
+
+const DividerSpacer = styled('div')`
+  width: 1px;
+`;
+
 export default SpanTree;

+ 1 - 0
src/sentry/static/sentry/app/components/events/interfaces/spans/styles.tsx

@@ -11,6 +11,7 @@ export const zIndex = {
 };
 
 export const SPAN_ROW_HEIGHT = 24;
+export const SPAN_ROW_PADDING = 4;
 
 type SpanRowProps = {
   visible?: boolean;

+ 89 - 0
src/sentry/static/sentry/app/components/events/interfaces/spans/utils.tsx

@@ -120,6 +120,13 @@ export type SpanGeneratedBoundsType =
       isSpanVisibleInView: boolean;
     };
 
+export type SpanViewBoundsType = {
+  warning: undefined | string;
+  left: undefined | number;
+  width: undefined | number;
+  isSpanVisibleInView: boolean;
+};
+
 const normalizeTimestamps = (spanBounds: SpanBoundsType): SpanBoundsType => {
   const {startTimestamp, endTimestamp} = spanBounds;
 
@@ -609,3 +616,85 @@ export function isEventFromBrowserJavaScriptSDK(event: SentryTransactionEvent):
 // PerformanceMark: Duration is 0 as per https://developer.mozilla.org/en-US/docs/Web/API/PerformanceMark
 // PerformancePaintTiming: Duration is 0 as per https://developer.mozilla.org/en-US/docs/Web/API/PerformancePaintTiming
 export const durationlessBrowserOps = ['mark', 'paint'];
+
+export function getMeasurements(event: SentryTransactionEvent): Map<number, string[]> {
+  if (!event.measurements) {
+    return new Map();
+  }
+
+  const measurements = Object.keys(event.measurements)
+    .filter(name => name.startsWith('mark.'))
+    .map(name => {
+      return {
+        name,
+        timestamp: event.measurements![name].value,
+      };
+    });
+
+  const mergedMeasurements = new Map<number, string[]>();
+
+  measurements.forEach(measurement => {
+    const name = measurement.name.slice('mark.'.length);
+
+    if (mergedMeasurements.has(measurement.timestamp)) {
+      const names = mergedMeasurements.get(measurement.timestamp) as string[];
+      names.push(name);
+      mergedMeasurements.set(measurement.timestamp, names);
+      return;
+    }
+
+    mergedMeasurements.set(measurement.timestamp, [name]);
+  });
+
+  return mergedMeasurements;
+}
+
+export function getMeasurementBounds(
+  timestamp: number,
+  generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType
+): SpanViewBoundsType {
+  const bounds = generateBounds({
+    startTimestamp: timestamp,
+    endTimestamp: timestamp,
+  });
+
+  switch (bounds.type) {
+    case 'TRACE_TIMESTAMPS_EQUAL':
+    case 'INVALID_VIEW_WINDOW': {
+      return {
+        warning: undefined,
+        left: undefined,
+        width: undefined,
+        isSpanVisibleInView: bounds.isSpanVisibleInView,
+      };
+    }
+    case 'TIMESTAMPS_EQUAL': {
+      return {
+        warning: undefined,
+        left: bounds.start,
+        width: 0.00001,
+        isSpanVisibleInView: bounds.isSpanVisibleInView,
+      };
+    }
+    case 'TIMESTAMPS_REVERSED': {
+      return {
+        warning: undefined,
+        left: bounds.start,
+        width: bounds.end - bounds.start,
+        isSpanVisibleInView: bounds.isSpanVisibleInView,
+      };
+    }
+    case 'TIMESTAMPS_STABLE': {
+      return {
+        warning: void 0,
+        left: bounds.start,
+        width: bounds.end - bounds.start,
+        isSpanVisibleInView: bounds.isSpanVisibleInView,
+      };
+    }
+    default: {
+      const _exhaustiveCheck: never = bounds;
+      return _exhaustiveCheck;
+    }
+  }
+}

+ 6 - 10
src/sentry/static/sentry/app/components/events/realUserMonitoring.tsx

@@ -9,16 +9,12 @@ import {Panel} from 'app/components/panels';
 import space from 'app/styles/space';
 import Tooltip from 'app/components/tooltip';
 import {IconFire} from 'app/icons';
-import {WEB_VITAL_DETAILS} from 'app/views/performance/transactionVitals/constants';
+import {
+  WEB_VITAL_DETAILS,
+  LONG_WEB_VITAL_NAMES,
+} from 'app/views/performance/transactionVitals/constants';
 import {formattedValue} from 'app/utils/measurements/index';
 
-// translate known short form names into their long forms
-const LONG_MEASUREMENT_NAMES = Object.fromEntries(
-  Object.values(WEB_VITAL_DETAILS).map(value => {
-    return [value.slug, value.name];
-  })
-);
-
 type Props = {
   organization: Organization;
   event: Event;
@@ -58,14 +54,14 @@ class RealUserMonitoring extends React.Component<Props> {
       const currentValue = formattedValue(record, value);
       const thresholdValue = formattedValue(record, record?.failureThreshold ?? 0);
 
-      if (!LONG_MEASUREMENT_NAMES.hasOwnProperty(name)) {
+      if (!LONG_WEB_VITAL_NAMES.hasOwnProperty(name)) {
         return null;
       }
 
       return (
         <div key={name}>
           <StyledPanel failedThreshold={failedThreshold}>
-            <Name>{LONG_MEASUREMENT_NAMES[name] ?? name}</Name>
+            <Name>{LONG_WEB_VITAL_NAMES[name] ?? name}</Name>
             <ValueRow>
               {failedThreshold ? (
                 <WarningIconContainer size="sm">

+ 4 - 4
src/sentry/static/sentry/app/views/performance/compare/spanBar.tsx

@@ -8,6 +8,7 @@ import Count from 'app/components/count';
 import {TreeDepthType} from 'app/components/events/interfaces/spans/types';
 import {
   SPAN_ROW_HEIGHT,
+  SPAN_ROW_PADDING,
   SpanRow,
   getHatchPattern,
 } from 'app/components/events/interfaces/spans/styles';
@@ -272,9 +273,7 @@ class SpanBar extends React.Component<Props, State> {
       if (!width) {
         return undefined;
       }
-
-      // there is a "padding" of 1px on either side of the span rectangle
-      return `max(1px, calc(${width} - 2px))`;
+      return `max(1px, ${width})`;
     }
 
     switch (span.comparisonResult) {
@@ -526,7 +525,8 @@ const ComparisonLabel = styled('div')`
   position: absolute;
   user-select: none;
   right: ${space(1)};
-  line-height: 16px;
+  line-height: ${SPAN_ROW_HEIGHT - 2 * SPAN_ROW_PADDING}px;
+  top: ${SPAN_ROW_PADDING}px;
   font-size: ${p => p.theme.fontSizeExtraSmall};
 `;
 

+ 7 - 1
src/sentry/static/sentry/app/views/performance/compare/styles.tsx

@@ -1,8 +1,14 @@
 import styled from '@emotion/styled';
 
+import {
+  SPAN_ROW_HEIGHT,
+  SPAN_ROW_PADDING,
+} from 'app/components/events/interfaces/spans/styles';
+
 export const SpanBarRectangle = styled('div')`
   position: relative;
-  height: 100%;
+  height: ${SPAN_ROW_HEIGHT - 2 * SPAN_ROW_PADDING}px;
+  top: ${SPAN_ROW_PADDING}px;
   min-width: 1px;
   user-select: none;
   transition: border-color 0.15s ease-in-out;

+ 20 - 0
src/sentry/static/sentry/app/views/performance/transactionVitals/constants.tsx

@@ -13,6 +13,7 @@ export const WEB_VITAL_DETAILS: Record<WebVital, Vital> = {
   [WebVital.FP]: {
     slug: 'fp',
     name: t('First Paint'),
+    acronym: 'FP',
     description: t(
       'Render time of the first pixel loaded in the viewport (may overlap with FCP).'
     ),
@@ -23,6 +24,7 @@ export const WEB_VITAL_DETAILS: Record<WebVital, Vital> = {
   [WebVital.FCP]: {
     slug: 'fcp',
     name: t('First Contentful Paint'),
+    acronym: 'FCP',
     description: t(
       'Render time of the first image, text or other DOM node in the viewport.'
     ),
@@ -33,6 +35,7 @@ export const WEB_VITAL_DETAILS: Record<WebVital, Vital> = {
   [WebVital.LCP]: {
     slug: 'lcp',
     name: t('Largest Contentful Paint'),
+    acronym: 'LCP',
     description: t(
       'Render time of the largest image, text or other DOM node in the viewport.'
     ),
@@ -43,6 +46,7 @@ export const WEB_VITAL_DETAILS: Record<WebVital, Vital> = {
   [WebVital.FID]: {
     slug: 'fid',
     name: t('First Input Delay'),
+    acronym: 'FID',
     description: t(
       'Response time of the browser to a user interaction (clicking, tapping, etc).'
     ),
@@ -53,6 +57,7 @@ export const WEB_VITAL_DETAILS: Record<WebVital, Vital> = {
   [WebVital.CLS]: {
     slug: 'cls',
     name: t('Cumulative Layout Shift'),
+    acronym: 'CLS',
     description: t(
       'Sum of layout shift scores that measure the visual stability of the page.'
     ),
@@ -62,6 +67,7 @@ export const WEB_VITAL_DETAILS: Record<WebVital, Vital> = {
   [WebVital.TTFB]: {
     slug: 'ttfb',
     name: t('Time to First Byte'),
+    acronym: 'TTFB',
     description: t(
       "The time that it takes for a user's browser to receive the first byte of page content."
     ),
@@ -71,6 +77,7 @@ export const WEB_VITAL_DETAILS: Record<WebVital, Vital> = {
   [WebVital.RequestTime]: {
     slug: 'ttfb.requesttime',
     name: t('Request Time'),
+    acronym: 'RT',
     description: t(
       'Captures the time spent making the request and receiving the first byte of the response.'
     ),
@@ -79,6 +86,19 @@ export const WEB_VITAL_DETAILS: Record<WebVital, Vital> = {
   },
 };
 
+// translate known short form names into their long forms
+export const LONG_WEB_VITAL_NAMES = Object.fromEntries(
+  Object.values(WEB_VITAL_DETAILS).map(value => {
+    return [value.slug, value.name];
+  })
+);
+
+export const WEB_VITAL_ACRONYMS = Object.fromEntries(
+  Object.values(WEB_VITAL_DETAILS).map(value => {
+    return [value.slug, value.acronym];
+  })
+);
+
 export const FILTER_OPTIONS: SelectValue<string>[] = [
   {label: t('Exclude Outliers'), value: 'exclude_outliers'},
   {label: t('View All'), value: 'all'},

Некоторые файлы не были показаны из-за большого количества измененных файлов