mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
1293 lines
34 KiB
TypeScript
1293 lines
34 KiB
TypeScript
import isEqual from 'lodash/isEqual';
|
|
|
|
import {RELEASE_ADOPTION_STAGES} from 'sentry/constants';
|
|
import type {MetricType, Organization, SelectValue} from 'sentry/types';
|
|
import {assert} from 'sentry/types/utils';
|
|
import {isMRIField} from 'sentry/utils/metrics/mri';
|
|
import {
|
|
SESSIONS_FIELDS,
|
|
SESSIONS_OPERATIONS,
|
|
} from 'sentry/views/dashboards/widgetBuilder/releaseWidget/fields';
|
|
import {STARFISH_FIELDS} from 'sentry/views/starfish/components/chart';
|
|
import {STARFISH_AGGREGATION_FIELDS} from 'sentry/views/starfish/types';
|
|
|
|
import {
|
|
AGGREGATION_FIELDS,
|
|
AggregationKey,
|
|
DISCOVER_FIELDS,
|
|
FieldKey,
|
|
FieldValueType,
|
|
getFieldDefinition,
|
|
MEASUREMENT_FIELDS,
|
|
SpanOpBreakdown,
|
|
WebVital,
|
|
} from '../fields';
|
|
|
|
export type Sort = {
|
|
field: string;
|
|
kind: 'asc' | 'desc';
|
|
};
|
|
|
|
// Contains the URL field value & the related table column width.
|
|
// Can be parsed into a Column using explodeField()
|
|
export type Field = {
|
|
field: string;
|
|
// When an alias is defined for a field, it will be shown as a column name in the table visualization.
|
|
alias?: string;
|
|
width?: number;
|
|
};
|
|
|
|
// ColumnType is kept as a string literal union instead of an enum due to the countless uses of it and refactoring would take huge effort.
|
|
export type ColumnType = `${Exclude<FieldValueType, FieldValueType.NEVER>}`;
|
|
|
|
export type ColumnValueType = ColumnType | `${FieldValueType.NEVER}`;
|
|
|
|
export type ParsedFunction = {
|
|
arguments: string[];
|
|
name: string;
|
|
};
|
|
|
|
type ValidateColumnValueFunction = (data: {
|
|
dataType: ColumnType;
|
|
name: string;
|
|
}) => boolean;
|
|
|
|
export type ValidateColumnTypes =
|
|
| ColumnType[]
|
|
| MetricType[]
|
|
| ValidateColumnValueFunction;
|
|
|
|
export type AggregateParameter =
|
|
| {
|
|
columnTypes: Readonly<ValidateColumnTypes>;
|
|
kind: 'column';
|
|
required: boolean;
|
|
defaultValue?: string;
|
|
}
|
|
| {
|
|
dataType: ColumnType;
|
|
kind: 'value';
|
|
required: boolean;
|
|
defaultValue?: string;
|
|
placeholder?: string;
|
|
}
|
|
| {
|
|
dataType: string;
|
|
kind: 'dropdown';
|
|
options: SelectValue<string>[];
|
|
required: boolean;
|
|
defaultValue?: string;
|
|
placeholder?: string;
|
|
};
|
|
|
|
export type AggregationRefinement = string | undefined;
|
|
|
|
// The parsed result of a Field.
|
|
// Functions and Fields are handled as subtypes to enable other
|
|
// code to work more simply.
|
|
// This type can be converted into a Field.field using generateFieldAsString()
|
|
// When an alias is defined for a field, it will be shown as a column name in the table visualization.
|
|
export type QueryFieldValue =
|
|
| {
|
|
field: string;
|
|
kind: 'field';
|
|
alias?: string;
|
|
}
|
|
| {
|
|
field: string;
|
|
kind: 'calculatedField';
|
|
alias?: string;
|
|
}
|
|
| {
|
|
field: string;
|
|
kind: 'equation';
|
|
alias?: string;
|
|
}
|
|
| {
|
|
function: [
|
|
AggregationKeyWithAlias,
|
|
string,
|
|
AggregationRefinement,
|
|
AggregationRefinement,
|
|
];
|
|
kind: 'function';
|
|
alias?: string;
|
|
};
|
|
|
|
// Column is just an alias of a Query value
|
|
export type Column = QueryFieldValue;
|
|
|
|
export type Alignments = 'left' | 'right';
|
|
|
|
export enum RateUnit {
|
|
PER_SECOND = '1/second',
|
|
PER_MINUTE = '1/minute',
|
|
PER_HOUR = '1/hour',
|
|
}
|
|
|
|
// Rates normalized to /second unit
|
|
export const RATE_UNIT_MULTIPLIERS = {
|
|
[RateUnit.PER_SECOND]: 1,
|
|
[RateUnit.PER_MINUTE]: 1 / 60,
|
|
[RateUnit.PER_HOUR]: 1 / (60 * 60),
|
|
};
|
|
|
|
export const RATE_UNIT_LABELS = {
|
|
[RateUnit.PER_SECOND]: '/s',
|
|
[RateUnit.PER_MINUTE]: '/min',
|
|
[RateUnit.PER_HOUR]: '/hr',
|
|
};
|
|
|
|
export const RATE_UNIT_TITLE = {
|
|
[RateUnit.PER_SECOND]: 'Per Second',
|
|
[RateUnit.PER_MINUTE]: 'Per Minute',
|
|
[RateUnit.PER_HOUR]: 'Per Hour',
|
|
};
|
|
|
|
const CONDITIONS_ARGUMENTS: SelectValue<string>[] = [
|
|
{
|
|
label: 'is equal to',
|
|
value: 'equals',
|
|
},
|
|
{
|
|
label: 'is not equal to',
|
|
value: 'notEquals',
|
|
},
|
|
{
|
|
label: 'is less than',
|
|
value: 'less',
|
|
},
|
|
{
|
|
label: 'is greater than',
|
|
value: 'greater',
|
|
},
|
|
{
|
|
label: 'is less than or equal to',
|
|
value: 'lessOrEquals',
|
|
},
|
|
{
|
|
label: 'is greater than or equal to',
|
|
value: 'greaterOrEquals',
|
|
},
|
|
];
|
|
|
|
const WEB_VITALS_QUALITY: SelectValue<string>[] = [
|
|
{
|
|
label: 'good',
|
|
value: 'good',
|
|
},
|
|
{
|
|
label: 'meh',
|
|
value: 'meh',
|
|
},
|
|
{
|
|
label: 'poor',
|
|
value: 'poor',
|
|
},
|
|
{
|
|
label: 'any',
|
|
value: 'any',
|
|
},
|
|
];
|
|
|
|
const getDocsAndOutputType = (key: AggregationKey) => {
|
|
return {
|
|
documentation: AGGREGATION_FIELDS[key].desc,
|
|
outputType: AGGREGATION_FIELDS[key].valueType as AggregationOutputType,
|
|
};
|
|
};
|
|
|
|
// Refer to src/sentry/search/events/fields.py
|
|
// Try to keep functions logically sorted, ie. all the count functions are grouped together
|
|
export const AGGREGATIONS = {
|
|
[AggregationKey.COUNT]: {
|
|
...getDocsAndOutputType(AggregationKey.COUNT),
|
|
parameters: [],
|
|
isSortable: true,
|
|
multiPlotType: 'area',
|
|
},
|
|
[AggregationKey.COUNT_UNIQUE]: {
|
|
...getDocsAndOutputType(AggregationKey.COUNT_UNIQUE),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: ['string', 'integer', 'number', 'duration', 'date', 'boolean'],
|
|
defaultValue: 'user',
|
|
required: true,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'area',
|
|
},
|
|
[AggregationKey.COUNT_MISERABLE]: {
|
|
...getDocsAndOutputType(AggregationKey.COUNT_MISERABLE),
|
|
getFieldOverrides({parameter}: DefaultValueInputs) {
|
|
if (parameter.kind === 'column') {
|
|
return {defaultValue: 'user'};
|
|
}
|
|
return {
|
|
defaultValue: parameter.defaultValue,
|
|
};
|
|
},
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateAllowedColumns(['user']),
|
|
defaultValue: 'user',
|
|
required: true,
|
|
},
|
|
{
|
|
kind: 'value',
|
|
dataType: 'number',
|
|
defaultValue: '300',
|
|
required: true,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'area',
|
|
},
|
|
[AggregationKey.COUNT_IF]: {
|
|
...getDocsAndOutputType(AggregationKey.COUNT_IF),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateDenyListColumns(
|
|
['string', 'duration', 'number'],
|
|
['id', 'issue', 'user.display']
|
|
),
|
|
defaultValue: 'transaction.duration',
|
|
required: true,
|
|
},
|
|
{
|
|
kind: 'dropdown',
|
|
options: CONDITIONS_ARGUMENTS,
|
|
dataType: 'string',
|
|
defaultValue: CONDITIONS_ARGUMENTS[0].value,
|
|
required: true,
|
|
},
|
|
{
|
|
kind: 'value',
|
|
dataType: 'string',
|
|
defaultValue: '300',
|
|
required: true,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'area',
|
|
},
|
|
[AggregationKey.COUNT_WEB_VITALS]: {
|
|
...getDocsAndOutputType(AggregationKey.COUNT_WEB_VITALS),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateAllowedColumns([
|
|
WebVital.LCP,
|
|
WebVital.FP,
|
|
WebVital.FCP,
|
|
WebVital.FID,
|
|
WebVital.CLS,
|
|
]),
|
|
defaultValue: WebVital.LCP,
|
|
required: true,
|
|
},
|
|
{
|
|
kind: 'dropdown',
|
|
options: WEB_VITALS_QUALITY,
|
|
dataType: 'string',
|
|
defaultValue: WEB_VITALS_QUALITY[0].value,
|
|
required: true,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'area',
|
|
},
|
|
[AggregationKey.EPS]: {
|
|
...getDocsAndOutputType(AggregationKey.EPS),
|
|
parameters: [],
|
|
isSortable: true,
|
|
multiPlotType: 'area',
|
|
},
|
|
[AggregationKey.EPM]: {
|
|
...getDocsAndOutputType(AggregationKey.EPM),
|
|
parameters: [],
|
|
isSortable: true,
|
|
multiPlotType: 'area',
|
|
},
|
|
[AggregationKey.FAILURE_COUNT]: {
|
|
...getDocsAndOutputType(AggregationKey.FAILURE_COUNT),
|
|
parameters: [],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.MIN]: {
|
|
...getDocsAndOutputType(AggregationKey.MIN),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate([
|
|
'integer',
|
|
'number',
|
|
'duration',
|
|
'date',
|
|
'percentage',
|
|
]),
|
|
defaultValue: 'transaction.duration',
|
|
required: true,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.MAX]: {
|
|
...getDocsAndOutputType(AggregationKey.MAX),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate([
|
|
'integer',
|
|
'number',
|
|
'duration',
|
|
'date',
|
|
'percentage',
|
|
]),
|
|
defaultValue: 'transaction.duration',
|
|
required: true,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.SUM]: {
|
|
...getDocsAndOutputType(AggregationKey.SUM),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']),
|
|
required: true,
|
|
defaultValue: 'transaction.duration',
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'area',
|
|
},
|
|
[AggregationKey.ANY]: {
|
|
...getDocsAndOutputType(AggregationKey.ANY),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: ['string', 'integer', 'number', 'duration', 'date', 'boolean'],
|
|
required: true,
|
|
defaultValue: 'transaction.duration',
|
|
},
|
|
],
|
|
isSortable: true,
|
|
},
|
|
[AggregationKey.P50]: {
|
|
...getDocsAndOutputType(AggregationKey.P50),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']),
|
|
defaultValue: 'transaction.duration',
|
|
required: false,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.P75]: {
|
|
...getDocsAndOutputType(AggregationKey.P75),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']),
|
|
defaultValue: 'transaction.duration',
|
|
required: false,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.P90]: {
|
|
...getDocsAndOutputType(AggregationKey.P90),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']),
|
|
defaultValue: 'transaction.duration',
|
|
required: false,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.P95]: {
|
|
...getDocsAndOutputType(AggregationKey.P95),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']),
|
|
defaultValue: 'transaction.duration',
|
|
required: false,
|
|
},
|
|
],
|
|
type: [],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.P99]: {
|
|
...getDocsAndOutputType(AggregationKey.P99),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']),
|
|
defaultValue: 'transaction.duration',
|
|
required: false,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.P100]: {
|
|
...getDocsAndOutputType(AggregationKey.P100),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']),
|
|
defaultValue: 'transaction.duration',
|
|
required: false,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.PERCENTILE]: {
|
|
...getDocsAndOutputType(AggregationKey.PERCENTILE),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']),
|
|
defaultValue: 'transaction.duration',
|
|
required: true,
|
|
},
|
|
{
|
|
kind: 'value',
|
|
dataType: 'number',
|
|
defaultValue: '0.5',
|
|
required: true,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.AVG]: {
|
|
...getDocsAndOutputType(AggregationKey.AVG),
|
|
parameters: [
|
|
{
|
|
kind: 'column',
|
|
columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']),
|
|
defaultValue: 'transaction.duration',
|
|
required: true,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.APDEX]: {
|
|
...getDocsAndOutputType(AggregationKey.APDEX),
|
|
parameters: [
|
|
{
|
|
kind: 'value',
|
|
dataType: 'number',
|
|
defaultValue: '300',
|
|
required: true,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.USER_MISERY]: {
|
|
...getDocsAndOutputType(AggregationKey.USER_MISERY),
|
|
parameters: [
|
|
{
|
|
kind: 'value',
|
|
dataType: 'number',
|
|
defaultValue: '300',
|
|
required: true,
|
|
},
|
|
],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.FAILURE_RATE]: {
|
|
...getDocsAndOutputType(AggregationKey.FAILURE_RATE),
|
|
parameters: [],
|
|
isSortable: true,
|
|
multiPlotType: 'line',
|
|
},
|
|
[AggregationKey.LAST_SEEN]: {
|
|
...getDocsAndOutputType(AggregationKey.LAST_SEEN),
|
|
parameters: [],
|
|
isSortable: true,
|
|
},
|
|
} as const;
|
|
|
|
// TPM and TPS are aliases that are only used in Performance
|
|
export const ALIASES = {
|
|
tpm: AggregationKey.EPM,
|
|
tps: AggregationKey.EPS,
|
|
};
|
|
|
|
assert(AGGREGATIONS as Readonly<{[key in AggregationKey]: Aggregation}>);
|
|
|
|
export type AggregationKeyWithAlias = `${AggregationKey}` | keyof typeof ALIASES | '';
|
|
|
|
export type AggregationOutputType = Extract<
|
|
ColumnType,
|
|
'number' | 'integer' | 'date' | 'duration' | 'percentage' | 'string' | 'size' | 'rate'
|
|
>;
|
|
|
|
export type PlotType = 'bar' | 'line' | 'area';
|
|
|
|
type DefaultValueInputs = {
|
|
parameter: AggregateParameter;
|
|
};
|
|
|
|
export type Aggregation = {
|
|
/**
|
|
* Can this function be used in a sort result
|
|
*/
|
|
isSortable: boolean;
|
|
/**
|
|
* The output type. Null means to inherit from the field.
|
|
*/
|
|
outputType: AggregationOutputType | null;
|
|
/**
|
|
* List of parameters for the function.
|
|
*/
|
|
parameters: Readonly<AggregateParameter[]>;
|
|
getFieldOverrides?: (
|
|
data: DefaultValueInputs
|
|
) => Partial<Omit<AggregateParameter, 'kind'>>;
|
|
/**
|
|
* How this function should be plotted when shown in a multiseries result (top5)
|
|
* Optional because some functions cannot be plotted (strings/dates)
|
|
*/
|
|
multiPlotType?: PlotType;
|
|
};
|
|
|
|
export const DEPRECATED_FIELDS: string[] = [FieldKey.CULPRIT];
|
|
|
|
export type FieldTag = {
|
|
key: FieldKey;
|
|
name: FieldKey;
|
|
};
|
|
|
|
export const FIELD_TAGS = Object.freeze(
|
|
Object.fromEntries(DISCOVER_FIELDS.map(item => [item, {key: item, name: item}]))
|
|
);
|
|
|
|
export const SEMVER_TAGS = {
|
|
[FieldKey.RELEASE_VERSION]: {
|
|
key: FieldKey.RELEASE_VERSION,
|
|
name: FieldKey.RELEASE_VERSION,
|
|
},
|
|
[FieldKey.RELEASE_BUILD]: {
|
|
key: FieldKey.RELEASE_BUILD,
|
|
name: FieldKey.RELEASE_BUILD,
|
|
},
|
|
[FieldKey.RELEASE_PACKAGE]: {
|
|
key: FieldKey.RELEASE_PACKAGE,
|
|
name: FieldKey.RELEASE_PACKAGE,
|
|
},
|
|
[FieldKey.RELEASE_STAGE]: {
|
|
key: FieldKey.RELEASE_STAGE,
|
|
name: FieldKey.RELEASE_STAGE,
|
|
predefined: true,
|
|
values: RELEASE_ADOPTION_STAGES,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Some tag keys should never be formatted as `tag[...]`
|
|
* when used as a filter because they are predefined.
|
|
*/
|
|
const EXCLUDED_TAG_KEYS = new Set(['release', 'user', 'device.class']);
|
|
|
|
export function formatTagKey(key: string): string {
|
|
// Some tags may be normalized from context, but not all of them are.
|
|
// This supports a user making a custom tag with the same name as one
|
|
// that comes from context as all of these are also tags.
|
|
if (key in FIELD_TAGS && !EXCLUDED_TAG_KEYS.has(key)) {
|
|
return `tags[${key}]`;
|
|
}
|
|
return key;
|
|
}
|
|
|
|
// Allows for a less strict field key definition in cases we are returning custom strings as fields
|
|
export type LooseFieldKey = FieldKey | string | '';
|
|
|
|
export type MeasurementType =
|
|
| FieldValueType.DURATION
|
|
| FieldValueType.NUMBER
|
|
| FieldValueType.INTEGER
|
|
| FieldValueType.PERCENTAGE;
|
|
|
|
export function isSpanOperationBreakdownField(field: string) {
|
|
return field.startsWith('spans.');
|
|
}
|
|
|
|
export const SPAN_OP_RELATIVE_BREAKDOWN_FIELD = 'span_ops_breakdown.relative';
|
|
|
|
export function isRelativeSpanOperationBreakdownField(field: string) {
|
|
return field === SPAN_OP_RELATIVE_BREAKDOWN_FIELD;
|
|
}
|
|
|
|
export const SPAN_OP_BREAKDOWN_FIELDS = Object.values(SpanOpBreakdown);
|
|
|
|
// This list contains fields/functions that are available with performance-view feature.
|
|
export const TRACING_FIELDS = [
|
|
AggregationKey.AVG,
|
|
AggregationKey.SUM,
|
|
FieldKey.TRANSACTION_DURATION,
|
|
FieldKey.TRANSACTION_OP,
|
|
FieldKey.TRANSACTION_STATUS,
|
|
FieldKey.ISSUE,
|
|
AggregationKey.P50,
|
|
AggregationKey.P75,
|
|
AggregationKey.P95,
|
|
AggregationKey.P99,
|
|
AggregationKey.P100,
|
|
AggregationKey.PERCENTILE,
|
|
AggregationKey.FAILURE_RATE,
|
|
AggregationKey.APDEX,
|
|
AggregationKey.COUNT_MISERABLE,
|
|
AggregationKey.USER_MISERY,
|
|
AggregationKey.EPS,
|
|
AggregationKey.EPM,
|
|
'team_key_transaction',
|
|
...Object.keys(MEASUREMENT_FIELDS),
|
|
...SPAN_OP_BREAKDOWN_FIELDS,
|
|
SPAN_OP_RELATIVE_BREAKDOWN_FIELD,
|
|
];
|
|
|
|
// This list contains fields/functions that are available with profiling feature.
|
|
export const PROFILING_FIELDS: string[] = [FieldKey.PROFILE_ID];
|
|
|
|
export const MEASUREMENT_PATTERN = /^measurements\.([a-zA-Z0-9-_.]+)$/;
|
|
export const SPAN_OP_BREAKDOWN_PATTERN = /^spans\.([a-zA-Z0-9-_.]+)$/;
|
|
|
|
export function isMeasurement(field: string): boolean {
|
|
return MEASUREMENT_PATTERN.test(field);
|
|
}
|
|
|
|
export function measurementType(field: string): MeasurementType {
|
|
if (MEASUREMENT_FIELDS.hasOwnProperty(field)) {
|
|
return MEASUREMENT_FIELDS[field].valueType as MeasurementType;
|
|
}
|
|
|
|
return FieldValueType.NUMBER;
|
|
}
|
|
|
|
export function getMeasurementSlug(field: string): string | null {
|
|
const results = field.match(MEASUREMENT_PATTERN);
|
|
if (results && results.length >= 2) {
|
|
return results[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const AGGREGATE_PATTERN = /^(\w+)\((.*)?\)$/;
|
|
// Identical to AGGREGATE_PATTERN, but without the $ for newline, or ^ for start of line
|
|
const AGGREGATE_BASE = /(\w+)\((.*)?\)/g;
|
|
|
|
export function getAggregateArg(field: string): string | null {
|
|
// only returns the first argument if field is an aggregate
|
|
const result = parseFunction(field);
|
|
|
|
if (result && result.arguments.length > 0) {
|
|
return result.arguments[0];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function parseFunction(field: string): ParsedFunction | null {
|
|
const results = field.match(AGGREGATE_PATTERN);
|
|
if (results && results.length === 3) {
|
|
return {
|
|
name: results[1],
|
|
arguments: parseArguments(results[1], results[2]),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function parseArguments(functionText: string, columnText: string): string[] {
|
|
// Some functions take a quoted string for their arguments that may contain commas
|
|
// This function attempts to be identical with the similarly named parse_arguments
|
|
// found in src/sentry/search/events/fields.py
|
|
if (
|
|
(functionText !== 'to_other' &&
|
|
functionText !== 'count_if' &&
|
|
functionText !== 'spans_histogram') ||
|
|
columnText?.length === 0
|
|
) {
|
|
return columnText ? columnText.split(',').map(result => result.trim()) : [];
|
|
}
|
|
|
|
const args: string[] = [];
|
|
|
|
let quoted = false;
|
|
let escaped = false;
|
|
|
|
let i: number = 0;
|
|
let j: number = 0;
|
|
|
|
while (j < columnText?.length) {
|
|
if (i === j && columnText[j] === '"') {
|
|
// when we see a quote at the beginning of
|
|
// an argument, then this is a quoted string
|
|
quoted = true;
|
|
} else if (i === j && columnText[j] === ' ') {
|
|
// argument has leading spaces, skip over them
|
|
i += 1;
|
|
} else if (quoted && !escaped && columnText[j] === '\\') {
|
|
// when we see a slash inside a quoted string,
|
|
// the next character is an escape character
|
|
escaped = true;
|
|
} else if (quoted && !escaped && columnText[j] === '"') {
|
|
// when we see a non-escaped quote while inside
|
|
// of a quoted string, we should end it
|
|
quoted = false;
|
|
} else if (quoted && escaped) {
|
|
// when we are inside a quoted string and have
|
|
// begun an escape character, we should end it
|
|
escaped = false;
|
|
} else if (quoted && columnText[j] === ',') {
|
|
// when we are inside a quoted string and see
|
|
// a comma, it should not be considered an
|
|
// argument separator
|
|
} else if (columnText[j] === ',') {
|
|
// when we see a comma outside of a quoted string
|
|
// it is an argument separator
|
|
args.push(columnText.substring(i, j).trim());
|
|
i = j + 1;
|
|
}
|
|
j += 1;
|
|
}
|
|
|
|
if (i !== j) {
|
|
// add in the last argument if any
|
|
args.push(columnText.substring(i).trim());
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
// `|` is an invalid field character, so it is used to determine whether a field is an equation or not
|
|
export const EQUATION_PREFIX = 'equation|';
|
|
const EQUATION_ALIAS_PATTERN = /^equation\[(\d+)\]$/;
|
|
export const CALCULATED_FIELD_PREFIX = 'calculated|';
|
|
|
|
export function isEquation(field: string): boolean {
|
|
return field.startsWith(EQUATION_PREFIX);
|
|
}
|
|
|
|
export function isEquationAlias(field: string): boolean {
|
|
return EQUATION_ALIAS_PATTERN.test(field);
|
|
}
|
|
|
|
export function maybeEquationAlias(field: string): boolean {
|
|
return field.includes(EQUATION_PREFIX);
|
|
}
|
|
|
|
export function stripEquationPrefix(field: string): string {
|
|
return field.replace(EQUATION_PREFIX, '');
|
|
}
|
|
|
|
export function getEquationAliasIndex(field: string): number {
|
|
const results = field.match(EQUATION_ALIAS_PATTERN);
|
|
|
|
if (results && results.length === 2) {
|
|
return parseInt(results[1], 10);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
export function getEquation(field: string): string {
|
|
return field.slice(EQUATION_PREFIX.length);
|
|
}
|
|
|
|
export function isAggregateEquation(field: string): boolean {
|
|
const results = field.match(AGGREGATE_BASE);
|
|
|
|
return isEquation(field) && results !== null && results.length > 0;
|
|
}
|
|
|
|
export function isLegalEquationColumn(column: Column): boolean {
|
|
// Any isn't allowed in arithmetic
|
|
if (column.kind === 'function' && column.function[0] === 'any') {
|
|
return false;
|
|
}
|
|
|
|
const columnType = getColumnType(column);
|
|
return columnType === 'number' || columnType === 'integer' || columnType === 'duration';
|
|
}
|
|
|
|
export function generateAggregateFields(
|
|
organization: Organization,
|
|
eventFields: readonly Field[] | Field[],
|
|
excludeFields: readonly string[] = []
|
|
): Field[] {
|
|
const functions = Object.keys(AGGREGATIONS);
|
|
const fields = Object.values(eventFields).map(field => field.field);
|
|
functions.forEach(func => {
|
|
const parameters = AGGREGATIONS[func].parameters.map(param => {
|
|
const overrides = AGGREGATIONS[func].getFieldOverrides;
|
|
if (typeof overrides === 'undefined') {
|
|
return param;
|
|
}
|
|
return {
|
|
...param,
|
|
...overrides({parameter: param, organization}),
|
|
};
|
|
});
|
|
|
|
if (parameters.every(param => typeof param.defaultValue !== 'undefined')) {
|
|
const newField = `${func}(${parameters
|
|
.map(param => param.defaultValue)
|
|
.join(',')})`;
|
|
if (!fields.includes(newField) && !excludeFields.includes(newField)) {
|
|
fields.push(newField);
|
|
}
|
|
}
|
|
});
|
|
return fields.map(field => ({field})) as Field[];
|
|
}
|
|
|
|
export function isDerivedMetric(field: string): boolean {
|
|
return field.startsWith(CALCULATED_FIELD_PREFIX);
|
|
}
|
|
|
|
export function stripDerivedMetricsPrefix(field: string): string {
|
|
return field.replace(CALCULATED_FIELD_PREFIX, '');
|
|
}
|
|
|
|
export function explodeFieldString(field: string, alias?: string): Column {
|
|
if (isEquation(field)) {
|
|
return {kind: 'equation', field: getEquation(field), alias};
|
|
}
|
|
|
|
if (isDerivedMetric(field)) {
|
|
return {kind: 'calculatedField', field: stripDerivedMetricsPrefix(field), alias};
|
|
}
|
|
|
|
const results = parseFunction(field);
|
|
|
|
if (results) {
|
|
return {
|
|
kind: 'function',
|
|
function: [
|
|
results.name as AggregationKey,
|
|
results.arguments[0] ?? '',
|
|
results.arguments[1] as AggregationRefinement,
|
|
results.arguments[2] as AggregationRefinement,
|
|
],
|
|
alias,
|
|
};
|
|
}
|
|
|
|
return {kind: 'field', field, alias};
|
|
}
|
|
|
|
export function generateFieldAsString(value: QueryFieldValue): string {
|
|
if (value.kind === 'field') {
|
|
return value.field;
|
|
}
|
|
|
|
if (value.kind === 'calculatedField') {
|
|
return `${CALCULATED_FIELD_PREFIX}${value.field}`;
|
|
}
|
|
|
|
if (value.kind === 'equation') {
|
|
return `${EQUATION_PREFIX}${value.field.trim()}`;
|
|
}
|
|
|
|
const aggregation = value.function[0];
|
|
const parameters = value.function.slice(1).filter(i => i);
|
|
return `${aggregation}(${parameters.join(',')})`;
|
|
}
|
|
|
|
export function explodeField(field: Field): Column {
|
|
return explodeFieldString(field.field, field.alias);
|
|
}
|
|
|
|
/**
|
|
* Get the alias that the API results will have for a given aggregate function name
|
|
*/
|
|
export function getAggregateAlias(field: string): string {
|
|
const result = parseFunction(field);
|
|
|
|
if (!result) {
|
|
return field;
|
|
}
|
|
|
|
let alias = result.name;
|
|
|
|
if (result.arguments.length > 0) {
|
|
alias += '_' + result.arguments.join('_');
|
|
}
|
|
|
|
return alias.replace(/[^\w]/g, '_').replace(/^_+/g, '').replace(/_+$/, '');
|
|
}
|
|
|
|
/**
|
|
* Check if a field name looks like an aggregate function or known aggregate alias.
|
|
*/
|
|
export function isAggregateField(field: string): boolean {
|
|
return parseFunction(field) !== null;
|
|
}
|
|
|
|
export function isAggregateFieldOrEquation(field: string): boolean {
|
|
return isAggregateField(field) || isAggregateEquation(field) || isNumericMetrics(field);
|
|
}
|
|
|
|
/**
|
|
* Temporary hardcoded hack to enable testing derived metrics.
|
|
* Can be removed after we get rid of getAggregateFields
|
|
*/
|
|
export function isNumericMetrics(field: string): boolean {
|
|
return [
|
|
'session.crash_free_rate',
|
|
'session.crashed',
|
|
'session.errored_preaggregated',
|
|
'session.errored_set',
|
|
'session.init',
|
|
].includes(field);
|
|
}
|
|
|
|
export function getAggregateFields(fields: string[]): string[] {
|
|
return fields.filter(
|
|
field =>
|
|
isAggregateField(field) || isAggregateEquation(field) || isNumericMetrics(field)
|
|
);
|
|
}
|
|
|
|
export function getColumnsAndAggregates(fields: string[]): {
|
|
aggregates: string[];
|
|
columns: string[];
|
|
} {
|
|
const aggregates = getAggregateFields(fields);
|
|
const columns = fields.filter(field => !aggregates.includes(field));
|
|
return {columns, aggregates};
|
|
}
|
|
|
|
export function getColumnsAndAggregatesAsStrings(fields: QueryFieldValue[]): {
|
|
aggregates: string[];
|
|
columns: string[];
|
|
fieldAliases: string[];
|
|
} {
|
|
// TODO(dam): distinguish between metrics, derived metrics and tags
|
|
const aggregateFields: string[] = [];
|
|
const nonAggregateFields: string[] = [];
|
|
const fieldAliases: string[] = [];
|
|
|
|
for (const field of fields) {
|
|
const fieldString = generateFieldAsString(field);
|
|
if (field.kind === 'function' || field.kind === 'calculatedField') {
|
|
aggregateFields.push(fieldString);
|
|
} else if (field.kind === 'equation') {
|
|
if (isAggregateEquation(fieldString)) {
|
|
aggregateFields.push(fieldString);
|
|
} else {
|
|
nonAggregateFields.push(fieldString);
|
|
}
|
|
} else {
|
|
nonAggregateFields.push(fieldString);
|
|
}
|
|
|
|
fieldAliases.push(field.alias ?? '');
|
|
}
|
|
|
|
return {aggregates: aggregateFields, columns: nonAggregateFields, fieldAliases};
|
|
}
|
|
|
|
/**
|
|
* Convert a function string into type it will output.
|
|
* This is useful when you need to format values in tooltips,
|
|
* or in series markers.
|
|
*/
|
|
export function aggregateOutputType(field: string | undefined): AggregationOutputType {
|
|
if (!field) {
|
|
return 'number';
|
|
}
|
|
const result = parseFunction(field);
|
|
if (!result) {
|
|
return 'number';
|
|
}
|
|
const outputType = aggregateFunctionOutputType(result.name, result.arguments[0]);
|
|
if (outputType === null) {
|
|
return 'number';
|
|
}
|
|
return outputType;
|
|
}
|
|
|
|
/**
|
|
* Converts a function string and its first argument into its output type.
|
|
* - If the function has a fixed output type, that will be the result.
|
|
* - If the function does not define an output type, the output type will be equal to
|
|
* the type of its first argument.
|
|
* - If the function has an optional first argument, and it was not defined, make sure
|
|
* to use the default argument as the first argument.
|
|
* - If the type could not be determined, return null.
|
|
*/
|
|
export function aggregateFunctionOutputType(
|
|
funcName: string,
|
|
firstArg: string | undefined
|
|
): AggregationOutputType | null {
|
|
const aggregate =
|
|
AGGREGATIONS[ALIASES[funcName] || funcName] ?? SESSIONS_OPERATIONS[funcName];
|
|
|
|
// Attempt to use the function's outputType.
|
|
if (aggregate?.outputType) {
|
|
return aggregate.outputType;
|
|
}
|
|
|
|
// If the first argument is undefined and it is not required,
|
|
// then we attempt to get the default value.
|
|
if (!firstArg && aggregate?.parameters?.[0]) {
|
|
if (aggregate.parameters[0].required === false) {
|
|
firstArg = aggregate.parameters[0].defaultValue;
|
|
}
|
|
}
|
|
|
|
if (firstArg && SESSIONS_FIELDS.hasOwnProperty(firstArg)) {
|
|
return SESSIONS_FIELDS[firstArg].type as AggregationOutputType;
|
|
}
|
|
|
|
if (firstArg && STARFISH_FIELDS[firstArg]) {
|
|
return STARFISH_FIELDS[firstArg].outputType;
|
|
}
|
|
|
|
if (!firstArg && STARFISH_AGGREGATION_FIELDS[funcName]) {
|
|
return STARFISH_AGGREGATION_FIELDS[funcName].defaultOutputType;
|
|
}
|
|
|
|
// If the function is an inherit type it will have a field as
|
|
// the first parameter and we can use that to get the type.
|
|
const fieldDef = getFieldDefinition(firstArg ?? '');
|
|
|
|
if (fieldDef !== null) {
|
|
return fieldDef.valueType as AggregationOutputType;
|
|
}
|
|
|
|
if (firstArg && isMeasurement(firstArg)) {
|
|
return measurementType(firstArg);
|
|
}
|
|
|
|
if (firstArg && isSpanOperationBreakdownField(firstArg)) {
|
|
return 'duration';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function errorsAndTransactionsAggregateFunctionOutputType(
|
|
funcName: string,
|
|
firstArg: string | undefined
|
|
): AggregationOutputType | null {
|
|
const aggregate = AGGREGATIONS[ALIASES[funcName] || funcName];
|
|
|
|
// Attempt to use the function's outputType.
|
|
if (aggregate?.outputType) {
|
|
return aggregate.outputType;
|
|
}
|
|
|
|
// If the first argument is undefined and it is not required,
|
|
// then we attempt to get the default value.
|
|
if (!firstArg && aggregate?.parameters?.[0]) {
|
|
if (aggregate.parameters[0].required === false) {
|
|
firstArg = aggregate.parameters[0].defaultValue;
|
|
}
|
|
}
|
|
|
|
// If the function is an inherit type it will have a field as
|
|
// the first parameter and we can use that to get the type.
|
|
const fieldDef = getFieldDefinition(firstArg ?? '');
|
|
|
|
if (fieldDef !== null) {
|
|
return fieldDef.valueType as AggregationOutputType;
|
|
}
|
|
|
|
if (firstArg && isMeasurement(firstArg)) {
|
|
return measurementType(firstArg);
|
|
}
|
|
|
|
if (firstArg && isSpanOperationBreakdownField(firstArg)) {
|
|
return 'duration';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function sessionsAggregateFunctionOutputType(
|
|
funcName: string,
|
|
firstArg: string | undefined
|
|
): AggregationOutputType | null {
|
|
const aggregate = SESSIONS_OPERATIONS[funcName];
|
|
|
|
// Attempt to use the function's outputType.
|
|
if (aggregate?.outputType) {
|
|
return aggregate.outputType;
|
|
}
|
|
|
|
// If the first argument is undefined and it is not required,
|
|
// then we attempt to get the default value.
|
|
if (!firstArg && aggregate?.parameters?.[0]) {
|
|
if (aggregate.parameters[0].required === false) {
|
|
firstArg = aggregate.parameters[0].defaultValue;
|
|
}
|
|
}
|
|
|
|
if (firstArg && SESSIONS_FIELDS.hasOwnProperty(firstArg)) {
|
|
return SESSIONS_FIELDS[firstArg].type as AggregationOutputType;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the multi-series chart type for an aggregate function.
|
|
*/
|
|
export function aggregateMultiPlotType(field: string): PlotType {
|
|
if (isEquation(field)) {
|
|
return 'line';
|
|
}
|
|
const result = parseFunction(field);
|
|
// Handle invalid data.
|
|
if (!result) {
|
|
return 'area';
|
|
}
|
|
if (!AGGREGATIONS.hasOwnProperty(result.name)) {
|
|
return 'area';
|
|
}
|
|
return AGGREGATIONS[result.name].multiPlotType;
|
|
}
|
|
|
|
function validateForNumericAggregate(
|
|
validColumnTypes: ColumnType[]
|
|
): ValidateColumnValueFunction {
|
|
return function ({name, dataType}: {dataType: ColumnType; name: string}): boolean {
|
|
// these built-in columns cannot be applied to numeric aggregates such as percentile(...)
|
|
if (
|
|
[
|
|
FieldKey.DEVICE_BATTERY_LEVEL,
|
|
FieldKey.STACK_COLNO,
|
|
FieldKey.STACK_LINENO,
|
|
FieldKey.STACK_STACK_LEVEL,
|
|
].includes(name as FieldKey)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return validColumnTypes.includes(dataType);
|
|
};
|
|
}
|
|
|
|
function validateDenyListColumns(
|
|
validColumnTypes: ColumnType[],
|
|
deniedColumns: string[]
|
|
): ValidateColumnValueFunction {
|
|
return function ({name, dataType}: {dataType: ColumnType; name: string}): boolean {
|
|
return validColumnTypes.includes(dataType) && !deniedColumns.includes(name);
|
|
};
|
|
}
|
|
|
|
function validateAllowedColumns(validColumns: string[]): ValidateColumnValueFunction {
|
|
return function ({name}): boolean {
|
|
return validColumns.includes(name);
|
|
};
|
|
}
|
|
|
|
const alignedTypes: ColumnValueType[] = [
|
|
'number',
|
|
'duration',
|
|
'integer',
|
|
'percentage',
|
|
'percent_change',
|
|
'rate',
|
|
'size',
|
|
];
|
|
|
|
export function fieldAlignment(
|
|
columnName: string,
|
|
columnType?: undefined | ColumnValueType,
|
|
metadata?: Record<string, ColumnValueType>
|
|
): Alignments {
|
|
let align: Alignments = 'left';
|
|
if (isMRIField(columnName)) {
|
|
return 'right';
|
|
}
|
|
if (columnType) {
|
|
align = alignedTypes.includes(columnType) ? 'right' : 'left';
|
|
}
|
|
if (columnType === undefined || columnType === 'never') {
|
|
// fallback to align the column based on the table metadata
|
|
const maybeType = metadata ? metadata[getAggregateAlias(columnName)] : undefined;
|
|
|
|
if (maybeType !== undefined && alignedTypes.includes(maybeType)) {
|
|
align = 'right';
|
|
}
|
|
}
|
|
return align;
|
|
}
|
|
|
|
/**
|
|
* Match on types that are legal to show on a timeseries chart.
|
|
*/
|
|
export function isLegalYAxisType(match: ColumnType | MetricType) {
|
|
return ['number', 'integer', 'duration', 'percentage'].includes(match);
|
|
}
|
|
|
|
export function getSpanOperationName(field: string): string | null {
|
|
const results = field.match(SPAN_OP_BREAKDOWN_PATTERN);
|
|
if (results && results.length >= 2) {
|
|
return results[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function getColumnType(column: Column): ColumnType {
|
|
if (column.kind === 'function') {
|
|
const outputType = aggregateFunctionOutputType(
|
|
column.function[0],
|
|
column.function[1]
|
|
);
|
|
if (outputType !== null) {
|
|
return outputType;
|
|
}
|
|
} else if (column.kind === 'field') {
|
|
const fieldDef = getFieldDefinition(column.field);
|
|
|
|
if (fieldDef !== null) {
|
|
return fieldDef.valueType as ColumnType;
|
|
}
|
|
if (isMeasurement(column.field)) {
|
|
return measurementType(column.field);
|
|
}
|
|
if (isSpanOperationBreakdownField(column.field)) {
|
|
return 'duration';
|
|
}
|
|
}
|
|
return 'string';
|
|
}
|
|
|
|
export function hasDuplicate(columnList: Column[], column: Column): boolean {
|
|
if (column.kind !== 'function' && column.kind !== 'field') {
|
|
return false;
|
|
}
|
|
return columnList.filter(newColumn => isEqual(newColumn, column)).length > 1;
|
|
}
|