Skip to content
5 changes: 4 additions & 1 deletion static/app/components/modals/widgetViewerModal.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1195,7 +1195,7 @@ describe('Modals -> WidgetViewerModal', () => {
url: '/organizations/org-slug/metrics/data/',
body: MetricsTotalCountByReleaseIn24h(),
headers: {
link:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link is the correct casing used, checked this in the response headers of the network tab. It's also the http standard casing This was causing a test case to fail

Link:
'<http://localhost/api/0/organizations/org-slug/metrics/data/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1",' +
'<http://localhost/api/0/organizations/org-slug/metrics/data/?cursor=0:10:0>; rel="next"; results="true"; cursor="0:10:0"',
},
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions static/app/views/dashboards/datasetConfig/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
280 changes: 8 additions & 272 deletions static/app/views/dashboards/datasetConfig/releases.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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<SessionApiResponse, SessionApiResponse> = {
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,
Expand Down Expand Up @@ -192,42 +162,6 @@ function filterSeriesSortOptions(columns: Set<string>) {
};
}

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,
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export function useGenericWidgetQueries<SeriesResponse, TableResponse>(
enabled: isChartDisplay && !disabled && !propsLoading,
limit,
cursor,
afterFetchData: afterFetchSeriesData,
});

const hookTableResults = config.useTableQuery?.({
Expand All @@ -185,6 +186,7 @@ export function useGenericWidgetQueries<SeriesResponse, TableResponse>(
enabled: !isChartDisplay && !disabled && !propsLoading,
limit,
cursor,
afterFetchData: afterFetchTableData,
});

// Use the appropriate results based on display type
Expand Down
Loading
Loading