diff --git a/static/app/views/explore/hooks/useAnalytics.tsx b/static/app/views/explore/hooks/useAnalytics.tsx index ab3d3d6673674f..96f9d925ac1ac3 100644 --- a/static/app/views/explore/hooks/useAnalytics.tsx +++ b/static/app/views/explore/hooks/useAnalytics.tsx @@ -39,7 +39,11 @@ import { useQueryParamsVisualizes, } from 'sentry/views/explore/queryParams/context'; import type {ReadableQueryParams} from 'sentry/views/explore/queryParams/readableQueryParams'; -import {Visualize} from 'sentry/views/explore/queryParams/visualize'; +import { + isVisualizeEquation, + isVisualizeFunction, + Visualize, +} from 'sentry/views/explore/queryParams/visualize'; import {useSpansDataset} from 'sentry/views/explore/spans/spansQueryParams'; import { combineConfidenceForSeries, @@ -841,7 +845,13 @@ export function useMetricsPanelAnalytics({ const query = useQueryParamsQuery(); const groupBys = useQueryParamsGroupBys(); const visualize = useMetricVisualize(); - const aggregateFunctionBox = useBox(visualize.parsedFunction?.name ?? ''); + const aggregateFunctionBox = useBox( + isVisualizeFunction(visualize) + ? (visualize.parsedFunction?.name ?? '') + : isVisualizeEquation(visualize) + ? visualize.expression.text + : '' + ); const tableError = mode === Mode.AGGREGATE diff --git a/static/app/views/explore/metrics/hooks/useMetricReferences.tsx b/static/app/views/explore/metrics/hooks/useMetricReferences.tsx new file mode 100644 index 00000000000000..cef737d04c3a81 --- /dev/null +++ b/static/app/views/explore/metrics/hooks/useMetricReferences.tsx @@ -0,0 +1,22 @@ +import {useMemo} from 'react'; + +import {useMultiMetricsQueryParams} from 'sentry/views/explore/metrics/multiMetricsQueryParams'; +import {isVisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; +import {getVisualizeLabel} from 'sentry/views/explore/toolbar/toolbarVisualize'; + +export function useMetricReferences() { + const metricQueries = useMultiMetricsQueryParams(); + + // TODO: This is only correct since all queries are listed before equations. If + // this changes we need to update this to persist the labels of the queries so + // references are still valid. + return useMemo(() => { + return new Set( + metricQueries + .filter(metricQuery => + metricQuery.queryParams.visualizes.some(isVisualizeFunction) + ) + .map((_metricQuery, index) => getVisualizeLabel(index)) + ); + }, [metricQueries]); +} diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index ee58c132c26948..58b16db2f04cc7 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -35,9 +35,10 @@ const TWO_MINUTE_DELAY = 120; interface MetricPanelProps { queryIndex: number; traceMetric: TraceMetric; + references?: Set; } -export function MetricPanel({traceMetric, queryIndex}: MetricPanelProps) { +export function MetricPanel({traceMetric, queryIndex, references}: MetricPanelProps) { const organization = useOrganization(); const { orientation, @@ -94,7 +95,11 @@ export function MetricPanel({traceMetric, queryIndex}: MetricPanelProps) { - + {visualize.visible ? ( { it('parses all visualizes', () => { @@ -114,3 +119,41 @@ describe('decodeMetricsQueryParams', () => { ); }); }); + +describe('defaultMetricQuery', () => { + it('returns a default metric query', () => { + const result = defaultMetricQuery(); + expect(result).toEqual({ + metric: {name: '', type: ''}, + queryParams: new ReadableQueryParams({ + extrapolate: true, + mode: Mode.SAMPLES, + query: '', + cursor: '', + fields: ['id', 'timestamp'], + sortBys: [{field: 'timestamp', kind: 'desc'}], + aggregateCursor: '', + aggregateFields: [new VisualizeFunction('sum(value)')], + aggregateSortBys: [{field: 'sum(value)', kind: 'desc'}], + }), + }); + }); + + it('returns a default metric query with an equation', () => { + const result = defaultMetricQuery({type: 'equation'}); + expect(result).toEqual({ + metric: {name: '', type: ''}, + queryParams: new ReadableQueryParams({ + extrapolate: true, + mode: Mode.SAMPLES, + query: '', + cursor: '', + fields: ['id', 'timestamp'], + sortBys: [{field: 'timestamp', kind: 'desc'}], + aggregateCursor: '', + aggregateFields: [new VisualizeEquation(EQUATION_PREFIX)], + aggregateSortBys: [{field: EQUATION_PREFIX, kind: 'desc'}], + }), + }); + }); +}); diff --git a/static/app/views/explore/metrics/metricQuery.tsx b/static/app/views/explore/metrics/metricQuery.tsx index cc92d7aef8c88b..287559d9e2c7a6 100644 --- a/static/app/views/explore/metrics/metricQuery.tsx +++ b/static/app/views/explore/metrics/metricQuery.tsx @@ -1,7 +1,7 @@ import type {Location} from 'history'; import {defined} from 'sentry/utils'; -import type {Sort} from 'sentry/utils/discover/fields'; +import {EQUATION_PREFIX, type Sort} from 'sentry/utils/discover/fields'; import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; import type {AggregateField} from 'sentry/views/explore/queryParams/aggregateField'; import {validateAggregateSort} from 'sentry/views/explore/queryParams/aggregateSortBy'; @@ -11,6 +11,7 @@ import { isBaseVisualize, isVisualize, Visualize, + VisualizeEquation, VisualizeFunction, } from 'sentry/views/explore/queryParams/visualize'; @@ -103,7 +104,11 @@ export function encodeMetricQueryParams(metricQuery: BaseMetricQuery): string { }); } -export function defaultMetricQuery(): BaseMetricQuery { +export function defaultMetricQuery({ + type = 'aggregate', +}: {type?: 'aggregate' | 'equation'} = {}): BaseMetricQuery { + const newFields = + type === 'equation' ? [defaultAggregateEquation()] : defaultAggregateFields(); return { metric: {name: '', type: ''}, queryParams: new ReadableQueryParams({ @@ -116,8 +121,8 @@ export function defaultMetricQuery(): BaseMetricQuery { sortBys: defaultSortBys(defaultFields()), aggregateCursor: '', - aggregateFields: defaultAggregateFields(), - aggregateSortBys: defaultAggregateSortBys(defaultAggregateFields()), + aggregateFields: newFields, + aggregateSortBys: defaultAggregateSortBys(newFields), }), }; } @@ -164,6 +169,10 @@ export function defaultAggregateFields(): AggregateField[] { return [defaultVisualize(), ...defaultGroupBys()]; } +function defaultAggregateEquation() { + return new VisualizeEquation(EQUATION_PREFIX); +} + export function defaultAggregateSortBys(aggregateFields: AggregateField[]): Sort[] { const visualize = aggregateFields.find(isVisualize); if (!defined(visualize)) { diff --git a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx index ab1dc14f95dd1a..62ce326f4ed6c4 100644 --- a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx +++ b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx @@ -20,6 +20,7 @@ import { useSetMetricVisualizes, } from 'sentry/views/explore/metrics/metricsQueryParams'; import {updateVisualizeYAxis} from 'sentry/views/explore/metrics/utils'; +import {isVisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; const MULTI_SELECT_GROUP_KEYS = new Set(['percentiles', 'stats']); @@ -29,9 +30,14 @@ export function AggregateDropdown({traceMetric}: {traceMetric: TraceMetric}) { const setMetricVisualizes = useSetMetricVisualizes(); const groups = GROUPED_OPTIONS_BY_TYPE[traceMetric.type] ?? []; - const selectedNames = new Set(visualizes.map(v => v.parsedFunction?.name ?? '')); + const selectedNames = new Set( + visualizes.map(v => (isVisualizeFunction(v) ? (v.parsedFunction?.name ?? '') : '')) + ); function handleChange(selectedOptions: Array>) { + if (!isVisualizeFunction(visualize)) { + return; + } if (selectedOptions.length === 0) { setMetricVisualizes([ updateVisualizeYAxis( diff --git a/static/app/views/explore/metrics/metricToolbar/index.tsx b/static/app/views/explore/metrics/metricToolbar/index.tsx index 219e1d0677be82..6fbc3f8529a81c 100644 --- a/static/app/views/explore/metrics/metricToolbar/index.tsx +++ b/static/app/views/explore/metrics/metricToolbar/index.tsx @@ -1,7 +1,10 @@ -import {useCallback} from 'react'; +import {Fragment, useCallback} from 'react'; import {Flex, Grid} from '@sentry/scraps/layout'; +import {ArithmeticBuilder} from 'sentry/components/arithmeticBuilder'; +import type {Expression} from 'sentry/components/arithmeticBuilder/expression'; +import {EQUATION_PREFIX} from 'sentry/utils/discover/fields'; import {useOrganization} from 'sentry/utils/useOrganization'; import {type TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; import {canUseMetricsUIRefresh} from 'sentry/views/explore/metrics/metricsFlags'; @@ -17,13 +20,18 @@ import {GroupBySelector} from 'sentry/views/explore/metrics/metricToolbar/groupB import {MetricSelector} from 'sentry/views/explore/metrics/metricToolbar/metricSelector'; import {VisualizeLabel} from 'sentry/views/explore/metrics/metricToolbar/visualizeLabel'; import {useMultiMetricsQueryParams} from 'sentry/views/explore/metrics/multiMetricsQueryParams'; +import { + isVisualizeEquation, + isVisualizeFunction, +} from 'sentry/views/explore/queryParams/visualize'; interface MetricToolbarProps { queryIndex: number; traceMetric: TraceMetric; + references?: Set; } -export function MetricToolbar({traceMetric, queryIndex}: MetricToolbarProps) { +export function MetricToolbar({traceMetric, queryIndex, references}: MetricToolbarProps) { const organization = useOrganization(); const metricQueries = useMultiMetricsQueryParams(); const visualize = useMetricVisualize(); @@ -34,13 +42,28 @@ export function MetricToolbar({traceMetric, queryIndex}: MetricToolbarProps) { const setTraceMetric = useSetTraceMetric(); const canRemoveMetric = metricQueries.length > 1; + const handleExpressionChange = useCallback( + (newExpression: Expression) => { + const isValid = newExpression.isValid; + if (!isValid) { + return; + } + setVisualize(visualize.replace({yAxis: `${EQUATION_PREFIX}${newExpression.text}`})); + }, + [setVisualize, visualize] + ); + if (canUseMetricsUIRefresh(organization)) { return ( - - - - - - - - - - - - - - + {isVisualizeFunction(visualize) ? ( + + + + + + + + + + + + + + + + + ) : isVisualizeEquation(visualize) ? ( + null} + references={references} + setExpression={handleExpressionChange} + /> + ) : null} {canRemoveMetric && } ); @@ -75,7 +111,11 @@ export function MetricToolbar({traceMetric, queryIndex}: MetricToolbarProps) { width="100%" align="center" gap="md" - columns={`34px 2fr 3fr 6fr ${canRemoveMetric ? '40px' : '0'}`} + columns={ + isVisualizeFunction(visualize) + ? `34px 2fr 3fr 6fr ${canRemoveMetric ? '40px' : '0'}` + : `34px 1fr ${canRemoveMetric ? '40px' : '0'}` + } data-test-id="metric-toolbar" > - - - - - - - - - - - - - - + {isVisualizeFunction(visualize) ? ( + + + + + + + + + + + + + + + + + ) : isVisualizeEquation(visualize) ? ( + null} + references={references} + setExpression={handleExpressionChange} + /> + ) : null} {canRemoveMetric && } ); diff --git a/static/app/views/explore/metrics/metricsFlags.tsx b/static/app/views/explore/metrics/metricsFlags.tsx index c61163b215f8d7..f48fa1d305c9ae 100644 --- a/static/app/views/explore/metrics/metricsFlags.tsx +++ b/static/app/views/explore/metrics/metricsFlags.tsx @@ -34,3 +34,10 @@ export const canUseMetricsStatsBytesUI = (organization: Organization) => { organization.features.includes('tracemetrics-stats-bytes-ui') ); }; + +export const canUseMetricsEquations = (organization: Organization) => { + return ( + canUseMetricsUI(organization) && + organization.features.includes('tracemetrics-equations-in-explore') + ); +}; diff --git a/static/app/views/explore/metrics/metricsQueryParams.tsx b/static/app/views/explore/metrics/metricsQueryParams.tsx index 7938305e091e2e..bb55a99c9a328c 100644 --- a/static/app/views/explore/metrics/metricsQueryParams.tsx +++ b/static/app/views/explore/metrics/metricsQueryParams.tsx @@ -3,7 +3,9 @@ import {useCallback, useMemo} from 'react'; import {defined} from 'sentry/utils'; import {createDefinedContext} from 'sentry/utils/performance/contexts/utils'; +import {useOrganization} from 'sentry/utils/useOrganization'; import {defaultQuery, type TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; +import {canUseMetricsEquations} from 'sentry/views/explore/metrics/metricsFlags'; import { MetricsFrozenContextProvider, type MetricsFrozenForTracesProviderProps, @@ -18,9 +20,10 @@ import { import {isGroupBy} from 'sentry/views/explore/queryParams/groupBy'; import {ReadableQueryParams} from 'sentry/views/explore/queryParams/readableQueryParams'; import { + isVisualizeEquation, isVisualizeFunction, parseVisualize, - VisualizeFunction, + Visualize, } from 'sentry/views/explore/queryParams/visualize'; import type {WritableQueryParams} from 'sentry/views/explore/queryParams/writableQueryParams'; @@ -117,17 +120,30 @@ function getUpdatedValue( return undefined; } -export function useMetricVisualize(): VisualizeFunction { +export function useMetricVisualize(): Visualize { + const organization = useOrganization(); const visualizes = useQueryParamsVisualizes(); - if (visualizes.length > 0 && isVisualizeFunction(visualizes[0]!)) { + const hasEquations = canUseMetricsEquations(organization); + if ( + visualizes.length > 0 && + (isVisualizeFunction(visualizes[0]!) || + (isVisualizeEquation(visualizes[0]!) && hasEquations)) + ) { return visualizes[0]; } throw new Error('No visualize found'); } -export function useMetricVisualizes(): readonly VisualizeFunction[] { +export function useMetricVisualizes(): readonly Visualize[] { + const organization = useOrganization(); const visualizes = useQueryParamsVisualizes(); - if (visualizes.length > 0 && visualizes.every(isVisualizeFunction)) { + const hasEquations = canUseMetricsEquations(organization); + if ( + visualizes.length > 0 && + visualizes.every( + v => isVisualizeFunction(v) || (isVisualizeEquation(v) && hasEquations) + ) + ) { return visualizes; } throw new Error('Only visualize functions are allowed'); @@ -142,11 +158,13 @@ export function useMetricLabel(): string { const visualize = useMetricVisualize(); const {metric} = useTraceMetricContext(); - if (!visualize.parsedFunction) { - return metric.name; + if (isVisualizeEquation(visualize)) { + return visualize.expression.text; } - - return `${visualize.parsedFunction.name}(${metric.name})`; + if (isVisualizeFunction(visualize) && visualize.parsedFunction) { + return `${visualize.parsedFunction.name}(${metric.name})`; + } + return metric.name; } export function useTraceMetric(): TraceMetric { @@ -167,7 +185,7 @@ export function useRemoveMetric() { export function useSetMetricVisualize() { const setVisualizes = useSetQueryParamsVisualizes(); const setVisualize = useCallback( - (newVisualize: VisualizeFunction) => { + (newVisualize: Visualize) => { setVisualizes([newVisualize.serialize()]); }, [setVisualizes] @@ -178,7 +196,7 @@ export function useSetMetricVisualize() { export function useSetMetricVisualizes() { const setVisualizes = useSetQueryParamsVisualizes(); const setMetricVisualizes = useCallback( - (newVisualizes: VisualizeFunction[]) => { + (newVisualizes: Visualize[]) => { setVisualizes(newVisualizes.map(v => v.serialize())); }, [setVisualizes] diff --git a/static/app/views/explore/metrics/metricsTab.spec.tsx b/static/app/views/explore/metrics/metricsTab.spec.tsx index 13167a1da30ba2..842ce87401a11a 100644 --- a/static/app/views/explore/metrics/metricsTab.spec.tsx +++ b/static/app/views/explore/metrics/metricsTab.spec.tsx @@ -1,3 +1,4 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; import {TimeSeriesFixture} from 'sentry-fixture/timeSeries'; import { createTraceMetricFixtures, @@ -582,4 +583,33 @@ describe('MetricsTabContent', () => { }); expect(parsedQuery.aggregateFields).toContainEqual({groupBy: 'test.region'}); }); + + it('does not show the Add Equation button when the feature flag is disabled', async () => { + render( + + + , + { + organization, + } + ); + expect(await screen.findByText('Add Metric')).toBeInTheDocument(); + expect(screen.queryByText('Add Equation')).not.toBeInTheDocument(); + }); + + it('shows the Add Equation button when the feature flag is enabled', async () => { + const orgWithFeature = OrganizationFixture({ + features: ['tracemetrics-enabled', 'tracemetrics-equations-in-explore'], + }); + render( + + + , + { + organization: orgWithFeature, + } + ); + expect(await screen.findByText('Add Metric')).toBeInTheDocument(); + expect(screen.getByText('Add Equation')).toBeInTheDocument(); + }); }); diff --git a/static/app/views/explore/metrics/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx index 07cd33f3add88e..67a47132e85954 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -19,8 +19,12 @@ import { import {ToolbarVisualizeAddChart} from 'sentry/views/explore/components/toolbar/toolbarVisualize'; import {useMetricsAnalytics} from 'sentry/views/explore/hooks/useAnalytics'; import {useMetricOptions} from 'sentry/views/explore/hooks/useMetricOptions'; +import {useMetricReferences} from 'sentry/views/explore/metrics/hooks/useMetricReferences'; import {MetricPanel} from 'sentry/views/explore/metrics/metricPanel'; -import {canUseMetricsUIRefresh} from 'sentry/views/explore/metrics/metricsFlags'; +import { + canUseMetricsEquations, + canUseMetricsUIRefresh, +} from 'sentry/views/explore/metrics/metricsFlags'; import {MetricsQueryParamsProvider} from 'sentry/views/explore/metrics/metricsQueryParams'; import {MetricToolbar} from 'sentry/views/explore/metrics/metricToolbar'; import {MetricSaveAs} from 'sentry/views/explore/metrics/metricToolbar/metricSaveAs'; @@ -73,6 +77,8 @@ function MetricsTabFilterSection({datePageFilterProps}: MetricsTabProps) { const organization = useOrganization(); const metricQueries = useMultiMetricsQueryParams(); const addMetricQuery = useAddMetricQuery(); + const addEquationQuery = useAddMetricQuery({type: 'equation'}); + const hasEquations = canUseMetricsEquations(organization); if (canUseMetricsUIRefresh(organization)) { return ( @@ -94,6 +100,15 @@ function MetricsTabFilterSection({datePageFilterProps}: MetricsTabProps) { label={t('Add Metric')} display="button" /> + + {hasEquations && ( + = MAX_METRICS_ALLOWED} + label={t('Add Equation')} + /> + )} @@ -125,6 +140,9 @@ function MetricsQueryBuilderSection() { const organization = useOrganization(); const metricQueries = useMultiMetricsQueryParams(); const addMetricQuery = useAddMetricQuery(); + const addEquationQuery = useAddMetricQuery({type: 'equation'}); + const hasEquations = canUseMetricsEquations(organization); + const references = useMetricReferences(); if (canUseMetricsUIRefresh(organization)) { return null; @@ -143,15 +161,28 @@ function MetricsQueryBuilderSection() { setTraceMetric={metricQuery.setTraceMetric} removeMetric={metricQuery.removeMetric} > - + ); })} - = MAX_METRICS_ALLOWED} - label={t('Add Metric')} - /> + + = MAX_METRICS_ALLOWED} + label={t('Add Metric')} + /> + {hasEquations && ( + = MAX_METRICS_ALLOWED} + label={t('Add Equation')} + /> + )} + ); @@ -165,12 +196,15 @@ function MetricsTabBodySection() { const {isFetching: areToolbarsLoading, isMetricOptionsEmpty} = useMetricOptions({ enabled: true, }); + const addEquationQuery = useAddMetricQuery({type: 'equation'}); + const hasEquations = canUseMetricsEquations(organization); useMetricsAnalytics({ interval, metricQueries, areToolbarsLoading, isMetricOptionsEmpty, }); + const references = useMetricReferences(); if (canUseMetricsUIRefresh(organization)) { return ( @@ -187,18 +221,30 @@ function MetricsTabBodySection() { setTraceMetric={metricQuery.setTraceMetric} removeMetric={metricQuery.removeMetric} > - + ); })} - + = MAX_METRICS_ALLOWED} label={t('Add Metric')} display="button" /> - + {hasEquations && ( + = MAX_METRICS_ALLOWED} + label={t('Add Equation')} + /> + )} + @@ -220,7 +266,11 @@ function MetricsTabBodySection() { setTraceMetric={metricQuery.setTraceMetric} removeMetric={metricQuery.removeMetric} > - + ); })} diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx index 9a690e1cd50fe7..b3deda94cf9873 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx @@ -1,14 +1,20 @@ import type {ReactNode} from 'react'; +import {OrganizationFixture} from 'sentry-fixture/organization'; import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary'; +import {EQUATION_PREFIX} from 'sentry/utils/discover/fields'; import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; import { MultiMetricsQueryParamsProvider, + useAddMetricQuery, useMultiMetricsQueryParams, } from 'sentry/views/explore/metrics/multiMetricsQueryParams'; import {ReadableQueryParams} from 'sentry/views/explore/queryParams/readableQueryParams'; -import {VisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; +import { + VisualizeEquation, + VisualizeFunction, +} from 'sentry/views/explore/queryParams/visualize'; function Wrapper({children}: {children: ReactNode}) { return {children}; @@ -259,4 +265,198 @@ describe('MultiMetricsQueryParamsProvider', () => { new VisualizeFunction('p50(value,bar,distribution,-)'), ]); }); + + describe('useAddMetricQuery', () => { + it('adds new metric at the end of the list when adding without equations', () => { + const {result, router} = renderHookWithProviders(useAddMetricQuery, { + additionalWrapper: Wrapper, + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/explore/metrics/', + query: { + metric: [ + JSON.stringify({ + metric: {name: 'foo', type: 'distribution'}, + query: '', + aggregateFields: [ + new VisualizeFunction('p50(value,foo,distribution,-)').serialize(), + ], + aggregateSortBys: [], + mode: 'samples', + }), + ], + }, + }, + }, + }); + + act(() => result.current()); + + expect(router.location.query.metric).toHaveLength(2); + + expect(JSON.parse(router.location.query.metric![0]!)).toEqual( + expect.objectContaining({ + metric: {name: 'foo', type: 'distribution'}, + query: '', + aggregateFields: [ + new VisualizeFunction('p50(value,foo,distribution,-)').serialize(), + ], + }) + ); + + // The last field was copied + expect(JSON.parse(router.location.query.metric![1]!)).toEqual( + expect.objectContaining({ + metric: {name: 'foo', type: 'distribution'}, + query: '', + aggregateFields: [ + new VisualizeFunction('p50(value,foo,distribution,-)').serialize(), + ], + }) + ); + }); + + it('duplicates the last metric when adding with equations', () => { + const {result, router} = renderHookWithProviders(useAddMetricQuery, { + additionalWrapper: Wrapper, + organization: OrganizationFixture({ + features: ['tracemetrics-enabled', 'tracemetrics-equations-in-explore'], + }), + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/explore/metrics/', + query: { + metric: [ + JSON.stringify({ + metric: {name: 'foo', type: 'distribution'}, + query: '', + aggregateFields: [ + new VisualizeFunction('p50(value,foo,distribution,-)').serialize(), + ], + aggregateSortBys: [], + mode: 'samples', + }), + JSON.stringify({ + metric: {name: '', type: ''}, + query: '', + aggregateFields: [new VisualizeEquation(EQUATION_PREFIX).serialize()], + aggregateSortBys: [], + mode: 'samples', + }), + ], + }, + }, + }, + }); + + act(() => result.current()); + + expect(router.location.query.metric).toHaveLength(3); + + expect(JSON.parse(router.location.query.metric![0]!)).toEqual( + expect.objectContaining({ + metric: {name: 'foo', type: 'distribution'}, + query: '', + aggregateFields: [ + new VisualizeFunction('p50(value,foo,distribution,-)').serialize(), + ], + }) + ); + + // The last metric query before the equation was duplicated + expect(JSON.parse(router.location.query.metric![1]!)).toEqual( + expect.objectContaining({ + metric: {name: 'foo', type: 'distribution'}, + query: '', + aggregateFields: [ + new VisualizeFunction('p50(value,foo,distribution,-)').serialize(), + ], + }) + ); + + // The equation remains + expect(JSON.parse(router.location.query.metric![2]!)).toEqual( + expect.objectContaining({ + query: '', + aggregateFields: [new VisualizeEquation(EQUATION_PREFIX).serialize()], + }) + ); + }); + + it('adds equations to the end of the list', () => { + const {result, router} = renderHookWithProviders(useAddMetricQuery, { + additionalWrapper: Wrapper, + organization: OrganizationFixture({ + features: ['tracemetrics-enabled', 'tracemetrics-equations-in-explore'], + }), + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/explore/metrics/', + query: { + metric: [ + JSON.stringify({ + metric: {name: 'foo', type: 'distribution'}, + query: '', + aggregateFields: [ + new VisualizeFunction('p50(value,foo,distribution,-)').serialize(), + ], + aggregateSortBys: [], + mode: 'samples', + }), + JSON.stringify({ + metric: {name: '', type: ''}, + query: '', + aggregateFields: [ + new VisualizeEquation( + `${EQUATION_PREFIX}p50(value,foo,distribution,-)` + ).serialize(), + ], + aggregateSortBys: [], + mode: 'samples', + }), + ], + }, + }, + }, + initialProps: { + type: 'equation', + }, + }); + + act(() => result.current()); + + expect(router.location.query.metric).toHaveLength(3); + + expect(JSON.parse(router.location.query.metric![0]!)).toEqual( + expect.objectContaining({ + metric: {name: 'foo', type: 'distribution'}, + query: '', + aggregateFields: [ + new VisualizeFunction('p50(value,foo,distribution,-)').serialize(), + ], + }) + ); + + // The old equation remains + expect(JSON.parse(router.location.query.metric![1]!)).toEqual( + expect.objectContaining({ + metric: {name: '', type: ''}, + query: '', + aggregateFields: [ + new VisualizeEquation( + `${EQUATION_PREFIX}p50(value,foo,distribution,-)` + ).serialize(), + ], + }) + ); + + // The new equation is added to the end of the list + expect(JSON.parse(router.location.query.metric![2]!)).toEqual( + expect.objectContaining({ + query: '', + aggregateFields: [new VisualizeEquation(EQUATION_PREFIX).serialize()], + }) + ); + }); + }); }); diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx index 92ed87b0b90d47..94bcf60384bddf 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx @@ -6,6 +6,7 @@ import {createDefinedContext} from 'sentry/utils/performance/contexts/utils'; import {decodeList} from 'sentry/utils/queryString'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; +import {useOrganization} from 'sentry/utils/useOrganization'; import { DEFAULT_YAXIS_BY_TYPE, OPTIONS_BY_TYPE, @@ -18,10 +19,14 @@ import { type MetricQuery, type TraceMetric, } from 'sentry/views/explore/metrics/metricQuery'; +import {canUseMetricsEquations} from 'sentry/views/explore/metrics/metricsFlags'; import {updateVisualizeYAxis} from 'sentry/views/explore/metrics/utils'; import {isGroupBy} from 'sentry/views/explore/queryParams/groupBy'; import type {ReadableQueryParams} from 'sentry/views/explore/queryParams/readableQueryParams'; -import {isVisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; +import { + isVisualizeEquation, + isVisualizeFunction, +} from 'sentry/views/explore/queryParams/visualize'; interface MultiMetricsQueryParamsContextValue { metricQueries: MetricQuery[]; @@ -182,22 +187,35 @@ export function useMultiMetricsQueryParams() { return metricQueries; } -export function useAddMetricQuery() { +export function useAddMetricQuery({ + type = 'aggregate', +}: {type?: 'aggregate' | 'equation'} = {}) { const location = useLocation(); const navigate = useNavigate(); - const {metricQueries} = useMultiMetricsQueryParamsContext(); + const organization = useOrganization(); + const {metricQueries}: {metricQueries: BaseMetricQuery[]} = + useMultiMetricsQueryParamsContext(); + const hasEquations = canUseMetricsEquations(organization); return function () { const target = {...location, query: {...location.query}}; - - const newMetricQueries = [ - ...metricQueries, - metricQueries[metricQueries.length - 1] ?? defaultMetricQuery(), - ] + const equationStart = metricQueries.findIndex(metricQuery => + isVisualizeEquation(metricQuery.queryParams.visualizes[0]!) + ); + const insertAt = + hasEquations && equationStart !== -1 && type === 'aggregate' + ? equationStart + : metricQueries.length; + const lastAggregate = metricQueries.at(insertAt - 1) ?? defaultMetricQuery(); + const canDuplicate = + type === 'aggregate' && + lastAggregate?.queryParams.visualizes.some(isVisualizeFunction); + const newQuery = canDuplicate ? lastAggregate : defaultMetricQuery({type}); + const newMetricQueries = metricQueries.toSpliced(insertAt, 0, newQuery); + target.query.metric = newMetricQueries .map((metricQuery: BaseMetricQuery) => encodeMetricQueryParams(metricQuery)) .filter(defined) .filter(Boolean); - target.query.metric = newMetricQueries; navigate(target); };