mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
import cloneDeep from 'lodash/cloneDeep';
|
|
import isEqual from 'lodash/isEqual';
|
|
import trimStart from 'lodash/trimStart';
|
|
|
|
import {t} from 'sentry/locale';
|
|
import type {OrganizationSummary, SelectValue, TagCollection} from 'sentry/types';
|
|
import {
|
|
aggregateFunctionOutputType,
|
|
aggregateOutputType,
|
|
getEquationAliasIndex,
|
|
isEquation,
|
|
isEquationAlias,
|
|
isLegalYAxisType,
|
|
SPAN_OP_BREAKDOWN_FIELDS,
|
|
stripDerivedMetricsPrefix,
|
|
stripEquationPrefix,
|
|
} from 'sentry/utils/discover/fields';
|
|
import type {MeasurementCollection} from 'sentry/utils/measurements/measurements';
|
|
import type {Widget, WidgetQuery} from 'sentry/views/dashboards/types';
|
|
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
|
|
import type {FieldValueOption} from 'sentry/views/discover/table/queryField';
|
|
import {FieldValueKind} from 'sentry/views/discover/table/types';
|
|
import {generateFieldOptions} from 'sentry/views/discover/utils';
|
|
import {IssueSortOptions} from 'sentry/views/issueList/utils';
|
|
|
|
import type {FlatValidationError, ValidationError} from '../utils';
|
|
import {getNumEquations} from '../utils';
|
|
|
|
import {DISABLED_SORT, TAG_SORT_DENY_LIST} from './releaseWidget/fields';
|
|
|
|
// Used in the widget builder to limit the number of lines plotted in the chart
|
|
export const DEFAULT_RESULTS_LIMIT = 5;
|
|
const RESULTS_LIMIT = 10;
|
|
|
|
// Both dashboards and widgets use the 'new' keyword when creating
|
|
export const NEW_DASHBOARD_ID = 'new';
|
|
|
|
export enum DataSet {
|
|
EVENTS = 'events',
|
|
ISSUES = 'issues',
|
|
RELEASES = 'releases',
|
|
METRICS = 'metrics',
|
|
}
|
|
|
|
export enum SortDirection {
|
|
HIGH_TO_LOW = 'high_to_low',
|
|
LOW_TO_HIGH = 'low_to_high',
|
|
}
|
|
|
|
export const sortDirections = {
|
|
[SortDirection.HIGH_TO_LOW]: t('High to low'),
|
|
[SortDirection.LOW_TO_HIGH]: t('Low to high'),
|
|
};
|
|
|
|
export const displayTypes = {
|
|
[DisplayType.AREA]: t('Area Chart'),
|
|
[DisplayType.BAR]: t('Bar Chart'),
|
|
[DisplayType.LINE]: t('Line Chart'),
|
|
[DisplayType.TABLE]: t('Table'),
|
|
[DisplayType.BIG_NUMBER]: t('Big Number'),
|
|
};
|
|
|
|
export function mapErrors(
|
|
data: ValidationError,
|
|
update: FlatValidationError
|
|
): FlatValidationError {
|
|
Object.keys(data).forEach((key: string) => {
|
|
const value = data[key];
|
|
if (typeof value === 'string') {
|
|
update[key] = value;
|
|
return;
|
|
}
|
|
// Recurse into nested objects.
|
|
if (Array.isArray(value) && typeof value[0] === 'string') {
|
|
update[key] = value[0];
|
|
return;
|
|
}
|
|
if (Array.isArray(value) && typeof value[0] === 'object') {
|
|
update[key] = (value as ValidationError[]).map(item => mapErrors(item, {}));
|
|
} else {
|
|
update[key] = mapErrors(value as ValidationError, {});
|
|
}
|
|
});
|
|
|
|
return update;
|
|
}
|
|
|
|
export const generateOrderOptions = ({
|
|
aggregates,
|
|
columns,
|
|
widgetType,
|
|
}: {
|
|
aggregates: string[];
|
|
columns: string[];
|
|
widgetType: WidgetType;
|
|
}): SelectValue<string>[] => {
|
|
const isRelease = widgetType === WidgetType.RELEASE;
|
|
const options: SelectValue<string>[] = [];
|
|
let equations = 0;
|
|
(isRelease
|
|
? [...aggregates.map(stripDerivedMetricsPrefix), ...columns]
|
|
: [...aggregates, ...columns]
|
|
)
|
|
.filter(field => !!field)
|
|
.filter(field => !DISABLED_SORT.includes(field))
|
|
.filter(field => (isRelease ? !TAG_SORT_DENY_LIST.includes(field) : true))
|
|
.forEach(field => {
|
|
let alias;
|
|
const label = stripEquationPrefix(field);
|
|
// Equations are referenced via a standard alias following this pattern
|
|
if (isEquation(field)) {
|
|
alias = `equation[${equations}]`;
|
|
equations += 1;
|
|
}
|
|
|
|
options.push({label, value: alias ?? field});
|
|
});
|
|
|
|
return options;
|
|
};
|
|
|
|
export function normalizeQueries({
|
|
displayType,
|
|
queries,
|
|
widgetType,
|
|
}: {
|
|
displayType: DisplayType;
|
|
queries: Widget['queries'];
|
|
widgetType?: Widget['widgetType'];
|
|
}): Widget['queries'] {
|
|
const isTimeseriesChart = getIsTimeseriesChart(displayType);
|
|
const isTabularChart = [DisplayType.TABLE, DisplayType.TOP_N].includes(displayType);
|
|
queries = cloneDeep(queries);
|
|
|
|
if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(displayType)) {
|
|
// Some display types may only support at most 1 query.
|
|
queries = queries.slice(0, 1);
|
|
} else if (isTimeseriesChart) {
|
|
// Timeseries charts supports at most 3 queries.
|
|
queries = queries.slice(0, 3);
|
|
}
|
|
|
|
queries = queries.map(query => {
|
|
const {fields = [], columns} = query;
|
|
|
|
if (isTabularChart) {
|
|
// If the groupBy field has values, port everything over to the columnEditCollect field.
|
|
query.fields = [...new Set([...fields, ...columns])];
|
|
} else {
|
|
// If columnEditCollect has field values , port everything over to the groupBy field.
|
|
query.fields = fields.filter(field => !columns.includes(field));
|
|
}
|
|
|
|
if (
|
|
getIsTimeseriesChart(displayType) &&
|
|
!query.columns.filter(column => !!column).length
|
|
) {
|
|
// The orderby is only applicable for timeseries charts when there's a
|
|
// grouping selected, if all fields are empty then we also reset the orderby
|
|
query.orderby = '';
|
|
return query;
|
|
}
|
|
|
|
const queryOrderBy =
|
|
widgetType === WidgetType.RELEASE
|
|
? stripDerivedMetricsPrefix(queries[0].orderby)
|
|
: queries[0].orderby;
|
|
const rawOrderBy = trimStart(queryOrderBy, '-');
|
|
|
|
const resetOrderBy =
|
|
// Raw Equation from Top N only applies to timeseries
|
|
(isTabularChart && isEquation(rawOrderBy)) ||
|
|
// Not contained as tag, field, or function
|
|
(!isEquation(rawOrderBy) &&
|
|
!isEquationAlias(rawOrderBy) &&
|
|
![...query.columns, ...query.aggregates].includes(rawOrderBy)) ||
|
|
// Equation alias and not contained
|
|
(isEquationAlias(rawOrderBy) &&
|
|
getEquationAliasIndex(rawOrderBy) >
|
|
getNumEquations([...query.columns, ...query.aggregates]) - 1);
|
|
const orderBy =
|
|
(!resetOrderBy && trimStart(queryOrderBy, '-')) ||
|
|
(widgetType === WidgetType.ISSUE
|
|
? queryOrderBy ?? IssueSortOptions.DATE
|
|
: generateOrderOptions({
|
|
widgetType: widgetType ?? WidgetType.DISCOVER,
|
|
columns: queries[0].columns,
|
|
aggregates: queries[0].aggregates,
|
|
})[0]?.value);
|
|
|
|
if (!orderBy) {
|
|
query.orderby = '';
|
|
return query;
|
|
}
|
|
|
|
// A widget should be descending if:
|
|
// - There is no orderby, so we're defaulting to desc
|
|
// - Not an issues widget since issues doesn't support descending and
|
|
// the original ordering was descending
|
|
const isDescending =
|
|
!query.orderby || (widgetType !== WidgetType.ISSUE && queryOrderBy.startsWith('-'));
|
|
|
|
query.orderby = isDescending ? `-${String(orderBy)}` : String(orderBy);
|
|
|
|
return query;
|
|
});
|
|
|
|
if (isTabularChart) {
|
|
return queries;
|
|
}
|
|
|
|
// Filter out non-aggregate fields
|
|
queries = queries.map(query => {
|
|
let aggregates = query.aggregates;
|
|
|
|
if (isTimeseriesChart) {
|
|
// Filter out fields that will not generate numeric output types
|
|
aggregates = aggregates.filter(aggregate =>
|
|
isLegalYAxisType(aggregateOutputType(aggregate))
|
|
);
|
|
}
|
|
|
|
if (isTimeseriesChart && aggregates.length && aggregates.length > 3) {
|
|
// Timeseries charts supports at most 3 fields.
|
|
aggregates = aggregates.slice(0, 3);
|
|
}
|
|
|
|
return {
|
|
...query,
|
|
fields: aggregates.length ? aggregates : ['count()'],
|
|
columns: query.columns ? query.columns : [],
|
|
aggregates: aggregates.length ? aggregates : ['count()'],
|
|
};
|
|
});
|
|
|
|
if (isTimeseriesChart) {
|
|
// For timeseries widget, all queries must share identical set of fields.
|
|
|
|
const referenceAggregates = [...queries[0].aggregates];
|
|
|
|
queryLoop: for (const query of queries) {
|
|
if (referenceAggregates.length >= 3) {
|
|
break;
|
|
}
|
|
|
|
if (isEqual(referenceAggregates, query.aggregates)) {
|
|
continue;
|
|
}
|
|
|
|
for (const aggregate of query.aggregates) {
|
|
if (referenceAggregates.length >= 3) {
|
|
break queryLoop;
|
|
}
|
|
|
|
if (!referenceAggregates.includes(aggregate)) {
|
|
referenceAggregates.push(aggregate);
|
|
}
|
|
}
|
|
}
|
|
|
|
queries = queries.map(query => {
|
|
return {
|
|
...query,
|
|
columns: query.columns ? query.columns : [],
|
|
aggregates: referenceAggregates,
|
|
fields: referenceAggregates,
|
|
};
|
|
});
|
|
}
|
|
|
|
if (DisplayType.BIG_NUMBER === displayType) {
|
|
// For world map chart, cap fields of the queries to only one field.
|
|
queries = queries.map(query => {
|
|
return {
|
|
...query,
|
|
fields: query.aggregates.slice(0, 1),
|
|
aggregates: query.aggregates.slice(0, 1),
|
|
orderby: '',
|
|
columns: [],
|
|
};
|
|
});
|
|
}
|
|
|
|
return queries;
|
|
}
|
|
|
|
export function getParsedDefaultWidgetQuery(query = ''): WidgetQuery | undefined {
|
|
// "any" was needed here because it doesn't pass in getsentry
|
|
const urlSeachParams = new URLSearchParams(query) as any;
|
|
const parsedQuery = Object.fromEntries(urlSeachParams.entries());
|
|
|
|
if (!Object.keys(parsedQuery).length) {
|
|
return undefined;
|
|
}
|
|
|
|
const columns = parsedQuery.columns ? getFields(parsedQuery.columns) : [];
|
|
const aggregates = parsedQuery.aggregates ? getFields(parsedQuery.aggregates) : [];
|
|
const fields = [...columns, ...aggregates];
|
|
|
|
return {
|
|
...parsedQuery,
|
|
fields,
|
|
columns,
|
|
aggregates,
|
|
} as WidgetQuery;
|
|
}
|
|
|
|
export function getFields(fieldsString: string): string[] {
|
|
// Use a negative lookahead to avoid splitting on commas inside equation fields
|
|
return fieldsString.split(/,(?![^(]*\))/g);
|
|
}
|
|
|
|
export function getAmendedFieldOptions({
|
|
measurements,
|
|
organization,
|
|
tags,
|
|
}: {
|
|
measurements: MeasurementCollection;
|
|
organization: OrganizationSummary;
|
|
tags: TagCollection;
|
|
}) {
|
|
return generateFieldOptions({
|
|
organization,
|
|
tagKeys: Object.values(tags).map(({key}) => key),
|
|
measurementKeys: Object.values(measurements).map(({key}) => key),
|
|
spanOperationBreakdownKeys: SPAN_OP_BREAKDOWN_FIELDS,
|
|
});
|
|
}
|
|
|
|
// Extract metric names from aggregation functions present in the widget queries
|
|
export function getMetricFields(queries: WidgetQuery[]) {
|
|
return queries.reduce<string[]>((acc, query) => {
|
|
for (const field of [...query.aggregates, ...query.columns]) {
|
|
const fieldParameter = /\(([^)]*)\)/.exec(field)?.[1];
|
|
if (fieldParameter && !acc.includes(fieldParameter)) {
|
|
acc.push(fieldParameter);
|
|
}
|
|
}
|
|
|
|
return acc;
|
|
}, []);
|
|
}
|
|
|
|
// Used to limit the number of results of the "filter your results" fields dropdown
|
|
export const MAX_SEARCH_ITEMS = 5;
|
|
|
|
// Used to set the max height of the smartSearchBar menu
|
|
export const MAX_MENU_HEIGHT = 250;
|
|
|
|
// Any function/field choice for Big Number widgets is legal since the
|
|
// data source is from an endpoint that is not timeseries-based.
|
|
// The function/field choice for World Map widget will need to be numeric-like.
|
|
// Column builder for Table widget is already handled above.
|
|
export function doNotValidateYAxis(displayType: DisplayType) {
|
|
return displayType === DisplayType.BIG_NUMBER;
|
|
}
|
|
|
|
export function filterPrimaryOptions({
|
|
option,
|
|
widgetType,
|
|
displayType,
|
|
}: {
|
|
displayType: DisplayType;
|
|
option: FieldValueOption;
|
|
widgetType?: WidgetType;
|
|
}) {
|
|
if (widgetType === WidgetType.RELEASE) {
|
|
if (displayType === DisplayType.TABLE) {
|
|
return [
|
|
FieldValueKind.FUNCTION,
|
|
FieldValueKind.FIELD,
|
|
FieldValueKind.NUMERIC_METRICS,
|
|
].includes(option.value.kind);
|
|
}
|
|
if (displayType === DisplayType.TOP_N) {
|
|
return option.value.kind === FieldValueKind.TAG;
|
|
}
|
|
}
|
|
|
|
// Only validate function names for timeseries widgets and
|
|
// world map widgets.
|
|
if (!doNotValidateYAxis(displayType) && option.value.kind === FieldValueKind.FUNCTION) {
|
|
const primaryOutput = aggregateFunctionOutputType(option.value.meta.name, undefined);
|
|
if (primaryOutput) {
|
|
// If a function returns a specific type, then validate it.
|
|
return isLegalYAxisType(primaryOutput);
|
|
}
|
|
}
|
|
|
|
return [FieldValueKind.FUNCTION, FieldValueKind.NUMERIC_METRICS].includes(
|
|
option.value.kind
|
|
);
|
|
}
|
|
|
|
export function getResultsLimit(numQueries: number, numYAxes: number) {
|
|
if (numQueries === 0 || numYAxes === 0) {
|
|
return DEFAULT_RESULTS_LIMIT;
|
|
}
|
|
|
|
return Math.floor(RESULTS_LIMIT / (numQueries * numYAxes));
|
|
}
|
|
|
|
export function getIsTimeseriesChart(displayType: DisplayType) {
|
|
return [DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(displayType);
|
|
}
|