From b742387b63346561c4e8371450ebb9fa80542b6e Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Tue, 7 Apr 2026 15:07:31 -0400 Subject: [PATCH 01/12] feat(tracemetrics): Add foundation for equation UI --- .../metrics/hooks/useHasMetricEquations.tsx | 6 + .../app/views/explore/metrics/metricQuery.tsx | 17 ++- .../metricToolbar/aggregateDropdown.tsx | 18 ++- .../explore/metrics/metricToolbar/index.tsx | 110 +++++++++++++----- .../explore/metrics/metricsQueryParams.tsx | 37 ++++-- .../app/views/explore/metrics/metricsTab.tsx | 70 ++++++++--- .../metrics/multiMetricsQueryParams.tsx | 16 +++ 7 files changed, 211 insertions(+), 63 deletions(-) create mode 100644 static/app/views/explore/metrics/hooks/useHasMetricEquations.tsx diff --git a/static/app/views/explore/metrics/hooks/useHasMetricEquations.tsx b/static/app/views/explore/metrics/hooks/useHasMetricEquations.tsx new file mode 100644 index 00000000000000..b69ce9582cbd7e --- /dev/null +++ b/static/app/views/explore/metrics/hooks/useHasMetricEquations.tsx @@ -0,0 +1,6 @@ +import {useOrganization} from 'sentry/utils/useOrganization'; + +export function useHasMetricEquations() { + const organization = useOrganization(); + return organization.features.includes('tracemetrics-equations-in-explore'); +} diff --git a/static/app/views/explore/metrics/metricQuery.tsx b/static/app/views/explore/metrics/metricQuery.tsx index cc92d7aef8c88b..b42f882f46f524 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,9 @@ export function encodeMetricQueryParams(metricQuery: BaseMetricQuery): string { }); } -export function defaultMetricQuery(): BaseMetricQuery { +export function defaultMetricQuery({ + equation, +}: {equation?: boolean} = {}): BaseMetricQuery { return { metric: {name: '', type: ''}, queryParams: new ReadableQueryParams({ @@ -116,8 +119,10 @@ export function defaultMetricQuery(): BaseMetricQuery { sortBys: defaultSortBys(defaultFields()), aggregateCursor: '', - aggregateFields: defaultAggregateFields(), - aggregateSortBys: defaultAggregateSortBys(defaultAggregateFields()), + aggregateFields: equation ? [defaultAggregateEquation()] : defaultAggregateFields(), + aggregateSortBys: defaultAggregateSortBys( + equation ? [defaultAggregateEquation()] : defaultAggregateFields() + ), }), }; } @@ -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..be78a0d53daa99 100644 --- a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx +++ b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx @@ -20,6 +20,10 @@ import { useSetMetricVisualizes, } from 'sentry/views/explore/metrics/metricsQueryParams'; import {updateVisualizeYAxis} from 'sentry/views/explore/metrics/utils'; +import { + isVisualizeFunction, + VisualizeFunction, +} from 'sentry/views/explore/queryParams/visualize'; const MULTI_SELECT_GROUP_KEYS = new Set(['percentiles', 'stats']); @@ -28,21 +32,29 @@ export function AggregateDropdown({traceMetric}: {traceMetric: TraceMetric}) { const visualizes = useMetricVisualizes(); const setMetricVisualizes = useSetMetricVisualizes(); + if (!isVisualizeFunction(visualize)) { + return null; + } + 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 (selectedOptions.length === 0) { setMetricVisualizes([ updateVisualizeYAxis( - visualize, + visualize as VisualizeFunction, DEFAULT_YAXIS_BY_TYPE[traceMetric.type]!, traceMetric ), ]); } else { setMetricVisualizes( - selectedOptions.map(o => updateVisualizeYAxis(visualize, o.value, traceMetric)) + selectedOptions.map(o => + updateVisualizeYAxis(visualize as VisualizeFunction, o.value, traceMetric) + ) ); } } diff --git a/static/app/views/explore/metrics/metricToolbar/index.tsx b/static/app/views/explore/metrics/metricToolbar/index.tsx index 2b3214e8a254d4..22fe93b2271871 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 {Container, Flex, Grid, Stack} 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; + references: Set; traceMetric: TraceMetric; } -export function MetricToolbar({traceMetric, queryIndex}: MetricToolbarProps) { +export function MetricToolbar({traceMetric, queryIndex, references}: MetricToolbarProps) { const organization = useOrganization(); const metricQueries = useMultiMetricsQueryParams(); const visualize = useMetricVisualize(); @@ -34,6 +42,17 @@ 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 ( @@ -42,23 +61,39 @@ export function MetricToolbar({traceMetric, queryIndex}: MetricToolbarProps) { visualize={visualize} onClick={toggleVisibility} /> - - - - - + {isVisualizeFunction(visualize) ? ( + + + + + + + {canRemoveMetric && } + + + + + + + + + + + + + ) : isVisualizeEquation(visualize) ? ( + + null} + references={references} + setExpression={handleExpressionChange} + /> {canRemoveMetric && } - - - - - - - - - - + ) : null} ); } @@ -76,20 +111,33 @@ export function MetricToolbar({traceMetric, queryIndex}: MetricToolbarProps) { visualize={visualize} onClick={toggleVisibility} /> - - - - - - - - - - - - - - + {isVisualizeFunction(visualize) ? ( + + + + + + + + + + + + + + + + + ) : isVisualizeEquation(visualize) ? ( + null} + references={references} + setExpression={handleExpressionChange} + /> + ) : null} {canRemoveMetric && } ); diff --git a/static/app/views/explore/metrics/metricsQueryParams.tsx b/static/app/views/explore/metrics/metricsQueryParams.tsx index 7938305e091e2e..98e2b7c6b272e3 100644 --- a/static/app/views/explore/metrics/metricsQueryParams.tsx +++ b/static/app/views/explore/metrics/metricsQueryParams.tsx @@ -3,6 +3,7 @@ import {useCallback, useMemo} from 'react'; import {defined} from 'sentry/utils'; import {createDefinedContext} from 'sentry/utils/performance/contexts/utils'; +import {useHasMetricEquations} from 'sentry/views/explore/metrics/hooks/useHasMetricEquations'; import {defaultQuery, type TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; import { MetricsFrozenContextProvider, @@ -18,9 +19,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 +119,28 @@ function getUpdatedValue( return undefined; } -export function useMetricVisualize(): VisualizeFunction { +export function useMetricVisualize(): Visualize { const visualizes = useQueryParamsVisualizes(); - if (visualizes.length > 0 && isVisualizeFunction(visualizes[0]!)) { + const hasEquations = useHasMetricEquations(); + 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 visualizes = useQueryParamsVisualizes(); - if (visualizes.length > 0 && visualizes.every(isVisualizeFunction)) { + const hasEquations = useHasMetricEquations(); + if ( + visualizes.length > 0 && + visualizes.every( + v => isVisualizeFunction(v) || (isVisualizeEquation(v) && hasEquations) + ) + ) { return visualizes; } throw new Error('Only visualize functions are allowed'); @@ -142,11 +155,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 +182,7 @@ export function useRemoveMetric() { export function useSetMetricVisualize() { const setVisualizes = useSetQueryParamsVisualizes(); const setVisualize = useCallback( - (newVisualize: VisualizeFunction) => { + (newVisualize: Visualize) => { setVisualizes([newVisualize.serialize()]); }, [setVisualizes] @@ -178,7 +193,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.tsx b/static/app/views/explore/metrics/metricsTab.tsx index 14a248c3ff8767..427a40c433d7f5 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -1,3 +1,4 @@ +import {useMemo} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; @@ -25,6 +26,7 @@ import {ToolbarVisualizeAddChart} from 'sentry/views/explore/components/toolbar/ import {useMetricsAnalytics} from 'sentry/views/explore/hooks/useAnalytics'; import {useControlSectionExpanded} from 'sentry/views/explore/hooks/useControlSectionExpanded'; import {useMetricOptions} from 'sentry/views/explore/hooks/useMetricOptions'; +import {useHasMetricEquations} from 'sentry/views/explore/metrics/hooks/useHasMetricEquations'; import {MetricPanel} from 'sentry/views/explore/metrics/metricPanel'; import {canUseMetricsUIRefresh} from 'sentry/views/explore/metrics/metricsFlags'; import {MetricsQueryParamsProvider} from 'sentry/views/explore/metrics/metricsQueryParams'; @@ -32,6 +34,7 @@ import {MetricToolbar} from 'sentry/views/explore/metrics/metricToolbar'; import {MetricSaveAs} from 'sentry/views/explore/metrics/metricToolbar/metricSaveAs'; import { MultiMetricsQueryParamsProvider, + useAddEquationQuery, useAddMetricQuery, useMultiMetricsQueryParams, } from 'sentry/views/explore/metrics/multiMetricsQueryParams'; @@ -39,6 +42,8 @@ import { FilterBarWithSaveAsContainer, StyledPageFilterBar, } from 'sentry/views/explore/metrics/styles'; +import {isVisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; +import {getVisualizeLabel} from 'sentry/views/explore/toolbar/toolbarVisualize'; const MAX_METRICS_ALLOWED = 8; export const METRICS_CHART_GROUP = 'metrics-charts-group'; @@ -116,7 +121,17 @@ function MetricsQueryBuilderSection({ const organization = useOrganization(); const metricQueries = useMultiMetricsQueryParams(); const addMetricQuery = useAddMetricQuery(); - + const addEquationQuery = useAddEquationQuery(); + const hasEquations = useHasMetricEquations(); + const references = useMemo(() => { + return new Set( + metricQueries + .filter(metricQuery => + metricQuery.queryParams.visualizes.some(isVisualizeFunction) + ) + .map((_metricQuery, index) => getVisualizeLabel(index)) + ); + }, [metricQueries]); if (canUseMetricsUIRefresh(organization)) { return ( @@ -133,17 +148,31 @@ function MetricsQueryBuilderSection({ 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')} + /> + )} + ) : null} @@ -164,15 +193,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')} + /> + )} + ); diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx index 92ed87b0b90d47..21265e314cb715 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx @@ -202,3 +202,19 @@ export function useAddMetricQuery() { navigate(target); }; } + +export function useAddEquationQuery() { + const location = useLocation(); + const navigate = useNavigate(); + const {metricQueries} = useMultiMetricsQueryParamsContext(); + + return function () { + const target = {...location, query: {...location.query}}; + target.query.metric = [...metricQueries, defaultMetricQuery({equation: true})] + .map((metricQuery: BaseMetricQuery) => encodeMetricQueryParams(metricQuery)) + .filter(defined) + .filter(Boolean); + + navigate(target); + }; +} From 4ee4d2297f84ec6aeef1c8ac2231c70b91c082f8 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Wed, 8 Apr 2026 11:10:37 -0400 Subject: [PATCH 02/12] wip --- .../app/views/explore/hooks/useAnalytics.tsx | 14 ++++++-- .../metrics/multiMetricsQueryParams.tsx | 32 +++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/static/app/views/explore/hooks/useAnalytics.tsx b/static/app/views/explore/hooks/useAnalytics.tsx index ab3d3d6673674f..31f735d1e68250 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) + ? 'equation' + : '' + ); const tableError = mode === Mode.AGGREGATE diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx index 21265e314cb715..4a3d2fcc025681 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx @@ -10,6 +10,7 @@ import { DEFAULT_YAXIS_BY_TYPE, OPTIONS_BY_TYPE, } from 'sentry/views/explore/metrics/constants'; +import {useHasMetricEquations} from 'sentry/views/explore/metrics/hooks/useHasMetricEquations'; import { decodeMetricsQueryParams, defaultMetricQuery, @@ -21,7 +22,10 @@ import { 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[]; @@ -186,18 +190,34 @@ export function useAddMetricQuery() { const location = useLocation(); const navigate = useNavigate(); const {metricQueries} = useMultiMetricsQueryParamsContext(); + const hasEquations = useHasMetricEquations(); return function () { const target = {...location, query: {...location.query}}; + const equationStart = metricQueries.findIndex(metricQuery => + isVisualizeEquation(metricQuery.queryParams.visualizes[0]!) + ); + + let newMetricQueries: BaseMetricQuery[] = []; + if (hasEquations && equationStart !== -1) { + // new metric queries need to be added before the first equation to + // maintain the order of references + newMetricQueries = [ + ...metricQueries.slice(0, equationStart), + defaultMetricQuery(), + ...metricQueries.slice(equationStart), + ]; + } else { + newMetricQueries = [ + ...metricQueries, + metricQueries[metricQueries.length - 1] ?? defaultMetricQuery(), + ]; + } - const newMetricQueries = [ - ...metricQueries, - metricQueries[metricQueries.length - 1] ?? defaultMetricQuery(), - ] + target.query.metric = newMetricQueries .map((metricQuery: BaseMetricQuery) => encodeMetricQueryParams(metricQuery)) .filter(defined) .filter(Boolean); - target.query.metric = newMetricQueries; navigate(target); }; From 7d3cb88374b9be36db5c9bbf7abc97e913b754f0 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Wed, 8 Apr 2026 15:00:43 -0400 Subject: [PATCH 03/12] add tests and update API to switch on type --- .../explore/metrics/metricQuery.spec.tsx | 45 +++- .../app/views/explore/metrics/metricQuery.tsx | 9 +- .../metricToolbar/aggregateDropdown.tsx | 10 +- .../views/explore/metrics/metricsTab.spec.tsx | 30 +++ .../app/views/explore/metrics/metricsTab.tsx | 9 +- .../metrics/multiMetricsQueryParams.spec.tsx | 202 +++++++++++++++++- .../metrics/multiMetricsQueryParams.tsx | 49 ++--- 7 files changed, 303 insertions(+), 51 deletions(-) diff --git a/static/app/views/explore/metrics/metricQuery.spec.tsx b/static/app/views/explore/metrics/metricQuery.spec.tsx index ef89c2892965e9..2b291a24a54bc3 100644 --- a/static/app/views/explore/metrics/metricQuery.spec.tsx +++ b/static/app/views/explore/metrics/metricQuery.spec.tsx @@ -1,10 +1,15 @@ +import {EQUATION_PREFIX} from 'sentry/utils/discover/fields'; import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; import { decodeMetricsQueryParams, + defaultMetricQuery, encodeMetricQueryParams, } from 'sentry/views/explore/metrics/metricQuery'; 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'; describe('decodeMetricsQueryParams', () => { 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 b42f882f46f524..e606b76c728fad 100644 --- a/static/app/views/explore/metrics/metricQuery.tsx +++ b/static/app/views/explore/metrics/metricQuery.tsx @@ -105,8 +105,8 @@ export function encodeMetricQueryParams(metricQuery: BaseMetricQuery): string { } export function defaultMetricQuery({ - equation, -}: {equation?: boolean} = {}): BaseMetricQuery { + type = 'aggregate', +}: {type?: 'aggregate' | 'equation'} = {}): BaseMetricQuery { return { metric: {name: '', type: ''}, queryParams: new ReadableQueryParams({ @@ -119,9 +119,10 @@ export function defaultMetricQuery({ sortBys: defaultSortBys(defaultFields()), aggregateCursor: '', - aggregateFields: equation ? [defaultAggregateEquation()] : defaultAggregateFields(), + aggregateFields: + type === 'equation' ? [defaultAggregateEquation()] : defaultAggregateFields(), aggregateSortBys: defaultAggregateSortBys( - equation ? [defaultAggregateEquation()] : defaultAggregateFields() + type === 'equation' ? [defaultAggregateEquation()] : defaultAggregateFields() ), }), }; diff --git a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx index be78a0d53daa99..666d3a8e475e7c 100644 --- a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx +++ b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx @@ -20,10 +20,7 @@ import { useSetMetricVisualizes, } from 'sentry/views/explore/metrics/metricsQueryParams'; import {updateVisualizeYAxis} from 'sentry/views/explore/metrics/utils'; -import { - isVisualizeFunction, - VisualizeFunction, -} from 'sentry/views/explore/queryParams/visualize'; +import {isVisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; const MULTI_SELECT_GROUP_KEYS = new Set(['percentiles', 'stats']); @@ -36,6 +33,7 @@ export function AggregateDropdown({traceMetric}: {traceMetric: TraceMetric}) { return null; } + const narrowedVisualize = visualize; const groups = GROUPED_OPTIONS_BY_TYPE[traceMetric.type] ?? []; const selectedNames = new Set( visualizes.map(v => (isVisualizeFunction(v) ? (v.parsedFunction?.name ?? '') : '')) @@ -45,7 +43,7 @@ export function AggregateDropdown({traceMetric}: {traceMetric: TraceMetric}) { if (selectedOptions.length === 0) { setMetricVisualizes([ updateVisualizeYAxis( - visualize as VisualizeFunction, + narrowedVisualize, DEFAULT_YAXIS_BY_TYPE[traceMetric.type]!, traceMetric ), @@ -53,7 +51,7 @@ export function AggregateDropdown({traceMetric}: {traceMetric: TraceMetric}) { } else { setMetricVisualizes( selectedOptions.map(o => - updateVisualizeYAxis(visualize as VisualizeFunction, o.value, traceMetric) + updateVisualizeYAxis(narrowedVisualize, o.value, traceMetric) ) ); } diff --git a/static/app/views/explore/metrics/metricsTab.spec.tsx b/static/app/views/explore/metrics/metricsTab.spec.tsx index 8ab9b4ec224177..af0eb8542fd5ff 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,6 +583,35 @@ 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(); + }); }); describe('MetricsTabContent (tracemetrics-ui-refresh)', () => { diff --git a/static/app/views/explore/metrics/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx index 427a40c433d7f5..a669666afbd408 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -34,7 +34,6 @@ import {MetricToolbar} from 'sentry/views/explore/metrics/metricToolbar'; import {MetricSaveAs} from 'sentry/views/explore/metrics/metricToolbar/metricSaveAs'; import { MultiMetricsQueryParamsProvider, - useAddEquationQuery, useAddMetricQuery, useMultiMetricsQueryParams, } from 'sentry/views/explore/metrics/multiMetricsQueryParams'; @@ -121,17 +120,19 @@ function MetricsQueryBuilderSection({ const organization = useOrganization(); const metricQueries = useMultiMetricsQueryParams(); const addMetricQuery = useAddMetricQuery(); - const addEquationQuery = useAddEquationQuery(); + const addEquationQuery = useAddMetricQuery({type: 'equation'}); const hasEquations = useHasMetricEquations(); const references = useMemo(() => { return new Set( metricQueries - .filter(metricQuery => + .map((metricQuery, queryIndex) => ({metricQuery, queryIndex})) + .filter(({metricQuery}) => metricQuery.queryParams.visualizes.some(isVisualizeFunction) ) - .map((_metricQuery, index) => getVisualizeLabel(index)) + .map(({queryIndex}) => getVisualizeLabel(queryIndex)) ); }, [metricQueries]); + if (canUseMetricsUIRefresh(organization)) { return ( diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx index 9a690e1cd50fe7..4a17ad09983aa4 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-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-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 4a3d2fcc025681..fb7d59cfea6ce0 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx @@ -186,10 +186,13 @@ 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 {metricQueries}: {metricQueries: BaseMetricQuery[]} = + useMultiMetricsQueryParamsContext(); const hasEquations = useHasMetricEquations(); return function () { @@ -197,23 +200,15 @@ export function useAddMetricQuery() { const equationStart = metricQueries.findIndex(metricQuery => isVisualizeEquation(metricQuery.queryParams.visualizes[0]!) ); - - let newMetricQueries: BaseMetricQuery[] = []; - if (hasEquations && equationStart !== -1) { - // new metric queries need to be added before the first equation to - // maintain the order of references - newMetricQueries = [ - ...metricQueries.slice(0, equationStart), - defaultMetricQuery(), - ...metricQueries.slice(equationStart), - ]; - } else { - newMetricQueries = [ - ...metricQueries, - metricQueries[metricQueries.length - 1] ?? defaultMetricQuery(), - ]; - } - + const insertAt = + hasEquations && equationStart !== -1 && type === 'aggregate' + ? equationStart + : metricQueries.length; + const newQuery = + type === 'equation' + ? defaultMetricQuery({type}) + : (metricQueries.at(insertAt - 1) ?? defaultMetricQuery({type})); + const newMetricQueries = metricQueries.toSpliced(insertAt, 0, newQuery); target.query.metric = newMetricQueries .map((metricQuery: BaseMetricQuery) => encodeMetricQueryParams(metricQuery)) .filter(defined) @@ -222,19 +217,3 @@ export function useAddMetricQuery() { navigate(target); }; } - -export function useAddEquationQuery() { - const location = useLocation(); - const navigate = useNavigate(); - const {metricQueries} = useMultiMetricsQueryParamsContext(); - - return function () { - const target = {...location, query: {...location.query}}; - target.query.metric = [...metricQueries, defaultMetricQuery({equation: true})] - .map((metricQuery: BaseMetricQuery) => encodeMetricQueryParams(metricQuery)) - .filter(defined) - .filter(Boolean); - - navigate(target); - }; -} From f16967e36f13b9e8719b398d6bbaa125ad5d45fe Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Wed, 8 Apr 2026 15:46:20 -0400 Subject: [PATCH 04/12] fix grid styling for equation builder --- static/app/views/explore/metrics/metricToolbar/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/app/views/explore/metrics/metricToolbar/index.tsx b/static/app/views/explore/metrics/metricToolbar/index.tsx index 22fe93b2271871..b2a40580d6545d 100644 --- a/static/app/views/explore/metrics/metricToolbar/index.tsx +++ b/static/app/views/explore/metrics/metricToolbar/index.tsx @@ -103,7 +103,11 @@ export function MetricToolbar({traceMetric, queryIndex, references}: MetricToolb 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" > Date: Wed, 8 Apr 2026 15:51:26 -0400 Subject: [PATCH 05/12] fix accidentally duplicating equation --- .../views/explore/metrics/multiMetricsQueryParams.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx index fb7d59cfea6ce0..f282c9e78382f4 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx @@ -204,10 +204,11 @@ export function useAddMetricQuery({ hasEquations && equationStart !== -1 && type === 'aggregate' ? equationStart : metricQueries.length; - const newQuery = - type === 'equation' - ? defaultMetricQuery({type}) - : (metricQueries.at(insertAt - 1) ?? defaultMetricQuery({type})); + 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)) From 84e9bfb801f20d3b1a186e5423a9fb697ee5eec0 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Thu, 9 Apr 2026 12:20:06 -0400 Subject: [PATCH 06/12] Co-locate metric equation flag check in metricFlags --- .../explore/metrics/hooks/useHasMetricEquations.tsx | 6 ------ static/app/views/explore/metrics/metricsFlags.tsx | 7 +++++++ .../app/views/explore/metrics/metricsQueryParams.tsx | 9 ++++++--- static/app/views/explore/metrics/metricsTab.tsx | 12 +++++++----- .../explore/metrics/multiMetricsQueryParams.tsx | 6 ++++-- 5 files changed, 24 insertions(+), 16 deletions(-) delete mode 100644 static/app/views/explore/metrics/hooks/useHasMetricEquations.tsx diff --git a/static/app/views/explore/metrics/hooks/useHasMetricEquations.tsx b/static/app/views/explore/metrics/hooks/useHasMetricEquations.tsx deleted file mode 100644 index b69ce9582cbd7e..00000000000000 --- a/static/app/views/explore/metrics/hooks/useHasMetricEquations.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import {useOrganization} from 'sentry/utils/useOrganization'; - -export function useHasMetricEquations() { - const organization = useOrganization(); - return organization.features.includes('tracemetrics-equations-in-explore'); -} 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 98e2b7c6b272e3..bb55a99c9a328c 100644 --- a/static/app/views/explore/metrics/metricsQueryParams.tsx +++ b/static/app/views/explore/metrics/metricsQueryParams.tsx @@ -3,8 +3,9 @@ import {useCallback, useMemo} from 'react'; import {defined} from 'sentry/utils'; import {createDefinedContext} from 'sentry/utils/performance/contexts/utils'; -import {useHasMetricEquations} from 'sentry/views/explore/metrics/hooks/useHasMetricEquations'; +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, @@ -120,8 +121,9 @@ function getUpdatedValue( } export function useMetricVisualize(): Visualize { + const organization = useOrganization(); const visualizes = useQueryParamsVisualizes(); - const hasEquations = useHasMetricEquations(); + const hasEquations = canUseMetricsEquations(organization); if ( visualizes.length > 0 && (isVisualizeFunction(visualizes[0]!) || @@ -133,8 +135,9 @@ export function useMetricVisualize(): Visualize { } export function useMetricVisualizes(): readonly Visualize[] { + const organization = useOrganization(); const visualizes = useQueryParamsVisualizes(); - const hasEquations = useHasMetricEquations(); + const hasEquations = canUseMetricsEquations(organization); if ( visualizes.length > 0 && visualizes.every( diff --git a/static/app/views/explore/metrics/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx index bae9cfbeefe720..67447f66a390c2 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -20,9 +20,11 @@ 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 {useHasMetricEquations} from 'sentry/views/explore/metrics/hooks/useHasMetricEquations'; 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'; @@ -78,7 +80,7 @@ function MetricsTabFilterSection({datePageFilterProps}: MetricsTabProps) { const metricQueries = useMultiMetricsQueryParams(); const addMetricQuery = useAddMetricQuery(); const addEquationQuery = useAddMetricQuery({type: 'equation'}); - const hasEquations = useHasMetricEquations(); + const hasEquations = canUseMetricsEquations(organization); if (canUseMetricsUIRefresh(organization)) { return ( @@ -141,7 +143,7 @@ function MetricsQueryBuilderSection() { const metricQueries = useMultiMetricsQueryParams(); const addMetricQuery = useAddMetricQuery(); const addEquationQuery = useAddMetricQuery({type: 'equation'}); - const hasEquations = useHasMetricEquations(); + const hasEquations = canUseMetricsEquations(organization); const references = useMemo(() => { return new Set( metricQueries @@ -206,7 +208,7 @@ function MetricsTabBodySection() { enabled: true, }); const addEquationQuery = useAddMetricQuery({type: 'equation'}); - const hasEquations = useHasMetricEquations(); + const hasEquations = canUseMetricsEquations(organization); useMetricsAnalytics({ interval, metricQueries, diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx index f282c9e78382f4..94bcf60384bddf 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx @@ -6,11 +6,11 @@ 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, } from 'sentry/views/explore/metrics/constants'; -import {useHasMetricEquations} from 'sentry/views/explore/metrics/hooks/useHasMetricEquations'; import { decodeMetricsQueryParams, defaultMetricQuery, @@ -19,6 +19,7 @@ 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'; @@ -191,9 +192,10 @@ export function useAddMetricQuery({ }: {type?: 'aggregate' | 'equation'} = {}) { const location = useLocation(); const navigate = useNavigate(); + const organization = useOrganization(); const {metricQueries}: {metricQueries: BaseMetricQuery[]} = useMultiMetricsQueryParamsContext(); - const hasEquations = useHasMetricEquations(); + const hasEquations = canUseMetricsEquations(organization); return function () { const target = {...location, query: {...location.query}}; From 88a20424e11a332b47e10c98d68a06b50a647ce7 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Thu, 9 Apr 2026 12:20:58 -0400 Subject: [PATCH 07/12] pass the expression for analytics --- static/app/views/explore/hooks/useAnalytics.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/hooks/useAnalytics.tsx b/static/app/views/explore/hooks/useAnalytics.tsx index 31f735d1e68250..96f9d925ac1ac3 100644 --- a/static/app/views/explore/hooks/useAnalytics.tsx +++ b/static/app/views/explore/hooks/useAnalytics.tsx @@ -849,7 +849,7 @@ export function useMetricsPanelAnalytics({ isVisualizeFunction(visualize) ? (visualize.parsedFunction?.name ?? '') : isVisualizeEquation(visualize) - ? 'equation' + ? visualize.expression.text : '' ); From 80126452bef3ee98f4f6df54249411987688ed6b Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Thu, 9 Apr 2026 12:25:18 -0400 Subject: [PATCH 08/12] Also fix type narrowing declaration --- .../explore/metrics/metricToolbar/aggregateDropdown.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx index 666d3a8e475e7c..b68e4b1ced621a 100644 --- a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx +++ b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx @@ -33,7 +33,6 @@ export function AggregateDropdown({traceMetric}: {traceMetric: TraceMetric}) { return null; } - const narrowedVisualize = visualize; const groups = GROUPED_OPTIONS_BY_TYPE[traceMetric.type] ?? []; const selectedNames = new Set( visualizes.map(v => (isVisualizeFunction(v) ? (v.parsedFunction?.name ?? '') : '')) @@ -43,16 +42,14 @@ export function AggregateDropdown({traceMetric}: {traceMetric: TraceMetric}) { if (selectedOptions.length === 0) { setMetricVisualizes([ updateVisualizeYAxis( - narrowedVisualize, + visualize, DEFAULT_YAXIS_BY_TYPE[traceMetric.type]!, traceMetric ), ]); } else { setMetricVisualizes( - selectedOptions.map(o => - updateVisualizeYAxis(narrowedVisualize, o.value, traceMetric) - ) + selectedOptions.map(o => updateVisualizeYAxis(visualize, o.value, traceMetric)) ); } } From a839cbf46570cc1c2461b696b513af635f54c02d Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Thu, 9 Apr 2026 12:29:52 -0400 Subject: [PATCH 09/12] fix types --- static/app/views/explore/metrics/metricPanel/index.tsx | 2 +- .../explore/metrics/metricToolbar/aggregateDropdown.tsx | 7 +++---- static/app/views/explore/metrics/metricToolbar/index.tsx | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index f7d76509e611e9..58b16db2f04cc7 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -34,8 +34,8 @@ const TWO_MINUTE_DELAY = 120; interface MetricPanelProps { queryIndex: number; - references: Set; traceMetric: TraceMetric; + references?: Set; } export function MetricPanel({traceMetric, queryIndex, references}: MetricPanelProps) { diff --git a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx index b68e4b1ced621a..62ce326f4ed6c4 100644 --- a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx +++ b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx @@ -29,16 +29,15 @@ export function AggregateDropdown({traceMetric}: {traceMetric: TraceMetric}) { const visualizes = useMetricVisualizes(); const setMetricVisualizes = useSetMetricVisualizes(); - if (!isVisualizeFunction(visualize)) { - return null; - } - const groups = GROUPED_OPTIONS_BY_TYPE[traceMetric.type] ?? []; 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 a5336e2fc107fd..6fbc3f8529a81c 100644 --- a/static/app/views/explore/metrics/metricToolbar/index.tsx +++ b/static/app/views/explore/metrics/metricToolbar/index.tsx @@ -27,8 +27,8 @@ import { interface MetricToolbarProps { queryIndex: number; - references: Set; traceMetric: TraceMetric; + references?: Set; } export function MetricToolbar({traceMetric, queryIndex, references}: MetricToolbarProps) { From 920ecbdd3d545a0930d4d2695145d4df3824e0ac Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Thu, 9 Apr 2026 13:58:55 -0400 Subject: [PATCH 10/12] feat(tracemetrics): Add hook for metric reference gathering --- .../metrics/hooks/useMetricReferences.tsx | 19 +++++++++++++ .../app/views/explore/metrics/metricsTab.tsx | 27 +++---------------- .../metrics/multiMetricsQueryParams.spec.tsx | 4 +-- 3 files changed, 24 insertions(+), 26 deletions(-) create mode 100644 static/app/views/explore/metrics/hooks/useMetricReferences.tsx 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..7a4bdc6d89b82a --- /dev/null +++ b/static/app/views/explore/metrics/hooks/useMetricReferences.tsx @@ -0,0 +1,19 @@ +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(); + return useMemo(() => { + return new Set( + metricQueries + .map((metricQuery, queryIndex) => ({metricQuery, queryIndex})) + .filter(({metricQuery}) => + metricQuery.queryParams.visualizes.some(isVisualizeFunction) + ) + .map(({queryIndex}) => getVisualizeLabel(queryIndex)) + ); + }, [metricQueries]); +} diff --git a/static/app/views/explore/metrics/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx index 67447f66a390c2..67a47132e85954 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -1,4 +1,3 @@ -import {useMemo} from 'react'; import styled from '@emotion/styled'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; @@ -20,6 +19,7 @@ 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 { canUseMetricsEquations, @@ -37,8 +37,6 @@ import { FilterBarWithSaveAsContainer, StyledPageFilterBar, } from 'sentry/views/explore/metrics/styles'; -import {isVisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; -import {getVisualizeLabel} from 'sentry/views/explore/toolbar/toolbarVisualize'; const MAX_METRICS_ALLOWED = 8; export const METRICS_CHART_GROUP = 'metrics-charts-group'; @@ -144,16 +142,7 @@ function MetricsQueryBuilderSection() { const addMetricQuery = useAddMetricQuery(); const addEquationQuery = useAddMetricQuery({type: 'equation'}); const hasEquations = canUseMetricsEquations(organization); - const references = useMemo(() => { - return new Set( - metricQueries - .map((metricQuery, queryIndex) => ({metricQuery, queryIndex})) - .filter(({metricQuery}) => - metricQuery.queryParams.visualizes.some(isVisualizeFunction) - ) - .map(({queryIndex}) => getVisualizeLabel(queryIndex)) - ); - }, [metricQueries]); + const references = useMetricReferences(); if (canUseMetricsUIRefresh(organization)) { return null; @@ -215,17 +204,7 @@ function MetricsTabBodySection() { areToolbarsLoading, isMetricOptionsEmpty, }); - - const references = useMemo(() => { - return new Set( - metricQueries - .map((metricQuery, queryIndex) => ({metricQuery, queryIndex})) - .filter(({metricQuery}) => - metricQuery.queryParams.visualizes.some(isVisualizeFunction) - ) - .map(({queryIndex}) => getVisualizeLabel(queryIndex)) - ); - }, [metricQueries]); + const references = useMetricReferences(); if (canUseMetricsUIRefresh(organization)) { return ( diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx index 4a17ad09983aa4..b3deda94cf9873 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx @@ -320,7 +320,7 @@ describe('MultiMetricsQueryParamsProvider', () => { const {result, router} = renderHookWithProviders(useAddMetricQuery, { additionalWrapper: Wrapper, organization: OrganizationFixture({ - features: ['tracemetrics-equations-in-explore'], + features: ['tracemetrics-enabled', 'tracemetrics-equations-in-explore'], }), initialRouterConfig: { location: { @@ -387,7 +387,7 @@ describe('MultiMetricsQueryParamsProvider', () => { const {result, router} = renderHookWithProviders(useAddMetricQuery, { additionalWrapper: Wrapper, organization: OrganizationFixture({ - features: ['tracemetrics-equations-in-explore'], + features: ['tracemetrics-enabled', 'tracemetrics-equations-in-explore'], }), initialRouterConfig: { location: { From f81facefa984f7910681dc117126c157a0efc562 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Thu, 9 Apr 2026 14:52:03 -0400 Subject: [PATCH 11/12] Drop queryIndex inspection --- .../views/explore/metrics/hooks/useMetricReferences.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/static/app/views/explore/metrics/hooks/useMetricReferences.tsx b/static/app/views/explore/metrics/hooks/useMetricReferences.tsx index 7a4bdc6d89b82a..cef737d04c3a81 100644 --- a/static/app/views/explore/metrics/hooks/useMetricReferences.tsx +++ b/static/app/views/explore/metrics/hooks/useMetricReferences.tsx @@ -6,14 +6,17 @@ 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 - .map((metricQuery, queryIndex) => ({metricQuery, queryIndex})) - .filter(({metricQuery}) => + .filter(metricQuery => metricQuery.queryParams.visualizes.some(isVisualizeFunction) ) - .map(({queryIndex}) => getVisualizeLabel(queryIndex)) + .map((_metricQuery, index) => getVisualizeLabel(index)) ); }, [metricQueries]); } From a814ca38234cbe0987c870b07455f8af17872b7b Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Thu, 9 Apr 2026 14:54:11 -0400 Subject: [PATCH 12/12] remove duplicate default initializers --- static/app/views/explore/metrics/metricQuery.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/static/app/views/explore/metrics/metricQuery.tsx b/static/app/views/explore/metrics/metricQuery.tsx index e606b76c728fad..287559d9e2c7a6 100644 --- a/static/app/views/explore/metrics/metricQuery.tsx +++ b/static/app/views/explore/metrics/metricQuery.tsx @@ -107,6 +107,8 @@ export function encodeMetricQueryParams(metricQuery: BaseMetricQuery): string { export function defaultMetricQuery({ type = 'aggregate', }: {type?: 'aggregate' | 'equation'} = {}): BaseMetricQuery { + const newFields = + type === 'equation' ? [defaultAggregateEquation()] : defaultAggregateFields(); return { metric: {name: '', type: ''}, queryParams: new ReadableQueryParams({ @@ -119,11 +121,8 @@ export function defaultMetricQuery({ sortBys: defaultSortBys(defaultFields()), aggregateCursor: '', - aggregateFields: - type === 'equation' ? [defaultAggregateEquation()] : defaultAggregateFields(), - aggregateSortBys: defaultAggregateSortBys( - type === 'equation' ? [defaultAggregateEquation()] : defaultAggregateFields() - ), + aggregateFields: newFields, + aggregateSortBys: defaultAggregateSortBys(newFields), }), }; }