import isEqual from 'lodash/isEqual'; import {RELEASE_ADOPTION_STAGES} from 'sentry/constants'; import {t} from 'sentry/locale'; import {MetricsType, Organization, SelectValue} from 'sentry/types'; import {assert} from 'sentry/types/utils'; import { SESSIONS_FIELDS, SESSIONS_OPERATIONS, } from 'sentry/views/dashboardsV2/widgetBuilder/releaseWidget/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; }; export type ColumnType = | 'boolean' | 'date' | 'duration' | 'integer' | 'number' | 'percentage' | 'string'; export type ColumnValueType = ColumnType | 'never'; // Matches to nothing export type ParsedFunction = { arguments: string[]; name: string; }; type ValidateColumnValueFunction = ({name: string, dataType: ColumnType}) => boolean; export type ValidateColumnTypes = | ColumnType[] | MetricsType[] | ValidateColumnValueFunction; export type AggregateParameter = | { columnTypes: Readonly; kind: 'column'; required: boolean; defaultValue?: string; } | { dataType: ColumnType; kind: 'value'; required: boolean; defaultValue?: string; placeholder?: string; } | { dataType: string; kind: 'dropdown'; options: SelectValue[]; 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: [AggregationKey, 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'; const CONDITIONS_ARGUMENTS: SelectValue[] = [ { 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[] = [ { label: 'good', value: 'good', }, { label: 'meh', value: 'meh', }, { label: 'poor', value: 'poor', }, { label: 'any', value: 'any', }, ]; export enum WebVital { FP = 'measurements.fp', FCP = 'measurements.fcp', LCP = 'measurements.lcp', FID = 'measurements.fid', CLS = 'measurements.cls', TTFB = 'measurements.ttfb', RequestTime = 'measurements.ttfb.requesttime', } export enum MobileVital { AppStartCold = 'measurements.app_start_cold', AppStartWarm = 'measurements.app_start_warm', FramesTotal = 'measurements.frames_total', FramesSlow = 'measurements.frames_slow', FramesFrozen = 'measurements.frames_frozen', FramesSlowRate = 'measurements.frames_slow_rate', FramesFrozenRate = 'measurements.frames_frozen_rate', StallCount = 'measurements.stall_count', StallTotalTime = 'measurements.stall_total_time', StallLongestTime = 'measurements.stall_longest_time', StallPercentage = 'measurements.stall_percentage', } // 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 = { count: { parameters: [], documentation: t('number of events'), outputType: 'number', isSortable: true, multiPlotType: 'area', }, count_unique: { parameters: [ { kind: 'column', columnTypes: ['string', 'integer', 'number', 'duration', 'date', 'boolean'], defaultValue: 'user', required: true, }, ], documentation: t('unique number of events'), outputType: 'integer', isSortable: true, multiPlotType: 'line', }, 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, }, ], documentation: t('miserable number of events'), outputType: 'number', isSortable: true, multiPlotType: 'area', }, 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, }, ], documentation: t('conditional number of events'), outputType: 'number', isSortable: true, multiPlotType: 'area', }, 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, }, ], documentation: t('events matching vital thresholds'), outputType: 'number', isSortable: true, multiPlotType: 'area', }, eps: { parameters: [], documentation: t('events per second'), outputType: 'number', isSortable: true, multiPlotType: 'area', }, epm: { parameters: [], documentation: t('events per minute'), outputType: 'number', isSortable: true, multiPlotType: 'area', }, failure_count: { parameters: [], documentation: t('number of failed events'), outputType: 'number', isSortable: true, multiPlotType: 'line', }, min: { parameters: [ { kind: 'column', columnTypes: validateForNumericAggregate([ 'integer', 'number', 'duration', 'date', 'percentage', ]), defaultValue: 'transaction.duration', required: true, }, ], documentation: t('minimum'), outputType: null, isSortable: true, multiPlotType: 'line', }, max: { parameters: [ { kind: 'column', columnTypes: validateForNumericAggregate([ 'integer', 'number', 'duration', 'date', 'percentage', ]), defaultValue: 'transaction.duration', required: true, }, ], documentation: t('maximum'), outputType: null, isSortable: true, multiPlotType: 'line', }, sum: { parameters: [ { kind: 'column', columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']), required: true, defaultValue: 'transaction.duration', }, ], documentation: t('total value'), outputType: null, isSortable: true, multiPlotType: 'area', }, any: { parameters: [ { kind: 'column', columnTypes: ['string', 'integer', 'number', 'duration', 'date', 'boolean'], required: true, defaultValue: 'transaction.duration', }, ], documentation: t('pick any value'), outputType: null, isSortable: true, }, p50: { parameters: [ { kind: 'column', columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']), defaultValue: 'transaction.duration', required: false, }, ], documentation: t('median'), outputType: null, isSortable: true, multiPlotType: 'line', }, p75: { parameters: [ { kind: 'column', columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']), defaultValue: 'transaction.duration', required: false, }, ], documentation: t('75th percentile'), outputType: null, isSortable: true, multiPlotType: 'line', }, p95: { parameters: [ { kind: 'column', columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']), defaultValue: 'transaction.duration', required: false, }, ], documentation: t('95th percentile'), outputType: null, type: [], isSortable: true, multiPlotType: 'line', }, p99: { parameters: [ { kind: 'column', columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']), defaultValue: 'transaction.duration', required: false, }, ], documentation: t('99th percentile'), outputType: null, isSortable: true, multiPlotType: 'line', }, p100: { parameters: [ { kind: 'column', columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']), defaultValue: 'transaction.duration', required: false, }, ], documentation: t('maximum'), outputType: null, isSortable: true, multiPlotType: 'line', }, percentile: { parameters: [ { kind: 'column', columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']), defaultValue: 'transaction.duration', required: true, }, { kind: 'value', dataType: 'number', defaultValue: '0.5', required: true, }, ], documentation: t('arbitrary percentile'), outputType: null, isSortable: true, multiPlotType: 'line', }, avg: { parameters: [ { kind: 'column', columnTypes: validateForNumericAggregate(['duration', 'number', 'percentage']), defaultValue: 'transaction.duration', required: true, }, ], documentation: t('average'), outputType: null, isSortable: true, multiPlotType: 'line', }, apdex: { parameters: [ { kind: 'value', dataType: 'number', defaultValue: '300', required: true, }, ], documentation: t('performance score, higher is better'), outputType: 'number', isSortable: true, multiPlotType: 'line', }, user_misery: { parameters: [ { kind: 'value', dataType: 'number', defaultValue: '300', required: true, }, ], documentation: t('ratio of miserable users'), outputType: 'number', isSortable: true, multiPlotType: 'line', }, failure_rate: { parameters: [], documentation: t('failure_count()/count()'), outputType: 'percentage', isSortable: true, multiPlotType: 'line', }, last_seen: { parameters: [], documentation: t('largest timestamp'), outputType: 'date', isSortable: true, }, } as const; // TPM and TPS are aliases that are only used in Performance export const ALIASES = { tpm: 'epm', tps: 'eps', }; assert(AGGREGATIONS as Readonly<{[key in keyof typeof AGGREGATIONS]: Aggregation}>); export type AggregationKey = keyof typeof AGGREGATIONS | keyof typeof ALIASES | ''; export type AggregationOutputType = Extract< ColumnType, 'number' | 'integer' | 'date' | 'duration' | 'percentage' | 'string' >; 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; getFieldOverrides?: ( data: DefaultValueInputs ) => Partial>; /** * How this function should be plotted when shown in a multiseries result (top5) * Optional because some functions cannot be plotted (strings/dates) */ multiPlotType?: PlotType; }; enum FieldKey { CULPRIT = 'culprit', DEVICE_ARCH = 'device.arch', DEVICE_BATTERY_LEVEL = 'device.battery_level', DEVICE_BRAND = 'device.brand', DEVICE_CHARGING = 'device.charging', DEVICE_FAMILY = 'device.family', DEVICE_LOCALE = 'device.locale', DEVICE_NAME = 'device.name', DEVICE_ONLINE = 'device.online', DEVICE_ORIENTATION = 'device.orientation', DEVICE_SIMULATOR = 'device.simulator', DEVICE_UUID = 'device.uuid', DIST = 'dist', ENVIRONMENT = 'environment', ERROR_HANDLED = 'error.handled', ERROR_UNHANDLED = 'error.unhandled', ERROR_MECHANISM = 'error.mechanism', ERROR_TYPE = 'error.type', ERROR_VALUE = 'error.value', EVENT_TYPE = 'event.type', GEO_CITY = 'geo.city', GEO_COUNTRY_CODE = 'geo.country_code', GEO_REGION = 'geo.region', HTTP_METHOD = 'http.method', HTTP_REFERER = 'http.referer', HTTP_URL = 'http.url', ID = 'id', ISSUE = 'issue', LEVEL = 'level', LOCATION = 'location', MESSAGE = 'message', OS_BUILD = 'os.build', OS_KERNEL_VERSION = 'os.kernel_version', PLATFORM_NAME = 'platform.name', PROJECT = 'project', RELEASE = 'release', SDK_NAME = 'sdk.name', SDK_VERSION = 'sdk.version', STACK_ABS_PATH = 'stack.abs_path', STACK_COLNO = 'stack.colno', STACK_FILENAME = 'stack.filename', STACK_FUNCTION = 'stack.function', STACK_IN_APP = 'stack.in_app', STACK_LINENO = 'stack.lineno', STACK_MODULE = 'stack.module', STACK_PACKAGE = 'stack.package', STACK_STACK_LEVEL = 'stack.stack_level', TIMESTAMP = 'timestamp', TIMESTAMP_TO_HOUR = 'timestamp.to_hour', TIMESTAMP_TO_DAY = 'timestamp.to_day', TITLE = 'title', TRACE = 'trace', TRACE_PARENT_SPAN = 'trace.parent_span', TRACE_SPAN = 'trace.span', TRANSACTION = 'transaction', TRANSACTION_DURATION = 'transaction.duration', TRANSACTION_OP = 'transaction.op', TRANSACTION_STATUS = 'transaction.status', USER = 'user', USER_EMAIL = 'user.email', USER_ID = 'user.id', USER_IP = 'user.ip', USER_USERNAME = 'user.username', USER_DISPLAY = 'user.display', } /** * Refer to src/sentry/snuba/events.py, search for Columns */ export const FIELDS: Readonly> = { [FieldKey.ID]: 'string', // issue.id and project.id are omitted on purpose. // Customers should use `issue` and `project` instead. [FieldKey.TIMESTAMP]: 'date', // time is omitted on purpose. // Customers should use `timestamp` or `timestamp.to_hour`. [FieldKey.TIMESTAMP_TO_HOUR]: 'date', [FieldKey.TIMESTAMP_TO_DAY]: 'date', [FieldKey.CULPRIT]: 'string', [FieldKey.LOCATION]: 'string', [FieldKey.MESSAGE]: 'string', [FieldKey.PLATFORM_NAME]: 'string', [FieldKey.ENVIRONMENT]: 'string', [FieldKey.RELEASE]: 'string', [FieldKey.DIST]: 'string', [FieldKey.TITLE]: 'string', [FieldKey.EVENT_TYPE]: 'string', // tags.key and tags.value are omitted on purpose as well. [FieldKey.TRANSACTION]: 'string', [FieldKey.USER]: 'string', [FieldKey.USER_ID]: 'string', [FieldKey.USER_EMAIL]: 'string', [FieldKey.USER_USERNAME]: 'string', [FieldKey.USER_IP]: 'string', [FieldKey.SDK_NAME]: 'string', [FieldKey.SDK_VERSION]: 'string', [FieldKey.HTTP_METHOD]: 'string', [FieldKey.HTTP_REFERER]: 'string', [FieldKey.HTTP_URL]: 'string', [FieldKey.OS_BUILD]: 'string', [FieldKey.OS_KERNEL_VERSION]: 'string', [FieldKey.DEVICE_NAME]: 'string', [FieldKey.DEVICE_BRAND]: 'string', [FieldKey.DEVICE_LOCALE]: 'string', [FieldKey.DEVICE_UUID]: 'string', [FieldKey.DEVICE_ARCH]: 'string', [FieldKey.DEVICE_FAMILY]: 'string', [FieldKey.DEVICE_BATTERY_LEVEL]: 'number', [FieldKey.DEVICE_ORIENTATION]: 'string', [FieldKey.DEVICE_SIMULATOR]: 'boolean', [FieldKey.DEVICE_ONLINE]: 'boolean', [FieldKey.DEVICE_CHARGING]: 'boolean', [FieldKey.GEO_COUNTRY_CODE]: 'string', [FieldKey.GEO_REGION]: 'string', [FieldKey.GEO_CITY]: 'string', [FieldKey.ERROR_TYPE]: 'string', [FieldKey.ERROR_VALUE]: 'string', [FieldKey.ERROR_MECHANISM]: 'string', [FieldKey.ERROR_HANDLED]: 'boolean', [FieldKey.ERROR_UNHANDLED]: 'boolean', [FieldKey.LEVEL]: 'string', [FieldKey.STACK_ABS_PATH]: 'string', [FieldKey.STACK_FILENAME]: 'string', [FieldKey.STACK_PACKAGE]: 'string', [FieldKey.STACK_MODULE]: 'string', [FieldKey.STACK_FUNCTION]: 'string', [FieldKey.STACK_IN_APP]: 'boolean', [FieldKey.STACK_COLNO]: 'number', [FieldKey.STACK_LINENO]: 'number', [FieldKey.STACK_STACK_LEVEL]: 'number', // contexts.key and contexts.value omitted on purpose. // Transaction event fields. [FieldKey.TRANSACTION_DURATION]: 'duration', [FieldKey.TRANSACTION_OP]: 'string', [FieldKey.TRANSACTION_STATUS]: 'string', [FieldKey.TRACE]: 'string', [FieldKey.TRACE_SPAN]: 'string', [FieldKey.TRACE_PARENT_SPAN]: 'string', // Field alises defined in src/sentry/api/event_search.py [FieldKey.PROJECT]: 'string', [FieldKey.ISSUE]: 'string', [FieldKey.USER_DISPLAY]: 'string', }; export const DEPRECATED_FIELDS: string[] = [FieldKey.CULPRIT]; export type FieldTag = { key: FieldKey; name: FieldKey; }; export const FIELD_TAGS = Object.freeze( Object.fromEntries(Object.keys(FIELDS).map(item => [item, {key: item, name: item}])) ); export const SEMVER_TAGS = { 'release.version': { key: 'release.version', name: 'release.version', }, 'release.build': { key: 'release.build', name: 'release.build', }, 'release.package': { key: 'release.package', name: 'release.package', }, 'release.stage': { key: 'release.stage', name: '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']); 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 = 'duration' | 'number' | 'integer' | 'percentage'; const MEASUREMENTS: Readonly> = { [WebVital.FP]: 'duration', [WebVital.FCP]: 'duration', [WebVital.LCP]: 'duration', [WebVital.FID]: 'duration', [WebVital.CLS]: 'number', [WebVital.TTFB]: 'duration', [WebVital.RequestTime]: 'duration', [MobileVital.AppStartCold]: 'duration', [MobileVital.AppStartWarm]: 'duration', [MobileVital.FramesTotal]: 'integer', [MobileVital.FramesSlow]: 'integer', [MobileVital.FramesFrozen]: 'integer', [MobileVital.FramesSlowRate]: 'percentage', [MobileVital.FramesFrozenRate]: 'percentage', [MobileVital.StallCount]: 'integer', [MobileVital.StallTotalTime]: 'duration', [MobileVital.StallLongestTime]: 'duration', [MobileVital.StallPercentage]: '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 = [ 'spans.http', 'spans.db', 'spans.browser', 'spans.resource', 'spans.ui', ]; // This list contains fields/functions that are available with performance-view feature. export const TRACING_FIELDS = [ 'avg', 'sum', 'transaction.duration', 'transaction.op', 'transaction.status', 'p50', 'p75', 'p95', 'p99', 'p100', 'percentile', 'failure_rate', 'apdex', 'count_miserable', 'user_misery', 'eps', 'epm', 'team_key_transaction', ...Object.keys(MEASUREMENTS), ...SPAN_OP_BREAKDOWN_FIELDS, SPAN_OP_RELATIVE_BREAKDOWN_FIELD, ]; 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 { const results = field.match(MEASUREMENT_PATTERN); return !!results; } export function measurementType(field: string): MeasurementType { if (MEASUREMENTS.hasOwnProperty(field)) { return MEASUREMENTS[field]; } return '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.indexOf(newField) === -1 && excludeFields.indexOf(newField) === -1) { 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}`; } 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 const FIELDS_DOCS: Readonly> = { has: t('check field exists'), 'release.version': t('semantic version'), 'release.stage': t('adoption stage'), 'release.package': t('semantic package'), 'release.build': t('semantic build number'), [FieldKey.CULPRIT]: t('deprecated'), [FieldKey.DEVICE_ARCH]: t('cpu architecture'), [FieldKey.DEVICE_FAMILY]: t('common part of name'), [FieldKey.DEVICE_BATTERY_LEVEL]: t('0-100 of level'), [FieldKey.DEVICE_BRAND]: t('brand of device'), [FieldKey.DEVICE_CHARGING]: t('True or False'), [FieldKey.DEVICE_LOCALE]: t('deprecated'), [FieldKey.DEVICE_NAME]: t('details of device'), [FieldKey.DEVICE_ONLINE]: t('True or False'), [FieldKey.DEVICE_ORIENTATION]: t('portrait or landscape'), [FieldKey.DEVICE_SIMULATOR]: t('True or False'), [FieldKey.DEVICE_UUID]: t('uuid'), [FieldKey.DIST]: t('build or variant'), [FieldKey.ENVIRONMENT]: t('deployment name'), [FieldKey.ERROR_HANDLED]: t('True or False'), [FieldKey.ERROR_MECHANISM]: t('created error'), [FieldKey.ERROR_TYPE]: t('exception type'), [FieldKey.ERROR_UNHANDLED]: t('True or False'), [FieldKey.ERROR_VALUE]: t('error value'), [FieldKey.EVENT_TYPE]: t('type of event'), [FieldKey.GEO_CITY]: t('full name'), [FieldKey.GEO_COUNTRY_CODE]: t('ISO 3166-1'), [FieldKey.GEO_REGION]: t('full name'), [FieldKey.HTTP_METHOD]: t('method of request'), [FieldKey.HTTP_REFERER]: t('referer of request'), [FieldKey.HTTP_URL]: t('url of request'), [FieldKey.ID]: t('event identifier'), [FieldKey.ISSUE]: t('issue short id'), [FieldKey.LEVEL]: t('string'), [FieldKey.LOCATION]: t('where error happened'), [FieldKey.MESSAGE]: t('title or name'), [FieldKey.OS_BUILD]: t('internal build revision'), [FieldKey.OS_KERNEL_VERSION]: t('kernel string'), [FieldKey.PLATFORM_NAME]: t('name of platform'), [FieldKey.PROJECT]: t('project name'), [FieldKey.RELEASE]: t('code version'), [FieldKey.SDK_NAME]: t('sentry sdk name'), [FieldKey.SDK_VERSION]: t('sentry sdk version'), [FieldKey.STACK_ABS_PATH]: t('absolute path'), [FieldKey.STACK_COLNO]: t('column number'), [FieldKey.STACK_FILENAME]: t('source file'), [FieldKey.STACK_FUNCTION]: t('function called'), [FieldKey.STACK_IN_APP]: t('True or False'), [FieldKey.STACK_LINENO]: t('line number'), [FieldKey.STACK_MODULE]: t('platform specific'), [FieldKey.STACK_PACKAGE]: t('package of frame'), [FieldKey.STACK_STACK_LEVEL]: t('number'), [FieldKey.TIMESTAMP]: t('time event occurred'), [FieldKey.TIMESTAMP_TO_DAY]: t('rounded timestamp'), [FieldKey.TIMESTAMP_TO_HOUR]: t('rounded timestamp'), [FieldKey.TITLE]: t('title or name'), [FieldKey.TRACE]: t('uuid'), [FieldKey.TRACE_PARENT_SPAN]: t('span for parent trace'), [FieldKey.TRACE_SPAN]: t('span for trace'), [FieldKey.TRANSACTION]: t('transaction name'), [FieldKey.TRANSACTION_DURATION]: t('duration'), [FieldKey.TRANSACTION_OP]: t('short code'), [FieldKey.TRANSACTION_STATUS]: t('final status'), [FieldKey.USER]: t('unparsed user field'), [FieldKey.USER_DISPLAY]: t('email>username>id>ip'), [FieldKey.USER_EMAIL]: t('email'), [FieldKey.USER_ID]: t('identifier'), [FieldKey.USER_IP]: t('ip address'), [FieldKey.USER_USERNAME]: t('username'), [MobileVital.AppStartCold]: t('first launch duration'), [MobileVital.AppStartWarm]: t('subsequent launch duration'), [MobileVital.FramesFrozenRate]: t('frames_frozen/frames_total'), [MobileVital.FramesFrozen]: t('framse slower than 700ms'), [MobileVital.FramesSlowRate]: t('frames_slow/frames_total'), [MobileVital.FramesSlow]: t('frames slower than 16ms'), [MobileVital.FramesTotal]: t('number of frames'), [MobileVital.StallCount]: t('stalled event loops'), [MobileVital.StallLongestTime]: t('largest stalled event loop'), [MobileVital.StallPercentage]: t('stall_total_time/duration'), [MobileVital.StallTotalTime]: t('duration of stall'), [WebVital.CLS]: t('cumulative layout shift'), [WebVital.FCP]: t('first contentful paint'), [WebVital.FID]: t('first input delay'), [WebVital.FP]: t('first paint'), [WebVital.LCP]: t('largest contentful paint'), [WebVital.RequestTime]: t('time until response start'), [WebVital.TTFB]: t('time to first byte'), }; export function getFieldDoc(field: string): React.ReactNode { if (FIELDS_DOCS.hasOwnProperty(field)) { return FIELDS_DOCS[field]; } const parsed = parseFunction(field); if (parsed && AGGREGATIONS.hasOwnProperty(parsed.name)) { return AGGREGATIONS[parsed.name].documentation; } return ''; } 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): AggregationOutputType { 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 the function is an inherit type it will have a field as // the first parameter and we can use that to get the type. if (firstArg && FIELDS.hasOwnProperty(firstArg)) { return FIELDS[firstArg]; } 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. if (firstArg && FIELDS.hasOwnProperty(firstArg)) { return FIELDS[firstArg]; } 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']; export function fieldAlignment( columnName: string, columnType?: undefined | ColumnValueType, metadata?: Record ): Alignments { let align: Alignments = 'left'; 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 | MetricsType) { 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') { if (FIELDS.hasOwnProperty(column.field)) { return FIELDS[column.field]; } 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; }