mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
532 lines
17 KiB
TypeScript
532 lines
17 KiB
TypeScript
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 {statsPeriodToDays} from 'sentry/utils/dates';
|
|
import type {TableData} from 'sentry/utils/discover/discoverQuery';
|
|
import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
|
|
import type {QueryFieldValue} from 'sentry/utils/discover/fields';
|
|
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);
|
|
}
|