From 32d6db216aacedea282cb877727f7b23663d2229 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 23 Jan 2026 15:31:22 -0500 Subject: [PATCH 01/15] add migrate releases to hooks --- .../dashboards/datasetConfig/releases.tsx | 284 +----------------- 1 file changed, 11 insertions(+), 273 deletions(-) diff --git a/static/app/views/dashboards/datasetConfig/releases.tsx b/static/app/views/dashboards/datasetConfig/releases.tsx index 1a73b5f8b9dd8b..b2ce6c585a0626 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 {PageFilters, 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, @@ -275,7 +209,9 @@ function getReleasesTableFieldOptions(_organization: Organization) { /** @internal exported for tests **/ export function transformSessionsResponseToTable( data: SessionApiResponse, - widgetQuery: WidgetQuery + widgetQuery: WidgetQuery, + _organization: Organization, + _pageFilters: PageFilters ): TableData { const useSessionAPI = widgetQuery.columns.includes('session.status'); const {derivedStatusFields, injectedFields} = resolveDerivedStatusFields( @@ -306,201 +242,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; -} From ec1f0eaf39f2c8a219534006e5bfce427ebc3ff4 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 23 Jan 2026 15:32:08 -0500 Subject: [PATCH 02/15] commit files --- .../hooks/useReleasesWidgetQuery.spec.tsx | 456 ++++++++++++++++++ .../hooks/useReleasesWidgetQuery.tsx | 402 +++++++++++++++ .../widgetCard/hooks/utils/releasesUtils.tsx | 187 +++++++ 3 files changed, 1045 insertions(+) create mode 100644 static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.spec.tsx create mode 100644 static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx create mode 100644 static/app/views/dashboards/widgetCard/hooks/utils/releasesUtils.tsx 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..bd0650ae62782e --- /dev/null +++ b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx @@ -0,0 +1,402 @@ +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 type {Series} from 'sentry/types/echarts'; +import type {SessionApiResponse} from 'sentry/types/organization'; +import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery'; +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 & {skipDashboardFilterParens?: boolean} +): 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' + ); + + const queryKeys = useMemo(() => { + return 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, + }; + }); + }, [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); + } + }, + }; + 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 = (() => { + const isFetching = queryResults.some(q => q?.isFetching); + const allHaveData = queryResults.every(q => q?.data?.[0]); + const errorMessage = queryResults.find(q => q?.error)?.error?.message; + + 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, + rawData: finalRawData, + }; + })(); + + return transformedData; +} + +export function useReleasesTableQuery( + params: WidgetQueryParams & {skipDashboardFilterParens?: boolean} +): 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' + ); + + const queryKeys = useMemo(() => { + return 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, + }; + }); + }, [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); + } + }, + }; + 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 = (() => { + const isFetching = queryResults.some(q => q?.isFetching); + const allHaveData = queryResults.every(q => q?.data?.[0]); + const errorMessage = queryResults.find(q => q?.error)?.error?.message; + + 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, + 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..17162b4093de08 --- /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. + */ +export 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, + }; +} From 0590e5656df469a64e6fe7ebbb9bd81fea163e6d Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 23 Jan 2026 15:36:51 -0500 Subject: [PATCH 03/15] knip --- .../views/dashboards/widgetCard/hooks/utils/releasesUtils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/dashboards/widgetCard/hooks/utils/releasesUtils.tsx b/static/app/views/dashboards/widgetCard/hooks/utils/releasesUtils.tsx index 17162b4093de08..5fd7194dc591a5 100644 --- a/static/app/views/dashboards/widgetCard/hooks/utils/releasesUtils.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/utils/releasesUtils.tsx @@ -43,7 +43,7 @@ function fieldsToDerivedMetrics(field: string): string { * 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. */ -export function getCustomReleaseSortLimit( +function getCustomReleaseSortLimit( period: string | null, start?: DateString, end?: DateString, From 8e962626b44dd5f81ffd54b6b94f9270591c6b93 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 26 Jan 2026 10:29:08 -0500 Subject: [PATCH 04/15] fix widget queries tests --- .../widgetCard/hooks/useReleasesWidgetQuery.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx index bd0650ae62782e..6995ea9ab34e9b 100644 --- a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx @@ -8,6 +8,7 @@ import type {ApiResult} from 'sentry/api'; 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'; @@ -165,7 +166,12 @@ export function useReleasesSeriesQuery( const transformedData = (() => { const isFetching = queryResults.some(q => q?.isFetching); const allHaveData = queryResults.every(q => q?.data?.[0]); - const errorMessage = queryResults.find(q => q?.error)?.error?.message; + const error = queryResults.find(q => q?.error)?.error as RequestError | undefined; + const errorMessage = error?.responseJSON?.detail + ? typeof error.responseJSON.detail === 'string' + ? error.responseJSON.detail + : error.responseJSON.detail.message + : error?.message; if (!allHaveData || isFetching) { const loading = isFetching || !errorMessage; @@ -219,6 +225,7 @@ export function useReleasesSeriesQuery( loading: false, errorMessage: undefined, timeseriesResults, + tableResults: undefined, rawData: finalRawData, }; })(); @@ -330,7 +337,12 @@ export function useReleasesTableQuery( const transformedData = (() => { const isFetching = queryResults.some(q => q?.isFetching); const allHaveData = queryResults.every(q => q?.data?.[0]); - const errorMessage = queryResults.find(q => q?.error)?.error?.message; + const error = queryResults.find(q => q?.error)?.error as RequestError | undefined; + const errorMessage = error?.responseJSON?.detail + ? typeof error.responseJSON.detail === 'string' + ? error.responseJSON.detail + : error.responseJSON.detail.message + : error?.message; if (!allHaveData || isFetching) { const loading = isFetching || !errorMessage; @@ -393,6 +405,7 @@ export function useReleasesTableQuery( loading: false, errorMessage: undefined, tableResults, + timeseriesResults: undefined, pageLinks: responsePageLinks, rawData: finalRawData, }; From 50df3437b5e2df713083107757595dbc58812063 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 26 Jan 2026 10:40:04 -0500 Subject: [PATCH 05/15] fix widgetView tests --- static/app/components/modals/widgetViewerModal.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/modals/widgetViewerModal.spec.tsx b/static/app/components/modals/widgetViewerModal.spec.tsx index a9762348bc98a9..ebe5f1db734e6c 100644 --- a/static/app/components/modals/widgetViewerModal.spec.tsx +++ b/static/app/components/modals/widgetViewerModal.spec.tsx @@ -1193,7 +1193,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"', }, From 06f31bfb2897edddb0daf95668f1173a7b37feb9 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 26 Jan 2026 11:12:47 -0500 Subject: [PATCH 06/15] tsc --- static/app/views/dashboards/datasetConfig/releases.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/static/app/views/dashboards/datasetConfig/releases.tsx b/static/app/views/dashboards/datasetConfig/releases.tsx index b2ce6c585a0626..80ca0d95ab6f14 100644 --- a/static/app/views/dashboards/datasetConfig/releases.tsx +++ b/static/app/views/dashboards/datasetConfig/releases.tsx @@ -1,7 +1,7 @@ import omit from 'lodash/omit'; import {t} from 'sentry/locale'; -import type {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'; @@ -209,9 +209,7 @@ function getReleasesTableFieldOptions(_organization: Organization) { /** @internal exported for tests **/ export function transformSessionsResponseToTable( data: SessionApiResponse, - widgetQuery: WidgetQuery, - _organization: Organization, - _pageFilters: PageFilters + widgetQuery: WidgetQuery ): TableData { const useSessionAPI = widgetQuery.columns.includes('session.status'); const {derivedStatusFields, injectedFields} = resolveDerivedStatusFields( From 5135dc09be4a7f4259f69fdf9d9d751b215693ca Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 26 Jan 2026 12:24:20 -0500 Subject: [PATCH 07/15] pr comments --- .../views/dashboards/datasetConfig/base.tsx | 5 + .../widgetCard/genericWidgetQueries.tsx | 2 + .../hooks/useReleasesWidgetQuery.tsx | 165 +++++++++++------- 3 files changed, 111 insertions(+), 61 deletions(-) diff --git a/static/app/views/dashboards/datasetConfig/base.tsx b/static/app/views/dashboards/datasetConfig/base.tsx index 886f52b739d14c..567c4bec6ce44e 100644 --- a/static/app/views/dashboards/datasetConfig/base.tsx +++ b/static/app/views/dashboards/datasetConfig/base.tsx @@ -87,6 +87,11 @@ export type WidgetQueryParams = { * The widget configuration containing all queries. */ widget: Widget; + /** + * Optional callback to transform data before it's processed. + * Used for custom sorting or data manipulation (e.g., release ordering). + */ + afterFetchData?: (data: any) => void; /** * Optional pagination cursor. */ diff --git a/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx b/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx index eb82f33c9cdc97..a208a371c77981 100644 --- a/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx +++ b/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx @@ -171,6 +171,7 @@ export function useGenericWidgetQueries( enabled: isChartDisplay && !disabled && !propsLoading, limit, cursor, + afterFetchData: afterFetchSeriesData, }); const hookTableResults = config.useTableQuery?.({ @@ -185,6 +186,7 @@ export function useGenericWidgetQueries( enabled: !isChartDisplay && !disabled && !propsLoading, limit, cursor, + afterFetchData: afterFetchTableData, }); // Use the appropriate results based on display type diff --git a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx index 6995ea9ab34e9b..59a8660ac24430 100644 --- a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx @@ -1,4 +1,4 @@ -import {useCallback, useMemo, useRef} from 'react'; +import React, {useCallback, useMemo, useRef} from 'react'; import {useQueries} from '@tanstack/react-query'; import cloneDeep from 'lodash/cloneDeep'; @@ -52,15 +52,14 @@ function applyDashboardFilters( const EMPTY_ARRAY: any[] = []; -export function useReleasesSeriesQuery( - params: WidgetQueryParams & {skipDashboardFilterParens?: boolean} -): HookWidgetQueryResult { +export function useReleasesSeriesQuery(params: WidgetQueryParams): HookWidgetQueryResult { const { widget, organization, pageFilters, enabled, dashboardFilters, + afterFetchData, skipDashboardFilterParens, } = params; @@ -76,40 +75,52 @@ export function useReleasesSeriesQuery( 'visibility-dashboards-async-queue' ); - const queryKeys = useMemo(() => { - return 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 - ); + // Track validation errors from getReleasesRequestData + const [validationError, setValidationError] = React.useState(); - return { - queryKey: [ - `/organizations/${organization.slug}/sessions/`, - {method: 'GET' as const, query: requestData}, - ], - queryIndex, - useSessionAPI: requestData.useSessionAPI, - }; - }); + const queryKeys = useMemo(() => { + try { + setValidationError(undefined); + return 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, + }; + }); + } catch (error) { + // Catch synchronous errors from getReleasesRequestData (e.g., date validation) + const errorMessage = error instanceof Error ? error.message : String(error); + setValidationError(errorMessage); + // Return empty array to prevent queries from running + return []; + } }, [filteredWidget, organization, pageFilters]); const createQueryFn = useCallback( @@ -164,6 +175,14 @@ export function useReleasesSeriesQuery( }); 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; @@ -193,6 +212,10 @@ export function useReleasesSeriesQuery( const responseData = q.data[0]; rawData[requestIndex] = responseData; + if (afterFetchData) { + afterFetchData(responseData); + } + const transformedResult = ReleasesConfig.transformSeries?.( responseData, filteredWidget.queries[requestIndex]!, @@ -233,9 +256,7 @@ export function useReleasesSeriesQuery( return transformedData; } -export function useReleasesTableQuery( - params: WidgetQueryParams & {skipDashboardFilterParens?: boolean} -): HookWidgetQueryResult { +export function useReleasesTableQuery(params: WidgetQueryParams): HookWidgetQueryResult { const { widget, organization, @@ -244,6 +265,7 @@ export function useReleasesTableQuery( cursor, limit, dashboardFilters, + afterFetchData, skipDashboardFilterParens, } = params; @@ -259,28 +281,37 @@ export function useReleasesTableQuery( 'visibility-dashboards-async-queue' ); - const queryKeys = useMemo(() => { - return filteredWidget.queries.map((query, queryIndex) => { - const requestData = getReleasesRequestData( - 0, // includeSeries - 1, // includeTotals - query, - organization, - pageFilters, - undefined, // interval - limit ?? filteredWidget.limit, - cursor - ); + const [validationError, setValidationError] = React.useState(); - return { - queryKey: [ - `/organizations/${organization.slug}/sessions/`, - {method: 'GET' as const, query: requestData}, - ], - queryIndex, - useSessionAPI: requestData.useSessionAPI, - }; - }); + const queryKeys = useMemo(() => { + try { + setValidationError(undefined); + return 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, + }; + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + setValidationError(errorMessage); + return []; + } }, [filteredWidget, organization, pageFilters, limit, cursor]); const createQueryFn = useCallback( @@ -335,6 +366,14 @@ export function useReleasesTableQuery( }); 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; @@ -366,6 +405,10 @@ export function useReleasesTableQuery( const responseMeta = q.data[2]; rawData[i] = responseData; + if (afterFetchData) { + afterFetchData(responseData); + } + const tableData = ReleasesConfig.transformTable?.( responseData, filteredWidget.queries[i]!, From 6185b9babcfb2a399c54cf060527019a29611a26 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 26 Jan 2026 12:40:14 -0500 Subject: [PATCH 08/15] unskip --- static/app/components/modals/widgetViewerModal.spec.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/static/app/components/modals/widgetViewerModal.spec.tsx b/static/app/components/modals/widgetViewerModal.spec.tsx index 2af049cc3571ea..ebe5f1db734e6c 100644 --- a/static/app/components/modals/widgetViewerModal.spec.tsx +++ b/static/app/components/modals/widgetViewerModal.spec.tsx @@ -806,9 +806,7 @@ describe('Modals -> WidgetViewerModal', () => { expect(eventsStatsMock).toHaveBeenCalled(); }); - // TODO: this test is flakey in CI https://linear.app/getsentry/issue/BROWSE-264/fix-flakey-test - // eslint-disable-next-line jest/no-disabled-tests - it.skip('appends the orderby to the query if it is not already selected as an aggregate', async () => { + it('appends the orderby to the query if it is not already selected as an aggregate', async () => { const eventsStatsMock = mockEventsStats(); mockEvents(); From 2d2337752ab3f8e4459a1b1f979b5ddd3ebbfb68 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 26 Jan 2026 13:42:54 -0500 Subject: [PATCH 09/15] Revert "unskip" This reverts commit 6185b9babcfb2a399c54cf060527019a29611a26. --- static/app/components/modals/widgetViewerModal.spec.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/app/components/modals/widgetViewerModal.spec.tsx b/static/app/components/modals/widgetViewerModal.spec.tsx index ebe5f1db734e6c..2af049cc3571ea 100644 --- a/static/app/components/modals/widgetViewerModal.spec.tsx +++ b/static/app/components/modals/widgetViewerModal.spec.tsx @@ -806,7 +806,9 @@ describe('Modals -> WidgetViewerModal', () => { expect(eventsStatsMock).toHaveBeenCalled(); }); - it('appends the orderby to the query if it is not already selected as an aggregate', async () => { + // TODO: this test is flakey in CI https://linear.app/getsentry/issue/BROWSE-264/fix-flakey-test + // eslint-disable-next-line jest/no-disabled-tests + it.skip('appends the orderby to the query if it is not already selected as an aggregate', async () => { const eventsStatsMock = mockEventsStats(); mockEvents(); From 9daabbefe29a8b2c3db3b2c99d2ba6eb42023205 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 26 Jan 2026 14:29:01 -0500 Subject: [PATCH 10/15] fix --- .../dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx index 59a8660ac24430..e7e9ed71a9923b 100644 --- a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx @@ -143,7 +143,7 @@ export function useReleasesSeriesQuery(params: WidgetQueryParams): HookWidgetQue const result = await fetchFn(); resolve(result); } catch (error) { - reject(error); + reject(error instanceof Error ? error : new Error(String(error))); } }, }; @@ -334,7 +334,7 @@ export function useReleasesTableQuery(params: WidgetQueryParams): HookWidgetQuer const result = await fetchFn(); resolve(result); } catch (error) { - reject(error); + reject(error instanceof Error ? error : new Error(String(error))); } }, }; From a282fb97d4b1ce2b2432391e7599f3113bf2c49b Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 26 Jan 2026 14:34:18 -0500 Subject: [PATCH 11/15] fix --- .../hooks/useReleasesWidgetQuery.tsx | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx index e7e9ed71a9923b..7eb6fe4b7293e0 100644 --- a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useRef} from 'react'; +import {useCallback, useMemo, useRef} from 'react'; import {useQueries} from '@tanstack/react-query'; import cloneDeep from 'lodash/cloneDeep'; @@ -75,13 +75,10 @@ export function useReleasesSeriesQuery(params: WidgetQueryParams): HookWidgetQue 'visibility-dashboards-async-queue' ); - // Track validation errors from getReleasesRequestData - const [validationError, setValidationError] = React.useState(); - - const queryKeys = useMemo(() => { + // Compute validation error and query keys together + const {queryKeys, validationError} = useMemo(() => { try { - setValidationError(undefined); - return filteredWidget.queries.map((query, queryIndex) => { + const keys = filteredWidget.queries.map((query, queryIndex) => { const {datetime} = pageFilters; const {start, end, period} = datetime; @@ -114,12 +111,12 @@ export function useReleasesSeriesQuery(params: WidgetQueryParams): HookWidgetQue 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); - setValidationError(errorMessage); // Return empty array to prevent queries from running - return []; + return {queryKeys: [], validationError: errorMessage}; } }, [filteredWidget, organization, pageFilters]); @@ -281,12 +278,10 @@ export function useReleasesTableQuery(params: WidgetQueryParams): HookWidgetQuer 'visibility-dashboards-async-queue' ); - const [validationError, setValidationError] = React.useState(); - - const queryKeys = useMemo(() => { + // Compute validation error and query keys together + const {queryKeys, validationError} = useMemo(() => { try { - setValidationError(undefined); - return filteredWidget.queries.map((query, queryIndex) => { + const keys = filteredWidget.queries.map((query, queryIndex) => { const requestData = getReleasesRequestData( 0, // includeSeries 1, // includeTotals @@ -307,10 +302,12 @@ export function useReleasesTableQuery(params: WidgetQueryParams): HookWidgetQuer 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); - setValidationError(errorMessage); - return []; + // Return empty array to prevent queries from running + return {queryKeys: [], validationError: errorMessage}; } }, [filteredWidget, organization, pageFilters, limit, cursor]); From 09a2b8f506c062923473c231c814cf5c1829e17d Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 26 Jan 2026 14:42:57 -0500 Subject: [PATCH 12/15] fix test --- static/app/components/modals/widgetViewerModal.spec.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/static/app/components/modals/widgetViewerModal.spec.tsx b/static/app/components/modals/widgetViewerModal.spec.tsx index 2af049cc3571ea..612eaf1f669209 100644 --- a/static/app/components/modals/widgetViewerModal.spec.tsx +++ b/static/app/components/modals/widgetViewerModal.spec.tsx @@ -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(); From bc87a1047dd99a50ba66329d2a1cffe4ffa64475 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Tue, 27 Jan 2026 09:54:04 -0500 Subject: [PATCH 13/15] remove duplicate after fetch --- static/app/views/dashboards/datasetConfig/base.tsx | 5 ----- .../dashboards/widgetCard/genericWidgetQueries.tsx | 2 -- .../widgetCard/hooks/useReleasesWidgetQuery.tsx | 10 ---------- 3 files changed, 17 deletions(-) diff --git a/static/app/views/dashboards/datasetConfig/base.tsx b/static/app/views/dashboards/datasetConfig/base.tsx index 567c4bec6ce44e..886f52b739d14c 100644 --- a/static/app/views/dashboards/datasetConfig/base.tsx +++ b/static/app/views/dashboards/datasetConfig/base.tsx @@ -87,11 +87,6 @@ export type WidgetQueryParams = { * The widget configuration containing all queries. */ widget: Widget; - /** - * Optional callback to transform data before it's processed. - * Used for custom sorting or data manipulation (e.g., release ordering). - */ - afterFetchData?: (data: any) => void; /** * Optional pagination cursor. */ diff --git a/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx b/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx index a208a371c77981..eb82f33c9cdc97 100644 --- a/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx +++ b/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx @@ -171,7 +171,6 @@ export function useGenericWidgetQueries( enabled: isChartDisplay && !disabled && !propsLoading, limit, cursor, - afterFetchData: afterFetchSeriesData, }); const hookTableResults = config.useTableQuery?.({ @@ -186,7 +185,6 @@ export function useGenericWidgetQueries( enabled: !isChartDisplay && !disabled && !propsLoading, limit, cursor, - afterFetchData: afterFetchTableData, }); // Use the appropriate results based on display type diff --git a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx index 7eb6fe4b7293e0..56924e452ebebd 100644 --- a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx @@ -59,7 +59,6 @@ export function useReleasesSeriesQuery(params: WidgetQueryParams): HookWidgetQue pageFilters, enabled, dashboardFilters, - afterFetchData, skipDashboardFilterParens, } = params; @@ -209,10 +208,6 @@ export function useReleasesSeriesQuery(params: WidgetQueryParams): HookWidgetQue const responseData = q.data[0]; rawData[requestIndex] = responseData; - if (afterFetchData) { - afterFetchData(responseData); - } - const transformedResult = ReleasesConfig.transformSeries?.( responseData, filteredWidget.queries[requestIndex]!, @@ -262,7 +257,6 @@ export function useReleasesTableQuery(params: WidgetQueryParams): HookWidgetQuer cursor, limit, dashboardFilters, - afterFetchData, skipDashboardFilterParens, } = params; @@ -402,10 +396,6 @@ export function useReleasesTableQuery(params: WidgetQueryParams): HookWidgetQuer const responseMeta = q.data[2]; rawData[i] = responseData; - if (afterFetchData) { - afterFetchData(responseData); - } - const tableData = ReleasesConfig.transformTable?.( responseData, filteredWidget.queries[i]!, From 25450f8d38e9b74b173323a9d76458a187d938b9 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Tue, 27 Jan 2026 11:21:31 -0500 Subject: [PATCH 14/15] fix(dashboards): Add fallback error message in releases widget query Ensures errorMessage always has a value when an error occurs to prevent widgets from getting stuck in loading state. Adds fallback to generic 'An unknown error occurred.' message when error.responseJSON.detail and error.message are both undefined. This matches the old Promise-based error handling behavior. --- .../dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx index 56924e452ebebd..5f5903410567a5 100644 --- a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx @@ -5,6 +5,7 @@ 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'; @@ -186,7 +187,7 @@ export function useReleasesSeriesQuery(params: WidgetQueryParams): HookWidgetQue ? typeof error.responseJSON.detail === 'string' ? error.responseJSON.detail : error.responseJSON.detail.message - : error?.message; + : error?.message || t('An unknown error occurred.'); if (!allHaveData || isFetching) { const loading = isFetching || !errorMessage; @@ -372,7 +373,7 @@ export function useReleasesTableQuery(params: WidgetQueryParams): HookWidgetQuer ? typeof error.responseJSON.detail === 'string' ? error.responseJSON.detail : error.responseJSON.detail.message - : error?.message; + : error?.message || t('An unknown error occurred.'); if (!allHaveData || isFetching) { const loading = isFetching || !errorMessage; From a218e8f4883535b58c8e7f37557094fd74b88c88 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Tue, 27 Jan 2026 12:04:57 -0500 Subject: [PATCH 15/15] fix(dashboards): Only apply error fallback when error exists Previous fix incorrectly applied the fallback message even when there was no error, causing errorMessage to be truthy in initial/disabled states. This broke the loading state logic. Now errorMessage is only set when error exists: - No error: errorMessage = undefined, loading = true - Has error: errorMessage = detail || message || fallback, loading = false --- .../hooks/useReleasesWidgetQuery.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx index 5f5903410567a5..2af033a459586f 100644 --- a/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/useReleasesWidgetQuery.tsx @@ -183,11 +183,13 @@ export function useReleasesSeriesQuery(params: WidgetQueryParams): HookWidgetQue 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?.responseJSON?.detail - ? typeof error.responseJSON.detail === 'string' - ? error.responseJSON.detail - : error.responseJSON.detail.message - : error?.message || t('An unknown error occurred.'); + 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; @@ -369,11 +371,13 @@ export function useReleasesTableQuery(params: WidgetQueryParams): HookWidgetQuer 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?.responseJSON?.detail - ? typeof error.responseJSON.detail === 'string' - ? error.responseJSON.detail - : error.responseJSON.detail.message - : error?.message || t('An unknown error occurred.'); + 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;