diff --git a/static/app/components/modals/widgetViewerModal.spec.tsx b/static/app/components/modals/widgetViewerModal.spec.tsx index f6bd93b43c1e7f..612eaf1f669209 100644 --- a/static/app/components/modals/widgetViewerModal.spec.tsx +++ b/static/app/components/modals/widgetViewerModal.spec.tsx @@ -1195,7 +1195,7 @@ describe('Modals -> WidgetViewerModal', () => { url: '/organizations/org-slug/metrics/data/', body: MetricsTotalCountByReleaseIn24h(), headers: { - link: + Link: '; rel="previous"; results="false"; cursor="0:0:1",' + '; rel="next"; results="true"; cursor="0:10:0"', }, @@ -1233,6 +1233,9 @@ describe('Modals -> WidgetViewerModal', () => { it('renders table header and body', async () => { await renderModal({initialData, widget: mockWidget}); + await waitFor(() => { + expect(metricsMock).toHaveBeenCalled(); + }); expect(await screen.findByText('release')).toBeInTheDocument(); expect(await screen.findByText('e102abb2c46e')).toBeInTheDocument(); expect(screen.getByText('sum(session)')).toBeInTheDocument(); diff --git a/static/app/views/dashboards/datasetConfig/releases.tsx b/static/app/views/dashboards/datasetConfig/releases.tsx index 1a73b5f8b9dd8b..80ca0d95ab6f14 100644 --- a/static/app/views/dashboards/datasetConfig/releases.tsx +++ b/static/app/views/dashboards/datasetConfig/releases.tsx @@ -1,11 +1,7 @@ import omit from 'lodash/omit'; -import trimStart from 'lodash/trimStart'; -import {doReleaseHealthRequest} from 'sentry/actionCreators/metrics'; -import {doSessionsRequest} from 'sentry/actionCreators/sessions'; -import type {Client} from 'sentry/api'; import {t} from 'sentry/locale'; -import type {DateString, PageFilters, SelectValue} from 'sentry/types/core'; +import type {SelectValue} from 'sentry/types/core'; import type {Organization, SessionApiResponse} from 'sentry/types/organization'; import type {SessionsMeta} from 'sentry/types/sessions'; import {SessionField} from 'sentry/types/sessions'; @@ -15,11 +11,8 @@ import type { AggregationKeyWithAlias, QueryFieldValue, } from 'sentry/utils/discover/fields'; -import {statsPeriodToDays} from 'sentry/utils/duration/statsPeriodToDays'; -import type {OnDemandControlContext} from 'sentry/utils/performance/contexts/onDemandControl'; -import type {Widget, WidgetQuery} from 'sentry/views/dashboards/types'; +import type {WidgetQuery} from 'sentry/views/dashboards/types'; import {DisplayType} from 'sentry/views/dashboards/types'; -import {getWidgetInterval} from 'sentry/views/dashboards/utils'; import {transformSessionsResponseToSeries} from 'sentry/views/dashboards/utils/transformSessionsResponseToSeries'; import { changeObjectValuesToTypes, @@ -31,18 +24,17 @@ import { useReleasesSearchBarDataProvider, } from 'sentry/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/releaseSearchBar'; import { - DerivedStatusFields, DISABLED_SORT, - FIELD_TO_METRICS_EXPRESSION, generateReleaseWidgetFieldOptions, SESSIONS_FIELDS, SESSIONS_TAGS, TAG_SORT_DENY_LIST, } from 'sentry/views/dashboards/widgetBuilder/releaseWidget/fields'; import { - requiresCustomReleaseSorting, - resolveDerivedStatusFields, -} from 'sentry/views/dashboards/widgetCard/releaseWidgetQueries'; + useReleasesSeriesQuery, + useReleasesTableQuery, +} from 'sentry/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery'; +import {resolveDerivedStatusFields} from 'sentry/views/dashboards/widgetCard/releaseWidgetQueries'; import type {FieldValueOption} from 'sentry/views/discover/table/queryField'; import type {FieldValue} from 'sentry/views/discover/table/types'; import {FieldValueKind} from 'sentry/views/discover/table/types'; @@ -70,35 +62,13 @@ const DEFAULT_FIELD: QueryFieldValue = { kind: FieldValueKind.FUNCTION, }; -const METRICS_BACKED_SESSIONS_START_DATE = new Date('2022-07-12'); - export const ReleasesConfig: DatasetConfig = { defaultField: DEFAULT_FIELD, defaultWidgetQuery: DEFAULT_WIDGET_QUERY, enableEquations: false, disableSortOptions, - getTableRequest: ( - api: Client, - _: Widget, - query: WidgetQuery, - organization: Organization, - pageFilters: PageFilters, - __?: OnDemandControlContext, - limit?: number, - cursor?: string - ) => - getReleasesRequest( - 0, - 1, - api, - query, - organization, - pageFilters, - undefined, - limit, - cursor - ), - getSeriesRequest: getReleasesSeriesRequest, + useTableQuery: useReleasesTableQuery, + useSeriesQuery: useReleasesSeriesQuery, getTableSortOptions, getTimeseriesSortOptions, filterTableOptions: filterPrimaryReleaseTableOptions, @@ -192,42 +162,6 @@ function filterSeriesSortOptions(columns: Set) { }; } -function getReleasesSeriesRequest( - api: Client, - widget: Widget, - queryIndex: number, - organization: Organization, - pageFilters: PageFilters -) { - const query = widget.queries[queryIndex]!; - const {limit} = widget; - - const {datetime} = pageFilters; - const {start, end, period} = datetime; - - const isCustomReleaseSorting = requiresCustomReleaseSorting(query); - - const includeTotals = query.columns.length > 0 ? 1 : 0; - const interval = getWidgetInterval( - widget, - {start, end, period}, - '5m', - // requesting medium fidelity for release sort because metrics api can't return 100 rows of high fidelity series data - isCustomReleaseSorting ? 'medium' : undefined - ); - - return getReleasesRequest( - 1, - includeTotals, - api, - query, - organization, - pageFilters, - interval, - limit - ); -} - function filterPrimaryReleaseTableOptions(option: FieldValueOption) { return [ FieldValueKind.FUNCTION, @@ -306,201 +240,3 @@ export function transformSessionsResponseToTable( }; return {meta, data: rows}; } - -function fieldsToDerivedMetrics(field: string): string { - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - return FIELD_TO_METRICS_EXPRESSION[field] ?? field; -} - -const RATE_FUNCTIONS = [ - 'unhealthy_rate', - 'abnormal_rate', - 'errored_rate', - 'unhandled_rate', - 'crash_rate', -]; - -function getReleasesRequest( - includeSeries: number, - includeTotals: number, - api: Client, - query: WidgetQuery, - organization: Organization, - pageFilters: PageFilters, - interval?: string, - limit?: number, - cursor?: string -) { - const {environments, projects, datetime} = pageFilters; - const {start, end, period} = datetime; - - let showIncompleteDataAlert = false; - - if (start) { - let startDate: Date | undefined = undefined; - if (typeof start === 'string') { - startDate = new Date(start); - } else { - startDate = start; - } - showIncompleteDataAlert = startDate < METRICS_BACKED_SESSIONS_START_DATE; - } else if (period) { - const periodInDays = statsPeriodToDays(period); - const current = new Date(); - const prior = new Date(new Date().setDate(current.getDate() - periodInDays)); - showIncompleteDataAlert = prior < METRICS_BACKED_SESSIONS_START_DATE; - } - - if (showIncompleteDataAlert) { - return Promise.reject( - new Error( - t( - 'Releases data is only available from Jul 12. Please retry your query with a more recent date range.' - ) - ) - ); - } - - // Only time we need to use sessions API is when session.status is requested - // as a group by, or we are using a rate function. - const useSessionAPI = - query.columns.includes('session.status') || - Boolean( - query.fields?.some(field => - RATE_FUNCTIONS.some(rateFunction => field.startsWith(rateFunction)) - ) - ); - const isCustomReleaseSorting = requiresCustomReleaseSorting(query); - const isDescending = query.orderby.startsWith('-'); - const rawOrderby = trimStart(query.orderby, '-'); - const unsupportedOrderby = - DISABLED_SORT.includes(rawOrderby) || useSessionAPI || rawOrderby === 'release'; - const columns = query.columns; - - // Temporary solution to support sorting on releases when querying the - // Metrics API: - // - // We first request the top 50 recent releases from postgres. Note that the - // release request is based on the project and environment selected in the - // page filters. - // - // We then construct a massive OR condition and append it to any specified - // filter condition. We also maintain an ordered array of release versions - // to order the results returned from the metrics endpoint. - // - // Also note that we request a limit on the metrics endpoint, this - // is because in a query, the limit should be applied after the results are - // sorted based on the release version. The larger number of rows we - // request, the more accurate our results are going to be. - // - // After the results are sorted, we truncate the data to the requested - // limit. This will result in a few edge cases: - // - // 1. low to high sort may not show releases at the beginning of the - // selected period if there are more than 50 releases in the selected - // period. - // - // 2. if a recent release is not returned due to the row limit - // imposed on the metrics query the user won't see it on the - // table/chart/ - // - - const {aggregates, injectedFields} = resolveDerivedStatusFields( - query.aggregates, - query.orderby, - useSessionAPI - ); - - if (useSessionAPI) { - const sessionAggregates = aggregates.filter( - agg => !Object.values(DerivedStatusFields).includes(agg as DerivedStatusFields) - ); - return doSessionsRequest(api, { - field: sessionAggregates, - orgSlug: organization.slug, - end, - environment: environments, - groupBy: columns, - limit: undefined, - orderBy: '', // Orderby not supported with session.status - interval, - project: projects, - query: query.conditions, - start, - statsPeriod: period, - cursor, - }); - } - - const requestData = { - field: aggregates.map(fieldsToDerivedMetrics), - }; - - if ( - rawOrderby && - !unsupportedOrderby && - !aggregates.includes(rawOrderby) && - !columns.includes(rawOrderby) - ) { - requestData.field = [...requestData.field, fieldsToDerivedMetrics(rawOrderby)]; - if (!injectedFields.includes(rawOrderby)) { - injectedFields.push(rawOrderby); - } - } - - return doReleaseHealthRequest(api, { - field: requestData.field, - orgSlug: organization.slug, - end, - environment: environments, - groupBy: columns.map(fieldsToDerivedMetrics), - limit: - columns.length === 0 - ? 1 - : isCustomReleaseSorting - ? getCustomReleaseSortLimit(period, start, end, interval) - : limit, - orderBy: unsupportedOrderby - ? '' - : isDescending - ? `-${fieldsToDerivedMetrics(rawOrderby)}` - : fieldsToDerivedMetrics(rawOrderby), - interval, - project: projects, - query: query.conditions, - start, - statsPeriod: period, - cursor, - includeSeries, - includeTotals, - }); -} - -/** - * This is the maximum number of data points that can be returned by the metrics API. - * Should be kept in sync with MAX_POINTS constant in backend - * @file src/sentry/snuba/metrics/utils.py - */ -const MAX_POINTS = 10000; - -/** - * This is used to decide the "limit" parameter for the release health request. - * This limit is actually passed to the "per_page" parameter of the request. - * The limit is determined by the following formula: limit < MAX_POINTS / numberOfIntervals. - * This is to prevent the "requested intervals is too granular for per_page..." error from the backend. - */ -function getCustomReleaseSortLimit( - period: string | null, - start?: DateString, - end?: DateString, - interval?: string -) { - const periodInDays = statsPeriodToDays(period, start, end); - const intervalInDays = statsPeriodToDays(interval); - const numberOfIntervals = periodInDays / intervalInDays; - const limit = Math.floor(MAX_POINTS / numberOfIntervals) - 1; - if (limit < 1 || limit > 100) { - return 100; - } - return limit; -} diff --git a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.spec.tsx b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.spec.tsx new file mode 100644 index 00000000000000..618050c112dfa9 --- /dev/null +++ b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.spec.tsx @@ -0,0 +1,456 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; +import {SessionsFieldFixture} from 'sentry-fixture/sessions'; +import {WidgetFixture} from 'sentry-fixture/widget'; + +import {renderHook, waitFor} from 'sentry-test/reactTestingLibrary'; + +import PageFiltersStore from 'sentry/stores/pageFiltersStore'; +import {SessionField} from 'sentry/types/sessions'; +import {QueryClient, QueryClientProvider} from 'sentry/utils/queryClient'; +import {DisplayType} from 'sentry/views/dashboards/types'; + +import {useReleasesSeriesQuery, useReleasesTableQuery} from './useReleasesWidgetQuery'; + +jest.mock('sentry/views/dashboards/utils/widgetQueryQueue', () => ({ + useWidgetQueryQueue: () => ({queue: null}), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; +} + +describe('useReleasesSeriesQuery', () => { + const organization = OrganizationFixture(); + const pageFilters = PageFiltersFixture(); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState(pageFilters); + }); + + it('makes a request to the metrics/data endpoint', async () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + { + name: 'test', + fields: [`crash_free_rate(${SessionField.SESSION})`], + aggregates: [`crash_free_rate(${SessionField.SESSION})`], + columns: [], + conditions: '', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/metrics/data/', + body: SessionsFieldFixture(`crash_free_rate(${SessionField.SESSION})`), + }); + + renderHook( + () => + useReleasesSeriesQuery({ + widget, + organization, + pageFilters, + enabled: true, + }), + {wrapper: createWrapper()} + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith( + '/organizations/org-slug/metrics/data/', + expect.objectContaining({ + query: expect.objectContaining({ + field: ['session.crash_free_rate'], + includeSeries: 1, + }), + }) + ); + }); + }); + + it('applies dashboard filters to widget query', async () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + { + name: 'test', + fields: [`crash_free_rate(${SessionField.SESSION})`], + aggregates: [`crash_free_rate(${SessionField.SESSION})`], + columns: [], + conditions: 'release:1.0.0', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/metrics/data/', + body: SessionsFieldFixture(`crash_free_rate(${SessionField.SESSION})`), + }); + + renderHook( + () => + useReleasesSeriesQuery({ + widget, + organization, + pageFilters, + dashboardFilters: { + release: ['2.0.0'], + }, + enabled: true, + }), + {wrapper: createWrapper()} + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith( + '/organizations/org-slug/metrics/data/', + expect.objectContaining({ + query: expect.objectContaining({ + query: expect.stringContaining('release:"2.0.0"'), + }), + }) + ); + }); + }); + + it('uses session API for session.status grouping', async () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + { + name: 'test', + fields: [`sum(${SessionField.SESSION})`], + aggregates: [`sum(${SessionField.SESSION})`], + columns: ['session.status'], + conditions: '', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sessions/', + body: SessionsFieldFixture(`sum(${SessionField.SESSION})`), + }); + + renderHook( + () => + useReleasesSeriesQuery({ + widget, + organization, + pageFilters, + enabled: true, + }), + {wrapper: createWrapper()} + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith( + '/organizations/org-slug/sessions/', + expect.objectContaining({ + query: expect.objectContaining({ + field: [`sum(${SessionField.SESSION})`], + groupBy: ['session.status'], + }), + }) + ); + }); + }); + + it('includes totals when columns are present', async () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + { + name: 'test', + fields: [`crash_free_rate(${SessionField.SESSION})`], + aggregates: [`crash_free_rate(${SessionField.SESSION})`], + columns: ['release'], + conditions: '', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/metrics/data/', + body: SessionsFieldFixture(`crash_free_rate(${SessionField.SESSION})`), + }); + + renderHook( + () => + useReleasesSeriesQuery({ + widget, + organization, + pageFilters, + enabled: true, + }), + {wrapper: createWrapper()} + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith( + '/organizations/org-slug/metrics/data/', + expect.objectContaining({ + query: expect.objectContaining({ + includeSeries: 1, + includeTotals: 1, + }), + }) + ); + }); + }); +}); + +describe('useReleasesTableQuery', () => { + const organization = OrganizationFixture(); + const pageFilters = PageFiltersFixture(); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState(pageFilters); + }); + + it('makes a request to the metrics/data endpoint', async () => { + const widget = WidgetFixture({ + displayType: DisplayType.TABLE, + queries: [ + { + name: 'test', + fields: ['release', `crash_free_rate(${SessionField.SESSION})`], + aggregates: [`crash_free_rate(${SessionField.SESSION})`], + columns: ['release'], + conditions: '', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/metrics/data/', + body: SessionsFieldFixture(`crash_free_rate(${SessionField.SESSION})`), + }); + + renderHook( + () => + useReleasesTableQuery({ + widget, + organization, + pageFilters, + enabled: true, + }), + {wrapper: createWrapper()} + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith( + '/organizations/org-slug/metrics/data/', + expect.objectContaining({ + query: expect.objectContaining({ + field: ['session.crash_free_rate'], + includeSeries: 0, + includeTotals: 1, + }), + }) + ); + }); + }); + + it('handles pagination parameters', async () => { + const widget = WidgetFixture({ + displayType: DisplayType.TABLE, + queries: [ + { + name: 'test', + fields: ['release', `crash_free_rate(${SessionField.SESSION})`], + aggregates: [`crash_free_rate(${SessionField.SESSION})`], + columns: ['release'], + conditions: '', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/metrics/data/', + body: SessionsFieldFixture(`crash_free_rate(${SessionField.SESSION})`), + }); + + renderHook( + () => + useReleasesTableQuery({ + widget, + organization, + pageFilters, + limit: 50, + cursor: 'test-cursor', + enabled: true, + }), + {wrapper: createWrapper()} + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith( + '/organizations/org-slug/metrics/data/', + expect.objectContaining({ + query: expect.objectContaining({ + per_page: 50, + cursor: 'test-cursor', + }), + }) + ); + }); + }); + + it('applies dashboard filters to table query', async () => { + const widget = WidgetFixture({ + displayType: DisplayType.TABLE, + queries: [ + { + name: 'test', + fields: ['release', `crash_free_rate(${SessionField.SESSION})`], + aggregates: [`crash_free_rate(${SessionField.SESSION})`], + columns: ['release'], + conditions: 'environment:production', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/metrics/data/', + body: SessionsFieldFixture(`crash_free_rate(${SessionField.SESSION})`), + }); + + renderHook( + () => + useReleasesTableQuery({ + widget, + organization, + pageFilters, + dashboardFilters: { + release: ['1.0.0'], + }, + enabled: true, + }), + {wrapper: createWrapper()} + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith( + '/organizations/org-slug/metrics/data/', + expect.objectContaining({ + query: expect.objectContaining({ + query: expect.stringMatching(/release:"1\.0\.0"/), + }), + }) + ); + }); + }); + + it('uses session API when grouping by session.status', async () => { + const widget = WidgetFixture({ + displayType: DisplayType.TABLE, + queries: [ + { + name: 'test', + fields: ['session.status', `sum(${SessionField.SESSION})`], + aggregates: [`sum(${SessionField.SESSION})`], + columns: ['session.status'], + conditions: '', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sessions/', + body: SessionsFieldFixture(`sum(${SessionField.SESSION})`), + }); + + renderHook( + () => + useReleasesTableQuery({ + widget, + organization, + pageFilters, + enabled: true, + }), + {wrapper: createWrapper()} + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith( + '/organizations/org-slug/sessions/', + expect.objectContaining({ + query: expect.objectContaining({ + field: [`sum(${SessionField.SESSION})`], + groupBy: ['session.status'], + }), + }) + ); + }); + }); + + it('respects limit from widget', async () => { + const widget = WidgetFixture({ + displayType: DisplayType.TABLE, + limit: 25, + queries: [ + { + name: 'test', + fields: ['release', `crash_free_rate(${SessionField.SESSION})`], + aggregates: [`crash_free_rate(${SessionField.SESSION})`], + columns: ['release'], + conditions: '', + orderby: '', + }, + ], + }); + + const mockRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/metrics/data/', + body: SessionsFieldFixture(`crash_free_rate(${SessionField.SESSION})`), + }); + + renderHook( + () => + useReleasesTableQuery({ + widget, + organization, + pageFilters, + enabled: true, + }), + {wrapper: createWrapper()} + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith( + '/organizations/org-slug/metrics/data/', + expect.objectContaining({ + query: expect.objectContaining({ + per_page: 25, + }), + }) + ); + }); + }); +}); diff --git a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx new file mode 100644 index 00000000000000..2af033a459586f --- /dev/null +++ b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx @@ -0,0 +1,450 @@ +import {useCallback, useMemo, useRef} from 'react'; +import {useQueries} from '@tanstack/react-query'; +import cloneDeep from 'lodash/cloneDeep'; + +import {doReleaseHealthRequest} from 'sentry/actionCreators/metrics'; +import {doSessionsRequest} from 'sentry/actionCreators/sessions'; +import type {ApiResult} from 'sentry/api'; +import {t} from 'sentry/locale'; +import type {Series} from 'sentry/types/echarts'; +import type {SessionApiResponse} from 'sentry/types/organization'; +import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import type {WidgetQueryParams} from 'sentry/views/dashboards/datasetConfig/base'; +import {ReleasesConfig} from 'sentry/views/dashboards/datasetConfig/releases'; +import type {DashboardFilters, Widget} from 'sentry/views/dashboards/types'; +import {dashboardFiltersToString, getWidgetInterval} from 'sentry/views/dashboards/utils'; +import {useWidgetQueryQueue} from 'sentry/views/dashboards/utils/widgetQueryQueue'; +import type {HookWidgetQueryResult} from 'sentry/views/dashboards/widgetCard/genericWidgetQueries'; +import {cleanWidgetForRequest} from 'sentry/views/dashboards/widgetCard/genericWidgetQueries'; +import {requiresCustomReleaseSorting} from 'sentry/views/dashboards/widgetCard/releaseWidgetQueries'; + +import {getReleasesRequestData} from './utils/releasesUtils'; + +function applyDashboardFilters( + widget: Widget, + dashboardFilters?: DashboardFilters, + skipParens?: boolean +): Widget { + let processedWidget = widget; + + if (dashboardFilters) { + const filtered = cloneDeep(widget); + const dashboardFilterConditions = dashboardFiltersToString( + dashboardFilters, + filtered.widgetType + ); + + filtered.queries.forEach(query => { + if (dashboardFilterConditions) { + if (query.conditions && !skipParens) { + query.conditions = `(${query.conditions})`; + } + query.conditions = query.conditions + ` ${dashboardFilterConditions}`; + } + }); + + processedWidget = filtered; + } + + return cleanWidgetForRequest(processedWidget); +} + +const EMPTY_ARRAY: any[] = []; + +export function useReleasesSeriesQuery(params: WidgetQueryParams): HookWidgetQueryResult { + const { + widget, + organization, + pageFilters, + enabled, + dashboardFilters, + skipDashboardFilterParens, + } = params; + + const api = useApi(); + const {queue} = useWidgetQueryQueue(); + const prevRawDataRef = useRef(undefined); + + const filteredWidget = useMemo(() => { + return applyDashboardFilters(widget, dashboardFilters, skipDashboardFilterParens); + }, [widget, dashboardFilters, skipDashboardFilterParens]); + + const hasQueueFeature = organization.features.includes( + 'visibility-dashboards-async-queue' + ); + + // Compute validation error and query keys together + const {queryKeys, validationError} = useMemo(() => { + try { + const keys = filteredWidget.queries.map((query, queryIndex) => { + const {datetime} = pageFilters; + const {start, end, period} = datetime; + + const isCustomReleaseSorting = requiresCustomReleaseSorting(query); + const includeTotals = query.columns.length > 0 ? 1 : 0; + const interval = getWidgetInterval( + filteredWidget, + {start, end, period}, + '5m', + // requesting medium fidelity for release sort because metrics api can't return 100 rows of high fidelity series data + isCustomReleaseSorting ? 'medium' : undefined + ); + + const requestData = getReleasesRequestData( + 1, // includeSeries + includeTotals, + query, + organization, + pageFilters, + interval, + filteredWidget.limit + ); + + return { + queryKey: [ + `/organizations/${organization.slug}/sessions/`, + {method: 'GET' as const, query: requestData}, + ], + queryIndex, + useSessionAPI: requestData.useSessionAPI, + }; + }); + return {queryKeys: keys, validationError: undefined}; + } catch (error) { + // Catch synchronous errors from getReleasesRequestData (e.g., date validation) + const errorMessage = error instanceof Error ? error.message : String(error); + // Return empty array to prevent queries from running + return {queryKeys: [], validationError: errorMessage}; + } + }, [filteredWidget, organization, pageFilters]); + + const createQueryFn = useCallback( + (useSessionAPI: boolean) => + async (context: any): Promise> => { + const queryParams = context.queryKey[1].query; + + const fetchFn = async () => { + if (useSessionAPI) { + return doSessionsRequest(api, queryParams); + } + return doReleaseHealthRequest(api, queryParams); + }; + + if (queue) { + return new Promise((resolve, reject) => { + const fetchFnRef = { + current: async () => { + try { + const result = await fetchFn(); + resolve(result); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }, + }; + queue.addItem({fetchDataRef: fetchFnRef}); + }); + } + + return fetchFn(); + }, + [api, queue] + ); + + const queryResults = useQueries({ + queries: queryKeys.map(({queryKey, useSessionAPI}) => ({ + queryKey, + queryFn: createQueryFn(useSessionAPI), + staleTime: 0, + enabled, + retry: hasQueueFeature + ? false + : (failureCount: number, error: any) => { + if (error?.status === 429 && failureCount < 10) { + return true; + } + return false; + }, + placeholderData: (previousData: unknown) => previousData, + })), + }); + + const transformedData = (() => { + if (validationError) { + return { + loading: false, + errorMessage: validationError, + rawData: EMPTY_ARRAY, + }; + } + + const isFetching = queryResults.some(q => q?.isFetching); + const allHaveData = queryResults.every(q => q?.data?.[0]); + const error = queryResults.find(q => q?.error)?.error as RequestError | undefined; + const errorMessage = error + ? error.responseJSON?.detail + ? typeof error.responseJSON.detail === 'string' + ? error.responseJSON.detail + : error.responseJSON.detail.message + : error.message || t('An unknown error occurred.') + : undefined; + + if (!allHaveData || isFetching) { + const loading = isFetching || !errorMessage; + return { + loading, + errorMessage, + rawData: EMPTY_ARRAY, + }; + } + + const timeseriesResults: Series[] = []; + const rawData: SessionApiResponse[] = []; + + queryResults.forEach((q, requestIndex) => { + if (!q?.data?.[0]) { + return; + } + + const responseData = q.data[0]; + rawData[requestIndex] = responseData; + + const transformedResult = ReleasesConfig.transformSeries?.( + responseData, + filteredWidget.queries[requestIndex]!, + organization + ); + + if (!transformedResult) { + return; + } + + transformedResult.forEach((result: Series, resultIndex: number) => { + timeseriesResults[requestIndex * transformedResult.length + resultIndex] = result; + }); + }); + + // Memoize raw data to prevent unnecessary rerenders + let finalRawData = rawData; + if (prevRawDataRef.current && prevRawDataRef.current.length === rawData.length) { + const allSame = rawData.every((data, i) => data === prevRawDataRef.current?.[i]); + if (allSame) { + finalRawData = prevRawDataRef.current; + } + } + + if (finalRawData !== prevRawDataRef.current) { + prevRawDataRef.current = finalRawData; + } + + return { + loading: false, + errorMessage: undefined, + timeseriesResults, + tableResults: undefined, + rawData: finalRawData, + }; + })(); + + return transformedData; +} + +export function useReleasesTableQuery(params: WidgetQueryParams): HookWidgetQueryResult { + const { + widget, + organization, + pageFilters, + enabled, + cursor, + limit, + dashboardFilters, + skipDashboardFilterParens, + } = params; + + const api = useApi(); + const {queue} = useWidgetQueryQueue(); + const prevRawDataRef = useRef(undefined); + + const filteredWidget = useMemo(() => { + return applyDashboardFilters(widget, dashboardFilters, skipDashboardFilterParens); + }, [widget, dashboardFilters, skipDashboardFilterParens]); + + const hasQueueFeature = organization.features.includes( + 'visibility-dashboards-async-queue' + ); + + // Compute validation error and query keys together + const {queryKeys, validationError} = useMemo(() => { + try { + const keys = filteredWidget.queries.map((query, queryIndex) => { + const requestData = getReleasesRequestData( + 0, // includeSeries + 1, // includeTotals + query, + organization, + pageFilters, + undefined, // interval + limit ?? filteredWidget.limit, + cursor + ); + + return { + queryKey: [ + `/organizations/${organization.slug}/sessions/`, + {method: 'GET' as const, query: requestData}, + ], + queryIndex, + useSessionAPI: requestData.useSessionAPI, + }; + }); + return {queryKeys: keys, validationError: undefined}; + } catch (error) { + // Catch synchronous errors from getReleasesRequestData (e.g., date validation) + const errorMessage = error instanceof Error ? error.message : String(error); + // Return empty array to prevent queries from running + return {queryKeys: [], validationError: errorMessage}; + } + }, [filteredWidget, organization, pageFilters, limit, cursor]); + + const createQueryFn = useCallback( + (useSessionAPI: boolean) => + async (context: any): Promise> => { + const queryParams = context.queryKey[1].query; + + const fetchFn = async () => { + if (useSessionAPI) { + return doSessionsRequest(api, queryParams); + } + return doReleaseHealthRequest(api, queryParams); + }; + + if (queue) { + return new Promise((resolve, reject) => { + const fetchFnRef = { + current: async () => { + try { + const result = await fetchFn(); + resolve(result); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }, + }; + queue.addItem({fetchDataRef: fetchFnRef}); + }); + } + + return fetchFn(); + }, + [api, queue] + ); + + const queryResults = useQueries({ + queries: queryKeys.map(({queryKey, useSessionAPI}) => ({ + queryKey, + queryFn: createQueryFn(useSessionAPI), + staleTime: 0, + enabled, + retry: hasQueueFeature + ? false + : (failureCount: number, error: any) => { + if (error?.status === 429 && failureCount < 10) { + return true; + } + return false; + }, + placeholderData: (previousData: unknown) => previousData, + })), + }); + + const transformedData = (() => { + if (validationError) { + return { + loading: false, + errorMessage: validationError, + rawData: EMPTY_ARRAY, + }; + } + + const isFetching = queryResults.some(q => q?.isFetching); + const allHaveData = queryResults.every(q => q?.data?.[0]); + const error = queryResults.find(q => q?.error)?.error as RequestError | undefined; + const errorMessage = error + ? error.responseJSON?.detail + ? typeof error.responseJSON.detail === 'string' + ? error.responseJSON.detail + : error.responseJSON.detail.message + : error.message || t('An unknown error occurred.') + : undefined; + + if (!allHaveData || isFetching) { + const loading = isFetching || !errorMessage; + return { + loading, + errorMessage, + rawData: EMPTY_ARRAY, + }; + } + + const tableResults: TableDataWithTitle[] = []; + const rawData: SessionApiResponse[] = []; + let responsePageLinks: string | undefined; + + queryResults.forEach((q, i) => { + if (!q?.data?.[0]) { + return; + } + + const responseData = q.data[0]; + const responseMeta = q.data[2]; + rawData[i] = responseData; + + const tableData = ReleasesConfig.transformTable?.( + responseData, + filteredWidget.queries[i]!, + organization, + pageFilters + ); + + if (!tableData) { + return; + } + + const transformedDataItem: TableDataWithTitle = { + ...tableData, + title: filteredWidget.queries[i]?.name ?? '', + }; + + tableResults.push(transformedDataItem); + + // Get page links from response meta + responsePageLinks = responseMeta?.getResponseHeader('Link') ?? undefined; + }); + + // Memoize raw data to prevent unnecessary rerenders + let finalRawData = rawData; + if (prevRawDataRef.current && prevRawDataRef.current.length === rawData.length) { + const allSame = rawData.every((data, i) => data === prevRawDataRef.current?.[i]); + if (allSame) { + finalRawData = prevRawDataRef.current; + } + } + + if (finalRawData !== prevRawDataRef.current) { + prevRawDataRef.current = finalRawData; + } + + return { + loading: false, + errorMessage: undefined, + tableResults, + timeseriesResults: undefined, + pageLinks: responsePageLinks, + rawData: finalRawData, + }; + })(); + + return transformedData; +} diff --git a/static/app/views/dashboards/widgetCard/hooks/utils/releasesUtils.tsx b/static/app/views/dashboards/widgetCard/hooks/utils/releasesUtils.tsx new file mode 100644 index 00000000000000..5fd7194dc591a5 --- /dev/null +++ b/static/app/views/dashboards/widgetCard/hooks/utils/releasesUtils.tsx @@ -0,0 +1,187 @@ +import trimStart from 'lodash/trimStart'; + +import {t} from 'sentry/locale'; +import type {DateString, PageFilters} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; +import {statsPeriodToDays} from 'sentry/utils/duration/statsPeriodToDays'; +import type {WidgetQuery} from 'sentry/views/dashboards/types'; +import { + DerivedStatusFields, + DISABLED_SORT, + FIELD_TO_METRICS_EXPRESSION, +} from 'sentry/views/dashboards/widgetBuilder/releaseWidget/fields'; +import { + requiresCustomReleaseSorting, + resolveDerivedStatusFields, +} from 'sentry/views/dashboards/widgetCard/releaseWidgetQueries'; + +const METRICS_BACKED_SESSIONS_START_DATE = new Date('2022-07-12'); + +const RATE_FUNCTIONS = [ + 'unhealthy_rate', + 'abnormal_rate', + 'errored_rate', + 'unhandled_rate', + 'crash_rate', +]; + +/** + * This is the maximum number of data points that can be returned by the metrics API. + * Should be kept in sync with MAX_POINTS constant in backend + * @file src/sentry/snuba/metrics/utils.py + */ +const MAX_POINTS = 10000; + +function fieldsToDerivedMetrics(field: string): string { + // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + return FIELD_TO_METRICS_EXPRESSION[field] ?? field; +} + +/** + * This is used to decide the "limit" parameter for the release health request. + * This limit is actually passed to the "per_page" parameter of the request. + * The limit is determined by the following formula: limit < MAX_POINTS / numberOfIntervals. + * This is to prevent the "requested intervals is too granular for per_page..." error from the backend. + */ +function getCustomReleaseSortLimit( + period: string | null, + start?: DateString, + end?: DateString, + interval?: string +) { + const periodInDays = statsPeriodToDays(period, start, end); + const intervalInDays = statsPeriodToDays(interval); + const numberOfIntervals = periodInDays / intervalInDays; + const limit = Math.floor(MAX_POINTS / numberOfIntervals) - 1; + if (limit < 1 || limit > 100) { + return 100; + } + return limit; +} + +export function getReleasesRequestData( + includeSeries: number, + includeTotals: number, + query: WidgetQuery, + organization: Organization, + pageFilters: PageFilters, + interval?: string, + limit?: number, + cursor?: string +) { + const {environments, projects, datetime} = pageFilters; + const {start, end, period} = datetime; + + let showIncompleteDataAlert = false; + + if (start) { + let startDate: Date | undefined = undefined; + if (typeof start === 'string') { + startDate = new Date(start); + } else { + startDate = start; + } + showIncompleteDataAlert = startDate < METRICS_BACKED_SESSIONS_START_DATE; + } else if (period) { + const periodInDays = statsPeriodToDays(period); + const current = new Date(); + const prior = new Date(new Date().setDate(current.getDate() - periodInDays)); + showIncompleteDataAlert = prior < METRICS_BACKED_SESSIONS_START_DATE; + } + + if (showIncompleteDataAlert) { + throw new Error( + t( + 'Releases data is only available from Jul 12. Please retry your query with a more recent date range.' + ) + ); + } + + // Only time we need to use sessions API is when session.status is requested + // as a group by, or we are using a rate function. + const useSessionAPI = + query.columns.includes('session.status') || + Boolean( + query.fields?.some(field => + RATE_FUNCTIONS.some(rateFunction => field.startsWith(rateFunction)) + ) + ); + const isCustomReleaseSorting = requiresCustomReleaseSorting(query); + const isDescending = query.orderby.startsWith('-'); + const rawOrderby = trimStart(query.orderby, '-'); + const unsupportedOrderby = + DISABLED_SORT.includes(rawOrderby) || useSessionAPI || rawOrderby === 'release'; + const columns = query.columns; + + const {aggregates, injectedFields} = resolveDerivedStatusFields( + query.aggregates, + query.orderby, + useSessionAPI + ); + + if (useSessionAPI) { + const sessionAggregates = aggregates.filter( + agg => !Object.values(DerivedStatusFields).includes(agg as DerivedStatusFields) + ); + return { + field: sessionAggregates, + orgSlug: organization.slug, + end, + environment: environments, + groupBy: columns, + limit: undefined, + orderBy: '', // Orderby not supported with session.status + interval, + project: projects, + query: query.conditions, + start, + statsPeriod: period, + cursor, + useSessionAPI, + }; + } + + const requestData = { + field: aggregates.map(fieldsToDerivedMetrics), + }; + + if ( + rawOrderby && + !unsupportedOrderby && + !aggregates.includes(rawOrderby) && + !columns.includes(rawOrderby) + ) { + requestData.field = [...requestData.field, fieldsToDerivedMetrics(rawOrderby)]; + if (!injectedFields.includes(rawOrderby)) { + injectedFields.push(rawOrderby); + } + } + + return { + field: requestData.field, + orgSlug: organization.slug, + end, + environment: environments, + groupBy: columns.map(fieldsToDerivedMetrics), + limit: + columns.length === 0 + ? 1 + : isCustomReleaseSorting + ? getCustomReleaseSortLimit(period, start, end, interval) + : limit, + orderBy: unsupportedOrderby + ? '' + : isDescending + ? `-${fieldsToDerivedMetrics(rawOrderby)}` + : fieldsToDerivedMetrics(rawOrderby), + interval, + project: projects, + query: query.conditions, + start, + statsPeriod: period, + cursor, + includeSeries, + includeTotals, + useSessionAPI, + }; +}