Files
2024-02-15 17:01:50 +00:00

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;
}