123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531 |
- 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 {
- MetricsApiResponse,
- Organization,
- PageFilters,
- SelectValue,
- SessionApiResponse,
- SessionsMeta,
- } from 'sentry/types';
- import {SessionField} from 'sentry/types';
- import type {Series} from 'sentry/types/echarts';
- import {defined} from 'sentry/utils';
- import type {TableData} from 'sentry/utils/discover/discoverQuery';
- import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
- import type {QueryFieldValue} from 'sentry/utils/discover/fields';
- import {statsPeriodToDays} from 'sentry/utils/duration/statsPeriodToDays';
- import type {OnDemandControlContext} from 'sentry/utils/performance/contexts/onDemandControl';
- 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';
- import type {Widget, WidgetQuery} from '../types';
- import {DisplayType} from '../types';
- import {getWidgetInterval} from '../utils';
- import {ReleaseSearchBar} from '../widgetBuilder/buildSteps/filterResultsStep/releaseSearchBar';
- import {
- DERIVED_STATUS_METRICS_PATTERN,
- DerivedStatusFields,
- DISABLED_SORT,
- FIELD_TO_METRICS_EXPRESSION,
- generateReleaseWidgetFieldOptions,
- SESSIONS_FIELDS,
- SESSIONS_TAGS,
- TAG_SORT_DENY_LIST,
- } from '../widgetBuilder/releaseWidget/fields';
- import {
- derivedMetricsToField,
- requiresCustomReleaseSorting,
- resolveDerivedStatusFields,
- } from '../widgetCard/releaseWidgetQueries';
- import {getSeriesName} from '../widgetCard/transformSessionsResponseToSeries';
- import {
- changeObjectValuesToTypes,
- getDerivedMetrics,
- mapDerivedMetricsToFields,
- } from '../widgetCard/transformSessionsResponseToTable';
- import type {DatasetConfig} from './base';
- import {handleOrderByReset} from './base';
- const DEFAULT_WIDGET_QUERY: WidgetQuery = {
- name: '',
- fields: [`crash_free_rate(${SessionField.SESSION})`],
- columns: [],
- fieldAliases: [],
- aggregates: [`crash_free_rate(${SessionField.SESSION})`],
- conditions: '',
- orderby: `-crash_free_rate(${SessionField.SESSION})`,
- };
- const METRICS_BACKED_SESSIONS_START_DATE = new Date('2022-07-12');
- export const ReleasesConfig: DatasetConfig<
- SessionApiResponse | MetricsApiResponse,
- SessionApiResponse | MetricsApiResponse
- > = {
- 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,
- getTableSortOptions,
- getTimeseriesSortOptions,
- filterTableOptions: filterPrimaryReleaseTableOptions,
- filterAggregateParams,
- filterYAxisAggregateParams: (_fieldValue: QueryFieldValue, _displayType: DisplayType) =>
- filterAggregateParams,
- filterYAxisOptions,
- getCustomFieldRenderer: (field, meta) => getFieldRenderer(field, meta, false),
- SearchBar: ReleaseSearchBar,
- getTableFieldOptions: getReleasesTableFieldOptions,
- getGroupByFieldOptions: (_organization: Organization) =>
- generateReleaseWidgetFieldOptions([] as SessionsMeta[], SESSIONS_TAGS),
- handleColumnFieldChangeOverride,
- handleOrderByReset: handleReleasesTableOrderByReset,
- filterSeriesSortOptions,
- supportedDisplayTypes: [
- DisplayType.AREA,
- DisplayType.BAR,
- DisplayType.BIG_NUMBER,
- DisplayType.LINE,
- DisplayType.TABLE,
- DisplayType.TOP_N,
- ],
- transformSeries: transformSessionsResponseToSeries,
- transformTable: transformSessionsResponseToTable,
- };
- function disableSortOptions(widgetQuery: WidgetQuery) {
- const {columns} = widgetQuery;
- if (columns.includes('session.status')) {
- return {
- disableSort: true,
- disableSortDirection: true,
- disableSortReason: t('Sorting currently not supported with session.status'),
- };
- }
- return {
- disableSort: false,
- disableSortDirection: false,
- };
- }
- function getTableSortOptions(_organization: Organization, widgetQuery: WidgetQuery) {
- const {columns, aggregates} = widgetQuery;
- const options: SelectValue<string>[] = [];
- [...aggregates, ...columns]
- .filter(field => !!field)
- .filter(field => !DISABLED_SORT.includes(field))
- .filter(field => !TAG_SORT_DENY_LIST.includes(field))
- .forEach(field => {
- options.push({label: field, value: field});
- });
- return options;
- }
- function getTimeseriesSortOptions(_organization: Organization, widgetQuery: WidgetQuery) {
- const columnSet = new Set(widgetQuery.columns);
- const releaseFieldOptions = generateReleaseWidgetFieldOptions(
- Object.values(SESSIONS_FIELDS),
- SESSIONS_TAGS
- );
- const options: Record<string, SelectValue<FieldValue>> = {};
- Object.entries(releaseFieldOptions).forEach(([key, option]) => {
- if (['count_healthy', 'count_errored'].includes(option.value.meta.name)) {
- return;
- }
- if (option.value.kind === FieldValueKind.FIELD) {
- // Only allow sorting by release tag
- if (option.value.meta.name === 'release' && columnSet.has(option.value.meta.name)) {
- options[key] = option;
- }
- return;
- }
- options[key] = option;
- });
- return options;
- }
- function filterSeriesSortOptions(columns: Set<string>) {
- return (option: FieldValueOption) => {
- if (['count_healthy', 'count_errored'].includes(option.value.meta.name)) {
- return false;
- }
- if (option.value.kind === FieldValueKind.FIELD) {
- // Only allow sorting by release tag
- return columns.has(option.value.meta.name) && option.value.meta.name === 'release';
- }
- return filterPrimaryReleaseTableOptions(option);
- };
- }
- function getReleasesSeriesRequest(
- api: Client,
- widget: Widget,
- queryIndex: number,
- organization: Organization,
- pageFilters: PageFilters
- ) {
- const query = widget.queries[queryIndex];
- const {displayType, 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(
- displayType,
- {start, end, period},
- '5m',
- // requesting low fidelity for release sort because metrics api can't return 100 rows of high fidelity series data
- isCustomReleaseSorting ? 'low' : undefined
- );
- return getReleasesRequest(
- 1,
- includeTotals,
- api,
- query,
- organization,
- pageFilters,
- interval,
- limit
- );
- }
- function filterPrimaryReleaseTableOptions(option: FieldValueOption) {
- return [
- FieldValueKind.FUNCTION,
- FieldValueKind.FIELD,
- FieldValueKind.NUMERIC_METRICS,
- ].includes(option.value.kind);
- }
- function filterAggregateParams(option: FieldValueOption) {
- return option.value.kind === FieldValueKind.METRICS;
- }
- function filterYAxisOptions(_displayType: DisplayType) {
- return (option: FieldValueOption) => {
- return [FieldValueKind.FUNCTION, FieldValueKind.NUMERIC_METRICS].includes(
- option.value.kind
- );
- };
- }
- function handleReleasesTableOrderByReset(widgetQuery: WidgetQuery, newFields: string[]) {
- const disableSortBy = widgetQuery.columns.includes('session.status');
- if (disableSortBy) {
- widgetQuery.orderby = '';
- }
- return handleOrderByReset(widgetQuery, newFields);
- }
- function handleColumnFieldChangeOverride(widgetQuery: WidgetQuery): WidgetQuery {
- if (widgetQuery.aggregates.length === 0) {
- // Release Health widgets require an aggregate in tables
- const defaultReleaseHealthAggregate = `crash_free_rate(${SessionField.SESSION})`;
- widgetQuery.aggregates = [defaultReleaseHealthAggregate];
- widgetQuery.fields = widgetQuery.fields
- ? [...widgetQuery.fields, defaultReleaseHealthAggregate]
- : [defaultReleaseHealthAggregate];
- }
- return widgetQuery;
- }
- function getReleasesTableFieldOptions(_organization: Organization) {
- return generateReleaseWidgetFieldOptions(Object.values(SESSIONS_FIELDS), SESSIONS_TAGS);
- }
- export function transformSessionsResponseToTable(
- data: SessionApiResponse | MetricsApiResponse,
- widgetQuery: WidgetQuery
- ): TableData {
- const useSessionAPI = widgetQuery.columns.includes('session.status');
- const {derivedStatusFields, injectedFields} = resolveDerivedStatusFields(
- widgetQuery.aggregates,
- widgetQuery.orderby,
- useSessionAPI
- );
- const rows = data.groups.map((group, index) => ({
- id: String(index),
- ...mapDerivedMetricsToFields(group.by),
- // if `sum(session)` or `count_unique(user)` are not
- // requested as a part of the payload for
- // derived status metrics through the Sessions API,
- // they are injected into the payload and need to be
- // stripped.
- ...omit(mapDerivedMetricsToFields(group.totals), injectedFields),
- // if session.status is a groupby, some post processing
- // is needed to calculate the status derived metrics
- // from grouped results of `sum(session)` or `count_unique(user)`
- ...getDerivedMetrics(group.by, group.totals, derivedStatusFields),
- }));
- const singleRow = rows[0];
- const meta = {
- ...changeObjectValuesToTypes(omit(singleRow, 'id')),
- };
- return {meta, data: rows};
- }
- export function transformSessionsResponseToSeries(
- data: SessionApiResponse | MetricsApiResponse,
- widgetQuery: WidgetQuery
- ) {
- if (data === null) {
- return [];
- }
- const queryAlias = widgetQuery.name;
- const useSessionAPI = widgetQuery.columns.includes('session.status');
- const {derivedStatusFields: requestedStatusMetrics, injectedFields} =
- resolveDerivedStatusFields(
- widgetQuery.aggregates,
- widgetQuery.orderby,
- useSessionAPI
- );
- const results: Series[] = [];
- if (!data.groups.length) {
- return [
- {
- seriesName: `(${t('no results')})`,
- data: data.intervals.map(interval => ({
- name: interval,
- value: 0,
- })),
- },
- ];
- }
- data.groups.forEach(group => {
- Object.keys(group.series).forEach(field => {
- // if `sum(session)` or `count_unique(user)` are not
- // requested as a part of the payload for
- // derived status metrics through the Sessions API,
- // they are injected into the payload and need to be
- // stripped.
- if (!injectedFields.includes(derivedMetricsToField(field))) {
- results.push({
- seriesName: getSeriesName(field, group, queryAlias),
- data: data.intervals.map((interval, index) => ({
- name: interval,
- value: group.series[field][index] ?? 0,
- })),
- });
- }
- });
- // if session.status is a groupby, some post processing
- // is needed to calculate the status derived metrics
- // from grouped results of `sum(session)` or `count_unique(user)`
- if (requestedStatusMetrics.length && defined(group.by['session.status'])) {
- requestedStatusMetrics.forEach(status => {
- const result = status.match(DERIVED_STATUS_METRICS_PATTERN);
- if (result) {
- let metricField: string | undefined = undefined;
- if (group.by['session.status'] === result[1]) {
- if (result[2] === 'session') {
- metricField = 'sum(session)';
- } else if (result[2] === 'user') {
- metricField = 'count_unique(user)';
- }
- }
- results.push({
- seriesName: getSeriesName(status, group, queryAlias),
- data: data.intervals.map((interval, index) => ({
- name: interval,
- value: metricField ? group.series[metricField][index] ?? 0 : 0,
- })),
- });
- }
- });
- }
- });
- return results;
- }
- function fieldsToDerivedMetrics(field: string): string {
- return FIELD_TO_METRICS_EXPRESSION[field] ?? field;
- }
- 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: boolean = 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.
- const useSessionAPI = query.columns.includes('session.status');
- 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 of 100 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 100 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
- );
- let requestData;
- let requester;
- if (useSessionAPI) {
- const sessionAggregates = aggregates.filter(
- agg => !Object.values(DerivedStatusFields).includes(agg as DerivedStatusFields)
- );
- requestData = {
- 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,
- includeAllArgs: true,
- cursor,
- };
- requester = doSessionsRequest;
- } else {
- requestData = {
- field: aggregates.map(fieldsToDerivedMetrics),
- orgSlug: organization.slug,
- end,
- environment: environments,
- groupBy: columns.map(fieldsToDerivedMetrics),
- limit: columns.length === 0 ? 1 : isCustomReleaseSorting ? 100 : limit,
- orderBy: unsupportedOrderby
- ? ''
- : isDescending
- ? `-${fieldsToDerivedMetrics(rawOrderby)}`
- : fieldsToDerivedMetrics(rawOrderby),
- interval,
- project: projects,
- query: query.conditions,
- start,
- statsPeriod: period,
- includeAllArgs: true,
- cursor,
- includeSeries,
- includeTotals,
- };
- requester = doReleaseHealthRequest;
- if (
- rawOrderby &&
- !unsupportedOrderby &&
- !aggregates.includes(rawOrderby) &&
- !columns.includes(rawOrderby)
- ) {
- requestData.field = [...requestData.field, fieldsToDerivedMetrics(rawOrderby)];
- if (!injectedFields.includes(rawOrderby)) {
- injectedFields.push(rawOrderby);
- }
- }
- }
- return requester(api, requestData);
- }
|