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

feat(vitals): add vitals alert cta component (#36533)

This PR adds a new component that we will use to render a vitals alert
Stephen Cefali 2 лет назад
Родитель
Сommit
a06dd5b215

+ 23 - 0
static/app/components/alerts/notificationBar.tsx

@@ -0,0 +1,23 @@
+import styled from '@emotion/styled';
+
+import {IconInfo} from 'sentry/icons';
+import space from 'sentry/styles/space';
+
+export const StyledNotificationBarIconInfo = styled(IconInfo)`
+  margin-right: ${space(1)};
+  color: ${p => p.theme.alert.info.iconColor};
+`;
+
+export const NotificationBar = styled('div')`
+  display: flex;
+  align-items: center;
+  color: ${p => p.theme.textColor};
+  background-color: ${p => p.theme.alert.info.backgroundLight};
+  border-bottom: 1px solid ${p => p.theme.alert.info.border};
+  padding: ${space(1.5)};
+  font-size: 14px;
+  line-height: normal;
+  ${StyledNotificationBarIconInfo} {
+    color: ${p => p.theme.alert.info.iconColor};
+  }
+`;

+ 15 - 0
static/app/components/performance/vitalsAlert/constants.tsx

@@ -0,0 +1,15 @@
+import {VitalsKey} from './types';
+
+export const INDUSTRY_STANDARDS: Record<VitalsKey, number> = {
+  LCP: 2500,
+  FCP: 1800,
+  appStartCold: 5000,
+  appStartWarm: 2000,
+};
+
+export const SENTRY_CUSTOMERS: Record<VitalsKey, number> = {
+  LCP: 948,
+  FCP: 760,
+  appStartCold: 4000, // TODO: Update
+  appStartWarm: 1500, // TODO: Update
+};

+ 8 - 0
static/app/components/performance/vitalsAlert/types.tsx

@@ -0,0 +1,8 @@
+export interface VitalsResult {
+  FCP: number | null;
+  LCP: number | null;
+  appStartCold: number | null;
+  appStartWarm: number | null;
+}
+
+export type VitalsKey = keyof VitalsResult;

+ 28 - 0
static/app/components/performance/vitalsAlert/utils.tsx

@@ -0,0 +1,28 @@
+import {SENTRY_CUSTOMERS} from './constants';
+import {VitalsKey, VitalsResult} from './types';
+
+export function getRelativeDiff(value: number, benchmark: number) {
+  // get the difference and divide it by our benchmark
+  return (value - benchmark) / benchmark;
+}
+
+export function getWorstVital(data: VitalsResult) {
+  let worstField: VitalsKey | null = null;
+  let worstDecrease = 0;
+  let field: VitalsKey;
+  for (field in data) {
+    const value = data[field];
+    if (value) {
+      const benchmark = SENTRY_CUSTOMERS[field];
+      const relativeDiff = getRelativeDiff(value, benchmark);
+      if (relativeDiff > worstDecrease) {
+        worstDecrease = relativeDiff;
+        worstField = field;
+      }
+    }
+  }
+  if (worstDecrease > 0) {
+    return worstField;
+  }
+  return null;
+}

+ 185 - 0
static/app/components/performance/vitalsAlert/vitalsAlertCTA.tsx

@@ -0,0 +1,185 @@
+import styled from '@emotion/styled';
+
+import {promptsUpdate} from 'sentry/actionCreators/prompts';
+import {
+  NotificationBar,
+  StyledNotificationBarIconInfo,
+} from 'sentry/components/alerts/notificationBar';
+import Button from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import ExternalLink from 'sentry/components/links/externalLink';
+import {IconClose} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import {Organization} from 'sentry/types';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {INDUSTRY_STANDARDS, SENTRY_CUSTOMERS} from './constants';
+import {VitalsKey, VitalsResult} from './types';
+import {getRelativeDiff, getWorstVital} from './utils';
+
+interface Props {
+  data: VitalsResult;
+  dismissAlert: () => void;
+}
+
+function getPercentage(diff: number) {
+  return <strong>{Math.abs(Math.round(diff * 100))}%</strong>;
+}
+
+function getDocsLink(vital: VitalsKey) {
+  switch (vital) {
+    case 'FCP':
+      return 'https://docs.sentry.io/product/performance/web-vitals/#first-contentful-paint-fcp';
+    case 'LCP':
+      return 'https://docs.sentry.io/product/performance/web-vitals/#largest-contentful-paint-lcp';
+    default:
+      // just one link for mobile vitals
+      return 'https://docs.sentry.io/product/performance/mobile-vitals/#app-start';
+  }
+}
+
+function getVitalsType(vital: VitalsKey) {
+  return ['FCP', 'LCP'].includes(vital) ? 'web' : 'mobile';
+}
+
+function getVitalWithLink({
+  vital,
+  organization,
+}: {
+  organization: Organization;
+  vital: VitalsKey;
+}) {
+  const url = new URL(getDocsLink(vital));
+  url.searchParams.append('referrer', 'vitals-alert');
+  return (
+    <ExternalLink
+      onClick={() => {
+        trackAdvancedAnalyticsEvent('vitals_alert.clicked_docs', {vital, organization});
+      }}
+      href={url.toString()}
+    >
+      {vital}
+    </ExternalLink>
+  );
+}
+
+export default function VitalsAlertCTA({data, dismissAlert}: Props) {
+  const organization = useOrganization();
+  // persist to dismiss alert
+  const api = useApi({persistInFlight: true});
+  const vital = getWorstVital(data);
+  if (!vital) {
+    return null;
+  }
+  const ourValue = data[vital];
+  if (!ourValue) {
+    return null;
+  }
+  const sentryDiff = getRelativeDiff(ourValue, SENTRY_CUSTOMERS[vital]);
+  const industryDiff = getRelativeDiff(ourValue, INDUSTRY_STANDARDS[vital]);
+
+  // if worst vital is better than Sentry users, we shouldn't show this alert
+  if (sentryDiff < 0) {
+    return null;
+  }
+
+  const industryDiffPercentage = getPercentage(industryDiff);
+  const sentryDiffPercentage = getPercentage(sentryDiff);
+  const vitalsType = getVitalsType(vital);
+
+  const getText = () => {
+    const args = {
+      vital: getVitalWithLink({vital, organization}),
+      industryDiffPercentage,
+      sentryDiffPercentage,
+    };
+    // different language if we are better than the industry average
+    if (industryDiff < 0) {
+      return tct(
+        "Your organization's [vital] is [industryDiffPercentage] lower than the industry standard, but [sentryDiffPercentage] higher than typical Sentry users.",
+        args
+      );
+    }
+    return tct(
+      "Your organization's [vital] is [industryDiffPercentage] higher than the industry standard and [sentryDiffPercentage] higher than typical Sentry users.",
+      args
+    );
+  };
+
+  const getVitalsURL = () => {
+    // TODO: add logic for project selection
+    const performanceRoot = `/organizations/${organization.slug}/performance`;
+    const baseParams = {
+      statsPeriod: '7d',
+      referrer: `vitals-alert-${vital.toLowerCase()}`,
+    };
+    // we can land on a specific web vital
+    if (vitalsType === 'web') {
+      const searchParams = new URLSearchParams({
+        ...baseParams,
+        vitalName: `measurements.${vital.toLowerCase()}`,
+      });
+      return `${performanceRoot}/vitaldetail/?${searchParams}`;
+    }
+    // otherwise it's just the mobile vital screen
+    const searchParams = new URLSearchParams({
+      ...baseParams,
+      landingDisplay: 'mobile',
+    });
+    return `${performanceRoot}/?${searchParams}`;
+  };
+
+  const buttonText = vitalsType === 'web' ? t('See Web Vitals') : t('See Mobile Vitals');
+  const dismissAndPromptUpdate = () => {
+    promptsUpdate(api, {
+      organizationId: organization?.id,
+      feature: 'vitals_alert',
+      status: 'dismissed',
+    });
+    dismissAlert();
+  };
+
+  return (
+    <NotificationBar>
+      <StyledNotificationBarIconInfo />
+      {getText()}
+      <NotificationBarButtons gap={1}>
+        <Button
+          to={getVitalsURL()}
+          size="xs"
+          onClick={() => {
+            dismissAndPromptUpdate();
+            trackAdvancedAnalyticsEvent('vitals_alert.clicked_see_vitals', {
+              vital,
+              organization,
+            });
+          }}
+        >
+          {buttonText}
+        </Button>
+
+        <Button
+          icon={<IconClose />}
+          onClick={() => {
+            dismissAndPromptUpdate();
+            trackAdvancedAnalyticsEvent('vitals_alert.dismissed', {
+              vital,
+              organization,
+            });
+          }}
+          size="xs"
+          priority="link"
+          title={t('Dismiss')}
+          aria-label={t('Dismiss')}
+        />
+      </NotificationBarButtons>
+    </NotificationBar>
+  );
+}
+
+const NotificationBarButtons = styled(ButtonBar)`
+  margin-left: auto;
+  white-space: nowrap;
+`;

+ 10 - 0
static/app/utils/analytics/growthAnalyticsEvents.tsx

@@ -46,6 +46,10 @@ type SampleEvent = {
   source: string;
 };
 
+type VitalsAlert = {
+  vital: string;
+};
+
 // define the event key to payload mappings
 export type GrowthEventParameters = {
   'growth.clicked_enter_sandbox': {
@@ -125,6 +129,9 @@ export type GrowthEventParameters = {
   'sdk_updates.clicked': {};
   'sdk_updates.seen': {};
   'sdk_updates.snoozed': {};
+  'vitals_alert.clicked_docs': VitalsAlert;
+  'vitals_alert.clicked_see_vitals': VitalsAlert;
+  'vitals_alert.dismissed': VitalsAlert;
 };
 
 type GrowthAnalyticsKey = keyof GrowthEventParameters;
@@ -186,4 +193,7 @@ export const growthEventMap: Record<GrowthAnalyticsKey, string | null> = {
   'sample_event.button_viewed': null, // high-volume event
   'sample_event.created': 'Sample Event Created',
   'sample_event.failed': 'Sample Event Failed',
+  'vitals_alert.clicked_see_vitals': 'Vitals Alert: Clicked See Vitals',
+  'vitals_alert.dismissed': 'Vitals Alert: Dismissed',
+  'vitals_alert.clicked_docs': 'Vitals Alert: Clicked Docs',
 };