Browse Source

feat(metrics): Default custom metric + dismiss empty state (#70936)

Matej Minar 10 months ago
parent
commit
c26e3dc272

+ 2 - 0
static/app/utils/analytics/ddmAnalyticsEvents.tsx

@@ -18,6 +18,7 @@ export type DDMEventParameters = {
     target: 'event-id' | 'description' | 'trace-id' | 'profile';
   };
   'ddm.set-default-query': {};
+  'ddm.view_performance_metrics': {};
   'ddm.widget.add': {
     type: 'query' | 'equation';
   };
@@ -38,6 +39,7 @@ export const ddmEventMap: Record<keyof DDMEventParameters, string> = {
   'ddm.remove-default-query': 'DDM: Remove Default Query',
   'ddm.set-default-query': 'DDM: Set Default Query',
   'ddm.open-onboarding': 'DDM: Open Onboarding',
+  'ddm.view_performance_metrics': 'DDM: View Performance Metrics',
   'ddm.widget.add': 'DDM: Widget Added',
   'ddm.widget.sort': 'DDM: Group By Sort Changed',
   'ddm.widget.duplicate': 'DDM: Widget Duplicated',

+ 7 - 0
static/app/utils/metrics/constants.tsx

@@ -57,3 +57,10 @@ export const emptyMetricsFormulaWidget: MetricsEquationWidget = {
   isHidden: false,
   overlays: [MetricChartOverlayType.SAMPLES],
 };
+
+export const DEFAULT_AGGREGATES = {
+  c: 'sum',
+  d: 'avg',
+  s: 'count_unique',
+  g: 'avg',
+};

+ 23 - 1
static/app/utils/metrics/mri.spec.tsx

@@ -1,6 +1,13 @@
 import type {MetricType, MRI} from 'sentry/types';
 import type {ParsedMRI, UseCase} from 'sentry/types/metrics';
-import {getUseCaseFromMRI, parseField, parseMRI, toMRI} from 'sentry/utils/metrics/mri';
+import {DEFAULT_AGGREGATES} from 'sentry/utils/metrics/constants';
+import {
+  defaultAggregateForMRI,
+  getUseCaseFromMRI,
+  parseField,
+  parseMRI,
+  toMRI,
+} from 'sentry/utils/metrics/mri';
 
 describe('parseMRI', () => {
   it('should handle falsy values', () => {
@@ -205,3 +212,18 @@ describe('toMRI', () => {
     }
   );
 });
+
+describe('defaultAggregateForMRI', () => {
+  it.each(['c', 'd', 'g', 's'])(
+    'should give default aggregate - metric type %s',
+    metricType => {
+      const mri = `${metricType as MetricType}:custom/xyz@test` as MRI;
+
+      expect(defaultAggregateForMRI(mri)).toBe(DEFAULT_AGGREGATES[metricType]);
+    }
+  );
+
+  it('should fallback to sum', () => {
+    expect(defaultAggregateForMRI('b:roken/MRI@none' as MRI)).toBe('sum');
+  });
+});

+ 13 - 0
static/app/utils/metrics/mri.tsx

@@ -1,6 +1,7 @@
 import {t} from 'sentry/locale';
 import type {MetricType, MRI, ParsedMRI, UseCase} from 'sentry/types/metrics';
 import {parseFunction} from 'sentry/utils/discover/fields';
+import {DEFAULT_AGGREGATES} from 'sentry/utils/metrics/constants';
 
 export const DEFAULT_MRI: MRI = 'c:custom/sentry_metric@none';
 // This is a workaround as the alert builder requires a valid aggregate to be set
@@ -111,3 +112,15 @@ export function formatMRIField(aggregate: string) {
 
   return `${parsed.op}(${formatMRI(parsed.mri)})`;
 }
+
+export function defaultAggregateForMRI(mri: MRI) {
+  const parsedMRI = parseMRI(mri);
+
+  const fallbackAggregate = 'sum';
+
+  if (!parsedMRI) {
+    return fallbackAggregate;
+  }
+
+  return DEFAULT_AGGREGATES[parsedMRI.type] || fallbackAggregate;
+}

+ 38 - 17
static/app/views/metrics/context.tsx

@@ -10,6 +10,7 @@ import * as Sentry from '@sentry/react';
 import isEqual from 'lodash/isEqual';
 
 import type {Field} from 'sentry/components/metrics/metricSamplesTable';
+import type {MRI} from 'sentry/types';
 import {useInstantRef, useUpdateQuery} from 'sentry/utils/metrics';
 import {
   emptyMetricsFormulaWidget,
@@ -17,14 +18,15 @@ import {
   NO_QUERY_ID,
 } from 'sentry/utils/metrics/constants';
 import {MetricExpressionType, type MetricsWidget} from 'sentry/utils/metrics/types';
+import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
 import type {MetricsSamplesResults} from 'sentry/utils/metrics/useMetricsSamples';
 import {decodeInteger, decodeScalar} from 'sentry/utils/queryString';
 import useLocationQuery from 'sentry/utils/url/useLocationQuery';
 import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+import usePageFilters from 'sentry/utils/usePageFilters';
 import useRouter from 'sentry/utils/useRouter';
 import type {FocusAreaSelection} from 'sentry/views/metrics/chart/types';
 import {parseMetricWidgetsQueryParam} from 'sentry/views/metrics/utils/parseMetricWidgetsQueryParam';
-import {useSelectedProjects} from 'sentry/views/metrics/utils/useSelectedProjects';
 import {useStructuralSharing} from 'sentry/views/metrics/utils/useStructuralSharing';
 
 export type FocusAreaProps = {
@@ -38,8 +40,10 @@ interface MetricsContextValue {
   addWidget: (type?: MetricExpressionType) => void;
   duplicateWidget: (index: number) => void;
   focusArea: FocusAreaProps;
-  hasMetrics: boolean;
+  hasCustomMetrics: boolean;
+  hasPerformanceMetrics: boolean;
   isDefaultQuery: boolean;
+  isHasMetricsLoading: boolean;
   isMultiChartMode: boolean;
   removeWidget: (index: number) => void;
   selectedWidgetIndex: number;
@@ -62,10 +66,12 @@ export const MetricsContext = createContext<MetricsContextValue>({
   addWidget: () => {},
   duplicateWidget: () => {},
   focusArea: {},
-  hasMetrics: false,
+  hasCustomMetrics: false,
+  hasPerformanceMetrics: false,
   highlightedSampleId: undefined,
   isDefaultQuery: false,
   isMultiChartMode: false,
+  isHasMetricsLoading: true,
   metricsSamples: [],
   removeWidget: () => {},
   selectedWidgetIndex: 0,
@@ -84,12 +90,15 @@ export function useMetricsContext() {
   return useContext(MetricsContext);
 }
 
-export function useMetricWidgets() {
+export function useMetricWidgets(mri: MRI) {
   const {widgets: urlWidgets} = useLocationQuery({fields: {widgets: decodeScalar}});
   const updateQuery = useUpdateQuery();
 
   const widgets = useStructuralSharing(
-    useMemo<MetricsWidget[]>(() => parseMetricWidgetsQueryParam(urlWidgets), [urlWidgets])
+    useMemo<MetricsWidget[]>(
+      () => parseMetricWidgetsQueryParam(urlWidgets, mri),
+      [urlWidgets, mri]
+    )
   );
 
   // We want to have it as a ref, so that we can use it in the setWidget callback
@@ -210,13 +219,22 @@ export function MetricsContextProvider({children}: {children: React.ReactNode})
   const router = useRouter();
   const updateQuery = useUpdateQuery();
   const {multiChartMode} = useLocationQuery({fields: {multiChartMode: decodeInteger}});
+  const pageFilters = usePageFilters();
+  const {data: metaCustom, isLoading: isMetaCustomLoading} = useMetricsMeta(
+    pageFilters.selection,
+    ['custom']
+  );
+  const {data: metaPerformance, isLoading: isMetaPerformanceLoading} = useMetricsMeta(
+    pageFilters.selection,
+    ['transactions', 'spans']
+  );
   const isMultiChartMode = multiChartMode === 1;
 
   const {setDefaultQuery, isDefaultQuery} = useDefaultQuery();
 
   const [selectedWidgetIndex, setSelectedWidgetIndex] = useState(0);
   const {widgets, updateWidget, addWidget, removeWidget, duplicateWidget, setWidgets} =
-    useMetricWidgets();
+    useMetricWidgets(metaCustom[0]?.mri);
 
   const [metricsSamples, setMetricsSamples] = useState<
     MetricsSamplesResults<Field>['data'] | undefined
@@ -224,15 +242,13 @@ export function MetricsContextProvider({children}: {children: React.ReactNode})
 
   const [highlightedSampleId, setHighlightedSampleId] = useState<string | undefined>();
 
-  const selectedProjects = useSelectedProjects();
-  const hasMetrics = useMemo(
-    () =>
-      selectedProjects.some(
-        project =>
-          project.hasCustomMetrics || project.hasSessions || project.firstTransactionEvent
-      ),
-    [selectedProjects]
-  );
+  const hasCustomMetrics = useMemo(() => {
+    return !!metaCustom.length;
+  }, [metaCustom]);
+
+  const hasPerformanceMetrics = useMemo(() => {
+    return !!metaPerformance.length;
+  }, [metaPerformance]);
 
   const handleSetSelectedWidgetIndex = useCallback(
     (value: number) => {
@@ -348,7 +364,9 @@ export function MetricsContextProvider({children}: {children: React.ReactNode})
       removeWidget,
       duplicateWidget: handleDuplicate,
       widgets,
-      hasMetrics,
+      hasCustomMetrics,
+      hasPerformanceMetrics,
+      isHasMetricsLoading: isMetaCustomLoading || isMetaPerformanceLoading,
       focusArea,
       setDefaultQuery,
       isDefaultQuery,
@@ -370,7 +388,8 @@ export function MetricsContextProvider({children}: {children: React.ReactNode})
       handleUpdateWidget,
       removeWidget,
       handleDuplicate,
-      hasMetrics,
+      hasCustomMetrics,
+      hasPerformanceMetrics,
       focusArea,
       setDefaultQuery,
       isDefaultQuery,
@@ -379,6 +398,8 @@ export function MetricsContextProvider({children}: {children: React.ReactNode})
       handleSetIsMultiChartMode,
       metricsSamples,
       toggleWidgetVisibility,
+      isMetaCustomLoading,
+      isMetaPerformanceLoading,
     ]
   );
 

+ 43 - 9
static/app/views/metrics/layout.tsx

@@ -6,8 +6,10 @@ import emptyStateImg from 'sentry-images/spot/custom-metrics-empty-state.svg';
 
 import FeatureBadge from 'sentry/components/badge/featureBadge';
 import {Button} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
 import FloatingFeedbackWidget from 'sentry/components/feedback/widget/floatingFeedbackWidget';
 import * as Layout from 'sentry/components/layouts/thirds';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
 import OnboardingPanel from 'sentry/components/onboardingPanel';
 import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
 import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
@@ -18,7 +20,9 @@ import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {trackAnalytics} from 'sentry/utils/analytics';
 import {METRICS_DOCS_URL} from 'sentry/utils/metrics/constants';
+import useDismissAlert from 'sentry/utils/useDismissAlert';
 import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
 import {useMetricsContext} from 'sentry/views/metrics/context';
 import {useMetricsOnboardingSidebar} from 'sentry/views/metrics/ddmOnboarding/useMetricsOnboardingSidebar';
 import {IntervalSelect} from 'sentry/views/metrics/intervalSelect';
@@ -29,8 +33,15 @@ import {WidgetDetails} from 'sentry/views/metrics/widgetDetails';
 
 export const MetricsLayout = memo(() => {
   const organization = useOrganization();
-  const {hasMetrics} = useMetricsContext();
+  const pageFilters = usePageFilters();
+  const selectedProjects = pageFilters.selection.projects.join();
+  const {hasCustomMetrics, hasPerformanceMetrics, isHasMetricsLoading} =
+    useMetricsContext();
   const {activateSidebar} = useMetricsOnboardingSidebar();
+  const {dismiss: emptyStateDismiss, isDismissed: isEmptyStateDismissed} =
+    useDismissAlert({
+      key: `${organization.id}:${selectedProjects}:metrics-empty-state-dismissed`,
+    });
 
   const addCustomMetric = useCallback(
     (referrer: 'header' | 'onboarding_panel') => {
@@ -48,6 +59,14 @@ export const MetricsLayout = memo(() => {
     [activateSidebar, organization]
   );
 
+  const viewPerformanceMetrics = useCallback(() => {
+    Sentry.metrics.increment('ddm.view_performance_metrics', 1);
+    trackAnalytics('ddm.view_performance_metrics', {
+      organization,
+    });
+    emptyStateDismiss();
+  }, [emptyStateDismiss, organization]);
+
   return (
     <Fragment>
       <Layout.Header>
@@ -65,7 +84,7 @@ export const MetricsLayout = memo(() => {
         </Layout.HeaderContent>
         <Layout.HeaderActions>
           <PageHeaderActions
-            showCustomMetricButton={hasMetrics}
+            showCustomMetricButton={hasCustomMetrics || isEmptyStateDismissed}
             addCustomMetric={() => addCustomMetric('header')}
           />
         </Layout.HeaderActions>
@@ -81,7 +100,9 @@ export const MetricsLayout = memo(() => {
             </PageFilterBar>
             <IntervalSelect />
           </FilterContainer>
-          {hasMetrics ? (
+          {isHasMetricsLoading ? (
+            <LoadingIndicator />
+          ) : hasCustomMetrics || isEmptyStateDismissed ? (
             <Fragment>
               <Queries />
               <MetricScratchpad />
@@ -95,12 +116,21 @@ export const MetricsLayout = memo(() => {
                   "Send your own metrics to Sentry to track your system's behaviour and profit from the same powerful features as you do with errors, like alerting and dashboards."
                 )}
               </p>
-              <Button
-                priority="primary"
-                onClick={() => addCustomMetric('onboarding_panel')}
-              >
-                {t('Add Custom Metric')}
-              </Button>
+              <div>
+                <ButtonList gap={1}>
+                  <Button
+                    priority="primary"
+                    onClick={() => addCustomMetric('onboarding_panel')}
+                  >
+                    {t('Add Custom Metric')}
+                  </Button>
+                  {hasPerformanceMetrics && (
+                    <Button onClick={viewPerformanceMetrics}>
+                      {t('View Performance Metrics')}
+                    </Button>
+                  )}
+                </ButtonList>
+              </div>
             </OnboardingPanel>
           )}
         </Layout.Main>
@@ -140,3 +170,7 @@ const EmptyStateImage = styled('img')`
     width: 320px;
   }
 `;
+
+const ButtonList = styled(ButtonBar)`
+  grid-template-columns: repeat(auto-fit, minmax(130px, max-content));
+`;

+ 13 - 3
static/app/views/metrics/utils/parseMetricWidgetsQueryParam.tsx

@@ -1,10 +1,11 @@
+import type {MRI} from 'sentry/types';
 import {getDefaultMetricOp} from 'sentry/utils/metrics';
 import {
   DEFAULT_SORT_STATE,
   emptyMetricsQueryWidget,
   NO_QUERY_ID,
 } from 'sentry/utils/metrics/constants';
-import {isMRI} from 'sentry/utils/metrics/mri';
+import {defaultAggregateForMRI, isMRI} from 'sentry/utils/metrics/mri';
 import {
   type BaseWidgetParams,
   type FocusedMetricsSeries,
@@ -191,7 +192,10 @@ function fillIds(
   return entries;
 }
 
-export function parseMetricWidgetsQueryParam(queryParam?: string): MetricsWidget[] {
+export function parseMetricWidgetsQueryParam(
+  queryParam?: string,
+  defaultMRI?: MRI
+): MetricsWidget[] {
   let currentWidgets: unknown = undefined;
 
   try {
@@ -282,7 +286,13 @@ export function parseMetricWidgetsQueryParam(queryParam?: string): MetricsWidget
   // Iterate over the widgets without an id and assign them a unique one
 
   if (queries.length === 0) {
-    queries.push(emptyMetricsQueryWidget);
+    const mri = defaultMRI || emptyMetricsQueryWidget.mri;
+
+    queries.push({
+      ...emptyMetricsQueryWidget,
+      mri,
+      op: defaultAggregateForMRI(mri),
+    });
   }
 
   // We can reset the id if there is only one widget