From 65cb9150e9bae5f2cc7fa37d34f4edd1cbc68d46 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Mon, 25 May 2026 12:10:14 -0400 Subject: [PATCH 01/13] chore(autofix): Add log for autofix introspection reason (#116132) --- src/sentry/seer/autofix/on_completion_hook.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 9f6d198e95a0ab..67cb156ae63213 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -361,6 +361,19 @@ def _maybe_continue_pipeline( reached_stopping_point=reached_stopping_point, ) ) + logger.info( + "autofix.on_completion_hook.introspection", + extra={ + "organization_id": organization.id, + "project_id": group.project_id, + "group_id": group.id, + "referrer": referrer.value, + "step": current_step.value, + "action": decision.action.value, + "reason": decision.reason, + "reached_stopping_point": reached_stopping_point, + }, + ) if stopping_point is None or reached_stopping_point: # We've reached the stopping point From d74d87177169cad05635ddc2fc5513243fc2a37d Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Mon, 25 May 2026 12:23:40 -0400 Subject: [PATCH 02/13] feat(tracemetrics): Open in Explore for metrics dashboard widgets (#115805) Adds "Open in Explore" for metrics dashboard equation widgets. We only support a single equation in metrics widgets. Because of the type of widget queries and aggregates we need to loop over them, but there will only be one widget queries while the single equation condition holds. If the aggregate is an equation, it's parsed out into its subcomponents and the query that's saved on the `widgetQueries` is set on that equation. --- .../utils/getWidgetMetricsUrl.spec.tsx | 188 ++++++++++++++++++ .../dashboards/utils/getWidgetMetricsUrl.tsx | 41 +++- 2 files changed, 226 insertions(+), 3 deletions(-) diff --git a/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx b/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx index dd863d8e9f2b85..f694939ddda362 100644 --- a/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx +++ b/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx @@ -475,6 +475,194 @@ describe('getWidgetMetricsUrl', () => { expect(metricQuery.query).toBe(''); }); + describe('equations', () => { + it('parses equation into sub-component metric queries and equation row', () => { + const widget: Widget = { + id: '1', + title: 'Equation Widget', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [ + 'equation|avg(value,duration,distribution,none) + count(value,requests,counter,none)', + 'transaction', + ], + aggregates: [ + 'equation|avg(value,duration,distribution,none) + count(value,requests,counter,none)', + ], + columns: ['transaction'], + conditions: 'transaction:"/api/users"', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const url = getWidgetMetricsUrl(widget, undefined, selection, organization); + const {params} = parseMetricsUrl(url); + + expect(params.metric).toBeDefined(); + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + // 2 sub-component queries + 1 equation row + expect(metrics).toHaveLength(3); + + const parsedMetrics = metrics.map(metric => JSON.parse(metric!)); + + expect(parsedMetrics[0].metric).toEqual({ + name: 'duration', + type: 'distribution', + unit: 'none', + }); + expect(parsedMetrics[0].aggregateFields[0].yAxes[0]).toBe( + 'avg(value,duration,distribution,none)' + ); + + expect(parsedMetrics[1].metric).toEqual({ + name: 'requests', + type: 'counter', + unit: 'none', + }); + expect(parsedMetrics[1].aggregateFields).toHaveLength(1); + expect(parsedMetrics[1].aggregateFields[0].groupBy).toBeUndefined(); + expect(parsedMetrics[1].aggregateFields[0].yAxes[0]).toBe( + 'count(value,requests,counter,none)' + ); + + // 2 fields, one for the equation and one for the group by + expect(parsedMetrics[2].aggregateFields).toHaveLength(2); + expect(parsedMetrics[2].aggregateFields[0].yAxes[0]).toBe( + 'equation|avg(value,duration,distribution,none) + count(value,requests,counter,none)' + ); + expect(parsedMetrics[2].aggregateFields[1].groupBy).toBe('transaction'); + expect(parsedMetrics[2].query).toContain('transaction:"/api/users"'); + }); + + it('applies dashboard filters to equation query', () => { + const widget: Widget = { + id: '1', + title: 'Filtered Equation', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [], + aggregates: [ + 'equation|avg(value,duration,distribution,none) + count(value,duration,distribution,none)', + ], + columns: [], + conditions: '', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const dashboardFilters: DashboardFilters = { + release: ['v1.0.0'], + }; + + const url = getWidgetMetricsUrl(widget, dashboardFilters, selection, organization); + const {params} = parseMetricsUrl(url); + + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + const parsedMetrics = metrics.map(metric => JSON.parse(metric!)); + + // The equation row should have dashboard filters applied + const equationRow = parsedMetrics[parsedMetrics.length - 1]; + expect(equationRow.query).toContain('release'); + expect(equationRow.query).toContain('v1.0.0'); + }); + + it('handles equation with duplicate function calls', () => { + const widget: Widget = { + id: '1', + title: 'Duplicate Funcs', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [], + aggregates: [ + 'equation|avg(value,duration,distribution,none) + avg(value,duration,distribution,none)', + ], + columns: [], + conditions: '', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const url = getWidgetMetricsUrl(widget, undefined, selection, organization); + const {params} = parseMetricsUrl(url); + + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + // 1 unique sub-component + 1 equation row (duplicates are collapsed) + expect(metrics).toHaveLength(2); + }); + + it('handles equations with conditional subcomponents', () => { + const widget: Widget = { + id: '1', + title: 'Conditional Equation', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [], + aggregates: [ + 'equation|avg_if(`environment:prod`,value,duration,distribution,none) + count(value,duration,distribution,none)', + ], + columns: [], + conditions: '', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const url = getWidgetMetricsUrl(widget, undefined, selection, organization); + const {params} = parseMetricsUrl(url); + + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + const parsedMetrics = metrics.map(metric => JSON.parse(metric!)); + + expect(parsedMetrics).toHaveLength(3); + + // First subcomponent is normalized from avg_if to avg with a filter query + expect(parsedMetrics[0].metric).toEqual({ + name: 'duration', + type: 'distribution', + unit: 'none', + }); + expect(parsedMetrics[0].query).toContain('environment:prod'); + expect(parsedMetrics[0].aggregateFields[0].yAxes[0]).toBe( + 'avg(value,duration,distribution,none)' + ); + expect(parsedMetrics[1].metric).toEqual({ + name: 'duration', + type: 'distribution', + unit: 'none', + }); + expect(parsedMetrics[1].query).toBe(''); + expect(parsedMetrics[1].aggregateFields[0].yAxes[0]).toBe( + 'count(value,duration,distribution,none)' + ); + expect(parsedMetrics[2].aggregateFields[0].yAxes[0]).toBe( + 'equation|avg_if(`environment:prod`,value,duration,distribution,none) + count(value,duration,distribution,none)' + ); + }); + }); + describe('datetime selection', () => { it('includes absolute datetime when start and end are provided', () => { const absoluteSelection: PageFilters = { diff --git a/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx b/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx index d550e41473e453..cab94c1d388655 100644 --- a/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx +++ b/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx @@ -1,18 +1,23 @@ import type {PageFilters} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; -import {explodeFieldString} from 'sentry/utils/discover/fields'; +import {explodeFieldString, isEquation} from 'sentry/utils/discover/fields'; import {decodeSorts} from 'sentry/utils/queryString'; import type {DashboardFilters, Widget} from 'sentry/views/dashboards/types'; import {DisplayType} from 'sentry/views/dashboards/types'; import {applyDashboardFilters} from 'sentry/views/dashboards/utils'; import {extractTraceMetricFromColumn} from 'sentry/views/dashboards/widgetBuilder/utils/buildTraceMetricAggregate'; import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; +import type {BaseMetricQuery} from 'sentry/views/explore/metrics/metricQuery'; +import {parseAggregateExpression} from 'sentry/views/explore/metrics/parseAggregateExpression'; import {getMetricsUrl, makeMetricsPathname} from 'sentry/views/explore/metrics/utils'; import type {AggregateField} from 'sentry/views/explore/queryParams/aggregateField'; import type {GroupBy} from 'sentry/views/explore/queryParams/groupBy'; 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'; import {ChartType} from 'sentry/views/insights/common/components/chart'; /** @@ -33,7 +38,37 @@ export function getWidgetMetricsUrl( const metricQueries = widget.queries[0].aggregates .flatMap(aggregate => { - // For each aggregate, create a metric query for each widget query + if (isEquation(aggregate)) { + // Use flatMap because of the queries type, but for an equation we will only have one + // true query. The other metric queries filters are parsed out from the equation string. + return widget.queries.flatMap(query => { + const groupByFields: GroupBy[] = query.columns.map( + (col): GroupBy => ({groupBy: col}) + ); + const queryString = + applyDashboardFilters( + query.conditions, + dashboardFilters, + widget.widgetType + ) ?? ''; + + const parsed = parseAggregateExpression(aggregate, queryString); + const results: BaseMetricQuery[] = [...parsed.metricQueries]; + if (parsed.equationRow) { + results.push({ + ...parsed.equationRow, + queryParams: parsed.equationRow.queryParams.replace({ + aggregateFields: [ + new VisualizeEquation(aggregate, {chartType}), + ...groupByFields, + ], + }), + }); + } + return results; + }); + } + return widget.queries.map(query => { const queryString = applyDashboardFilters(query.conditions, dashboardFilters, widget.widgetType) ?? From f05d511f9b74e37a4a583d4af6a4cfe4f09de697 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Mon, 25 May 2026 12:26:11 -0400 Subject: [PATCH 03/13] feat(tracemetrics): Convert equation alias to full equation for queries (#116047) The RPC endpoint for tracemetrics doesn't take `equation[0]` as a valid sort and expects the full `equation|...` format. This PR ensures that when we make requests to the datasets with sorts (e.g. grouped series, or categorical bar charts) that the full equation is used. This is done by updating two surfaces: - Updating the series query params to treat `DiscoverDatasets.TRACEMETRICS` similar to spans and logs, which use RPC and have the same constraints/handling - Update the table request for categorical bar charts to detect if the sort is an equation alias, then get the index, get the equations, and use the right equation index to set the sort - Remove equation handling for the widget builder table sort because all datasets that support equations now support the full equation format. It's only the case that some datasets (RPC datasets) don't support the alias format This shouldn't require feature-flagging because it's gated on having the equation builder, which itself is feature flagged. I did a bunch of this PR manually and guided with claude before I realized there was a lot simpler of an implementation where we can internally use the `equation[0]` syntax for now and convert on the requests. At that point I prompted it to remove a bunch of the old cruft, rework the stuff that remained, and add tests. --- .../modals/dataWidgetViewerModal.tsx | 14 +- .../datasetConfig/traceMetrics.spec.tsx | 53 +++++++ .../dashboards/datasetConfig/traceMetrics.tsx | 24 +++- .../utils/getSeriesRequestData.spec.tsx | 134 ++++++++++++++++++ .../utils/getSeriesRequestData.tsx | 8 +- .../hooks/useTraceMetricsWidgetQuery.tsx | 27 +++- 6 files changed, 240 insertions(+), 20 deletions(-) diff --git a/static/app/components/modals/dataWidgetViewerModal.tsx b/static/app/components/modals/dataWidgetViewerModal.tsx index 41079fcb99f423..520f6ab2681198 100644 --- a/static/app/components/modals/dataWidgetViewerModal.tsx +++ b/static/app/components/modals/dataWidgetViewerModal.tsx @@ -11,7 +11,7 @@ import moment from 'moment-timezone'; import {Alert} from '@sentry/scraps/alert'; import {Button, LinkButton} from '@sentry/scraps/button'; -import {Flex, Grid, Stack, Container} from '@sentry/scraps/layout'; +import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; import {Pagination} from '@sentry/scraps/pagination'; import {Select, SelectOption} from '@sentry/scraps/select'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -79,7 +79,6 @@ import { dashboardFiltersToString, eventViewFromWidget, getFieldsFromEquations, - getNumEquations, getWidgetDiscoverUrl, getWidgetIssueUrl, getWidgetReleasesUrl, @@ -306,7 +305,6 @@ function DataWidgetViewerModal(props: Props) { }; const {aggregates, columns} = tableWidget.queries[0]!; const {orderby} = widget.queries[0]!; - const order = orderby.startsWith('-'); const rawOrderby = trimStart(orderby, '-'); const fields = @@ -342,14 +340,6 @@ function DataWidgetViewerModal(props: Props) { } } - // Need to set the orderby of the eventsv2 query to equation[index] format - // since eventsv2 does not accept the raw equation as a valid sort payload - if (isEquation(rawOrderby) && tableWidget.queries[0]!.orderby === orderby) { - tableWidget.queries[0]!.orderby = `${order ? '-' : ''}equation[${ - getNumEquations(fields) - 1 - }]`; - } - // Default table columns for visualizations that don't have a group by set const hasGroupBy = (widget.queries[0]?.columns.length ?? 0) > 0; const shouldReplaceTableColumns = @@ -1075,8 +1065,8 @@ function ViewerTableV2({ datasetConfig?.getFieldHeaderMap?.(tableWidget.queries[selectedQueryIndex]) ?? {} ); - // Inject any prettified function names that aren't currently aliased into the aliases for (const column of tableColumns) { + // Inject any prettified function names that aren't currently aliased into the aliases const parsedFunction = parseFunction(column.key); if (!aliases[column.key] && parsedFunction) { aliases[column.key] = prettifyParsedFunction(parsedFunction); diff --git a/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx b/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx index 13df2a15b087ce..1b6aaec654e31b 100644 --- a/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx +++ b/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx @@ -698,6 +698,59 @@ describe('TraceMetricsConfig', () => { }); }); + describe('getTableSortOptions', () => { + it('returns equation aliases with ƒ labels', () => { + const widgetQuery: WidgetQuery = { + name: '', + fields: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + columns: [], + fieldAliases: [], + aggregates: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + conditions: '', + orderby: '', + }; + + const options = TraceMetricsConfig.getTableSortOptions!(organization, widgetQuery); + + expect(options).toEqual( + expect.arrayContaining([ + expect.objectContaining({value: 'equation[0]', label: 'ƒ1'}), + ]) + ); + }); + + it('returns regular aggregates alongside equation aliases', () => { + const widgetQuery: WidgetQuery = { + name: '', + fields: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + columns: [], + fieldAliases: [], + aggregates: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + conditions: '', + orderby: '', + }; + + const options = TraceMetricsConfig.getTableSortOptions!(organization, widgetQuery); + + const labels = options.map(o => o.label); + expect(labels).toHaveLength(2); + expect(labels[0]).toBe('avg(test_metric)'); + expect(labels[1]).toBe('ƒ1'); + }); + }); + describe('TraceMetricsSearchBar', () => { const SearchBar = TraceMetricsConfig.SearchBar; diff --git a/static/app/views/dashboards/datasetConfig/traceMetrics.tsx b/static/app/views/dashboards/datasetConfig/traceMetrics.tsx index 0f15eed12dc544..328e8e82f44e93 100644 --- a/static/app/views/dashboards/datasetConfig/traceMetrics.tsx +++ b/static/app/views/dashboards/datasetConfig/traceMetrics.tsx @@ -8,8 +8,11 @@ import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/ import type {EventsTableData} from 'sentry/utils/discover/discoverQuery'; import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import { + getEquationAliasIndex, + isEquationAlias, parseFunction, RateUnit, + stripEquationPrefix, type AggregationOutputType, type DataUnit, type QueryFieldValue, @@ -264,10 +267,19 @@ export const TraceMetricsConfig: DatasetConfig< // We've forced the sort options to use the table sort options UI because // we only want to allow sorting by selected aggregates. getTableSortOptions: (organization, widgetQuery) => - getTableSortOptions(organization, widgetQuery).map(option => ({ - label: formatTraceMetricsFunction(option.value, option.label), - value: option.value, - })), + getTableSortOptions(organization, widgetQuery).map(({value, label}) => { + if (isEquationAlias(value)) { + return { + label: `ƒ${getEquationAliasIndex(value) + 1}`, + value, + }; + } + + return { + label, + value, + }; + }), getGroupByFieldOptions, supportedDisplayTypes: [ DisplayType.AREA, @@ -318,7 +330,9 @@ export const TraceMetricsConfig: DatasetConfig< getFieldHeaderMap: widgetQuery => { return ( widgetQuery?.aggregates.reduce>((acc, aggregate) => { - acc[aggregate] = formatTraceMetricsFunction(aggregate) as string; + acc[aggregate] = stripEquationPrefix( + formatTraceMetricsFunction(aggregate) as string + ); return acc; }, {}) ?? {} ); diff --git a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx index d65d9bc1fafd85..aa820d9fe38596 100644 --- a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx +++ b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx @@ -188,6 +188,140 @@ describe('utils', () => { expect(requestData.field).not.toContain('count_unique_user'); }); + describe('equation orderby handling for trace metrics', () => { + it('passes full equation orderby through and adds it to fields for TRACEMETRICS', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: [ + 'project', + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + aggregates: ['avg(value,test_metric,millisecond,none)'], + columns: ['project'], + orderby: '-equation|avg(value,test_metric,millisecond,none) / 2', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.TRACEMETRICS, + 'test-referrer' + ); + + expect(requestData.orderby).toBe( + '-equation|avg(value,test_metric,millisecond,none) / 2' + ); + expect(requestData.field).toContain( + 'equation|avg(value,test_metric,millisecond,none) / 2' + ); + }); + + it('resolves equation alias orderby to full equation form for TRACEMETRICS', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: [ + 'project', + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + aggregates: ['avg(value,test_metric,millisecond,none)'], + columns: ['project'], + orderby: '-equation[0]', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.TRACEMETRICS, + 'test-referrer' + ); + + expect(requestData.orderby).toBe( + '-equation|avg(value,test_metric,millisecond,none) / 2' + ); + }); + + it('resolves ascending equation alias orderby for TRACEMETRICS', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: [ + 'project', + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + aggregates: ['avg(value,test_metric,millisecond,none)'], + columns: ['project'], + orderby: 'equation[0]', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.TRACEMETRICS, + 'test-referrer' + ); + + expect(requestData.orderby).toBe( + 'equation|avg(value,test_metric,millisecond,none) / 2' + ); + }); + + it('converts full equation orderby to alias format for non-EAP datasets', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: ['project', 'count()', 'equation|count() / 2'], + aggregates: ['count()'], + columns: ['project'], + orderby: '-equation|count() / 2', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.ERRORS, + 'test-referrer' + ); + + // Non-EAP datasets convert to equation[N] alias format + // The index is based on the number of equations in aggregates (0 here) + expect(requestData.orderby).toBe('-equation[0]'); + expect(requestData.field).toContain('equation|count() / 2'); + }); + }); + it('adds the orderby to fields if it is not in fields, columns, or aggregates', () => { const widget = WidgetFixture({ displayType: DisplayType.LINE, diff --git a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx index 68905561156aa0..311c01de725ffe 100644 --- a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx +++ b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx @@ -95,7 +95,13 @@ export function getSeriesRequestData( requestData.excludeOther = widgetQuery.aggregates.length !== 1 || widget.queries.length !== 1; - if ([DiscoverDatasets.OURLOGS, DiscoverDatasets.SPANS].includes(dataset)) { + if ( + [ + DiscoverDatasets.OURLOGS, + DiscoverDatasets.SPANS, + DiscoverDatasets.TRACEMETRICS, + ].includes(dataset) + ) { if ( isEquation(trimStart(widgetQuery.orderby, '-')) && !requestData.field?.includes(trimStart(widgetQuery.orderby, '-')) diff --git a/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx index 2776f50b63fd30..d7c668c86ff6fe 100644 --- a/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx @@ -7,9 +7,16 @@ import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {toArray} from 'sentry/utils/array/toArray'; import {getUtcDateString} from 'sentry/utils/dates'; import type {EventsTableData} from 'sentry/utils/discover/discoverQuery'; -import type {AggregationOutputType, DataUnit} from 'sentry/utils/discover/fields'; +import { + getEquationAliasIndex, + isEquation, + isEquationAlias, + type AggregationOutputType, + type DataUnit, +} from 'sentry/utils/discover/fields'; import type {DiscoverQueryRequestParams} from 'sentry/utils/discover/genericDiscoverQuery'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {decodeSorts} from 'sentry/utils/queryString'; import {RequestError} from 'sentry/utils/requestError/requestError'; import {SERIES_QUERY_DELIMITER} from 'sentry/utils/timeSeries/transformLegacySeriesToTimeSeries'; import type {EventsTimeSeriesResponse} from 'sentry/utils/timeSeries/useFetchEventsTimeSeries'; @@ -280,7 +287,23 @@ export function useTraceMetricsTableQuery( }; if (query.orderby) { - requestParams.sort = toArray(query.orderby); + const baseSort = decodeSorts(query.orderby)[0]; + if (isEquationAlias(baseSort?.field ?? '')) { + const fields = query.fields ?? [...query.columns, ...query.aggregates]; + const equations = fields.filter(isEquation); + const equationIndex = getEquationAliasIndex(baseSort?.field ?? ''); + const equation = equations[equationIndex]; + if (equation) { + requestParams.sort = toArray( + baseSort?.kind === 'desc' ? `-${equation}` : equation + ); + } else { + // In case we failed to find an equation by its index, reset the sort + requestParams.sort = undefined; + } + } else { + requestParams.sort = toArray(query.orderby); + } } const queryParams = { From 3a5d766fd2be44c7bdafeb9097a1ff6473ccee82 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> Date: Mon, 25 May 2026 13:03:10 -0400 Subject: [PATCH 04/13] feat(dashboards): Validate display type against dataset config (#115951) Rejects widget create/update requests whose `(widget_type, display_type)` combination isn't allowed by the frontend Widget Builder's type selector (e.g. `table` on `tracemetrics`, `table` on `preprod-app-size`). Allowed combinations live in a new `DATASET_CONFIG` map keyed by `widget_type`. Adding the dataset configs also sets us up nicely for more per dataset validation For example, in the ui trace metrics don't support tables, so we shouldn't let them be saved as tables --- .../serializers/rest_framework/dashboard.py | 95 ++++++++++++++++++- ...t_organization_dashboard_widget_details.py | 50 +++++++++- .../endpoints/test_organization_dashboards.py | 86 +++++++++++++++++ 3 files changed, 229 insertions(+), 2 deletions(-) diff --git a/src/sentry/api/serializers/rest_framework/dashboard.py b/src/sentry/api/serializers/rest_framework/dashboard.py index db6930c4c259e1..874c9686f8faf9 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -105,6 +105,77 @@ def is_table_display_type(display_type): MAX_WIDGET_COLS = 6 +_DEFAULT_CHART_AND_TABLE_TYPES: frozenset[int] = frozenset( + { + DashboardWidgetDisplayTypes.LINE_CHART, + DashboardWidgetDisplayTypes.AREA_CHART, + DashboardWidgetDisplayTypes.BAR_CHART, + DashboardWidgetDisplayTypes.TABLE, + DashboardWidgetDisplayTypes.BIG_NUMBER, + DashboardWidgetDisplayTypes.CATEGORICAL_BAR_CHART, + } +) + + +class DatasetConfig(TypedDict): + supported_display_types: frozenset[int] + + +# Per-dataset config mirroring the frontend dataset configs +# (``static/app/views/dashboards/datasetConfig/*.tsx``). A display type is +# allowed for a widget_type iff it appears in ``supported_display_types`` here. +DATASET_CONFIG: dict[int, DatasetConfig] = { + DashboardWidgetTypes.DISCOVER: {"supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES}, + # ERROR_EVENTS is intentionally omitted: it's the ``create_widget`` default + # when a request omits widget_type, so any system display type a prebuilt + # config doesn't tag will land here. Without a config entry the validation + # falls through and lets the request pass. + DashboardWidgetTypes.TRANSACTION_LIKE: { + "supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES + }, + DashboardWidgetTypes.RELEASE_HEALTH: { + "supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES + }, + DashboardWidgetTypes.METRICS: {"supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES}, + DashboardWidgetTypes.LOGS: {"supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES}, + DashboardWidgetTypes.ISSUE: { + "supported_display_types": frozenset( + { + DashboardWidgetDisplayTypes.TABLE, + DashboardWidgetDisplayTypes.LINE_CHART, + DashboardWidgetDisplayTypes.AREA_CHART, + DashboardWidgetDisplayTypes.BAR_CHART, + } + ) + }, + DashboardWidgetTypes.SPANS: { + "supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES + | frozenset( + { + DashboardWidgetDisplayTypes.DETAILS, + DashboardWidgetDisplayTypes.SERVER_TREE, + # WHEEL is used by built-in performance-score widgets. + DashboardWidgetDisplayTypes.WHEEL, + } + ) + }, + DashboardWidgetTypes.TRACEMETRICS: { + "supported_display_types": frozenset( + { + DashboardWidgetDisplayTypes.LINE_CHART, + DashboardWidgetDisplayTypes.AREA_CHART, + DashboardWidgetDisplayTypes.BAR_CHART, + DashboardWidgetDisplayTypes.BIG_NUMBER, + DashboardWidgetDisplayTypes.CATEGORICAL_BAR_CHART, + } + ) + }, + DashboardWidgetTypes.PREPROD_APP_SIZE: { + "supported_display_types": frozenset({DashboardWidgetDisplayTypes.LINE_CHART}) + }, +} + + class WidgetLayoutSerializer(CamelSnakeSerializer[Dashboard]): """Widget grid layout position and dimensions. @@ -326,7 +397,24 @@ class DashboardWidgetSerializer(CamelSnakeSerializer[Dashboard]): ) def validate_display_type(self, display_type): - return DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) + display_type_id = DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) + + widget_type_name = self.context.get("widget_type") + if widget_type_name is not None and display_type_id is not None: + widget_type_id = DashboardWidgetTypes.get_id_for_type_name(widget_type_name) + config = DATASET_CONFIG.get(widget_type_id) + if config is not None and display_type_id not in config["supported_display_types"]: + supported_names = sorted( + DashboardWidgetDisplayTypes.get_type_name(d) or str(d) + for d in config["supported_display_types"] + ) + raise serializers.ValidationError( + f"Display type '{display_type}' is not supported for the " + f"'{widget_type_name}' dataset. Supported display types: " + f"{', '.join(supported_names)}." + ) + + return display_type_id def _validate_widget_type(self, data): widget_type = DashboardWidgetTypes.get_id_for_type_name(data.get("widget_type")) @@ -358,6 +446,11 @@ def to_internal_value(self, data): queries_serializer = self.fields["queries"] additional_context = {} + # Always reset; with ``many=True`` DRF reuses one child serializer + # instance across items, so stale values would otherwise leak between + # widgets in the same request. + self.context["widget_type"] = data.get("widget_type") + if data.get("display_type"): additional_context["display_type"] = data.get("display_type") if data.get("widget_type"): diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py index 75ec4434eacb62..3a11f45f97f09a 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py @@ -176,6 +176,30 @@ def test_invalid_display_type(self) -> None: assert response.status_code == 400, response.data assert "displayType" in response.data, response.data + def test_unsupported_display_type_for_widget_type(self) -> None: + data = { + "title": "Table on preprod-app-size", + "displayType": "table", + "widgetType": "preprod-app-size", + "queries": [ + { + "name": "", + "conditions": "", + "fields": ["count()"], + "columns": [], + "aggregates": ["count()"], + } + ], + } + response = self.do_request( + "post", + self.url(), + data=data, + ) + assert response.status_code == 400, response.data + assert "displayType" in response.data, response.data + assert "preprod-app-size" in str(response.data["displayType"]) + def test_invalid_equation(self) -> None: data = { "title": "Invalid query", @@ -1442,7 +1466,7 @@ def test_widget_type_tracemetrics(self) -> None: data = { "title": "Test Metrics Query", "widgetType": "tracemetrics", - "displayType": "table", + "displayType": "line", "queries": [ { "name": "", @@ -1461,6 +1485,30 @@ def test_widget_type_tracemetrics(self) -> None: ) assert response.status_code == 200, response.data + def test_widget_type_tracemetrics_rejects_table(self) -> None: + data = { + "title": "Test Metrics Query", + "widgetType": "tracemetrics", + "displayType": "table", + "queries": [ + { + "name": "", + "conditions": "metric.name:foo", + "fields": ["sum(value)"], + "columns": [], + "aggregates": ["sum(value)"], + }, + ], + } + + response = self.do_request( + "post", + self.url(), + data=data, + ) + assert response.status_code == 400, response.data + assert "displayType" in response.data, response.data + def test_text_widget_without_feature_flag(self) -> None: data = { "title": "Text Widget Title", diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py index d5c51ef1449870..459d9096cdd162 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py @@ -2169,6 +2169,92 @@ def test_post_with_text_widget(self) -> None: assert DashboardWidgetQuery.objects.filter(widget=text_widget).count() == 0 + def test_agents_traces_table_dashboard_save_and_update(self) -> None: + # Regression: the AI Agents Overview prebuilt config has an + # agents_traces_table widget without a widget_type. The backend defaults + # it to error-events on create. On the next PUT the frontend round-trips + # widget_type=error-events, which would otherwise fail validation. + data = { + "title": "AI Agents Overview", + "widgets": [ + { + "title": "Traces", + "displayType": "agents_traces_table", + "queries": [ + { + "name": "", + "fields": [], + "columns": [], + "aggregates": [], + "conditions": "", + } + ], + }, + ], + } + create = self.do_request("post", self.url, data=data) + assert create.status_code == 201, create.data + dashboard_id = create.data["id"] + widget_id = create.data["widgets"][0]["id"] + widget_type = create.data["widgets"][0].get("widgetType") + + put_url = f"/api/0/organizations/{self.organization.slug}/dashboards/{dashboard_id}/" + put_data = { + "title": "AI Agents Overview", + "widgets": [ + { + "id": widget_id, + "title": "Traces", + "displayType": "agents_traces_table", + "widgetType": widget_type, + "queries": [ + { + "name": "", + "fields": [], + "columns": [], + "aggregates": [], + "conditions": "", + } + ], + }, + ], + } + update = self.do_request("put", put_url, data=put_data) + assert update.status_code == 200, update.data + + def test_post_text_widget_after_restrictive_dataset_widget(self) -> None: + # Regression: DRF reuses a single child serializer for ``many=True``, + # so a previous widget's widget_type can leak via serializer context + # and incorrectly fail validation for a later TEXT widget. + with self.feature("organizations:dashboards-text-widgets"): + data = { + "title": "Dashboard from Post", + "widgets": [ + { + "title": "Mobile Size", + "displayType": "line", + "widgetType": "preprod-app-size", + "interval": "5m", + "queries": [ + { + "name": "", + "fields": ["count()"], + "columns": [], + "aggregates": ["count()"], + "conditions": "", + } + ], + }, + { + "title": "Text Widget", + "displayType": "text", + "description": "Notes", + }, + ], + } + response = self.do_request("post", self.url, data=data) + assert response.status_code == 201, response.data + def test_post_with_text_widget_without_feature_flag(self) -> None: data = { "title": "Dashboard from Post", From be7e6a753fba4a1b3f689b85e66012faa25ba7e0 Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Mon, 25 May 2026 13:44:35 -0400 Subject: [PATCH 05/13] fix(metrics): default to largest interval when using heatmaps visualization (#116129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Couple of things to note here. For heat maps we want to default to the largest interval because it shows the most patterns and then users can adjust how they'd like. There wasn't an option to use the largest interval so i added it in. I've also added tests for the `useChartInterval` hook. Test by changing the chart type in metrics to heat map and see the default interval change 🤩 --- static/app/utils/useChartInterval.spec.tsx | 83 ++++++++++++++++++- static/app/utils/useChartInterval.tsx | 20 ++++- .../explore/metrics/metricPanel/index.tsx | 13 ++- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/static/app/utils/useChartInterval.spec.tsx b/static/app/utils/useChartInterval.spec.tsx index 4d84c6de26f188..2849ac53aa2268 100644 --- a/static/app/utils/useChartInterval.spec.tsx +++ b/static/app/utils/useChartInterval.spec.tsx @@ -3,7 +3,11 @@ import {act, render} from 'sentry-test/reactTestingLibrary'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import {parsePeriodToHours} from 'sentry/utils/duration/parsePeriodToHours'; -import {getIntervalOptionsForPageFilter, useChartInterval} from './useChartInterval'; +import { + ChartIntervalUnspecifiedStrategy, + getIntervalOptionsForPageFilter, + useChartInterval, +} from './useChartInterval'; describe('useChartInterval', () => { beforeEach(() => { @@ -53,6 +57,83 @@ describe('useChartInterval', () => { }); expect(chartInterval).toBe('5m'); }); + + it('defaults to the smallest interval with USE_SMALLEST strategy', () => { + // Default 14d period produces ladder-derived options ['1h', '3h', '6h'] + let chartInterval!: ReturnType[0]; + + function TestPage() { + [chartInterval] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SMALLEST, + }); + return null; + } + + render(); + expect(chartInterval).toBe('1h'); + }); + + it('defaults to the largest ladder-derived interval with USE_BIGGEST strategy', () => { + // Default 14d period produces ladder-derived options ['1h', '3h', '6h']. + // The '1d' option is appended after the default is computed, so it is not + // considered when selecting the biggest default. + let chartInterval!: ReturnType[0]; + let intervalOptions!: ReturnType[2]; + + function TestPage() { + [chartInterval, , intervalOptions] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_BIGGEST, + }); + return null; + } + + render(); + expect(chartInterval).toBe('6h'); + // '1d' is still present as a selectable option even though it was not the default + expect(intervalOptions.map(o => o.value)).toContain('1d'); + }); + + it('defaults to the second-largest interval with USE_SECOND_BIGGEST strategy', () => { + // Default 14d period produces ladder-derived options ['1h', '3h', '6h'], + // so the second-biggest is '3h'. + let chartInterval!: ReturnType[0]; + + function TestPage() { + [chartInterval] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST, + }); + return null; + } + + render(); + expect(chartInterval).toBe('3h'); + }); + + it('falls back to the only option when USE_SECOND_BIGGEST is used with a single-option period', () => { + // A 1-minute period produces only ['1m'] as the valid interval option. + // options[length-2] is undefined, so the fallback is options[length-1] = '1m'. + let chartInterval!: ReturnType[0]; + + function TestPage() { + [chartInterval] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST, + }); + return null; + } + + render(); + + act(() => + PageFiltersStore.updateDateTime({ + period: '1m', + start: null, + end: null, + utc: true, + }) + ); + + expect(chartInterval).toBe('1m'); + }); }); describe('getIntervalOptionsForPageFilter', () => { diff --git a/static/app/utils/useChartInterval.tsx b/static/app/utils/useChartInterval.tsx index 743564809f02bb..9ebb5fe5e5d83c 100644 --- a/static/app/utils/useChartInterval.tsx +++ b/static/app/utils/useChartInterval.tsx @@ -26,6 +26,8 @@ export enum ChartIntervalUnspecifiedStrategy { USE_SECOND_BIGGEST = 'use_second_biggest', /** Use the smallest possible interval (e.g., the smallest possible buckets) */ USE_SMALLEST = 'use_smallest', + /** Use the biggest possible interval (e.g., the biggest possible buckets) */ + USE_BIGGEST = 'use_biggest', } interface Options { @@ -71,10 +73,20 @@ function useChartIntervalImpl({ const options = getIntervalOptionsForPageFilter(datetime); // Compute the default from the ladder-derived options, before appending extras - const fallback = - unspecifiedStrategy === ChartIntervalUnspecifiedStrategy.USE_SMALLEST - ? options[0]!.value - : (options[options.length - 2]?.value ?? options[options.length - 1]!.value); + let fallback: string; + switch (unspecifiedStrategy) { + case ChartIntervalUnspecifiedStrategy.USE_SMALLEST: + fallback = options[0]!.value; + break; + case ChartIntervalUnspecifiedStrategy.USE_BIGGEST: + fallback = options[options.length - 1]!.value; + break; + case ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST: + default: + fallback = + options[options.length - 2]?.value ?? options[options.length - 1]!.value; + break; + } if (diffInMinutes >= MINIMUM_DURATION_FOR_ONE_DAY_INTERVAL) { options.push(ONE_DAY_OPTION); diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index d337b70120ce11..ff08d9ce514ded 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -18,7 +18,10 @@ import {t} from 'sentry/locale'; import type {PageFilters} from 'sentry/types/core'; import type {DataUnit} from 'sentry/utils/discover/fields'; import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds'; -import {useChartInterval} from 'sentry/utils/useChartInterval'; +import { + ChartIntervalUnspecifiedStrategy, + useChartInterval, +} from 'sentry/utils/useChartInterval'; import {useDimensions} from 'sentry/utils/useDimensions'; import {useOrganization} from 'sentry/utils/useOrganization'; import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types'; @@ -120,11 +123,17 @@ export function MetricPanel({ const aggregateSortBys = useQueryParamsAggregateSortBys(); const groupBys = useQueryParamsGroupBys(); const setGroupBys = useSetQueryParamsGroupBys(); - const [interval, setInterval, intervalOptions] = useChartInterval(); const topEvents = useTopEvents(); const visualize = useMetricVisualize(); const visualizes = useMetricVisualizes(); const setVisualizes = useSetMetricVisualizes(); + // use the biggest interval for the heat map as this produces better patterns + const [interval, setInterval, intervalOptions] = useChartInterval({ + unspecifiedStrategy: + visualize.chartType === ChartType.HEATMAP + ? ChartIntervalUnspecifiedStrategy.USE_BIGGEST + : ChartIntervalUnspecifiedStrategy.USE_SMALLEST, + }); const [title, setTitle] = useState(() => { if (isVisualizeEquation(visualize)) { From 2e4a45d3c44613bf558586b7f2efde5c5e7d1e28 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> Date: Mon, 25 May 2026 13:57:53 -0400 Subject: [PATCH 06/13] ref(slack): remove widget unfurl feature flags (#116128) ## Summary - Removes the `organizations:data-browsing-widget-unfurl` and `organizations:dashboards-widget-unfurl` registrations from `src/sentry/features/temporary.py`. - Switches the Slack unfurl flag gates over to the base feature flags for each product (`organizations:visibility-explore-view` for explore, `organizations:dashboards-basic` for dashboards) in `explore.py`, `dashboards.py`, and `webhooks/event.py`. This keeps the existing "only unfurl when the org has the underlying product" guard while removing the unfurl-specific flags. --- src/sentry/features/temporary.py | 4 - .../integrations/slack/unfurl/dashboards.py | 2 +- .../integrations/slack/unfurl/explore.py | 3 +- .../integrations/slack/webhooks/event.py | 4 +- .../sentry/integrations/slack/test_unfurl.py | 126 ++++-------------- .../events/test_explore_link_shared.py | 2 +- 6 files changed, 33 insertions(+), 108 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 127c03cce2ca24..b184dd0aaea14f 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -66,8 +66,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:dashboards-basic", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True, default=True) # Enables custom editable dashboards manager.add("organizations:dashboards-edit", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True, default=True) - # Enable unfurling of dashboard widgets in Slack - manager.add("organizations:dashboards-widget-unfurl", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable metrics enhanced performance for AM2+ customers as they transition from AM2 to AM3 manager.add("organizations:dashboards-metrics-transition", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable drilldown flow for dashboards @@ -348,8 +346,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:insights-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable data browsing heat map widget manager.add("organizations:data-browsing-heat-map-widget", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable data browsing widget unfurl - manager.add("organizations:data-browsing-widget-unfurl", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable public RPC endpoint for local seer development manager.add("organizations:seer-public-rpc", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Organizations on the old usage-based (v0) Seer plan diff --git a/src/sentry/integrations/slack/unfurl/dashboards.py b/src/sentry/integrations/slack/unfurl/dashboards.py index ae2054773b9b56..6873b0796b5fc2 100644 --- a/src/sentry/integrations/slack/unfurl/dashboards.py +++ b/src/sentry/integrations/slack/unfurl/dashboards.py @@ -129,7 +129,7 @@ def _unfurl_dashboards( enabled_orgs = { slug: org for slug, org in orgs_by_slug.items() - if features.has("organizations:dashboards-widget-unfurl", org, actor=user) + if features.has("organizations:dashboards-basic", org, actor=user) } if not enabled_orgs: return {} diff --git a/src/sentry/integrations/slack/unfurl/explore.py b/src/sentry/integrations/slack/unfurl/explore.py index 841719b11c8aae..ced91ca3864d8e 100644 --- a/src/sentry/integrations/slack/unfurl/explore.py +++ b/src/sentry/integrations/slack/unfurl/explore.py @@ -375,11 +375,10 @@ def _unfurl_explore( ) orgs_by_slug = {org.slug: org for org in organizations} - # Check if any org has the feature flag enabled before doing any work enabled_orgs = { slug: org for slug, org in orgs_by_slug.items() - if features.has("organizations:data-browsing-widget-unfurl", org, actor=user) + if features.has("organizations:visibility-explore-view", org, actor=user) } if not enabled_orgs: return {} diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index eee70333cd08e1..504b0e0b4c044f 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -229,8 +229,8 @@ def _get_unfurlable_links( feature_flag = { LinkType.DISCOVER: "organizations:discover-basic", - LinkType.EXPLORE: "organizations:data-browsing-widget-unfurl", - LinkType.DASHBOARDS: "organizations:dashboards-widget-unfurl", + LinkType.EXPLORE: "organizations:visibility-explore-view", + LinkType.DASHBOARDS: "organizations:dashboards-basic", }.get(link_type) if ( diff --git a/tests/sentry/integrations/slack/test_unfurl.py b/tests/sentry/integrations/slack/test_unfurl.py index 04e85bac98f674..5ce347cd4cc9b7 100644 --- a/tests/sentry/integrations/slack/test_unfurl.py +++ b/tests/sentry/integrations/slack/test_unfurl.py @@ -23,6 +23,7 @@ from sentry.testutils.cases import TestCase from sentry.testutils.helpers import install_slack from sentry.testutils.helpers.datetime import before_now, freeze_time +from sentry.testutils.helpers.features import with_feature from sentry.testutils.skips import requires_snuba from sentry.types.group import PriorityLevel from sentry.workflow_engine.migration_helpers.alert_rule import migrate_alert_rule @@ -196,6 +197,7 @@ def test_match_link(url, expected) -> None: assert match_link(url) == expected +@with_feature("organizations:visibility-explore-view") class UnfurlTest(TestCase): def setUp(self) -> None: super().setUp() @@ -1135,8 +1137,7 @@ def test_unfurl_explore( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -1149,28 +1150,6 @@ def test_unfurl_explore( chart_data = mock_generate_chart.call_args[0][1] assert "timeSeries" in chart_data - @patch( - "sentry.integrations.slack.unfurl.explore.client.get", - ) - @patch("sentry.charts.backend.generate_chart", return_value="chart-url") - def test_unfurl_explore_no_feature_flag( - self, mock_generate_chart: MagicMock, mock_client_get: MagicMock - ) -> None: - mock_client_get.return_value = MagicMock(data=self._build_mock_timeseries_response()) - url = f"https://sentry.io/organizations/{self.organization.slug}/explore/traces/?aggregateField=%7B%22yAxes%22%3A%5B%22avg(span.duration)%22%5D%7D&project={self.project.id}&statsPeriod=24h" - link_type, args = match_link(url) - - if not args or not link_type: - raise AssertionError("Missing link_type/args") - - links = [ - UnfurlableUrl(url=url, args=args), - ] - - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) - assert len(unfurls) == 0 - assert len(mock_generate_chart.mock_calls) == 0 - @patch( "sentry.integrations.slack.unfurl.explore.client.get", ) @@ -1189,8 +1168,7 @@ def test_unfurl_explore_with_groupby( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1221,8 +1199,7 @@ def test_unfurl_explore_forwards_multiple_groupbys_to_api( raise AssertionError("Missing link_type/args") links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.integration, links, self.user) mock_view.assert_called_once() request = mock_view.call_args[0][0] @@ -1248,8 +1225,7 @@ def test_unfurl_explore_with_groupby_explicit_sort( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 @@ -1277,8 +1253,7 @@ def test_unfurl_explore_default_yaxis( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1305,8 +1280,7 @@ def test_unfurl_explore_malformed_aggregate_field( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) # Should still unfurl with default yAxis assert len(unfurls) == 1 @@ -1341,8 +1315,7 @@ def test_unfurl_explore_end_to_end( # Step 2: Run handler links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) # Step 3: Verify events-timeseries was called with correct args assert mock_client_get.call_count == 1 @@ -1402,8 +1375,7 @@ def test_unfurl_explore_with_visualize_chart_type( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1429,8 +1401,7 @@ def test_unfurl_explore_without_chart_type_defaults_to_line( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -1456,8 +1427,7 @@ def test_unfurl_explore_skips_unsupported_chart_type( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert unfurls == {} # Skip happens before the events-timeseries call, so neither the API @@ -1486,8 +1456,7 @@ def test_unfurl_explore_without_chart_type_count_defaults_to_bar( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -1729,8 +1698,7 @@ def test_unfurl_explore_with_interval( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 call_kwargs = mock_client_get.call_args[1] @@ -1880,8 +1848,7 @@ def test_unfurl_explore_logs( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2002,8 +1969,7 @@ def test_unfurl_explore_logs_customer_domain( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 call_kwargs = mock_client_get.call_args[1] @@ -2030,8 +1996,7 @@ def test_unfurl_explore_metrics( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2075,8 +2040,7 @@ def test_unfurl_explore_metrics_skips_hidden_charts( links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2112,8 +2076,7 @@ def test_unfurl_explore_metrics_all_hidden_returns_no_unfurl( links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert unfurls == {} assert mock_generate_chart.call_count == 0 @@ -2171,8 +2134,7 @@ def test_unfurl_dashboards_spans_widget( links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2213,36 +2175,11 @@ def test_unfurl_dashboards_customer_domain( assert args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert mock_generate_chart.call_count == 1 - @patch("sentry.integrations.slack.unfurl.dashboards.client.get") - @patch("sentry.charts.backend.generate_chart", return_value="chart-url") - def test_unfurl_dashboards_no_feature_flag( - self, mock_generate_chart: MagicMock, mock_client_get: MagicMock - ) -> None: - mock_client_get.return_value = MagicMock(data=self._build_mock_timeseries_response()) - dashboard, _ = self._create_spans_widget() - - url = ( - f"https://sentry.io/organizations/{self.organization.slug}" - f"/dashboard/{dashboard.id}/widget/0/?statsPeriod=7d" - ) - link_type, args = match_link(url) - - assert link_type == LinkType.DASHBOARDS - assert args is not None - - links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) - - assert len(unfurls) == 0 - assert mock_generate_chart.call_count == 0 - assert mock_client_get.call_count == 0 - @patch("sentry.integrations.slack.unfurl.dashboards.client.get") @patch("sentry.charts.backend.generate_chart", return_value="chart-url") def test_unfurl_dashboards_unsupported_widget_type_is_skipped( @@ -2273,8 +2210,7 @@ def test_unfurl_dashboards_unsupported_widget_type_is_skipped( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2298,8 +2234,7 @@ def test_unfurl_dashboards_unsupported_display_type( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2320,8 +2255,7 @@ def test_unfurl_dashboards_widget_not_found( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2372,8 +2306,7 @@ def test_unfurl_dashboards_multiple_queries_are_joined( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert mock_client_get.call_count == 2 @@ -2430,8 +2363,7 @@ def test_unfurl_dashboards_multi_query_same_aggregate( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.integration, links, self.user) chart_data = mock_generate_chart.call_args[0][1] pairs = chart_data["timeSeries"] @@ -2489,8 +2421,7 @@ def grouped_response(group_value: str): assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.integration, links, self.user) chart_data = mock_generate_chart.call_args[0][1] pairs = chart_data["timeSeries"] @@ -2518,8 +2449,7 @@ def test_unfurl_dashboards_bar_display_type( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] diff --git a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py index fc3bb427542b35..d7b6946d46e967 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py @@ -93,7 +93,7 @@ def share_explore_links_ephemeral_sdk(self, mock_match_link, mock_): return self.mock_post.call_args[1] def test_share_explore_links_unlinked_user(self) -> None: - with self.feature("organizations:data-browsing-widget-unfurl"): + with self.feature("organizations:visibility-explore-view"): data = self.share_explore_links_ephemeral_sdk() blocks = orjson.loads(data["blocks"]) From 49c48a75b0402a1f307cbedf8d4ee629584f540a Mon Sep 17 00:00:00 2001 From: "Benjamin E. Coe" Date: Mon, 25 May 2026 14:06:55 -0400 Subject: [PATCH 07/13] feat(amplitude): track whether users are viewing sentry-built dashboards (#116138) --- static/app/utils/analytics/dashboardsAnalyticsEvents.tsx | 1 + static/app/views/dashboards/dashboard.tsx | 3 ++- .../widgetBuilder/utils/trackEngagementAnalytics.tsx | 4 +++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx b/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx index 240790dfea562f..4ba171db6081a4 100644 --- a/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx +++ b/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx @@ -9,6 +9,7 @@ export enum WidgetBuilderVersion { type DashboardsEventParametersWidgetBuilder = { 'dashboards_views.engagement.load': { globalFilterCount: number; + isSentryBuilt: boolean; issuesRatio: number; logRatio: number; metricsRatio: number; diff --git a/static/app/views/dashboards/dashboard.tsx b/static/app/views/dashboards/dashboard.tsx index e5511bc1654cba..cb86a27c79aa4d 100644 --- a/static/app/views/dashboards/dashboard.tsx +++ b/static/app/views/dashboards/dashboard.tsx @@ -203,7 +203,8 @@ function DashboardInner({ dashboard.widgets, organization, dashboard.title, - dashboard.filters?.[DashboardFilterKeys.GLOBAL_FILTER]?.length ?? 0 + dashboard.filters?.[DashboardFilterKeys.GLOBAL_FILTER]?.length ?? 0, + dashboard.prebuiltId !== undefined && dashboard.prebuiltId !== null ); return () => { diff --git a/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx b/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx index b6ede32662b8c2..9e6b380922f6c9 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx +++ b/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx @@ -6,7 +6,8 @@ export function trackEngagementAnalytics( widgets: Widget[], organization: Organization, dashboardTitle: string, - globalFilterCount: number + globalFilterCount: number, + isSentryBuilt: boolean ) { // Handle edge-case of dashboard with no widgets. if (!widgets.length) return; @@ -48,6 +49,7 @@ export function trackEngagementAnalytics( logRatio: logWidgetCount / widgets.length, metricsRatio: metricsWidgetCount / widgets.length, globalFilterCount, + isSentryBuilt, }; trackAnalytics('dashboards_views.engagement.load', analyticsPayload); } From 82715484cfc5fe54144adf05a8736a659c544c97 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Mon, 25 May 2026 14:31:59 -0400 Subject: [PATCH 08/13] fix(relocation) Fix type errors when spawning a task (#116130) Fix TypeErrors related to UUID not being compatible with msgpack. Fixes SENTRY-5PTW --- src/sentry/relocation/services/relocation_export/impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/relocation/services/relocation_export/impl.py b/src/sentry/relocation/services/relocation_export/impl.py index 0cb40d86814249..5b9f2f8932de40 100644 --- a/src/sentry/relocation/services/relocation_export/impl.py +++ b/src/sentry/relocation/services/relocation_export/impl.py @@ -127,7 +127,7 @@ def reply_with_export( logger.info("SaaS -> SaaS relocation RelocationFile saved", extra=logger_data) - uploading_complete.apply_async(args=[relocation.uuid]) + uploading_complete.apply_async(args=[str(relocation.uuid)]) logger.info("SaaS -> SaaS relocation next task scheduled", extra=logger_data) From 8c618b21d69b4346a7e1362bceef660cbf2ec12d Mon Sep 17 00:00:00 2001 From: James Keane Date: Mon, 25 May 2026 15:07:08 -0400 Subject: [PATCH 09/13] fix(ui): Increase dropdown z-index to appear above sidebar (#116139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes DAIN-1690 ## Problem Widget context menus (triggered by clicking the '...' buttons on dashboard widgets) were appearing behind the dashboard navigation sidebar because `theme.zIndex.dropdown` (1001) was lower than `theme.zIndex.sidebarPanel` (1019). Before Screenshot 2026-05-25 at 1 06 18 PM After Monosnap General Template copy 35 —
sentry — Sentry 2026-05-25 13-53-18 ## Solution Updated `theme.zIndex.dropdown` from 1001 to 1020 (sidebarPanel + 1) in the theme definition. This ensures all dropdown menus across the application appear above the sidebar panel. Linear Issue: [DAIN-1690](https://linear.app/getsentry/issue/DAIN-1690/menu-appears-behind-dashboard-widget-actions) Co-authored-by: Cursor Agent --- static/app/utils/theme/theme.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/utils/theme/theme.tsx b/static/app/utils/theme/theme.tsx index 936707fe074d86..960548ef21a727 100644 --- a/static/app/utils/theme/theme.tsx +++ b/static/app/utils/theme/theme.tsx @@ -196,7 +196,6 @@ const commonTheme = { truncationFullValue: 10, header: 1000, - dropdown: 1001, // dashboard widget builder backdrop sits behind the sidebar // because it renders on the right next to the sidebar @@ -204,6 +203,7 @@ const commonTheme = { widgetBuilderDrawer: 1016, sidebarPanel: 1019, + dropdown: 1020, sidebar: 1020, // Sentry user feedback modal From 457979246069268fc75fc83bd4db8138496a84e8 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Mon, 25 May 2026 15:19:33 -0400 Subject: [PATCH 10/13] feat(tracemetrics): Include equations in Add to Dashboard (#116141) This enables the Add to Dashboard actions for equations and includes it in "All Application Metrics". This feature should be feature flagged --- .../metrics/useSaveAsMetricItems.spec.tsx | 172 ++++++++++++++++++ .../explore/metrics/useSaveAsMetricItems.tsx | 32 +++- 2 files changed, 194 insertions(+), 10 deletions(-) diff --git a/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx b/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx index f937bcf4b98d29..abd86222fc9490 100644 --- a/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx +++ b/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx @@ -10,6 +10,7 @@ import * as modal from 'sentry/actionCreators/modal'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; +import * as discoverUtils from 'sentry/views/discover/utils'; import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; import {MockMetricQueryParamsContext} from 'sentry/views/explore/metrics/hooks/testUtils'; import {encodeMetricQueryParams} from 'sentry/views/explore/metrics/metricQuery'; @@ -24,10 +25,17 @@ import {OrganizationContext} from 'sentry/views/organizationContext'; jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/useNavigate'); jest.mock('sentry/actionCreators/modal'); +jest.mock('sentry/views/discover/utils'); const mockedUseLocation = jest.mocked(useLocation); const mockUseNavigate = jest.mocked(useNavigate); const mockOpenSaveQueryModal = jest.mocked(modal.openSaveQueryModal); +const mockHandleAddQueryToDashboard = jest.mocked( + discoverUtils.handleAddQueryToDashboard +); +const mockHandleAddMultipleQueriesToDashboard = jest.mocked( + discoverUtils.handleAddMultipleQueriesToDashboard +); describe('useSaveAsMetricItems', () => { const organization = OrganizationFixture({ @@ -208,6 +216,170 @@ describe('useSaveAsMetricItems', () => { ); }); + it('disables equations in add-to-dashboard without the feature flag', () => { + const equation = + 'equation|sum(value,metric.a,counter,none) + avg(value,metric.a,counter,none)'; + const encodedMetricQuery = encodeMetricQueryParams({ + metric: {name: 'metric.a', type: 'counter'}, + queryParams: new ReadableQueryParams({ + extrapolate: true, + mode: Mode.AGGREGATE, + query: 'release:1.2.3', + aggregateCursor: '', + aggregateFields: [new VisualizeEquation(equation)], + aggregateSortBys: [{field: equation, kind: 'desc'}], + cursor: '', + fields: [], + sortBys: [], + }), + label: 'ƒ1', + }); + + mockedUseLocation.mockReturnValue( + LocationFixture({ + query: { + interval: '5m', + metric: [encodedMetricQuery], + }, + }) + ); + + const {result} = renderHook(useSaveAsMetricItems, { + wrapper: createWrapper(), + initialProps: {interval: '5m'}, + }); + + const addToDashboardItem = result.current.find( + item => item.key === 'add-to-dashboard' + ) as + | {children?: Array<{disabled: boolean; key: string; tooltip: string}>} + | undefined; + + const equationChild = addToDashboardItem?.children?.find( + item => item.key === 'add-to-dashboard-0' + ); + + expect(equationChild?.disabled).toBe(true); + expect(equationChild?.tooltip).toBe( + 'Equations cannot currently be added to a dashboard' + ); + }); + + it('enables equations in add-to-dashboard with the feature flag', () => { + const orgWithEquationsInDashboards = OrganizationFixture({ + features: [ + 'tracemetrics-enabled', + 'tracemetrics-equations-in-alerts', + 'tracemetrics-equations-in-dashboards', + ], + }); + + // Break the equation into its components to match how metric queries are encoded: + // sum, avg, and the final equation combining them. + const function1 = new VisualizeFunction('sum(value,metric.a,counter,none)'); + const function2 = new VisualizeFunction('avg(value,metric.a,counter,none)'); + const equation = `equation|${function1.yAxis} + ${function2.yAxis}`; + const equationObj = new VisualizeEquation(equation); + + const metricFunctions = [function1, function2, equationObj]; + const encodedMetricQueries = metricFunctions.map(fn => + encodeMetricQueryParams({ + metric: {name: 'metric.a', type: 'counter'}, + queryParams: new ReadableQueryParams({ + extrapolate: true, + mode: Mode.AGGREGATE, + query: 'release:1.2.3', + aggregateCursor: '', + aggregateFields: [fn], + aggregateSortBys: [{field: fn.yAxis, kind: 'desc'}], + cursor: '', + fields: [], + sortBys: [], + }), + }) + ); + + mockedUseLocation.mockReturnValue( + LocationFixture({ + query: { + interval: '5m', + metric: encodedMetricQueries, + }, + }) + ); + + function createWrapperWithEquationFlags() { + return function ({children}: {children?: React.ReactNode}) { + return ( + + + {children} + + + ); + }; + } + + const {result} = renderHook(useSaveAsMetricItems, { + wrapper: createWrapperWithEquationFlags(), + initialProps: { + interval: '5m', + }, + }); + + const addToDashboardItem = result.current.find( + item => item.key === 'add-to-dashboard' + ) as + | { + children?: Array<{ + disabled: boolean; + key: string; + label: string; + onAction: () => void; + tooltip: string | undefined; + }>; + } + | undefined; + + const equationChild = addToDashboardItem?.children?.find( + item => item.key === 'add-to-dashboard-2' + ); + + expect(equationChild?.label).toBe('ƒ1'); + expect(equationChild?.disabled).toBe(false); + expect(equationChild?.tooltip).toBeUndefined(); + + equationChild?.onAction?.(); + + expect(mockHandleAddQueryToDashboard).toHaveBeenCalledWith( + expect.objectContaining({ + eventView: expect.objectContaining({ + yAxis: equation, + }), + yAxis: equation, + }) + ); + + mockHandleAddQueryToDashboard.mockClear(); + mockHandleAddMultipleQueriesToDashboard.mockClear(); + + const addAllToDashboard = addToDashboardItem?.children?.find( + item => item.key === 'add-to-dashboard-all' + ); + + addAllToDashboard?.onAction?.(); + + expect(mockHandleAddMultipleQueriesToDashboard).toHaveBeenCalledWith( + expect.objectContaining({ + eventViews: expect.arrayContaining([ + expect.objectContaining({ + yAxis: equation, + }), + ]), + }) + ); + }); + it('formats alerts submenu labels for equations', () => { const equation = 'equation|sum(value,metric.a,counter,none) + avg(value,metric.a,counter,none)'; diff --git a/static/app/views/explore/metrics/useSaveAsMetricItems.tsx b/static/app/views/explore/metrics/useSaveAsMetricItems.tsx index bfb9a9187b81ee..180f23aa38421a 100644 --- a/static/app/views/explore/metrics/useSaveAsMetricItems.tsx +++ b/static/app/views/explore/metrics/useSaveAsMetricItems.tsx @@ -35,6 +35,7 @@ import {getAlertsUrl} from 'sentry/views/insights/common/utils/getAlertsUrl'; import { canUseMetricsAlertsUI, canUseMetricsEquationsInAlerts, + canUseMetricsEquationsInDashboards, canUseMetricsSavedQueriesUI, } from './metricsFlags'; @@ -54,6 +55,9 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { const metricQueries = useMultiMetricsQueryParams(); const {addToDashboard} = useAddMetricToDashboard(); + const metricsEquationsInDashboardsEnabled = + canUseMetricsEquationsInDashboards(organization); + const project = projects.length === 1 ? projects[0] @@ -193,9 +197,14 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { addToDashboard( metricQueries.filter( metricQuery => - !isVisualizeEquation(metricQuery.queryParams.visualizes[0]!) && - metricQuery.queryParams.visualizes[0]!.chartType !== - ChartType.HEATMAP + metricQuery.queryParams.visualizes[0]?.chartType !== + ChartType.HEATMAP && + // Allow all charts if you have the flag, otherwise only allow non-equation charts without the flag + (metricsEquationsInDashboardsEnabled || + (!metricsEquationsInDashboardsEnabled && + !isVisualizeEquation( + metricQuery.queryParams.visualizes[0]! + ))) ) ); }, @@ -205,7 +214,8 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { ...metricQueries.map((metricQuery, index) => { const visualize = metricQuery.queryParams.visualizes[0]!; const isUnsupported = - isVisualizeEquation(visualize) || visualize.chartType === ChartType.HEATMAP; + (!metricsEquationsInDashboardsEnabled && isVisualizeEquation(visualize)) || + visualize.chartType === ChartType.HEATMAP; const label = isVisualizeFunction(visualize) ? `${metricQuery.label ?? getVisualizeLabel(index, isVisualizeEquation(visualize))}: ${ formatTraceMetricsFunction( @@ -222,20 +232,22 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { if (isUnsupported) { return; } + // TODO: Handle sorting by equation better addToDashboard(metricQuery); }, disabled: isUnsupported, - tooltip: isVisualizeEquation(visualize) - ? t('Equations cannot currently be added to a dashboard') - : visualize.chartType === ChartType.HEATMAP - ? t('Heat maps cannot currently be added to a dashboard') - : undefined, + tooltip: + !metricsEquationsInDashboardsEnabled && isVisualizeEquation(visualize) + ? t('Equations cannot currently be added to a dashboard') + : visualize.chartType === ChartType.HEATMAP + ? t('Heat maps cannot currently be added to a dashboard') + : undefined, }; }), ], }, ]; - }, [addToDashboard, metricQueries]); + }, [addToDashboard, metricQueries, metricsEquationsInDashboardsEnabled]); return useMemo(() => { return [...saveAsItems, ...saveAsAlertItems, ...addToDashboardItems]; From dc499f61313d9cad3688cc342463b73022067802 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Mon, 25 May 2026 15:22:07 -0400 Subject: [PATCH 11/13] chore(autofix): Remove intelligence level from group ai autofix endpoint (#116145) We want to use medium everywhere, no need to expose this as a parameter. --- src/sentry/seer/endpoints/group_ai_autofix.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index b305f10f4403d8..f6bd2124ae198d 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -134,12 +134,6 @@ class ExplorerAutofixRequestSerializer(CamelSnakeSerializer): required=False, help_text="Coding agent provider (e.g., 'github_copilot'). Alternative to integration_id for user-authenticated providers.", ) - intelligence_level = serializers.ChoiceField( - required=False, - choices=["low", "medium", "high"], - default="medium", - help_text="The intelligence level to use.", - ) user_context = serializers.CharField( required=False, max_length=1000, @@ -328,7 +322,7 @@ def _post_agent(self, request: Request, group: Group) -> Response: referrer=_parse_autofix_referrer(data.get("referrer")), stopping_point=AutofixStoppingPoint(stopping_point) if stopping_point else None, run_id=run_id, - intelligence_level=data["intelligence_level"], + intelligence_level="medium", user_context=data.get("user_context"), insert_index=data.get("insert_index"), ) From 098901809345984262a2d8c211b7a91d50445a9b Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Mon, 25 May 2026 16:17:59 -0400 Subject: [PATCH 12/13] chore(autofix): Remove old useAutofixData hook (#116103) Replace all usages of useAutofixData with useExplorerAutofix --- .../events/autofix/autofixRootCause.tsx | 46 ---- .../events/autofix/autofixSolution.tsx | 41 ---- static/app/components/events/autofix/types.ts | 212 ------------------ .../components/events/autofix/useAutofix.tsx | 36 --- .../events/autofix/useExplorerAutofix.tsx | 2 +- .../app/components/events/autofix/utils.tsx | 36 +-- .../components/events/autofix/v3/drawer.tsx | 2 +- .../app/components/events/autofix/v3/utils.ts | 75 +++++-- .../streamline/eventNavigation.tsx | 4 +- .../hooks/useCopyIssueDetails.spec.tsx | 93 ++++---- .../streamline/hooks/useCopyIssueDetails.tsx | 41 +++- 11 files changed, 137 insertions(+), 451 deletions(-) delete mode 100644 static/app/components/events/autofix/autofixRootCause.tsx delete mode 100644 static/app/components/events/autofix/autofixSolution.tsx diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx deleted file mode 100644 index 382f0aac706764..00000000000000 --- a/static/app/components/events/autofix/autofixRootCause.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type {AutofixRootCauseData} from 'sentry/components/events/autofix/types'; - -export function formatRootCauseText( - cause: AutofixRootCauseData | undefined, - customRootCause?: string -) { - if (!cause && !customRootCause) { - return ''; - } - - if (customRootCause) { - return `# Root Cause of the Issue\n\n${customRootCause}`; - } - - if (!cause) { - return ''; - } - - const parts: string[] = ['# Root Cause of the Issue']; - - if (cause.description) { - parts.push(cause.description); - } - - if (cause.root_cause_reproduction) { - parts.push( - cause.root_cause_reproduction - .map(event => { - const eventParts = [`### ${event.title}`]; - - if (event.code_snippet_and_analysis) { - eventParts.push(event.code_snippet_and_analysis); - } - - if (event.relevant_code_file) { - eventParts.push(`(See @${event.relevant_code_file.file_path})`); - } - - return eventParts.join('\n'); - }) - .join('\n\n') - ); - } - - return parts.join('\n\n'); -} diff --git a/static/app/components/events/autofix/autofixSolution.tsx b/static/app/components/events/autofix/autofixSolution.tsx deleted file mode 100644 index cc1747e28a2de9..00000000000000 --- a/static/app/components/events/autofix/autofixSolution.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type {AutofixSolutionTimelineEvent} from 'sentry/components/events/autofix/types'; - -export function formatSolutionText( - solution: AutofixSolutionTimelineEvent[], - customSolution?: string -) { - if (!solution && !customSolution) { - return ''; - } - - if (customSolution) { - return `# Solution Plan\n\n${customSolution}`; - } - - if (!solution || solution.length === 0) { - return ''; - } - - const parts = ['# Solution Plan']; - - parts.push( - solution - .filter(event => event.is_active) - .map((event, index) => { - const eventParts = [`### ${index + 1}. ${event.title}`]; - - if (event.code_snippet_and_analysis) { - eventParts.push(event.code_snippet_and_analysis); - } - - if (event.relevant_code_file) { - eventParts.push(`(See @${event.relevant_code_file.file_path})`); - } - - return eventParts.join('\n'); - }) - .join('\n\n') - ); - - return parts.join('\n\n'); -} diff --git a/static/app/components/events/autofix/types.ts b/static/app/components/events/autofix/types.ts index 4837aa5ddfef41..0fad08866b7dcf 100644 --- a/static/app/components/events/autofix/types.ts +++ b/static/app/components/events/autofix/types.ts @@ -1,5 +1,4 @@ import {t} from 'sentry/locale'; -import type {User} from 'sentry/types/user'; import {isArrayOf} from 'sentry/types/utils'; export enum DiffFileType { @@ -30,22 +29,6 @@ function isDiffLineType(value: unknown): value is DiffLineType { ); } -export enum AutofixStepType { - DEFAULT = 'default', - ROOT_CAUSE_ANALYSIS = 'root_cause_analysis', - CHANGES = 'changes', - SOLUTION = 'solution', -} - -export enum AutofixStatus { - COMPLETED = 'COMPLETED', - ERROR = 'ERROR', - PROCESSING = 'PROCESSING', - NEED_MORE_INFORMATION = 'NEED_MORE_INFORMATION', - CANCELLED = 'CANCELLED', - WAITING_FOR_USER_RESPONSE = 'WAITING_FOR_USER_RESPONSE', -} - export enum AutofixStoppingPoint { ROOT_CAUSE = 'root_cause', SOLUTION = 'solution', @@ -53,22 +36,6 @@ export enum AutofixStoppingPoint { OPEN_PR = 'open_pr', } -type AutofixPullRequestDetails = { - pr_number: number; - pr_url: string; -}; - -type AutofixOptions = { - iterative_feedback?: boolean; -}; - -interface CodingAgentResult { - description: string; - pr_url: string | null; - repo_full_name: string; - repo_provider: string; -} - export enum CodingAgentStatus { PENDING = 'pending', RUNNING = 'running', @@ -89,185 +56,6 @@ export function getResultButtonLabel(url: string | null | undefined): string { return t('View Pull Request'); } -interface CodingAgentState { - id: string; - name: string; - provider: CodingAgentProvider; - started_at: string; - status: CodingAgentStatus; - agent_url?: string; - results?: CodingAgentResult[]; -} - -type CodebaseState = { - is_readable: boolean | null; - is_writeable: boolean | null; - repo_external_id: string | null; -}; - -export type AutofixData = { - codebases: Record; - last_triggered_at: string; - request: { - repos: SeerRepoDefinition[]; - options?: { - auto_run_source?: string | null; - }; - }; - run_id: string; - status: AutofixStatus; - actor_ids?: number[]; - codebase_indexing?: { - status: 'COMPLETED'; - }; - coding_agents?: Record; - completed_at?: string | null; - error_message?: string; - options?: AutofixOptions; - steps?: AutofixStep[]; - users?: Record; -}; - -type AutofixProgressItem = { - message: string; - timestamp: string; - type: 'INFO' | 'WARNING' | 'ERROR' | 'NEED_MORE_INFORMATION'; - data?: any; -}; - -type AutofixStep = - | AutofixDefaultStep - | AutofixRootCauseStep - | AutofixSolutionStep - | AutofixChangesStep; - -interface BaseStep { - id: string; - index: number; - progress: AutofixProgressItem[]; - status: AutofixStatus; - title: string; - type: AutofixStepType; - active_comment_thread?: CommentThread | null; - agent_comment_thread?: CommentThread | null; - completedMessage?: string; - key?: string; - output_stream?: string | null; -} - -type CommentThread = { - id: string; - is_completed: boolean; - messages: CommentThreadMessage[]; -}; - -interface CommentThreadMessage { - content: string; - role: 'user' | 'assistant'; - isLoading?: boolean; -} - -type AutofixInsight = { - insight: string; - justification: string; - change_diff?: FilePatch[]; - markdown_snippets?: string; - sources?: InsightSources; - type?: 'insight' | 'file_change'; -}; - -type InsightSources = { - breadcrumbs_used: boolean; - code_used_urls: string[]; - connected_error_ids_used: string[]; - diff_urls: string[]; - http_request_used: boolean; - profile_ids_used: string[]; - stacktrace_used: boolean; - thoughts: string; - trace_event_ids_used: string[]; - event_trace_id?: string; - event_trace_timestamp?: number; -}; - -interface AutofixDefaultStep extends BaseStep { - insights: AutofixInsight[]; - type: AutofixStepType.DEFAULT; -} - -type AutofixRootCauseSelection = - | { - cause_id: string; - } - | {custom_root_cause: string} - | null; - -interface AutofixRootCauseStep extends BaseStep { - causes: AutofixRootCauseData[]; - selection: AutofixRootCauseSelection; - type: AutofixStepType.ROOT_CAUSE_ANALYSIS; - termination_reason?: string; -} - -interface AutofixSolutionStep extends BaseStep { - solution: AutofixSolutionTimelineEvent[]; - solution_selected: boolean; - type: AutofixStepType.SOLUTION; - custom_solution?: string; - description?: string; -} - -type AutofixCodebaseChange = { - description: string; - diff: FilePatch[]; - repo_name: string; - title: string; - branch_name?: string; - diff_str?: string; - pull_request?: AutofixPullRequestDetails; - repo_external_id?: string; - repo_id?: number; // The repo_id is only here for temporary backwards compatibility for LA customers, and we should remove it soon. Use repo_external_id instead. -}; - -interface AutofixChangesStep extends BaseStep { - changes: AutofixCodebaseChange[]; - type: AutofixStepType.CHANGES; - termination_reason?: string; -} - -type AutofixRelevantCodeFile = { - file_path: string; - repo_name: string; -}; - -type AutofixRelevantCodeFileWithUrl = AutofixRelevantCodeFile & { - url?: string; -}; - -type AutofixTimelineEvent = { - code_snippet_and_analysis: string; - relevant_code_file: AutofixRelevantCodeFile; - timeline_item_type: 'internal_code' | 'external_system' | 'human_action'; - title: string; - is_most_important_event?: boolean; -}; - -export type AutofixSolutionTimelineEvent = { - timeline_item_type: 'internal_code' | 'human_instruction'; - title: string; - code_snippet_and_analysis?: string; - is_active?: boolean; - is_most_important_event?: boolean; - relevant_code_file?: AutofixRelevantCodeFileWithUrl; -}; - -export type AutofixRootCauseData = { - id: string; - description?: string; - reproduction_urls?: Array; - root_cause_reproduction?: AutofixTimelineEvent[]; -}; - export type FilePatch = { added: number; hunks: Hunk[]; diff --git a/static/app/components/events/autofix/useAutofix.tsx b/static/app/components/events/autofix/useAutofix.tsx index 023e2553b82d4b..abc969f0682d6a 100644 --- a/static/app/components/events/autofix/useAutofix.tsx +++ b/static/app/components/events/autofix/useAutofix.tsx @@ -1,42 +1,6 @@ -import {useQuery} from '@tanstack/react-query'; - -import {type AutofixData} from 'sentry/components/events/autofix/types'; import type {Organization} from 'sentry/types/organization'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import type {RequestError} from 'sentry/utils/requestError/requestError'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -type AutofixResponse = { - autofix: AutofixData | null; -}; - -function autofixApiOptions(orgSlug: string, groupId: string, isUserWatching = false) { - return apiOptions.as()( - '/organizations/$organizationIdOrSlug/issues/$issueId/autofix/', - { - path: {organizationIdOrSlug: orgSlug, issueId: groupId}, - query: {isUserWatching, mode: 'legacy'}, - staleTime: Infinity, - } - ); -} - -export const useAutofixData = ({ - groupId, - isUserWatching = false, -}: { - groupId: string; - isUserWatching?: boolean; -}) => { - const orgSlug = useOrganization().slug; - - const {data, isPending} = useQuery({ - ...autofixApiOptions(orgSlug, groupId, isUserWatching), - enabled: false, - }); - - return {data: data?.autofix ?? null, isPending}; -}; export type CodingAgentIntegration = { id: string | null; diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index 9befd99609f146..73c94332d7d6fe 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -119,7 +119,7 @@ export function isCodingAgentsArtifact( * State returned from the Explorer autofix endpoint. * This extends the SeerExplorer types with autofix-specific data. */ -interface ExplorerAutofixState { +export interface ExplorerAutofixState { blocks: Block[]; run_id: number; status: 'processing' | 'completed' | 'error' | 'awaiting_user_input'; diff --git a/static/app/components/events/autofix/utils.tsx b/static/app/components/events/autofix/utils.tsx index 9ab6b3654d9217..3cfb7d38912ab8 100644 --- a/static/app/components/events/autofix/utils.tsx +++ b/static/app/components/events/autofix/utils.tsx @@ -1,43 +1,9 @@ import {useCallback, useMemo} from 'react'; -import {formatRootCauseText} from 'sentry/components/events/autofix/autofixRootCause'; -import {formatSolutionText} from 'sentry/components/events/autofix/autofixSolution'; -import { - AUTOFIX_TTL_IN_DAYS, - AutofixStepType, - type AutofixData, -} from 'sentry/components/events/autofix/types'; +import {AUTOFIX_TTL_IN_DAYS} from 'sentry/components/events/autofix/types'; import type {Group} from 'sentry/types/group'; import {useOrganization} from 'sentry/utils/useOrganization'; -export function getRootCauseCopyText(autofixData: AutofixData) { - const rootCause = autofixData.steps?.find( - step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS - ); - if (!rootCause) { - return null; - } - - const cause = rootCause.causes.at(0); - - if (!cause) { - return null; - } - - return formatRootCauseText(cause); -} - -export function getSolutionCopyText(autofixData: AutofixData) { - const solution = autofixData.steps?.find( - step => step.type === AutofixStepType.SOLUTION - ); - if (!solution) { - return null; - } - - return formatSolutionText(solution.solution, solution.custom_solution); -} - const BASE_SUPPORTED_PROVIDERS = [ 'github', 'integrations:github', diff --git a/static/app/components/events/autofix/v3/drawer.tsx b/static/app/components/events/autofix/v3/drawer.tsx index 72facd29e2323e..b3952c1671f96c 100644 --- a/static/app/components/events/autofix/v3/drawer.tsx +++ b/static/app/components/events/autofix/v3/drawer.tsx @@ -96,7 +96,7 @@ function useHandleCopyMarkdown({ const markdown = getOrderedAutofixSections(aiAutofix.runState) .map(getAutofixArtifactFromSection) .filter(defined) - .map(artifactToMarkdown) + .map(artifact => artifactToMarkdown(artifact)) .filter(defined) .join('\n\n'); copy(markdown, {successMessage: t('Analysis copied to clipboard.')}); diff --git a/static/app/components/events/autofix/v3/utils.ts b/static/app/components/events/autofix/v3/utils.ts index 1895bc95562479..d1256664d5071a 100644 --- a/static/app/components/events/autofix/v3/utils.ts +++ b/static/app/components/events/autofix/v3/utils.ts @@ -17,44 +17,51 @@ import { type RepoPRState, } from 'sentry/views/seerExplorer/types'; -export function artifactToMarkdown(artifact: AutofixArtifact): string | null { +export function artifactToMarkdown( + artifact: AutofixArtifact, + headingLevel: 1 | 2 | 3 = 1 +): string | null { if (isRootCauseArtifact(artifact)) { - return rootCauseArtifactToMarkdown(artifact); + return rootCauseArtifactToMarkdown(artifact, headingLevel); } if (isSolutionArtifact(artifact)) { - return solutionArtifactToMarkdown(artifact); + return solutionArtifactToMarkdown(artifact, headingLevel); } if (isCodeChangesArtifact(artifact)) { - return filePatchesToMarkdown(artifact); + return filePatchesToMarkdown(artifact, headingLevel); } if (isPullRequestsArtifact(artifact)) { - return repoPRStatesToMarkdown(artifact); + return repoPRStatesToMarkdown(artifact, headingLevel); } if (isCodingAgentsArtifact(artifact)) { - return codingAgentsToMarkdown(artifact); + return codingAgentsToMarkdown(artifact, headingLevel); } return null; // unknown artifact } function rootCauseArtifactToMarkdown( - artifact: Artifact + artifact: Artifact, + headingLevel: number ): string | null { const rootCause = artifact.data; if (!defined(rootCause)) { return null; } - const parts: string[] = ['# Root Cause', '', rootCause.one_line_description]; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + + const parts: string[] = [`${h1} Root Cause`, '', rootCause.one_line_description]; if (rootCause.five_whys.length) { parts.push( '', - '## Why did this happen?', + `${h2} Why did this happen?`, '', ...rootCause.five_whys.map(why => `- ${why}`) ); @@ -63,7 +70,7 @@ function rootCauseArtifactToMarkdown( if (rootCause.reproduction_steps?.length) { parts.push( '', - '## Reproduction Steps', + `${h2} Reproduction Steps`, '', ...rootCause.reproduction_steps.map((step, index) => `${index + 1}. ${step}`) ); @@ -72,21 +79,28 @@ function rootCauseArtifactToMarkdown( return parts.join('\n'); } -function solutionArtifactToMarkdown(artifact: Artifact): string | null { +function solutionArtifactToMarkdown( + artifact: Artifact, + headingLevel: number +): string | null { const solution = artifact.data; if (!defined(solution)) { return null; } - const parts: string[] = ['# Plan', '', solution.one_line_summary]; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + const h3 = '#'.repeat(headingLevel + 2); + + const parts: string[] = [`${h1} Plan`, '', solution.one_line_summary]; if (solution.steps.length) { parts.push( '', - '## Steps to Resolve', + `${h2} Steps to Resolve`, '', ...solution.steps.flatMap((step, index) => [ - `### ${index + 1}. ${step.title}`, + `${h3} ${index + 1}. ${step.title}`, step.description, ]) ); @@ -95,17 +109,23 @@ function solutionArtifactToMarkdown(artifact: Artifact): strin return parts.join('\n'); } -function filePatchesToMarkdown(artifact: ExplorerFilePatch[]): string | null { +function filePatchesToMarkdown( + artifact: ExplorerFilePatch[], + headingLevel: number +): string | null { if (!artifact.length) { return null; } - const parts: string[] = ['# Code Changes']; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + + const parts: string[] = [`${h1} Code Changes`]; parts.push( ...artifact.flatMap(filePatch => [ '', - `## Repository: ${filePatch.repo_name}`, + `${h2} Repository: ${filePatch.repo_name}`, '', '```diff', filePatch.diff, @@ -116,12 +136,17 @@ function filePatchesToMarkdown(artifact: ExplorerFilePatch[]): string | null { return parts.join('\n'); } -function repoPRStatesToMarkdown(artifact: RepoPRState[]): string | null { +function repoPRStatesToMarkdown( + artifact: RepoPRState[], + headingLevel: number +): string | null { if (!artifact.length) { return null; } - const parts: string[] = ['# Pull Requests', '']; + const h1 = '#'.repeat(headingLevel); + + const parts: string[] = [`${h1} Pull Requests`, '']; parts.push( ...artifact @@ -137,12 +162,18 @@ function repoPRStatesToMarkdown(artifact: RepoPRState[]): string | null { return parts.join('\n'); } -function codingAgentsToMarkdown(artifact: ExplorerCodingAgentState[]): string | null { +function codingAgentsToMarkdown( + artifact: ExplorerCodingAgentState[], + headingLevel: number +): string | null { if (!artifact.length) { return null; } - const parts: string[] = ['# Coding Agents', '']; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + + const parts: string[] = [`${h1} Coding Agents`, '']; parts.push( ...artifact @@ -152,7 +183,7 @@ function codingAgentsToMarkdown(artifact: ExplorerCodingAgentState[]): string | } return [ - `## ${getCodingAgentName(codingAgent.provider)}`, + `${h2} ${getCodingAgentName(codingAgent.provider)}`, '', `[${codingAgent.name}](${codingAgent.agent_url})`, ]; diff --git a/static/app/views/issueDetails/streamline/eventNavigation.tsx b/static/app/views/issueDetails/streamline/eventNavigation.tsx index cd49eb2447cdf1..601e6e159ff7b3 100644 --- a/static/app/views/issueDetails/streamline/eventNavigation.tsx +++ b/static/app/views/issueDetails/streamline/eventNavigation.tsx @@ -12,7 +12,7 @@ import {CopyAsDropdown} from 'sentry/components/copyAsDropdown'; import {Count} from 'sentry/components/count'; import {DropdownButton} from 'sentry/components/dropdownButton'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {useAutofixData} from 'sentry/components/events/autofix/useAutofix'; +import {useExplorerAutofix} from 'sentry/components/events/autofix/useExplorerAutofix'; import {useGroupSummaryData} from 'sentry/components/group/groupSummary'; import {TourElement} from 'sentry/components/tours/components'; import {IconTelescope} from 'sentry/icons'; @@ -117,7 +117,7 @@ export function IssueEventNavigation({event, group}: IssueEventNavigationProps) // Get data for markdown copy functionality const {data: groupSummaryData} = useGroupSummaryData(group); - const {data: autofixData} = useAutofixData({groupId: group.id}); + const {runState: autofixData} = useExplorerAutofix(group.id, {enabled: false}); const handleCopyMarkdown = useCallback(() => { const markdownText = issueAndEventToMarkdown( diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx index 2bd3d13b7a0a16..dc7e69d152585a 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx @@ -5,12 +5,8 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {renderHook, userEvent} from 'sentry-test/reactTestingLibrary'; import * as indicators from 'sentry/actionCreators/indicator'; -import { - AutofixStatus, - AutofixStepType, - type AutofixData, -} from 'sentry/components/events/autofix/types'; -import * as autofixHooks from 'sentry/components/events/autofix/useAutofix'; +import type {ExplorerAutofixState} from 'sentry/components/events/autofix/useExplorerAutofix'; +import * as explorerAutofixHooks from 'sentry/components/events/autofix/useExplorerAutofix'; import type {GroupSummaryData} from 'sentry/components/group/groupSummary'; import * as groupSummaryHooks from 'sentry/components/group/groupSummary'; import {EntryType} from 'sentry/types/event'; @@ -39,46 +35,50 @@ describe('useCopyIssueDetails', () => { possibleCause: 'Missing parameter', }; - // Create a mock AutofixData with steps that includes root cause and solution steps - const mockAutofixData: AutofixData = { - last_triggered_at: '2023-01-01T00:00:00Z', - request: { - repos: [], - }, - codebases: {}, - run_id: '123', - status: AutofixStatus.COMPLETED, - steps: [ + const mockAutofixData: ExplorerAutofixState = { + run_id: 123, + status: 'completed', + updated_at: '2023-01-01T00:00:00Z', + blocks: [ { - id: 'root-cause-step', - index: 0, - progress: [], - status: AutofixStatus.COMPLETED, - title: 'Root Cause', - type: AutofixStepType.ROOT_CAUSE_ANALYSIS, - causes: [ + id: 'root-cause-block', + message: { + role: 'assistant' as const, + content: 'Found the root cause', + metadata: {step: 'root_cause'}, + }, + timestamp: '2023-01-01T00:00:00Z', + loading: false, + artifacts: [ { - id: 'cause-1', - description: 'Root cause text', + key: 'root_cause', + reason: 'Root cause analysis', + data: { + one_line_description: 'Root cause text', + five_whys: ['Why 1'], + }, }, ], - selection: null, }, { - id: 'solution-step', - index: 1, - progress: [], - status: AutofixStatus.COMPLETED, - title: 'Solution', - type: AutofixStepType.SOLUTION, - solution: [ + id: 'solution-block', + message: { + role: 'assistant' as const, + content: 'Here is the solution', + metadata: {step: 'solution'}, + }, + timestamp: '2023-01-01T00:00:01Z', + loading: false, + artifacts: [ { - timeline_item_type: 'internal_code', - title: 'Solution title', - code_snippet_and_analysis: 'Solution text', + key: 'solution', + reason: 'Solution plan', + data: { + one_line_summary: 'Solution title', + steps: [{title: 'Fix it', description: 'Solution text'}], + }, }, ], - solution_selected: true, }, ], }; @@ -124,7 +124,7 @@ describe('useCopyIssueDetails', () => { ); expect(result).toContain('## Root Cause'); - expect(result).toContain('## Solution'); + expect(result).toContain('## Plan'); }); it('includes tags when present in event', () => { @@ -379,10 +379,17 @@ describe('useCopyIssueDetails', () => { isPending: false, }); - jest.spyOn(autofixHooks, 'useAutofixData').mockReturnValue({ - data: mockAutofixData, - isPending: false, - }); + jest.spyOn(explorerAutofixHooks, 'useExplorerAutofix').mockReturnValue({ + runState: mockAutofixData, + isLoading: false, + isPolling: false, + startStep: jest.fn(), + createPR: jest.fn(), + reset: jest.fn(), + triggerCodingAgentHandoff: jest.fn(), + codingAgentErrors: [], + dismissCodingAgentError: jest.fn(), + } as any); jest.spyOn(indicators, 'addSuccessMessage').mockImplementation(() => {}); jest.spyOn(indicators, 'addErrorMessage').mockImplementation(() => {}); @@ -430,7 +437,7 @@ describe('useCopyIssueDetails', () => { expect(capturedText).toContain(`**Project:** ${group.project?.slug}`); expect(capturedText).toContain('## Issue Summary'); expect(capturedText).toContain('## Root Cause'); - expect(capturedText).toContain('## Solution'); + expect(capturedText).toContain('## Plan'); expect(capturedText).not.toContain('## Exception'); }); diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx index 85bd72e37344cd..bb636df57906cc 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx @@ -2,12 +2,15 @@ import {useCallback, useMemo, useSyncExternalStore} from 'react'; import {useHotkeys} from '@sentry/scraps/hotkey'; -import type {AutofixData} from 'sentry/components/events/autofix/types'; -import {useAutofixData} from 'sentry/components/events/autofix/useAutofix'; import { - getRootCauseCopyText, - getSolutionCopyText, -} from 'sentry/components/events/autofix/utils'; + type ExplorerAutofixState, + getAutofixArtifactFromSection, + getOrderedAutofixSections, + isRootCauseSection, + isSolutionSection, + useExplorerAutofix, +} from 'sentry/components/events/autofix/useExplorerAutofix'; +import {artifactToMarkdown} from 'sentry/components/events/autofix/v3/utils'; import { useGroupSummaryData, type GroupSummaryData, @@ -142,7 +145,7 @@ export const issueAndEventToMarkdown = ( group: Group, event: Event | null | undefined, groupSummaryData: GroupSummaryData | null | undefined, - autofixData: AutofixData | null | undefined, + autofixData: ExplorerAutofixState | null | undefined, activeThreadId: number | undefined ): string => { // Format the basic issue information @@ -169,14 +172,29 @@ export const issueAndEventToMarkdown = ( } if (autofixData) { - const rootCauseCopyText = getRootCauseCopyText(autofixData); - const solutionCopyText = getSolutionCopyText(autofixData); + const sections = getOrderedAutofixSections(autofixData); + const rootCauseSection = sections.find(isRootCauseSection); + const solutionSection = sections.find(isSolutionSection); + + const rootCauseArtifact = rootCauseSection + ? getAutofixArtifactFromSection(rootCauseSection) + : null; + const solutionArtifact = solutionSection + ? getAutofixArtifactFromSection(solutionSection) + : null; + + const rootCauseCopyText = rootCauseArtifact + ? artifactToMarkdown(rootCauseArtifact, 2) + : null; + const solutionCopyText = solutionArtifact + ? artifactToMarkdown(solutionArtifact, 2) + : null; if (rootCauseCopyText) { - markdownText += `\n## Root Cause\n\`\`\`\n${rootCauseCopyText}\n\`\`\`\n`; + markdownText += `\n${rootCauseCopyText}\n`; } if (solutionCopyText) { - markdownText += `\n## Solution\n\`\`\`\n${solutionCopyText}\n\`\`\`\n`; + markdownText += `\n${solutionCopyText}\n`; } } @@ -190,9 +208,8 @@ export const issueAndEventToMarkdown = ( export const useCopyIssueDetails = (group: Group, event?: Event) => { const organization = useOrganization(); - // These aren't guarded by useAiConfig because they are both non fetching, and should only return data when it's fetched elsewhere. const {data: groupSummaryData} = useGroupSummaryData(group); - const {data: autofixData} = useAutofixData({groupId: group.id}); + const {runState: autofixData} = useExplorerAutofix(group.id, {enabled: false}); const activeThreadId = useActiveThreadId(); const text = useMemo(() => { From 49fb05049cf9179001e3a9779e15700cd6cc6b40 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Mon, 25 May 2026 16:24:52 -0400 Subject: [PATCH 13/13] chore(relocation) Remove unused outbox handler (#116030) Relocations haven't used outboxes for at least a year, remove this code. Refs INFRENG-318 --- src/sentry/hybridcloud/outbox/category.py | 8 ++-- src/sentry/hybridcloud/rpc/service.py | 1 + src/sentry/options/defaults.py | 7 ---- src/sentry/receivers/outbox/cell.py | 42 ------------------- src/sentry/relocation/services/__init__.py | 0 .../services/relocation_export/impl.py | 10 ++++- src/sentry/relocation/tasks/process.py | 9 ++-- .../sentry/relocation/tasks/test_transfer.py | 4 +- 8 files changed, 19 insertions(+), 62 deletions(-) create mode 100644 src/sentry/relocation/services/__init__.py diff --git a/src/sentry/hybridcloud/outbox/category.py b/src/sentry/hybridcloud/outbox/category.py index acfbc5b27d6477..92bc2cb9269bd7 100644 --- a/src/sentry/hybridcloud/outbox/category.py +++ b/src/sentry/hybridcloud/outbox/category.py @@ -54,8 +54,8 @@ class OutboxCategory(IntEnum): ISSUE_COMMENT_UPDATE = 34 EXTERNAL_ACTOR_UPDATE = 35 - RELOCATION_EXPORT_REQUEST = 36 # no longer in use - RELOCATION_EXPORT_REPLY = 37 # no longer in use + UNUSED_FIVE = 36 + UNUSED_SIX = 37 SEND_VERCEL_INVOICE = 38 FTC_CONSENT = 39 @@ -337,9 +337,7 @@ class OutboxScope(IntEnum): ) SUBSCRIPTION_SCOPE = scope_categories(9, {OutboxCategory.SUBSCRIPTION_UPDATE}) # relocation scope is no longer in use. - RELOCATION_SCOPE = scope_categories( - 10, {OutboxCategory.RELOCATION_EXPORT_REQUEST, OutboxCategory.RELOCATION_EXPORT_REPLY} - ) + RELOCATION_SCOPE = scope_categories(10, {OutboxCategory.UNUSED_FIVE, OutboxCategory.UNUSED_SIX}) API_TOKEN_SCOPE = scope_categories(11, {OutboxCategory.API_TOKEN_UPDATE}) ACTION_SCOPE = scope_categories(12, {OutboxCategory.SENTRY_APP_NORMALIZE_ACTIONS}) SEER_SCOPE = scope_categories(13, {OutboxCategory.SEER_RUN_CREATE}) diff --git a/src/sentry/hybridcloud/rpc/service.py b/src/sentry/hybridcloud/rpc/service.py index 3e3f94691636da..02f2dde54c3eff 100644 --- a/src/sentry/hybridcloud/rpc/service.py +++ b/src/sentry/hybridcloud/rpc/service.py @@ -399,6 +399,7 @@ def list_all_service_method_signatures() -> Iterable[RpcMethodSignature]: "sentry.notifications.services", "sentry.organizations.services", "sentry.projects.services", + "sentry.relocation.services", "sentry.sentry_apps.services", "sentry.users.services", ) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 080744a8fd012e..ad9651c003abb9 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -3048,13 +3048,6 @@ flags=FLAG_SCALAR | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "relocation.outbox-orgslug.killswitch", - default=[], - type=Sequence, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - register( "profiling.killswitch.ingest-profiles", type=Sequence, diff --git a/src/sentry/receivers/outbox/cell.py b/src/sentry/receivers/outbox/cell.py index bb4f4acda34594..582654263399bf 100644 --- a/src/sentry/receivers/outbox/cell.py +++ b/src/sentry/receivers/outbox/cell.py @@ -14,7 +14,6 @@ from django.dispatch import receiver -from sentry import options from sentry.audit_log.services.log import AuditLogEvent, UserIpEvent, log_rpc_service from sentry.auth.services.auth import auth_service from sentry.auth.services.orgauthtoken import orgauthtoken_rpc_service @@ -28,11 +27,9 @@ ) from sentry.integrations.services.integration import integration_service from sentry.models.authproviderreplica import AuthProviderReplica -from sentry.models.files.utils import get_relocation_storage from sentry.models.organization import Organization from sentry.models.project import Project from sentry.receivers.outbox import maybe_process_tombstone -from sentry.relocation.services.relocation_export.service import control_relocation_export_service from sentry.seer.agent.client_utils import AgentChatRequest, make_agent_chat_request from sentry.seer.autofix.utils import make_autofix_start_request from sentry.seer.models.run import SeerRun, SeerRunMirrorStatus, SeerRunType @@ -210,45 +207,6 @@ def process_disable_auth_provider(object_identifier: int, shard_identifier: int, AuthProviderReplica.objects.filter(auth_provider_id=object_identifier).delete() -# See the comment on /src/sentry/relocation/tasks/process.py::uploading_start for a detailed description of -# how this outbox drain handler fits into the entire SAAS->SAAS relocation workflow. -@receiver(process_cell_outbox, sender=OutboxCategory.RELOCATION_EXPORT_REPLY) -def process_relocation_reply_with_export(payload: Any, **kwds): - uuid = payload["relocation_uuid"] - slug = payload["org_slug"] - - killswitch_orgs = options.get("relocation.outbox-orgslug.killswitch") - if slug in killswitch_orgs: - logger.info( - "relocation.killswitch.org", - extra={ - "org_slug": slug, - "relocation_uuid": uuid, - }, - ) - return - - relocation_storage = get_relocation_storage() - path = f"runs/{uuid}/saas_to_saas_export/{slug}.tar" - try: - encrypted_bytes = relocation_storage.open(path) - except Exception: - raise FileNotFoundError( - "Could not open SaaS -> SaaS export in export-side relocation bucket." - ) - - with encrypted_bytes: - control_relocation_export_service.reply_with_export( - relocation_uuid=uuid, - requesting_region_name=payload["requesting_region_name"], - replying_region_name=payload["replying_region_name"], - org_slug=slug, - # TODO(azaslavsky): finish transfer from `encrypted_contents` -> `encrypted_bytes`. - encrypted_contents=None, - encrypted_bytes=[int(byte) for byte in encrypted_bytes.read()], - ) - - @receiver(process_cell_outbox, sender=OutboxCategory.SEER_RUN_CREATE) def handle_seer_run_create(object_identifier: int, payload: Any, **kwds: Any) -> None: try: diff --git a/src/sentry/relocation/services/__init__.py b/src/sentry/relocation/services/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/relocation/services/relocation_export/impl.py b/src/sentry/relocation/services/relocation_export/impl.py index 5b9f2f8932de40..2a6447e62f4905 100644 --- a/src/sentry/relocation/services/relocation_export/impl.py +++ b/src/sentry/relocation/services/relocation_export/impl.py @@ -25,8 +25,6 @@ CellRelocationExportService, ControlRelocationExportService, ) -from sentry.relocation.tasks.process import fulfill_cross_region_export_request, uploading_complete -from sentry.relocation.tasks.transfer import process_relocation_transfer_control from sentry.relocation.utils import RELOCATION_BLOB_SIZE, RELOCATION_FILE_TYPE from sentry.utils.db import atomic_transaction @@ -43,6 +41,8 @@ def request_new_export( org_slug: str, encrypt_with_public_key: bytes, ) -> None: + from sentry.relocation.tasks.process import fulfill_cross_region_export_request + logger_data = { "uuid": relocation_uuid, "requesting_region_name": requesting_region_name, @@ -81,6 +81,8 @@ def reply_with_export( # TODO(azaslavsky): finish transfer from `encrypted_contents` -> `encrypted_bytes`. encrypted_contents: bytes | None = None, ) -> None: + from sentry.relocation.tasks.process import uploading_complete + with atomic_transaction( using=( router.db_for_write(Relocation), @@ -141,6 +143,8 @@ def request_new_export( org_slug: str, encrypt_with_public_key: bytes, ) -> None: + from sentry.relocation.tasks.transfer import process_relocation_transfer_control + logger_data = { "uuid": relocation_uuid, "requesting_region_name": requesting_region_name, @@ -173,6 +177,8 @@ def reply_with_export( # TODO(azaslavsky): finish transfer from `encrypted_contents` -> `encrypted_bytes`. encrypted_contents: bytes | None = None, ) -> None: + from sentry.relocation.tasks.transfer import process_relocation_transfer_control + logger_data = { "uuid": relocation_uuid, "requesting_region_name": requesting_region_name, diff --git a/src/sentry/relocation/tasks/process.py b/src/sentry/relocation/tasks/process.py index ac8f4ac645c5b8..f229c1a482e191 100644 --- a/src/sentry/relocation/tasks/process.py +++ b/src/sentry/relocation/tasks/process.py @@ -63,7 +63,9 @@ RegionRelocationTransfer, RelocationTransferState, ) -from sentry.relocation.tasks.transfer import process_relocation_transfer_region +from sentry.relocation.services.relocation_export.service import ( + control_relocation_export_service, +) from sentry.relocation.utils import ( TASK_TO_STEP, LoggingPrinter, @@ -247,9 +249,6 @@ def uploading_start(uuid: str, replying_cell_name: str | None, org_slug: str | N with the `Relocation` that originally triggered `uploading_start`, and the next task in the sequence (`uploading_complete`) is scheduled. """ - from sentry.relocation.services.relocation_export.service import ( - control_relocation_export_service, - ) uuid = str(uuid) (relocation, attempts_left) = start_relocation_task( @@ -333,6 +332,8 @@ def fulfill_cross_region_export_request( call is received with the encrypted export in tow, it will trigger the next step in the `SAAS_TO_SAAS` relocation's pipeline, namely `uploading_complete`. """ + from sentry.relocation.tasks.transfer import process_relocation_transfer_region + encrypt_with_public_key_bytes = base64.b64decode(encrypt_with_public_key.encode("utf8")) logger_data = { diff --git a/tests/sentry/relocation/tasks/test_transfer.py b/tests/sentry/relocation/tasks/test_transfer.py index 945c409bf2a49f..77fe9dd6835df9 100644 --- a/tests/sentry/relocation/tasks/test_transfer.py +++ b/tests/sentry/relocation/tasks/test_transfer.py @@ -136,7 +136,7 @@ def test_missing_transfer(self) -> None: res = process_relocation_transfer_control(transfer_id=999) assert res is None - @patch("sentry.relocation.services.relocation_export.impl.fulfill_cross_region_export_request") + @patch("sentry.relocation.tasks.process.fulfill_cross_region_export_request") def test_transfer_request_state(self, mock_fulfill: MagicMock) -> None: transfer = create_control_relocation_transfer( organization=self.organization, @@ -149,7 +149,7 @@ def test_transfer_request_state(self, mock_fulfill: MagicMock) -> None: # Should be removed on completion. assert not ControlRelocationTransfer.objects.filter(id=transfer.id).exists() - @patch("sentry.relocation.services.relocation_export.impl.uploading_complete") + @patch("sentry.relocation.tasks.process.uploading_complete") def test_transfer_reply_state(self, mock_uploading_complete: MagicMock) -> None: organization = self.organization with assume_test_silo_mode(SiloMode.CELL):