Skip to content

Commit 5cdd076

Browse files
authored
feat(tracemetrics): Implement Add to Dashboard (#105436)
Implements Add to Dashboard. Each metric visualized gets rendered as a dropdown item and renders a prettified label as well as the row label in the case of duplicate metrics. In the future maybe we can differentiate them better with specific filters or groupings but for now this is okay. Each menu item, when clicked, converts the metric into a widget format and calls `openAddToDashboardModal` where the user can follow with the normal flow we use for other widgets.
1 parent c4030a9 commit 5cdd076

File tree

7 files changed

+266
-11
lines changed

7 files changed

+266
-11
lines changed

static/app/views/dashboards/datasetConfig/traceMetrics.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import type {ReactNode} from 'react';
12
import pickBy from 'lodash/pickBy';
23

34
import {doEventsRequest} from 'sentry/actionCreators/events';
45
import type {ApiResult, Client} from 'sentry/api';
5-
import type {PageFilters, SelectValue} from 'sentry/types/core';
6+
import type {PageFilters} from 'sentry/types/core';
67
import type {TagCollection} from 'sentry/types/group';
78
import type {Organization} from 'sentry/types/organization';
89
import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
@@ -154,12 +155,15 @@ function useTraceMetricsSearchBarDataProvider(
154155
};
155156
}
156157

