Skip to content

Commit c26e3dc

Browse files
authored
feat(metrics): Default custom metric + dismiss empty state (#70936)
1 parent dfaa818 commit c26e3dc

File tree

7 files changed

+139
-30
lines changed

7 files changed

+139
-30
lines changed

static/app/utils/analytics/ddmAnalyticsEvents.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type DDMEventParameters = {
1818
target: 'event-id' | 'description' | 'trace-id' | 'profile';
1919
};
2020
'ddm.set-default-query': {};
21+
'ddm.view_performance_metrics': {};
2122
'ddm.widget.add': {
2223
type: 'query' | 'equation';
2324
};
@@ -38,6 +39,7 @@ export const ddmEventMap: Record<keyof DDMEventParameters, string> = {
3839
'ddm.remove-default-query': 'DDM: Remove Default Query',
3940
'ddm.set-default-query': 'DDM: Set Default Query',
4041
'ddm.open-onboarding': 'DDM: Open Onboarding',
42+
'ddm.view_performance_metrics': 'DDM: View Performance Metrics',
4143
'ddm.widget.add': 'DDM: Widget Added',
4244
'ddm.widget.sort': 'DDM: Group By Sort Changed',
4345
'ddm.widget.duplicate': 'DDM: Widget Duplicated',

static/app/utils/metrics/constants.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,10 @@ export const emptyMetricsFormulaWidget: MetricsEquationWidget = {
5757
isHidden: false,
5858
overlays: [MetricChartOverlayType.SAMPLES],
5959
};
60+
61+
export const DEFAULT_AGGREGATES = {
62+
c: 'sum',
63+
d: 'avg',
64+
s: 'count_unique',
65+
g: 'avg',
66+
};

static/app/utils/metrics/mri.spec.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import type {MetricType, MRI} from 'sentry/types';
22
import type {ParsedMRI, UseCase} from 'sentry/types/metrics';
3-
import {getUseCaseFromMRI, parseField, parseMRI, toMRI} from 'sentry/utils/metrics/mri';
3+
import {DEFAULT_AGGREGATES} from 'sentry/utils/metrics/constants';
4+
import {
5+
defaultAggregateForMRI,
6+
getUseCaseFromMRI,
7+
parseField,
8+
parseMRI,
9+
toMRI,
10+
} from 'sentry/utils/metrics/mri';
411

512
describe('parseMRI', () => {
613
it('should handle falsy values', () => {
@@ -205,3 +212,18 @@ describe('toMRI', () => {
205212
}
206213
);
207214
});
215+
216+
describe('defaultAggregateForMRI', () => {
217+
it.each(['c', 'd', 'g', 's'])(
218+
'should give default aggregate - metric type %s',
219+
metricType => {
220+
const mri = `${metricType as MetricType}:custom/xyz@test` as MRI;
221+
222+
expect(defaultAggregateForMRI(mri)).toBe(DEFAULT_AGGREGATES[metricType]);
223+
}
224+
);
225+
226+
it('should fallback to sum', () => {
227+
expect(defaultAggregateForMRI('b:roken/MRI@none' as MRI)).toBe('sum');
228+
});
229+
});

static/app/utils/metrics/mri.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {t} from 'sentry/locale';
22
import type {MetricType, MRI, ParsedMRI, UseCase} from 'sentry/types/metrics';
33
import {parseFunction} from 'sentry/utils/discover/fields';
4+
import {DEFAULT_AGGREGATES} from 'sentry/utils/metrics/constants';
45

56
export const DEFAULT_MRI: MRI = 'c:custom/sentry_metric@none';
67
// This is a workaround as the alert builder requires a valid aggregate to be set
@@ -111,3 +112,15 @@ export function formatMRIField(aggregate: string) {
111112

112113
return `${parsed.op}(${formatMRI(parsed.mri)})`;
113114
}
115+
116+
export function defaultAggregateForMRI(mri: MRI) {
117+
const parsedMRI = parseMRI(mri);
118+
119+
const fallbackAggregate = 'sum';
120+
121+
if (!parsedMRI) {
122+
return fallbackAggregate;
123+
}
124+
125+
return DEFAULT_AGGREGATES[parsedMRI.type] || fallbackAggregate;
126+
}

static/app/views/metrics/context.tsx

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,23 @@ import * as Sentry from '@sentry/react';
1010
import isEqual from 'lodash/isEqual';
1111

1212
import type {Field} from 'sentry/components/metrics/metricSamplesTable';
13+
import type {MRI} from 'sentry/types';
1314
import {useInstantRef, useUpdateQuery} from 'sentry/utils/metrics';
1415
import {
1516
emptyMetricsFormulaWidget,
1617
emptyMetricsQueryWidget,
1718
NO_QUERY_ID,
1819
} from 'sentry/utils/metrics/constants';
1920
import {MetricExpressionType, type MetricsWidget} from 'sentry/utils/metrics/types';
21+
import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
2022
import type {MetricsSamplesResults} from 'sentry/utils/metrics/useMetricsSamples';
2123
import {decodeInteger, decodeScalar} from 'sentry/utils/queryString';
2224
import useLocationQuery from 'sentry/utils/url/useLocationQuery';
2325
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
26+
import usePageFilters from 'sentry/utils/usePageFilters';
2427
import useRouter from 'sentry/utils/useRouter';
2528
import type {FocusAreaSelection} from 'sentry/views/metrics/chart/types';
2629
import {parseMetricWidgetsQueryParam} from 'sentry/views/metrics/utils/parseMetricWidgetsQueryParam';
27-
import {useSelectedProjects} from 'sentry/views/metrics/utils/useSelectedProjects';
2830
import {useStructuralSharing} from 'sentry/views/metrics/utils/useStructuralSharing';
2931

3032
export type FocusAreaProps = {
@@ -38,8 +40,10 @@ interface MetricsContextValue {
3840
addWidget: (type?: MetricExpressionType) => void;
3941
duplicateWidget: (index: number) => void;
4042
focusArea: FocusAreaProps;
41-
hasMetrics: boolean;
43+
hasCustomMetrics: boolean;
44+
hasPerformanceMetrics: boolean;
4245
isDefaultQuery: boolean;
46+
isHasMetricsLoading: boolean;
4347
isMultiChartMode: boolean;
4448
removeWidget: (index: number) => void;
4549
selectedWidgetIndex: number;
@@ -62,10 +66,12 @@ export const MetricsContext = createContext<MetricsContextValue>({
6266
addWidget: () => {},
6367
duplicateWidget: () => {},
6468
focusArea: {},
65-
hasMetrics: false,
69+
hasCustomMetrics: false,
70+
hasPerformanceMetrics: false,
6671
highlightedSampleId: undefined,
6772
isDefaultQuery: false,
6873
isMultiChartMode: false,
74+
isHasMetricsLoading: true,
6975
metricsSamples: [],
7076
removeWidget: () => {},
7177
selectedWidgetIndex: 0,
@@ -84,12 +90,15 @@ export function useMetricsContext() {
8490
return useContext(MetricsContext);
8591
}
8692

87-
export function useMetricWidgets() {
93+
export function useMetricWidgets(mri: MRI) {
8894
const {widgets: urlWidgets} = useLocationQuery({fields: {widgets: decodeScalar}});
8995
const updateQuery = useUpdateQuery();
9096

9197
const widgets = useStructuralSharing(
92-
useMemo<MetricsWidget[]>(() => parseMetricWidgetsQueryParam(urlWidgets), [urlWidgets])
98+
useMemo<MetricsWidget[]>(
99+
() => parseMetricWidgetsQueryParam(urlWidgets, mri),
100+
[urlWidgets, mri]
101+
)
93102
);
94103

95104
// We want to have it as a ref, so that we can use it in the setWidget callback
@@ -210,29 +219,36 @@ export function MetricsContextProvider({children}: {children: React.ReactNode})
210219
const router = useRouter();
211220
const updateQuery = useUpdateQuery();
212221
const {multiChartMode} = useLocationQuery({fields: {multiChartMode: decodeInteger}});
222+
const pageFilters = usePageFilters();
223+
const {data: metaCustom, isLoading: isMetaCustomLoading} = useMetricsMeta(
224+
pageFilters.selection,
225+
['custom']
226+
);
227+
const {data: metaPerformance, isLoading: isMetaPerformanceLoading} = useMetricsMeta(
228+
pageFilters.selection,
229+
['transactions', 'spans']
230+
);
213231
const isMultiChartMode = multiChartMode === 1;
214232

215233
const {setDefaultQuery, isDefaultQuery} = useDefaultQuery();
216234

217235
const [selectedWidgetIndex, setSelectedWidgetIndex] = useState(0);
218236
const {widgets, updateWidget, addWidget, removeWidget, duplicateWidget, setWidgets} =
219-
useMetricWidgets();
237+
useMetricWidgets(metaCustom[0]?.mri);
220238

221239
const [metricsSamples, setMetricsSamples] = useState<
222240
MetricsSamplesResults<Field>['data'] | undefined
223241
>();
224242

225243
const [highlightedSampleId, setHighlightedSampleId] = useState<string | undefined>();
226244

227-
const selectedProjects = useSelectedProjects();
228-
const hasMetrics = useMemo(
229-
() =>
230-
selectedProjects.some(
231-
project =>
232-
project.hasCustomMetrics || project.hasSessions || project.firstTransactionEvent
233-
),
234-
[selectedProjects]
235-
);
245+
const hasCustomMetrics = useMemo(() => {
246+
return !!metaCustom.length;
247+
}, [metaCustom]);
248+
249+
const hasPerformanceMetrics = useMemo(() => {
250+
return !!metaPerformance.length;
251+
}, [metaPerformance]);
236252

237253
const handleSetSelectedWidgetIndex = useCallback(
238254
(value: number) => {
@@ -348,7 +364,9 @@ export function MetricsContextProvider({children}: {children: React.ReactNode})
348364
removeWidget,
349365
duplicateWidget: handleDuplicate,
350366
widgets,
351-
hasMetrics,
367+
hasCustomMetrics,
368+
hasPerformanceMetrics,
369+
isHasMetricsLoading: isMetaCustomLoading || isMetaPerformanceLoading,
352370
focusArea,
353371
setDefaultQuery,
354372
isDefaultQuery,
@@ -370,7 +388,8 @@ export function MetricsContextProvider({children}: {children: React.ReactNode})
370388
handleUpdateWidget,
371389
removeWidget,
372390
handleDuplicate,
373-
hasMetrics,
391+
hasCustomMetrics,
392+
hasPerformanceMetrics,
374393
focusArea,
375394
setDefaultQuery,
376395
isDefaultQuery,
@@ -379,6 +398,8 @@ export function MetricsContextProvider({children}: {children: React.ReactNode})
379398
handleSetIsMultiChartMode,
380399
metricsSamples,
381400
toggleWidgetVisibility,
401+
isMetaCustomLoading,
402+
isMetaPerformanceLoading,
382403
]
383404
);
384405

static/app/views/metrics/layout.tsx

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import emptyStateImg from 'sentry-images/spot/custom-metrics-empty-state.svg';
66

77
import FeatureBadge from 'sentry/components/badge/featureBadge';
88
import {Button} from 'sentry/components/button';
9+
import ButtonBar from 'sentry/components/buttonBar';
910
import FloatingFeedbackWidget from 'sentry/components/feedback/widget/floatingFeedbackWidget';
1011
import * as Layout from 'sentry/components/layouts/thirds';
12+
import LoadingIndicator from 'sentry/components/loadingIndicator';
1113
import OnboardingPanel from 'sentry/components/onboardingPanel';
1214
import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
1315
import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
@@ -18,7 +20,9 @@ import {t} from 'sentry/locale';
1820
import {space} from 'sentry/styles/space';
1921
import {trackAnalytics} from 'sentry/utils/analytics';
2022
import {METRICS_DOCS_URL} from 'sentry/utils/metrics/constants';
23+
import useDismissAlert from 'sentry/utils/useDismissAlert';
2124
import useOrganization from 'sentry/utils/useOrganization';
25+
import usePageFilters from 'sentry/utils/usePageFilters';
2226
import {useMetricsContext} from 'sentry/views/metrics/context';
2327
import {useMetricsOnboardingSidebar} from 'sentry/views/metrics/ddmOnboarding/useMetricsOnboardingSidebar';
2428
import {IntervalSelect} from 'sentry/views/metrics/intervalSelect';
@@ -29,8 +33,15 @@ import {WidgetDetails} from 'sentry/views/metrics/widgetDetails';
2933

3034
export const MetricsLayout = memo(() => {
3135
const organization = useOrganization();
32-
const {hasMetrics} = useMetricsContext();
36+
const pageFilters = usePageFilters();
37+
const selectedProjects = pageFilters.selection.projects.join();
38+
const {hasCustomMetrics, hasPerformanceMetrics, isHasMetricsLoading} =
39+
useMetricsContext();
3340
const {activateSidebar} = useMetricsOnboardingSidebar();
41+
const {dismiss: emptyStateDismiss, isDismissed: isEmptyStateDismissed} =
42+
useDismissAlert({
43+
key: `${organization.id}:${selectedProjects}:metrics-empty-state-dismissed`,
44+
});
3445

3546
const addCustomMetric = useCallback(
3647
(referrer: 'header' | 'onboarding_panel') => {
@@ -48,6 +59,14 @@ export const MetricsLayout = memo(() => {
4859
[activateSidebar, organization]
4960
);
5061

62+
const viewPerformanceMetrics = useCallback(() => {
63+
Sentry.metrics.increment('ddm.view_performance_metrics', 1);
64+
trackAnalytics('ddm.view_performance_metrics', {
65+
organization,
66+
});
67+
emptyStateDismiss();
68+
}, [emptyStateDismiss, organization]);
69+
5170
return (
5271
<Fragment>
5372
<Layout.Header>
@@ -65,7 +84,7 @@ export const MetricsLayout = memo(() => {
6584
</Layout.HeaderContent>
6685
<Layout.HeaderActions>
6786
<PageHeaderActions
68-
showCustomMetricButton={hasMetrics}
87+
showCustomMetricButton={hasCustomMetrics || isEmptyStateDismissed}
6988
addCustomMetric={() => addCustomMetric('header')}
7089
/>
7190
</Layout.HeaderActions>
@@ -81,7 +100,9 @@ export const MetricsLayout = memo(() => {
81100
</PageFilterBar>
82101
<IntervalSelect />
83102
</FilterContainer>
84-
{hasMetrics ? (
103+
{isHasMetricsLoading ? (
104+
<LoadingIndicator />
105+
) : hasCustomMetrics || isEmptyStateDismissed ? (
85106
<Fragment>
86107
<Queries />
87108
<MetricScratchpad />
@@ -95,12 +116,21 @@ export const MetricsLayout = memo(() => {
95116
"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."
96117
)}
97118
</p>
98-
<Button
99-
priority="primary"
100-
onClick={() => addCustomMetric('onboarding_panel')}
101-
>
102-
{t('Add Custom Metric')}
103-
</Button>
119+
<div>
120+
<ButtonList gap={1}>
121+
<Button
122+
priority="primary"
123+
onClick={() => addCustomMetric('onboarding_panel')}
124+
>
125+
{t('Add Custom Metric')}
126+
</Button>
127+
{hasPerformanceMetrics && (
128+
<Button onClick={viewPerformanceMetrics}>
129+
{t('View Performance Metrics')}
130+
</Button>
131+
)}
132+
</ButtonList>
133+
</div>
104134
</OnboardingPanel>
105135
)}
106136
</Layout.Main>
@@ -140,3 +170,7 @@ const EmptyStateImage = styled('img')`
140170
width: 320px;
141171
}
142172
`;
173+
174+
const ButtonList = styled(ButtonBar)`
175+
grid-template-columns: repeat(auto-fit, minmax(130px, max-content));
176+
`;

static/app/views/metrics/utils/parseMetricWidgetsQueryParam.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import type {MRI} from 'sentry/types';
12
import {getDefaultMetricOp} from 'sentry/utils/metrics';
23
import {
34
DEFAULT_SORT_STATE,
45
emptyMetricsQueryWidget,
56
NO_QUERY_ID,
67
} from 'sentry/utils/metrics/constants';
7-
import {isMRI} from 'sentry/utils/metrics/mri';
8+
import {defaultAggregateForMRI, isMRI} from 'sentry/utils/metrics/mri';
89
import {
910
type BaseWidgetParams,
1011
type FocusedMetricsSeries,
@@ -191,7 +192,10 @@ function fillIds(
191192
return entries;
192193
}
193194

194-
export function parseMetricWidgetsQueryParam(queryParam?: string): MetricsWidget[] {
195+
export function parseMetricWidgetsQueryParam(
196+
queryParam?: string,
197+
defaultMRI?: MRI
198+
): MetricsWidget[] {
195199
let currentWidgets: unknown = undefined;
196200

197201
try {
@@ -282,7 +286,13 @@ export function parseMetricWidgetsQueryParam(queryParam?: string): MetricsWidget
282286
// Iterate over the widgets without an id and assign them a unique one
283287

284288
if (queries.length === 0) {
285-
queries.push(emptyMetricsQueryWidget);
289+
const mri = defaultMRI || emptyMetricsQueryWidget.mri;
290+
291+
queries.push({
292+
...emptyMetricsQueryWidget,
293+
mri,
294+
op: defaultAggregateForMRI(mri),
295+
});
286296
}
287297

288298
// We can reset the id if there is only one widget

0 commit comments

Comments
 (0)