123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- import {useEffect} from 'react';
- import styled from '@emotion/styled';
- import maxBy from 'lodash/maxBy';
- 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 trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
- import useApi from 'sentry/utils/useApi';
- import useOrganization from 'sentry/utils/useOrganization';
- import {
- INDUSTRY_STANDARDS,
- MIN_VITAL_COUNT_FOR_DISPLAY,
- SENTRY_CUSTOMERS,
- } from './constants';
- import {VitalsKey, VitalsResult} from './types';
- import {getCountParameterName, 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';
- }
- export default function VitalsAlertCTA({data, dismissAlert}: Props) {
- const organization = useOrganization();
- // persist to dismiss alert
- const api = useApi({persistInFlight: true});
- const vital = getWorstVital(data);
- const userVitalValue = vital ? data[vital] : 0;
- const userVitalCount = vital ? data[getCountParameterName(vital)] : 0;
- const sentryDiff = vital ? getRelativeDiff(userVitalValue, SENTRY_CUSTOMERS[vital]) : 0;
- const industryDiff = vital
- ? getRelativeDiff(userVitalValue, INDUSTRY_STANDARDS[vital])
- : 0;
- // must have the global-views
- // and either be an owner/manager or the org allows open membership
- const canSeeAllProjects =
- organization.features.includes('global-views') &&
- (['owner', 'manager'].includes(organization.orgRole || '') ||
- organization.features.includes('open-membership'));
- // find the project that has the most events of the same type
- const bestProjectData = vital
- ? maxBy(data.projectData, item => {
- const parameterName = getCountParameterName(vital);
- return item[parameterName];
- })
- : null;
- const industryDiffPercentage = getPercentage(industryDiff);
- const sentryDiffPercentage = getPercentage(sentryDiff);
- const vitalsType = vital ? getVitalsType(vital) : null;
- const getAnalyticsParams = () => {
- // shouldn't call any analytics function if this is missing
- // but this check helps us with typing
- if (!vital || !vitalsType) {
- throw new Error('Cannot get analytics params without vital');
- }
- return {
- vital,
- vitals_type: vitalsType,
- organization,
- user_vital_value: userVitalValue,
- user_vital_count: userVitalCount,
- sentry_diff: sentryDiff,
- industry_diff: industryDiff,
- can_see_all_projects: canSeeAllProjects,
- } as const;
- };
- const showVitalsAlert = () => {
- // check if we have the vital and the count is at least at the min
- if (!vital || userVitalCount < MIN_VITAL_COUNT_FOR_DISPLAY) {
- return false;
- }
- // if worst vital is better than Sentry users, we shouldn't show this alert
- if (sentryDiff < 0) {
- return false;
- }
- // must either be able to see all proejcts or we can pick a specific project
- return canSeeAllProjects || bestProjectData;
- };
- useEffect(() => {
- if (!vital || !showVitalsAlert()) {
- return;
- }
- trackAdvancedAnalyticsEvent('vitals_alert.displayed', getAnalyticsParams());
- });
- if (!vital || !showVitalsAlert()) {
- return null;
- }
- function getVitalWithLink() {
- if (!vital) {
- throw new Error('Cannot get vitals link without vital');
- }
- const url = new URL(getDocsLink(vital));
- url.searchParams.append('referrer', 'vitals-alert');
- return (
- <ExternalLink
- onClick={() => {
- trackAdvancedAnalyticsEvent('vitals_alert.clicked_docs', getAnalyticsParams());
- }}
- href={url.toString()}
- >
- {vital}
- </ExternalLink>
- );
- }
- const getText = () => {
- const args = {
- vital: getVitalWithLink(),
- 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 = () => {
- const performanceRoot = `/organizations/${organization.slug}/performance`;
- const baseParams: Record<string, string | undefined> = {
- statsPeriod: '7d',
- referrer: `vitals-alert-${vital.toLowerCase()}`,
- project: canSeeAllProjects ? '-1' : bestProjectData?.projectId,
- };
- // 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',
- getAnalyticsParams()
- );
- }}
- >
- {buttonText}
- </Button>
- <Button
- icon={<IconClose />}
- onClick={() => {
- dismissAndPromptUpdate();
- trackAdvancedAnalyticsEvent('vitals_alert.dismissed', getAnalyticsParams());
- }}
- size="xs"
- priority="link"
- title={t('Dismiss')}
- aria-label={t('Dismiss')}
- />
- </NotificationBarButtons>
- </NotificationBar>
- );
- }
- const NotificationBarButtons = styled(ButtonBar)`
- margin-left: auto;
- white-space: nowrap;
- `;
|