157-
function prettifySortOption(option: SelectValue<string>) {
158-
const parsedFunction = parseFunction(option.value);
158+
export function formatTraceMetricsFunction(
159+
valueToParse: string,
160+
defaultValue: string | ReactNode = ''
161+
) {
162+
const parsedFunction = parseFunction(valueToParse);
159163
if (parsedFunction) {
160164
return `${parsedFunction.name}(${parsedFunction.arguments[1] ?? '…'})`;
161165
}
162-
return option.label;
166+
return defaultValue;
163167
}
164168

165169
export const TraceMetricsConfig: DatasetConfig<EventsTimeSeriesResponse, never> = {
@@ -176,7 +180,7 @@ export const TraceMetricsConfig: DatasetConfig<EventsTimeSeriesResponse, never>
176180
// we only want to allow sorting by selected aggregates.
177181
getTableSortOptions: (organization, widgetQuery) =>
178182
getTableSortOptions(organization, widgetQuery).map(option => ({
179-
label: prettifySortOption(option),
183+
label: formatTraceMetricsFunction(option.value, option.label),
180184
value: option.value,
181185
})),
182186
getGroupByFieldOptions,

static/app/views/dashboards/types.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,5 @@ export enum DashboardWidgetSource {
234234
TRACE_EXPLORER = 'traceExplorer',
235235
LOGS = 'logs',
236236
INSIGHTS = 'insights',
237+
TRACEMETRICS = 'traceMetrics',
237238
}

static/app/views/discover/utils.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,7 @@ export function handleAddQueryToDashboard({
650650
yAxis?: string | string[];
651651
}) {
652652
const displayType =
653-
widgetType === WidgetType.SPANS
653+
widgetType === WidgetType.SPANS || widgetType === WidgetType.TRACEMETRICS
654654
? (eventView.display as DisplayType)
655655
: displayModeToDisplayType(eventView.display as DisplayModes);
656656
const defaultWidgetQuery = eventViewToWidgetQuery({
@@ -796,6 +796,7 @@ export function constructAddQueryToDashboardLink({
796796
fields: eventView.getFields(),
797797
columns:
798798
widgetType === WidgetType.SPANS ||
799+
widgetType === WidgetType.TRACEMETRICS ||
799800
displayType === DisplayType.TOP_N ||
800801
eventView.display === DisplayModes.DAILYTOP5
801802
? eventView
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
import {ProjectFixture} from 'sentry-fixture/project';
3+
4+
import {initializeOrg} from 'sentry-test/initializeOrg';
5+
import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary';
6+
7+
import {openAddToDashboardModal} from 'sentry/actionCreators/modal';
8+
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
9+
import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode';
10+
import {useAddMetricToDashboard} from 'sentry/views/explore/metrics/hooks/useAddMetricToDashboard';
11+
import type {BaseMetricQuery} from 'sentry/views/explore/metrics/metricQuery';
12+
import {ReadableQueryParams} from 'sentry/views/explore/queryParams/readableQueryParams';
13+
import {VisualizeFunction} from 'sentry/views/explore/queryParams/visualize';
14+
import {ChartType} from 'sentry/views/insights/common/components/chart';
15+
16+
jest.mock('sentry/actionCreators/modal');
17+
18+
describe('useAddMetricToDashboard', () => {
19+
const project = ProjectFixture();
20+
const organization = OrganizationFixture();
21+
const context = initializeOrg({
22+
organization,
23+
projects: [project],
24+
router: {
25+
location: {
26+
pathname: '/organizations/org-slug/explore/metrics/',
27+
query: {project: project.id},
28+
},
29+
params: {},
30+
},
31+
});
32+
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
it('opens the dashboard modal with the trace metrics widget configuration', () => {
38+
const yAxis = 'avg(value,metric.a,counter,-)';
39+
const visualize = new VisualizeFunction(yAxis, {chartType: ChartType.BAR});
40+
const metricQuery: BaseMetricQuery = {
41+
metric: {name: 'metric.a', type: 'counter'},
42+
queryParams: new ReadableQueryParams({
43+
extrapolate: true,
44+
mode: Mode.AGGREGATE,
45+
query: 'release:1.2.3',
46+
cursor: '',
47+
fields: [],
48+
sortBys: [],
49+
aggregateCursor: '',
50+
aggregateFields: [visualize, {groupBy: 'project'}],
51+
aggregateSortBys: [{field: yAxis, kind: 'desc'}],
52+
}),
53+
};
54+
55+
const {result} = renderHookWithProviders(useAddMetricToDashboard, {
56+
...context,
57+
});
58+
59+
act(() => {
60+
result.current.addToDashboard(metricQuery);
61+
});
62+
63+
expect(openAddToDashboardModal).toHaveBeenCalledWith(
64+
expect.objectContaining({
65+
widget: {
66+
title: 'Custom Widget',
67+
displayType: DisplayType.BAR,
68+
interval: undefined,
69+
limit: undefined,
70+
widgetType: WidgetType.TRACEMETRICS,
71+
queries: [
72+
{
73+
aggregates: [yAxis],
74+
columns: ['project'],
75+
fields: ['project'],
76+
conditions: 'release:1.2.3',
77+
orderby: `-${yAxis}`,
78+
name: '',
79+
},
80+
],
81+
},
82+
})
83+
);
84+
});
85+
86+
it('does not pass an orderby if there are no group bys', () => {
87+
const yAxis = 'avg(value,metric.a,counter,-)';
88+
const visualize = new VisualizeFunction(yAxis, {chartType: ChartType.BAR});
89+
const metricQuery: BaseMetricQuery = {
90+
metric: {name: 'metric.a', type: 'counter'},
91+
queryParams: new ReadableQueryParams({
92+
extrapolate: true,
93+
mode: Mode.AGGREGATE,
94+
query: 'release:1.2.3',
95+
cursor: '',
96+
fields: [],
97+
sortBys: [],
98+
aggregateCursor: '',
99+
aggregateFields: [visualize],
100+
aggregateSortBys: [{field: yAxis, kind: 'desc'}],
101+
}),
102+
};
103+
104+
const {result} = renderHookWithProviders(useAddMetricToDashboard, {
105+
...context,
106+
});
107+
108+
act(() => {
109+
result.current.addToDashboard(metricQuery);
110+
});
111+
112+
expect(openAddToDashboardModal).toHaveBeenCalledWith(
113+
expect.objectContaining({
114+
widget: {
115+
title: 'Custom Widget',
116+
displayType: DisplayType.BAR,
117+
interval: undefined,
118+
limit: undefined,
119+
widgetType: WidgetType.TRACEMETRICS,
120+
queries: [
121+
{
122+
aggregates: [yAxis],
123+
columns: [],
124+
fields: [],
125+
conditions: 'release:1.2.3',
126+
orderby: '',
127+
name: '',
128+
},
129+
],
130+
},
131+
})
132+
);
133+
});
134+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {useCallback} from 'react';
2+
3+
import type {NewQuery} from 'sentry/types/organization';
4+
import EventView from 'sentry/utils/discover/eventView';
5+
import {DiscoverDatasets} from 'sentry/utils/discover/types';
6+
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
7+
import {useLocation} from 'sentry/utils/useLocation';
8+
import useOrganization from 'sentry/utils/useOrganization';
9+
import usePageFilters from 'sentry/utils/usePageFilters';
10+
import {
11+
DashboardWidgetSource,
12+
DEFAULT_WIDGET_NAME,
13+
WidgetType,
14+
} from 'sentry/views/dashboards/types';
15+
import {handleAddQueryToDashboard} from 'sentry/views/discover/utils';
16+
import {CHART_TYPE_TO_DISPLAY_TYPE} from 'sentry/views/explore/hooks/useAddToDashboard';
17+
import type {BaseMetricQuery} from 'sentry/views/explore/metrics/metricQuery';
18+
import {isVisualize} from 'sentry/views/explore/queryParams/visualize';
19+
import {ChartType} from 'sentry/views/insights/common/components/chart';
20+
21+
export function useAddMetricToDashboard() {
22+
const location = useLocation();
23+
const organization = useOrganization();
24+
const {selection} = usePageFilters();
25+
26+
const getEventView = useCallback(
27+
(metricQuery: BaseMetricQuery) => {
28+
const queryValues = metricQuery?.queryParams;
29+
const visualize = queryValues?.aggregateFields?.find(isVisualize);
30+
const yAxis = visualize?.yAxis;
31+
const fields = queryValues?.groupBys ?? [];
32+
const aggregateSortBys = queryValues?.aggregateSortBys ?? [];
33+
34+
const search = new MutableSearch(queryValues?.query ?? '');
35+
36+
const discoverQuery: NewQuery = {
37+
name: DEFAULT_WIDGET_NAME,
38+
fields,
39+
query: search.formatString(),
40+
version: 2,
41+
dataset: DiscoverDatasets.TRACEMETRICS,
42+
yAxis: [yAxis ?? ''],
43+
};
44+
45+
const newEventView = EventView.fromNewQueryWithPageFilters(
46+
discoverQuery,
47+
selection
48+
);
49+
newEventView.display =
50+
CHART_TYPE_TO_DISPLAY_TYPE[visualize?.chartType ?? ChartType.LINE];
51+
52+
if (fields.length > 0) {
53+
newEventView.sorts = aggregateSortBys;
54+
}
55+
return newEventView;
56+
},
57+
[selection]
58+
);
59+
60+
const addToDashboard = useCallback(
61+
(metricQuery: BaseMetricQuery) => {
62+
const eventView = getEventView(metricQuery);
63+
64+
handleAddQueryToDashboard({
65+
organization,
66+
location,
67+
eventView,
68+
yAxis: eventView.yAxis,
69+
widgetType: WidgetType.TRACEMETRICS,
70+
source: DashboardWidgetSource.TRACEMETRICS,
71+
});
72+
},
73+
[organization, location, getEventView]
74+
);
75+
76+
return {
77+
addToDashboard,
78+
};
79+
}

static/app/views/explore/metrics/metricToolbar/metricSaveAs.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export function MetricSaveAs() {
1212
return null;
1313
}
1414

15-
if (items.length === 1) {
16-
const item = items[0]!;
15+
if (items.length === 1 && 'onAction' in items[0]! && !('children' in items[0])) {
16+
const item = items[0];
1717
return (
1818
<Button size="sm" onClick={item.onAction} aria-label={item.textValue}>
1919
{t('Save as')}

static/app/views/explore/metrics/useSaveAsMetricItems.tsx

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,15 @@ import {defined} from 'sentry/utils';
1212
import {trackAnalytics} from 'sentry/utils/analytics';
1313
import {useLocation} from 'sentry/utils/useLocation';
1414
import useOrganization from 'sentry/utils/useOrganization';
15+
import {formatTraceMetricsFunction} from 'sentry/views/dashboards/datasetConfig/traceMetrics';
16+
import {useHasTraceMetricsDashboards} from 'sentry/views/dashboards/hooks/useHasTraceMetricsDashboards';
1517
import {getIdFromLocation} from 'sentry/views/explore/contexts/pageParamsContext/id';
1618
import {useGetSavedQuery} from 'sentry/views/explore/hooks/useGetSavedQueries';
19+
import {useAddMetricToDashboard} from 'sentry/views/explore/metrics/hooks/useAddMetricToDashboard';
1720
import {useSaveMetricsMultiQuery} from 'sentry/views/explore/metrics/hooks/useSaveMetricsMultiQuery';
21+
import {useMultiMetricsQueryParams} from 'sentry/views/explore/metrics/multiMetricsQueryParams';
22+
import {isVisualize} from 'sentry/views/explore/queryParams/visualize';
23+
import {getVisualizeLabel} from 'sentry/views/explore/toolbar/toolbarVisualize';
1824
import {TraceItemDataset} from 'sentry/views/explore/types';
1925

2026
import {canUseMetricsSavedQueriesUI} from './metricsFlags';
@@ -30,6 +36,10 @@ export function useSaveAsMetricItems(_options: UseSaveAsMetricItemsOptions) {
3036
const id = getIdFromLocation(location);
3137
const {data: savedQuery} = useGetSavedQuery(id);
3238

39+
const metricQueries = useMultiMetricsQueryParams();
40+
const hasTraceMetricsDashboards = useHasTraceMetricsDashboards();
41+
const {addToDashboard} = useAddMetricToDashboard();
42+
3343
const saveAsItems = useMemo(() => {
3444
if (!canUseMetricsSavedQueriesUI(organization)) {
3545
return [];
@@ -85,9 +95,35 @@ export function useSaveAsMetricItems(_options: UseSaveAsMetricItemsOptions) {
8595

8696
// TODO: Implement alert functionality when organizations:tracemetrics-alerts flag is enabled
8797

88-
// TODO: Implement dashboard functionality when organizations:tracemetrics-dashboards flag is enabled
98+
const addToDashboardItems = useMemo(() => {
99+
const items = [];
100+
101+
if (hasTraceMetricsDashboards) {
102+
items.push({
103+
key: 'add-to-dashboard',
104+
label: <span>{t('A Dashboard widget')}</span>,
105+
textValue: t('A Dashboard widget'),
106+
isSubmenu: true,
107+
children: metricQueries.map((metricQuery, index) => {
108+
return {
109+
key: `add-to-dashboard-${index}`,
110+
label: `${getVisualizeLabel(index)}: ${
111+
formatTraceMetricsFunction(
112+
metricQuery.queryParams.aggregateFields.find(isVisualize)?.yAxis ?? ''
113+
) as string
114+
}`,
115+
onAction: () => {
116+
addToDashboard(metricQuery);
117+
},
118+
};
119+
}),
120+
});
121+
}
122+
123+
return items;
124+
}, [hasTraceMetricsDashboards, addToDashboard, metricQueries]);
89125

90126
return useMemo(() => {
91-
return saveAsItems;
92-
}, [saveAsItems]);
127+
return [...saveAsItems, ...addToDashboardItems];
128+
}, [saveAsItems, addToDashboardItems]);
93129
}

0 commit comments

Comments
 (0)