import * as Sentry from '@sentry/react'; import merge from 'lodash/merge'; import moment from 'moment-timezone'; import type {LocationRange} from 'pegjs'; import {t} from 'sentry/locale'; import type {TagCollection} from 'sentry/types/group'; import { isMeasurement, isSpanOperationBreakdownField, measurementType, } from 'sentry/utils/discover/fields'; import grammar from './grammar.pegjs'; import {getKeyName} from './utils'; type TextFn = () => string; type LocationFn = () => LocationRange; type ListItem = [ space: ReturnType, comma: string, space: ReturnType, value?: [notComma: undefined, value: V | null], ]; const listJoiner = ([s1, comma, s2, value]: ListItem) => { return { separator: [s1.value, comma, s2.value].join(''), value: value ? value[1] : null, }; }; /** * A token represents a node in the syntax tree. These are all extrapolated * from the grammar and may not be named exactly the same. */ export enum Token { SPACES = 'spaces', FILTER = 'filter', FREE_TEXT = 'freeText', LOGIC_GROUP = 'logicGroup', LOGIC_BOOLEAN = 'logicBoolean', KEY_SIMPLE = 'keySimple', KEY_EXPLICIT_TAG = 'keyExplicitTag', KEY_AGGREGATE = 'keyAggregate', KEY_AGGREGATE_ARGS = 'keyAggregateArgs', KEY_AGGREGATE_PARAMS = 'keyAggregateParam', L_PAREN = 'lParen', R_PAREN = 'rParen', VALUE_ISO_8601_DATE = 'valueIso8601Date', VALUE_RELATIVE_DATE = 'valueRelativeDate', VALUE_DURATION = 'valueDuration', VALUE_SIZE = 'valueSize', VALUE_PERCENTAGE = 'valuePercentage', VALUE_BOOLEAN = 'valueBoolean', VALUE_NUMBER = 'valueNumber', VALUE_TEXT = 'valueText', VALUE_NUMBER_LIST = 'valueNumberList', VALUE_TEXT_LIST = 'valueTextList', } /** * An operator in a key value term */ export enum TermOperator { DEFAULT = '', GREATER_THAN_EQUAL = '>=', LESS_THAN_EQUAL = '<=', GREATER_THAN = '>', LESS_THAN = '<', EQUAL = '=', NOT_EQUAL = '!=', } /** * Logic operators */ export enum BooleanOperator { AND = 'AND', OR = 'OR', } /** * The Token.Filter may be one of many types of filters. This enum declares the * each variant filter type. */ export enum FilterType { TEXT = 'text', TEXT_IN = 'textIn', DATE = 'date', SPECIFIC_DATE = 'specificDate', RELATIVE_DATE = 'relativeDate', DURATION = 'duration', SIZE = 'size', NUMERIC = 'numeric', NUMERIC_IN = 'numericIn', BOOLEAN = 'boolean', AGGREGATE_DURATION = 'aggregateDuration', AGGREGATE_SIZE = 'aggregateSize', AGGREGATE_PERCENTAGE = 'aggregatePercentage', AGGREGATE_NUMERIC = 'aggregateNumeric', AGGREGATE_DATE = 'aggregateDate', AGGREGATE_RELATIVE_DATE = 'aggregateRelativeDate', HAS = 'has', IS = 'is', } export const allOperators = [ TermOperator.DEFAULT, TermOperator.GREATER_THAN_EQUAL, TermOperator.LESS_THAN_EQUAL, TermOperator.GREATER_THAN, TermOperator.LESS_THAN, TermOperator.EQUAL, TermOperator.NOT_EQUAL, ] as const; const basicOperators = [TermOperator.DEFAULT, TermOperator.NOT_EQUAL] as const; /** * Map of certain filter types to other filter types with applicable operators * e.g. SpecificDate can use the operators from Date to become a Date filter. */ export const interchangeableFilterOperators = { [FilterType.SPECIFIC_DATE]: [FilterType.DATE], [FilterType.DATE]: [FilterType.SPECIFIC_DATE], }; const textKeys = [Token.KEY_SIMPLE, Token.KEY_EXPLICIT_TAG] as const; /** * This constant-type configuration object declares how each filter type * operates. Including what types of keys, operators, and values it may * receive. * * This configuration is used to generate the discriminate Filter type that is * returned from the tokenFilter converter. */ export const filterTypeConfig = { [FilterType.TEXT]: { validKeys: textKeys, validOps: basicOperators, validValues: [Token.VALUE_TEXT], canNegate: true, }, [FilterType.TEXT_IN]: { validKeys: textKeys, validOps: basicOperators, validValues: [Token.VALUE_TEXT_LIST], canNegate: true, }, [FilterType.DATE]: { validKeys: [Token.KEY_SIMPLE], validOps: allOperators, validValues: [Token.VALUE_ISO_8601_DATE], canNegate: false, }, [FilterType.SPECIFIC_DATE]: { validKeys: [Token.KEY_SIMPLE], validOps: [], validValues: [Token.VALUE_ISO_8601_DATE], canNegate: false, }, [FilterType.RELATIVE_DATE]: { validKeys: [Token.KEY_SIMPLE], validOps: [], validValues: [Token.VALUE_RELATIVE_DATE], canNegate: false, }, [FilterType.DURATION]: { validKeys: [Token.KEY_SIMPLE], validOps: allOperators, validValues: [Token.VALUE_DURATION], canNegate: true, }, [FilterType.SIZE]: { validKeys: [Token.KEY_SIMPLE], validOps: allOperators, validValues: [Token.VALUE_SIZE], canNegate: true, }, [FilterType.NUMERIC]: { validKeys: [Token.KEY_SIMPLE], validOps: allOperators, validValues: [Token.VALUE_NUMBER], canNegate: true, }, [FilterType.NUMERIC_IN]: { validKeys: [Token.KEY_SIMPLE], validOps: basicOperators, validValues: [Token.VALUE_NUMBER_LIST], canNegate: true, }, [FilterType.BOOLEAN]: { validKeys: [Token.KEY_SIMPLE], validOps: basicOperators, validValues: [Token.VALUE_BOOLEAN], canNegate: true, }, [FilterType.AGGREGATE_DURATION]: { validKeys: [Token.KEY_AGGREGATE], validOps: allOperators, validValues: [Token.VALUE_DURATION], canNegate: true, }, [FilterType.AGGREGATE_SIZE]: { validKeys: [Token.KEY_AGGREGATE], validOps: allOperators, validValues: [Token.VALUE_SIZE], canNegate: true, }, [FilterType.AGGREGATE_NUMERIC]: { validKeys: [Token.KEY_AGGREGATE], validOps: allOperators, validValues: [Token.VALUE_NUMBER], canNegate: true, }, [FilterType.AGGREGATE_PERCENTAGE]: { validKeys: [Token.KEY_AGGREGATE], validOps: allOperators, validValues: [Token.VALUE_PERCENTAGE], canNegate: true, }, [FilterType.AGGREGATE_DATE]: { validKeys: [Token.KEY_AGGREGATE], validOps: allOperators, validValues: [Token.VALUE_ISO_8601_DATE], canNegate: true, }, [FilterType.AGGREGATE_RELATIVE_DATE]: { validKeys: [Token.KEY_AGGREGATE], validOps: allOperators, validValues: [Token.VALUE_RELATIVE_DATE], canNegate: true, }, [FilterType.HAS]: { validKeys: [Token.KEY_SIMPLE], validOps: basicOperators, validValues: [], canNegate: true, }, [FilterType.IS]: { validKeys: [Token.KEY_SIMPLE], validOps: basicOperators, validValues: [Token.VALUE_TEXT], canNegate: true, }, } as const; type FilterTypeConfig = typeof filterTypeConfig; /** * The invalid reason is used to mark fields invalid fields and can be * used to determine why the field was invalid. This is primarily use for the * invalidMessages option */ export enum InvalidReason { FREE_TEXT_NOT_ALLOWED = 'free-text-not-allowed', WILDCARD_NOT_ALLOWED = 'wildcard-not-allowed', LOGICAL_OR_NOT_ALLOWED = 'logic-or-not-allowed', LOGICAL_AND_NOT_ALLOWED = 'logic-and-not-allowed', NEGATION_NOT_ALLOWED = 'negation-not-allowed', MUST_BE_QUOTED = 'must-be-quoted', FILTER_MUST_HAVE_VALUE = 'filter-must-have-value', INVALID_BOOLEAN = 'invalid-boolean', INVALID_FILE_SIZE = 'invalid-file-size', INVALID_NUMBER = 'invalid-number', EMPTY_VALUE_IN_LIST_NOT_ALLOWED = 'empty-value-in-list-not-allowed', EMPTY_PARAMETER_NOT_ALLOWED = 'empty-parameter-not-allowed', INVALID_KEY = 'invalid-key', INVALID_DURATION = 'invalid-duration', INVALID_DATE_FORMAT = 'invalid-date-format', PARENS_NOT_ALLOWED = 'parens-not-allowed', } /** * Object representing an invalid filter state */ type InvalidFilter = { /** * The message indicating why the filter is invalid */ reason: string; /** * The invalid reason type */ type: InvalidReason; /** * In the case where a filter is invalid, we may be expecting a different * type for this filter based on the key. This can be useful to hint to the * user what values they should be providing. * * This may be multiple filter types. */ expectedType?: FilterType[]; }; type FilterMap = { [F in keyof FilterTypeConfig]: { /** * The filter type being represented */ filter: F; /** * When a filter is marked as 'invalid' a reason is given. If the filter is * not invalid this will always be null */ invalid: InvalidFilter | null; /** * The key of the filter */ key: KVConverter; /** * Indicates if the filter has been negated */ negated: FilterTypeConfig[F]['canNegate'] extends true ? boolean : false; /** * The operator applied to the filter */ operator: FilterTypeConfig[F]['validOps'][number]; type: Token.FILTER; /** * The value of the filter */ value: KVConverter; /** * A warning message associated with this filter */ warning: React.ReactNode; }; }; type TextFilter = FilterMap[FilterType.TEXT]; type InFilter = FilterMap[FilterType.TEXT_IN] | FilterMap[FilterType.NUMERIC_IN]; type AggregateFilterType = | FilterMap[FilterType.AGGREGATE_DATE] | FilterMap[FilterType.AGGREGATE_DURATION] | FilterMap[FilterType.AGGREGATE_NUMERIC] | FilterMap[FilterType.AGGREGATE_PERCENTAGE] | FilterMap[FilterType.AGGREGATE_RELATIVE_DATE] | FilterMap[FilterType.AGGREGATE_SIZE]; /** * The Filter type discriminates on the FilterType enum using the `filter` key. * * When receiving this type you may narrow it to a specific filter by checking * this field. This will give you proper types on what the key, value, and * operator results are. */ type FilterResult = FilterMap[FilterType]; type TokenConverterOpts = { config: SearchConfig; location: LocationFn; text: TextFn; }; /** * Used to construct token results via the token grammar */ export class TokenConverter { text: TextFn; location: LocationFn; config: SearchConfig; constructor({text, location, config}: TokenConverterOpts) { this.text = text; this.location = location; this.config = config; } /** * Validates various types of keys */ keyValidation = { isNumeric: (key: string) => this.config.numericKeys.has(key) || isMeasurement(key) || isSpanOperationBreakdownField(key), isBoolean: (key: string) => this.config.booleanKeys.has(key), isPercentage: (key: string) => this.config.percentageKeys.has(key), isDate: (key: string) => this.config.dateKeys.has(key), isDuration: (key: string) => this.config.durationKeys.has(key) || isSpanOperationBreakdownField(key) || measurementType(key) === 'duration', isSize: (key: string) => this.config.sizeKeys.has(key), }; /** * Creates shared `text` and `location` keys. */ get defaultTokenFields() { return { text: this.text(), location: this.location(), }; } tokenSpaces = (value: string) => ({ ...this.defaultTokenFields, type: Token.SPACES as const, value, }); tokenFilter = ( filter: T, key: FilterMap[T]['key'], value: FilterMap[T]['value'], operator: FilterMap[T]['operator'] | undefined, negated: FilterMap[T]['negated'] ) => { const filterToken = { type: Token.FILTER as const, filter, key, value, negated, operator: operator ?? TermOperator.DEFAULT, invalid: this.checkInvalidFilter(filter, key, value, negated), warning: this.checkFilterWarning(key), } as FilterResult; return { ...this.defaultTokenFields, ...filterToken, }; }; tokenLParen = (value: '(') => ({ ...this.defaultTokenFields, type: Token.L_PAREN as const, value, invalid: this.checkInvalidParen(), }); tokenRParen = (value: ')') => ({ ...this.defaultTokenFields, type: Token.R_PAREN as const, value, invalid: this.checkInvalidParen(), }); tokenFreeText = (value: string, quoted: boolean) => ({ ...this.defaultTokenFields, type: Token.FREE_TEXT as const, value, quoted, invalid: this.checkInvalidFreeText(value), }); tokenLogicGroup = ( inner: Array< | ReturnType | ReturnType | ReturnType > ) => ({ ...this.defaultTokenFields, type: Token.LOGIC_GROUP as const, inner, }); tokenLogicBoolean = (bool: BooleanOperator) => ({ ...this.defaultTokenFields, type: Token.LOGIC_BOOLEAN as const, value: bool, invalid: this.checkInvalidLogicalBoolean(bool), }); tokenKeySimple = (value: string, quoted: boolean) => ({ ...this.defaultTokenFields, type: Token.KEY_SIMPLE as const, value, quoted, }); tokenKeyExplicitTag = ( prefix: string, key: ReturnType ) => ({ ...this.defaultTokenFields, type: Token.KEY_EXPLICIT_TAG as const, prefix, key, }); tokenKeyAggregateParam = (value: string, quoted: boolean) => ({ ...this.defaultTokenFields, type: Token.KEY_AGGREGATE_PARAMS as const, value, quoted, }); tokenKeyAggregate = ( name: ReturnType, args: ReturnType | null, argsSpaceBefore: ReturnType, argsSpaceAfter: ReturnType ) => ({ ...this.defaultTokenFields, type: Token.KEY_AGGREGATE as const, name, args, argsSpaceBefore, argsSpaceAfter, }); tokenKeyAggregateArgs = ( arg1: ReturnType, args: ListItem>[] ) => { return { ...this.defaultTokenFields, type: Token.KEY_AGGREGATE_ARGS as const, args: [{separator: '', value: arg1}, ...args.map(listJoiner)], }; }; tokenValueIso8601Date = ( value: string, date: Array, time?: Array>, tz?: Array ) => ({ ...this.defaultTokenFields, type: Token.VALUE_ISO_8601_DATE as const, value: value, parsed: this.config.parse ? parseDate(value) : undefined, date: date.flat().join(''), time: Array.isArray(time) ? time.flat().flat().join('').replace('T', '') : time, tz: Array.isArray(tz) ? tz.flat().join('') : tz, }); tokenValueRelativeDate = ( value: string, sign: '-' | '+', unit: 'w' | 'd' | 'h' | 'm' ) => ({ ...this.defaultTokenFields, type: Token.VALUE_RELATIVE_DATE as const, value: value, parsed: this.config.parse ? parseRelativeDate(value, {unit, sign}) : undefined, sign, unit, }); tokenValueDuration = ( value: string, unit: 'ms' | 's' | 'min' | 'm' | 'hr' | 'h' | 'day' | 'd' | 'wk' | 'w' ) => ({ ...this.defaultTokenFields, type: Token.VALUE_DURATION as const, value: value, parsed: this.config.parse ? parseDuration(value, unit) : undefined, unit, }); tokenValueSize = ( value: string, // warning: size units are case insensitive, this type is incomplete unit: | 'bit' | 'nb' | 'bytes' | 'kb' | 'mb' | 'gb' | 'tb' | 'pb' | 'eb' | 'zb' | 'yb' | 'kib' | 'mib' | 'gib' | 'tib' | 'pib' | 'eib' | 'zib' | 'yib' ) => ({ ...this.defaultTokenFields, type: Token.VALUE_SIZE as const, value: value, // units are case insensitive, normalize them in their parsed representation // so that we dont have to compare all possible permutations. parsed: this.config.parse ? parseSize(value, unit) : undefined, unit, }); tokenValuePercentage = (value: string) => ({ ...this.defaultTokenFields, type: Token.VALUE_PERCENTAGE as const, value: value, parsed: this.config.parse ? parsePercentage(value) : undefined, }); tokenValueBoolean = (value: string) => ({ ...this.defaultTokenFields, type: Token.VALUE_BOOLEAN as const, value: value, parsed: this.config.parse ? parseBoolean(value) : undefined, }); tokenValueNumber = (value: string, unit: 'k' | 'm' | 'b' | 'K' | 'M' | 'B') => { return { ...this.defaultTokenFields, type: Token.VALUE_NUMBER as const, value, unit, parsed: this.config.parse ? parseNumber(value, unit) : undefined, }; }; tokenValueNumberList = ( item1: ReturnType, items: ListItem>[] ) => ({ ...this.defaultTokenFields, type: Token.VALUE_NUMBER_LIST as const, items: [{separator: '', value: item1}, ...items.map(listJoiner)], }); tokenValueTextList = ( item1: ReturnType, items: ListItem>[] ) => ({ ...this.defaultTokenFields, type: Token.VALUE_TEXT_LIST as const, items: [{separator: '', value: item1}, ...items.map(listJoiner)], }); tokenValueText = (value: string, quoted: boolean) => { return { ...this.defaultTokenFields, type: Token.VALUE_TEXT as const, value, quoted, }; }; /** * This method is used while tokenizing to predicate whether a filter should * match or not. We do this because not all keys are valid for specific * filter types. For example, boolean filters should only match for keys * which can be filtered as booleans. * * See [0] and look for &{ predicate } to understand how predicates are * declared in the grammar * * [0]:https://pegjs.org/documentation */ predicateFilter = (type: T, key: FilterMap[T]['key']) => { const keyName = getKeyName(key); const aggregateKey = key as ReturnType; const {isNumeric, isDuration, isBoolean, isDate, isPercentage, isSize} = this.keyValidation; const checkAggregate = (check: (s: string) => boolean) => aggregateKey.args?.args.some(arg => check(arg?.value?.value ?? '')); switch (type) { case FilterType.NUMERIC: case FilterType.NUMERIC_IN: return isNumeric(keyName); case FilterType.DURATION: return isDuration(keyName); case FilterType.SIZE: return isSize(keyName); case FilterType.BOOLEAN: return isBoolean(keyName); case FilterType.DATE: case FilterType.RELATIVE_DATE: case FilterType.SPECIFIC_DATE: return isDate(keyName); case FilterType.AGGREGATE_DURATION: return checkAggregate(isDuration); case FilterType.AGGREGATE_DATE: return checkAggregate(isDate); case FilterType.AGGREGATE_PERCENTAGE: return checkAggregate(isPercentage); default: return true; } }; /** * Predicates weather a text filter have operators for specific keys. */ predicateTextOperator = (key: TextFilter['key']) => this.config.textOperatorKeys.has(getKeyName(key)); /** * When flattenParenGroups is enabled, paren groups should not be parsed, * instead parsing the parens and inner group as individual tokens. */ predicateParenGroup = (): boolean => { return !this.config.flattenParenGroups; }; /** * Checks the validity of a free text based on the provided search configuration */ checkInvalidFreeText = (value: string) => { if (this.config.disallowFreeText) { return { type: InvalidReason.FREE_TEXT_NOT_ALLOWED, reason: this.config.invalidMessages[InvalidReason.FREE_TEXT_NOT_ALLOWED], }; } if (this.config.disallowWildcard && value.includes('*')) { return { type: InvalidReason.WILDCARD_NOT_ALLOWED, reason: this.config.invalidMessages[InvalidReason.WILDCARD_NOT_ALLOWED], }; } return null; }; /** * Checks the validity of a logical boolean filter based on the provided search configuration */ checkInvalidLogicalBoolean = (value: BooleanOperator) => { if (this.config.disallowedLogicalOperators.has(value)) { if (value === BooleanOperator.OR) { return { type: InvalidReason.LOGICAL_OR_NOT_ALLOWED, reason: this.config.invalidMessages[InvalidReason.LOGICAL_OR_NOT_ALLOWED], }; } if (value === BooleanOperator.AND) { return { type: InvalidReason.LOGICAL_AND_NOT_ALLOWED, reason: this.config.invalidMessages[InvalidReason.LOGICAL_AND_NOT_ALLOWED], }; } } return null; }; /** * Checks the validity of a parens based on the provided search configuration */ checkInvalidParen = () => { if (!this.config.disallowParens) { return null; } return { type: InvalidReason.PARENS_NOT_ALLOWED, reason: this.config.invalidMessages[InvalidReason.PARENS_NOT_ALLOWED], }; }; /** * Checks a filter against some non-grammar validation rules */ checkFilterWarning = (key: FilterMap[T]['key']) => { if (![Token.KEY_SIMPLE, Token.KEY_EXPLICIT_TAG].includes(key.type)) { return null; } const keyName = getKeyName( key as TokenResult ); return this.config.getFilterTokenWarning?.(keyName) ?? null; }; /** * Checks a filter against some non-grammar validation rules */ checkInvalidFilter = ( filter: T, key: FilterMap[T]['key'], value: FilterMap[T]['value'], negated: FilterMap[T]['negated'] ) => { // Text filter is the "fall through" filter that will match when other // filter predicates fail. if ( this.config.validateKeys && this.config.supportedTags && !this.config.supportedTags[key.text] ) { return { type: InvalidReason.INVALID_KEY, reason: t('Invalid key. "%s" is not a supported search key.', key.text), }; } if (this.config.disallowNegation && negated) { return { type: InvalidReason.NEGATION_NOT_ALLOWED, reason: this.config.invalidMessages[InvalidReason.NEGATION_NOT_ALLOWED], }; } if (filter === FilterType.TEXT) { return this.checkInvalidTextFilter( key as TextFilter['key'], value as TextFilter['value'] ); } if (filter === FilterType.IS || filter === FilterType.HAS) { return this.checkInvalidTextValue(value as TextFilter['value']); } if ([FilterType.TEXT_IN, FilterType.NUMERIC_IN].includes(filter)) { return this.checkInvalidInFilter(value as InFilter['value']); } if ('name' in key) { return this.checkInvalidAggregateKey(key); } return null; }; /** * Validates text filters which may have failed predication */ checkInvalidTextFilter = (key: TextFilter['key'], value: TextFilter['value']) => { // Explicit tag keys will always be treated as text filters if (key.type === Token.KEY_EXPLICIT_TAG) { return this.checkInvalidTextValue(value); } const keyName = getKeyName(key); if (this.keyValidation.isDuration(keyName)) { return { type: InvalidReason.INVALID_DURATION, reason: t('Invalid duration. Expected number followed by duration unit suffix'), expectedType: [FilterType.DURATION], }; } if (this.keyValidation.isDate(keyName)) { const date = new Date(); date.setSeconds(0); date.setMilliseconds(0); const example = date.toISOString(); return { type: InvalidReason.INVALID_DATE_FORMAT, reason: t( 'Invalid date format. Expected +/-duration (e.g. +1h) or ISO 8601-like (e.g. %s or %s)', example.slice(0, 10), example ), expectedType: [ FilterType.DATE, FilterType.SPECIFIC_DATE, FilterType.RELATIVE_DATE, ], }; } if (this.keyValidation.isBoolean(keyName)) { return { type: InvalidReason.INVALID_BOOLEAN, reason: this.config.invalidMessages[InvalidReason.INVALID_BOOLEAN], expectedType: [FilterType.BOOLEAN], }; } if (this.keyValidation.isSize(keyName)) { return { type: InvalidReason.INVALID_FILE_SIZE, reason: this.config.invalidMessages[InvalidReason.INVALID_FILE_SIZE], expectedType: [FilterType.SIZE], }; } if (this.keyValidation.isNumeric(keyName)) { return { type: InvalidReason.INVALID_NUMBER, reason: this.config.invalidMessages[InvalidReason.INVALID_NUMBER], expectedType: [FilterType.NUMERIC, FilterType.NUMERIC_IN], }; } return this.checkInvalidTextValue(value); }; /** * Validates the value of a text filter */ checkInvalidTextValue = (value: TextFilter['value']) => { if (this.config.disallowWildcard && value.value.includes('*')) { return { type: InvalidReason.WILDCARD_NOT_ALLOWED, reason: this.config.invalidMessages[InvalidReason.WILDCARD_NOT_ALLOWED], }; } if (!value.quoted && /(^|[^\\])"/.test(value.value)) { return { type: InvalidReason.MUST_BE_QUOTED, reason: this.config.invalidMessages[InvalidReason.MUST_BE_QUOTED], }; } if (!value.quoted && value.value === '') { return { type: InvalidReason.FILTER_MUST_HAVE_VALUE, reason: this.config.invalidMessages[InvalidReason.FILTER_MUST_HAVE_VALUE], }; } return null; }; /** * Validates IN filter values do not have an missing elements */ checkInvalidInFilter = ({items}: InFilter['value']) => { const hasEmptyValue = items.some(item => item.value === null); if (hasEmptyValue) { return { type: InvalidReason.EMPTY_VALUE_IN_LIST_NOT_ALLOWED, reason: this.config.invalidMessages[InvalidReason.EMPTY_VALUE_IN_LIST_NOT_ALLOWED], }; } if ( this.config.disallowWildcard && items.some(item => item.value.value.includes('*')) ) { return { type: InvalidReason.WILDCARD_NOT_ALLOWED, reason: this.config.invalidMessages[InvalidReason.WILDCARD_NOT_ALLOWED], }; } return null; }; checkInvalidAggregateKey = (key: AggregateFilterType['key']) => { const hasEmptyParameter = key.args?.args.some(arg => arg.value === null); if (hasEmptyParameter) { return { type: InvalidReason.EMPTY_PARAMETER_NOT_ALLOWED, reason: this.config.invalidMessages[InvalidReason.EMPTY_PARAMETER_NOT_ALLOWED], }; } return null; }; } function parseDate(input: string): {value: Date} { const date = moment(input).toDate(); if (isNaN(date.getTime())) { throw new Error('Invalid date'); } return {value: date}; } function parseRelativeDate( input: string, {sign, unit}: {sign: '-' | '+'; unit: string} ): {value: Date} { let date = new Date().getTime(); const number = numeric(input); if (isNaN(date)) { throw new Error('Invalid date'); } let offset: number | undefined; switch (unit) { case 'm': offset = number * 1000 * 60; break; case 'h': offset = number * 1000 * 60 * 60; break; case 'd': offset = number * 1000 * 60 * 60 * 24; break; case 'w': offset = number * 1000 * 60 * 60 * 24 * 7; break; default: throw new Error('Invalid unit'); } if (offset === undefined) { throw new Error('Unreachable'); } date = sign === '-' ? date - offset : date + offset; return {value: new Date(date)}; } // The parser supports floats and ints, parseFloat handles both. function numeric(input: string) { const number = parseFloat(input); if (isNaN(number)) { throw new Error('Invalid number'); } return number; } function parseDuration( input: string, unit: 'ms' | 's' | 'min' | 'm' | 'hr' | 'h' | 'day' | 'd' | 'wk' | 'w' ): {value: number} { let number = numeric(input); switch (unit) { case 'ms': break; case 's': number *= 1e3; break; case 'min': case 'm': number *= 1e3 * 60; break; case 'hr': case 'h': number *= 1e3 * 60 * 60; break; case 'day': case 'd': number *= 1e3 * 60 * 60 * 24; break; case 'wk': case 'w': number *= 1e3 * 60 * 60 * 24 * 7; break; default: throw new Error('Invalid unit'); } return { value: number, }; } function parseNumber( input: string, unit: 'k' | 'm' | 'b' | 'K' | 'M' | 'B' ): {value: number} { let number = numeric(input); switch (unit) { case 'K': case 'k': number = number * 1e3; break; case 'M': case 'm': number = number * 1e6; break; case 'B': case 'b': number = number * 1e9; break; case null: case undefined: break; default: throw new Error('Invalid unit'); } return {value: number}; } function parseSize(input: string, unit: string): {value: number} { if (!unit) { unit = 'bytes'; } let number = numeric(input); // parser is case insensitive to units switch (unit.toLowerCase()) { case 'bit': number /= 8; break; case 'nb': number /= 2; break; case 'bytes': break; case 'kb': number *= 1000; break; case 'mb': number *= 1000 ** 2; break; case 'gb': number *= 1000 ** 3; break; case 'tb': number *= 1000 ** 4; break; case 'pb': number *= 1000 ** 5; break; case 'eb': number *= 1000 ** 6; break; case 'zb': number *= 1000 ** 7; break; case 'yb': number *= 1000 ** 8; break; case 'kib': number *= 1024; break; case 'mib': number *= 1024 ** 2; break; case 'gib': number *= 1024 ** 3; break; case 'tib': number *= 1024 ** 4; break; case 'pib': number *= 1024 ** 5; break; case 'eib': number *= 1024 ** 6; break; case 'zib': number *= 1024 ** 7; break; case 'yib': number *= 1024 ** 8; break; default: throw new Error('Invalid unit'); } return {value: number}; } function parsePercentage(input: string): {value: number} { return {value: numeric(input)}; } function parseBoolean(input: string): {value: boolean} { if (/^true$/i.test(input) || input === '1') { return {value: true}; } if (/^false$/i.test(input) || input === '0') { return {value: false}; } throw new Error('Invalid boolean'); } /** * Maps token conversion methods to their result types */ type ConverterResultMap = { [K in keyof TokenConverter & `token${string}`]: ReturnType; }; type Converter = keyof ConverterResultMap; /** * Converter keys specific to Key and Value tokens */ type KVTokens = Converter & `token${'Key' | 'Value'}${string}`; /** * Similar to TokenResult, but only includes Key* and Value* token type * results. This avoids a circular reference when this is used for the Filter * token converter result */ type KVConverter = ConverterResultMap[KVTokens] & {type: T}; /** * Each token type is discriminated by the `type` field. */ export type TokenResult = ConverterResultMap[Converter] & {type: T}; export type ParseResultToken = | TokenResult | TokenResult | TokenResult | TokenResult | TokenResult | TokenResult | TokenResult; /** * Result from parsing a search query. */ export type ParseResult = ParseResultToken[]; export type AggregateFilter = AggregateFilterType & { location: LocationRange; text: string; }; /** * Configures behavior of search parsing */ export type SearchConfig = { /** * Keys considered valid for boolean filter types */ booleanKeys: Set; /** * Keys considered valid for date filter types */ dateKeys: Set; /** * Disallow free text search */ disallowFreeText: boolean; /** * Disallow negation for filters */ disallowNegation: boolean; /** * Disallow parens in search */ disallowParens: boolean; /** * Disallow wildcards in free text search AND in tag values */ disallowWildcard: boolean; /** * Disallow specific boolean operators */ disallowedLogicalOperators: Set; /** * Keys which are considered valid for duration filters */ durationKeys: Set; /** * Configures the associated messages for invalid reasons */ invalidMessages: Partial>; /** * Keys considered valid for numeric filter types */ numericKeys: Set; /** * Keys considered valid for the percentage aggregate and may have percentage * search values */ percentageKeys: Set; /** * Keys considered valid for size filter types */ sizeKeys: Set; /** * Text filter keys we allow to have operators */ textOperatorKeys: Set; /** * When true, the parser will not parse paren groups and will return individual paren tokens */ flattenParenGroups?: boolean; /** * A function that returns a warning message for a given filter token key */ getFilterTokenWarning?: (key: string) => React.ReactNode; /** * Determines if user input values should be parsed */ parse?: boolean; /** * If validateKeys is set to true, tag keys that don't exist in supportedTags will be consider invalid */ supportedTags?: TagCollection; /** * If set to true, tag keys that don't exist in supportedTags will be consider invalid */ validateKeys?: boolean; }; export const defaultConfig: SearchConfig = { textOperatorKeys: new Set([ 'release.version', 'release.build', 'release.package', 'release.stage', ]), durationKeys: new Set(['transaction.duration']), percentageKeys: new Set(['percentage']), // do not put functions in this Set numericKeys: new Set([ 'project_id', 'project.id', 'issue.id', 'stack.colno', 'stack.lineno', 'stack.stack_level', 'transaction.duration', ]), dateKeys: new Set([ 'start', 'end', 'firstSeen', 'lastSeen', 'last_seen()', 'time', 'event.timestamp', 'timestamp', 'timestamp.to_hour', 'timestamp.to_day', ]), booleanKeys: new Set([ 'error.handled', 'error.unhandled', 'stack.in_app', 'team_key_transaction', ]), sizeKeys: new Set([]), disallowedLogicalOperators: new Set(), disallowFreeText: false, disallowWildcard: false, disallowNegation: false, disallowParens: false, invalidMessages: { [InvalidReason.FREE_TEXT_NOT_ALLOWED]: t('Free text is not supported in this search'), [InvalidReason.WILDCARD_NOT_ALLOWED]: t('Wildcards not supported in search'), [InvalidReason.LOGICAL_OR_NOT_ALLOWED]: t( 'The OR operator is not allowed in this search' ), [InvalidReason.LOGICAL_AND_NOT_ALLOWED]: t( 'The AND operator is not allowed in this search' ), [InvalidReason.MUST_BE_QUOTED]: t('Quotes must enclose text or be escaped'), [InvalidReason.NEGATION_NOT_ALLOWED]: t('Negation is not allowed in this search.'), [InvalidReason.FILTER_MUST_HAVE_VALUE]: t('Filter must have a value'), [InvalidReason.INVALID_BOOLEAN]: t('Invalid boolean. Expected true, 1, false, or 0.'), [InvalidReason.INVALID_FILE_SIZE]: t( 'Invalid file size. Expected number followed by file size unit suffix' ), [InvalidReason.INVALID_NUMBER]: t( 'Invalid number. Expected number then optional k, m, or b suffix (e.g. 500k)' ), [InvalidReason.EMPTY_PARAMETER_NOT_ALLOWED]: t( 'Function parameters should not have empty values' ), [InvalidReason.EMPTY_VALUE_IN_LIST_NOT_ALLOWED]: t( 'Lists should not have empty values' ), [InvalidReason.PARENS_NOT_ALLOWED]: t('Parentheses are not supported in this search'), }, }; function tryParseSearch( query: string, config: T ): ParseResult | null { try { return grammar.parse(query, config); } catch (e) { Sentry.withScope(scope => { scope.setFingerprint(['search-syntax-parse-error']); scope.setExtra('message', e.message?.slice(-100)); scope.setExtra('found', e.found); Sentry.captureException(e); }); return null; } } /** * Parse a search query into a ParseResult. Failing to parse the search query * will result in null. */ export function parseSearch( query: string, additionalConfig?: Partial ): ParseResult | null { const config = additionalConfig ? merge({...defaultConfig}, additionalConfig) : defaultConfig; return tryParseSearch(query, { config, TokenConverter, TermOperator, FilterType, }); } /** * Join a parsed query array into a string. * Should handle null cases to chain easily with parseSearch. * Option to add a leading space when applicable (e.g. to combine with other strings). * Option to add a space between elements (e.g. for when no Token.Spaces present). */ export function joinQuery( parsedTerms: ParseResult | null | undefined, leadingSpace?: boolean, additionalSpaceBetween?: boolean ): string { if (!parsedTerms || !parsedTerms.length) { return ''; } return ( (leadingSpace ? ' ' : '') + (parsedTerms.length === 1 ? parsedTerms[0].text : parsedTerms.map(p => p.text).join(additionalSpaceBetween ? ' ' : '')) ); }