Browse Source

feat(discover): Discover Search Bar - Parse custom performance metric filter units support size filters (#38749)

Uses the units from the measurements-meta endpoint to parse search bar
cm units.
Also adds support for filtering by size units (only cm utilizes this for
now).
edwardgou-sentry 2 years ago
parent
commit
cfe1c73672

+ 1 - 1
static/app/components/events/eventCustomPerformanceMetrics.tsx

@@ -77,7 +77,7 @@ type EventCustomPerformanceMetricProps = Props & {
   name: string;
 };
 
-function getFieldTypeFromUnit(unit) {
+export function getFieldTypeFromUnit(unit) {
   if (unit) {
     if (DURATION_UNITS[unit]) {
       return 'duration';

+ 1 - 0
static/app/components/events/searchBar.tsx

@@ -221,6 +221,7 @@ function SearchBar(props: SearchBarProps) {
           maxSearchItems={maxSearchItems}
           excludeEnvironment
           maxMenuHeight={maxMenuHeight ?? 300}
+          customPerformanceMetrics={customMeasurements}
           {...props}
         />
       )}

+ 25 - 0
static/app/components/searchSyntax/grammar.pegjs

@@ -46,10 +46,12 @@ filter
   / specific_date_filter
   / rel_date_filter
   / duration_filter
+  / size_filter
   / boolean_filter
   / numeric_in_filter
   / numeric_filter
   / aggregate_duration_filter
+  / aggregate_size_filter
   / aggregate_numeric_filter
   / aggregate_percentage_filter
   / aggregate_date_filter
@@ -91,6 +93,14 @@ duration_filter
       return tc.tokenFilter(FilterType.Duration, key, value, op, !!negation);
     }
 
+// filter for file size
+size_filter
+  = negation:negation? key:search_key sep op:operator? value:size_format &{
+      return tc.predicateFilter(FilterType.Size, key)
+    } {
+      return tc.tokenFilter(FilterType.Size, key, value, op, !!negation);
+    }
+
 // boolean comparison filter
 boolean_filter
   = negation:negation? key:search_key sep value:boolean_value &{
@@ -123,6 +133,14 @@ aggregate_duration_filter
       return tc.tokenFilter(FilterType.AggregateDuration, key, value, op, !!negation);
     }
 
+// aggregate file size filter
+aggregate_size_filter
+  = negation:negation? key:aggregate_key sep op:operator? value:size_format &{
+      return tc.predicateFilter(FilterType.AggregateSize, key)
+  } {
+      return tc.tokenFilter(FilterType.AggregateSize, key, value, op, !!negation);
+    }
+
 // aggregate percentage filter
 aggregate_percentage_filter
   = negation:negation? key:aggregate_key sep op:operator? value:percentage_format &{
@@ -332,6 +350,13 @@ duration_format
       return tc.tokenValueDuration(value, unit);
     }
 
+size_format
+  = value:numeric
+    unit:("bit"/"nb"/"bytes"/"kb"/"mb"/"gb"/"tb"/"pb"/"eb"/"zb"/"yb")
+    &end_value {
+      return tc.tokenValueSize(value, unit);
+    }
+
 percentage_format
   = value:numeric "%" {
       return tc.tokenValuePercentage(value);

+ 60 - 3
static/app/components/searchSyntax/parser.tsx

@@ -45,6 +45,7 @@ export enum Token {
   ValueIso8601Date = 'valueIso8601Date',
   ValueRelativeDate = 'valueRelativeDate',
   ValueDuration = 'valueDuration',
+  ValueSize = 'valueSize',
   ValuePercentage = 'valuePercentage',
   ValueBoolean = 'valueBoolean',
   ValueNumber = 'valueNumber',
@@ -85,10 +86,12 @@ export enum FilterType {
   SpecificDate = 'specificDate',
   RelativeDate = 'relativeDate',
   Duration = 'duration',
+  Size = 'size',
   Numeric = 'numeric',
   NumericIn = 'numericIn',
   Boolean = 'boolean',
   AggregateDuration = 'aggregateDuration',
+  AggregateSize = 'aggregateSize',
   AggregatePercentage = 'aggregatePercentage',
   AggregateNumeric = 'aggregateNumeric',
   AggregateDate = 'aggregateDate',
@@ -171,6 +174,12 @@ export const filterTypeConfig = {
     validValues: [Token.ValueDuration],
     canNegate: true,
   },
+  [FilterType.Size]: {
+    validKeys: [Token.KeySimple],
+    validOps: allOperators,
+    validValues: [Token.ValueSize],
+    canNegate: true,
+  },
   [FilterType.Numeric]: {
     validKeys: [Token.KeySimple],
     validOps: allOperators,
@@ -195,6 +204,12 @@ export const filterTypeConfig = {
     validValues: [Token.ValueDuration],
     canNegate: true,
   },
+  [FilterType.AggregateSize]: {
+    validKeys: [Token.KeyAggregate],
+    validOps: allOperators,
+    validValues: [Token.ValueSize],
+    canNegate: true,
+  },
   [FilterType.AggregateNumeric]: {
     validKeys: [Token.KeyAggregate],
     validOps: allOperators,
@@ -331,6 +346,7 @@ export class TokenConverter {
       this.config.durationKeys.has(key) ||
       isSpanOperationBreakdownField(key) ||
       measurementType(key) === 'duration',
+    isSize: (key: string) => this.config.sizeKeys.has(key),
   };
 
   /**
@@ -473,6 +489,17 @@ export class TokenConverter {
     unit,
   });
 
+  tokenValueSize = (
+    value: string,
+    unit: 'bit' | 'nb' | 'bytes' | 'kb' | 'mb' | 'gb' | 'tb' | 'pb' | 'eb' | 'zb' | 'yb'
+  ) => ({
+    ...this.defaultTokenFields,
+
+    type: Token.ValueSize as const,
+    value: Number(value),
+    unit,
+  });
+
   tokenValuePercentage = (value: string) => ({
     ...this.defaultTokenFields,
     type: Token.ValuePercentage as const,
@@ -534,7 +561,8 @@ export class TokenConverter {
     const keyName = getKeyName(key);
     const aggregateKey = key as ReturnType<TokenConverter['tokenKeyAggregate']>;
 
-    const {isNumeric, isDuration, isBoolean, isDate, isPercentage} = this.keyValidation;
+    const {isNumeric, isDuration, isBoolean, isDate, isPercentage, isSize} =
+      this.keyValidation;
 
     const checkAggregate = (check: (s: string) => boolean) =>
       aggregateKey.args?.args.some(arg => check(arg?.value?.value ?? ''));
@@ -547,6 +575,9 @@ export class TokenConverter {
       case FilterType.Duration:
         return isDuration(keyName);
 
+      case FilterType.Size:
+        return isSize(keyName);
+
       case FilterType.Boolean:
         return isBoolean(keyName);
 
@@ -644,6 +675,13 @@ export class TokenConverter {
       };
     }
 
+    if (this.keyValidation.isSize(keyName)) {
+      return {
+        reason: t('Invalid file size. Expected number followed by file size unit suffix'),
+        expectedType: [FilterType.Duration],
+      };
+    }
+
     if (this.keyValidation.isNumeric(keyName)) {
       return {
         reason: t(
@@ -751,6 +789,10 @@ export type SearchConfig = {
    * search values
    */
   percentageKeys: Set<string>;
+  /**
+   * Keys considered valid for size filter types
+   */
+  sizeKeys: Set<string>;
   /**
    * Text filter keys we allow to have operators
    */
@@ -794,6 +836,7 @@ const defaultConfig: SearchConfig = {
     'stack.in_app',
     'team_key_transaction',
   ]),
+  sizeKeys: new Set([]),
   allowBoolean: true,
 };
 
@@ -808,9 +851,23 @@ const options = {
  * Parse a search query into a ParseResult. Failing to parse the search query
  * will result in null.
  */
-export function parseSearch(query: string): ParseResult | null {
+export function parseSearch(
+  query: string,
+  additionalConfig?: Partial<SearchConfig>
+): ParseResult | null {
+  // Merge additionalConfig with defaultConfig
+  const config = additionalConfig
+    ? Object.keys(defaultConfig).reduce((configAccumulator, key) => {
+        configAccumulator[key] =
+          typeof defaultConfig[key] === 'object'
+            ? new Set([...defaultConfig[key], ...(additionalConfig[key] ?? [])])
+            : defaultConfig[key];
+        return configAccumulator;
+      }, {})
+    : defaultConfig;
+
   try {
-    return grammar.parse(query, options);
+    return grammar.parse(query, {...options, config});
   } catch (e) {
     // TODO(epurkhiser): Should we capture these errors somewhere?
   }

+ 54 - 0
static/app/components/smartSearchBar/index.spec.jsx

@@ -1447,4 +1447,58 @@ describe('SmartSearchBar', function () {
       expect(screen.getByLabelText('Use UTC')).not.toBeChecked();
     });
   });
+
+  describe('custom performance metric filters', () => {
+    it('raises Invalid file size when parsed filter unit is not a valid size unit', () => {
+      const props = {
+        organization,
+        location,
+        supportedTags,
+        customPerformanceMetrics: {
+          'measurements.custom.kibibyte': {
+            fieldType: 'size',
+          },
+        },
+      };
+
+      render(<SmartSearchBar {...props} />);
+
+      const textbox = screen.getByRole('textbox');
+      userEvent.click(textbox);
+      userEvent.type(textbox, 'measurements.custom.kibibyte:10ms ');
+      userEvent.keyboard('{arrowleft}');
+
+      expect(
+        screen.getByText(
+          'Invalid file size. Expected number followed by file size unit suffix'
+        )
+      ).toBeInTheDocument();
+    });
+
+    it('raises Invalid duration when parsed filter unit is not a valid duration unit', () => {
+      const props = {
+        organization,
+        location,
+        supportedTags,
+        customPerformanceMetrics: {
+          'measurements.custom.minute': {
+            fieldType: 'duration',
+          },
+        },
+      };
+
+      render(<SmartSearchBar {...props} />);
+
+      const textbox = screen.getByRole('textbox');
+      userEvent.click(textbox);
+      userEvent.type(textbox, 'measurements.custom.minute:10kb ');
+      userEvent.keyboard('{arrowleft}');
+
+      expect(
+        screen.getByText(
+          'Invalid duration. Expected number followed by duration unit suffix'
+        )
+      ).toBeInTheDocument();
+    });
+  });
 });

+ 62 - 13
static/app/components/smartSearchBar/index.tsx

@@ -17,6 +17,7 @@ import {
   FilterType,
   ParseResult,
   parseSearch,
+  SearchConfig,
   TermOperator,
   Token,
   TokenResult,
@@ -41,6 +42,7 @@ import {Organization, SavedSearchType, Tag, TagCollection, User} from 'sentry/ty
 import {defined} from 'sentry/utils';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import {callIfFunction} from 'sentry/utils/callIfFunction';
+import {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
 import {FieldValueType, getFieldDefinition} from 'sentry/utils/fields';
 import getDynamicComponent from 'sentry/utils/getDynamicComponent';
 import withApi from 'sentry/utils/withApi';
@@ -82,10 +84,41 @@ const ACTION_OVERFLOW_WIDTH = 400;
  */
 const ACTION_OVERFLOW_STEPS = 75;
 
-const makeQueryState = (query: string) => ({
-  query,
-  parsedQuery: parseSearch(query),
-});
+const getSearchConfigFromCustomPerformanceMetrics = (
+  customPerformanceMetrics?: CustomMeasurementCollection
+): Partial<SearchConfig> => {
+  const searchConfigMap: Record<string, string[]> = {
+    sizeKeys: [],
+    durationKeys: [],
+    percentageKeys: [],
+    numericKeys: [],
+  };
+  if (customPerformanceMetrics) {
+    Object.keys(customPerformanceMetrics).forEach(metricName => {
+      const {fieldType} = customPerformanceMetrics[metricName];
+      switch (fieldType) {
+        case 'size':
+          searchConfigMap.sizeKeys.push(metricName);
+          break;
+        case 'duration':
+          searchConfigMap.durationKeys.push(metricName);
+          break;
+        case 'percentage':
+          searchConfigMap.percentageKeys.push(metricName);
+          break;
+        default:
+          searchConfigMap.numericKeys.push(metricName);
+      }
+    });
+  }
+  const searchConfig = {
+    sizeKeys: new Set(searchConfigMap.sizeKeys),
+    durationKeys: new Set(searchConfigMap.durationKeys),
+    percentageKeys: new Set(searchConfigMap.percentageKeys),
+    numericKeys: new Set(searchConfigMap.numericKeys),
+  };
+  return searchConfig;
+};
 
 const generateOpAutocompleteGroup = (
   validOps: readonly TermOperator[],
@@ -148,6 +181,10 @@ type Props = WithRouterProps & {
   actionBarItems?: ActionBarItem[];
   className?: string;
 
+  /**
+   * Custom Performance Metrics for query string unit parsing
+   */
+  customPerformanceMetrics?: CustomMeasurementCollection;
   defaultQuery?: string;
   /**
    * Search items to display when there's no tag key. Is a tuple of search
@@ -344,12 +381,16 @@ class SmartSearchBar extends Component<Props, State> {
   }
 
   componentDidUpdate(prevProps: Props) {
-    const {query} = this.props;
-    const {query: lastQuery} = prevProps;
+    const {query, customPerformanceMetrics} = this.props;
+    const {query: lastQuery, customPerformanceMetrics: lastCustomPerformanceMetrics} =
+      prevProps;
 
-    if (query !== lastQuery && (defined(query) || defined(lastQuery))) {
+    if (
+      (query !== lastQuery && (defined(query) || defined(lastQuery))) ||
+      customPerformanceMetrics !== lastCustomPerformanceMetrics
+    ) {
       // eslint-disable-next-line react/no-did-update-set-state
-      this.setState(makeQueryState(addSpace(query ?? undefined)));
+      this.setState(this.makeQueryState(addSpace(query ?? undefined)));
     }
   }
 
@@ -363,6 +404,14 @@ class SmartSearchBar extends Component<Props, State> {
     return query !== null ? addSpace(query) : defaultQuery ?? '';
   }
 
+  makeQueryState(query: string) {
+    const additionalConfig: Partial<SearchConfig> =
+      getSearchConfigFromCustomPerformanceMetrics(this.props.customPerformanceMetrics);
+    return {
+      query,
+      parsedQuery: parseSearch(query, additionalConfig),
+    };
+  }
   /**
    * Ref to the search element itself
    */
@@ -640,7 +689,7 @@ class SmartSearchBar extends Component<Props, State> {
   };
 
   clearSearch = () => {
-    this.setState(makeQueryState(''), () => {
+    this.setState(this.makeQueryState(''), () => {
       this.close();
       callIfFunction(this.props.onSearch, this.state.query);
     });
@@ -670,7 +719,7 @@ class SmartSearchBar extends Component<Props, State> {
   onQueryChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
     const query = evt.target.value.replace('\n', '');
 
-    this.setState(makeQueryState(query), this.updateAutoCompleteItems);
+    this.setState(this.makeQueryState(query), this.updateAutoCompleteItems);
     callIfFunction(this.props.onChange, evt.target.value, evt);
   };
 
@@ -693,7 +742,7 @@ class SmartSearchBar extends Component<Props, State> {
     const mergedText = `${textBefore}${text}${textAfter}`;
 
     // Insert text manually
-    this.setState(makeQueryState(mergedText), () => {
+    this.setState(this.makeQueryState(mergedText), () => {
       this.updateAutoCompleteItems();
       // Update cursor position after updating text
       const newCursorPosition = cursorPosStart + text.length;
@@ -1542,7 +1591,7 @@ class SmartSearchBar extends Component<Props, State> {
   };
 
   updateQuery = (newQuery: string, cursorPosition?: number) =>
-    this.setState(makeQueryState(newQuery), () => {
+    this.setState(this.makeQueryState(newQuery), () => {
       // setting a new input value will lose focus; restore it
       if (this.searchInput.current) {
         this.searchInput.current.focus();
@@ -1659,7 +1708,7 @@ class SmartSearchBar extends Component<Props, State> {
         search_source: 'recent_search',
       });
 
-      this.setState(makeQueryState(replaceText), () => {
+      this.setState(this.makeQueryState(replaceText), () => {
         // Propagate onSearch and save to recent searches
         this.doSearch();
       });

+ 2 - 0
static/app/utils/customMeasurements/customMeasurements.tsx

@@ -1,7 +1,9 @@
 import {Measurement} from 'sentry/utils/measurements/measurements';
 
 export type CustomMeasurement = Measurement & {
+  fieldType: string;
   functions: string[];
+  unit: string;
 };
 
 export type CustomMeasurementCollection = Record<string, CustomMeasurement>;

+ 4 - 1
static/app/utils/customMeasurements/customMeasurementsProvider.tsx

@@ -3,6 +3,7 @@ import {Query} from 'history';
 
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
 import {Client} from 'sentry/api';
+import {getFieldTypeFromUnit} from 'sentry/components/events/eventCustomPerformanceMetrics';
 import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import {t} from 'sentry/locale';
 import {Organization, PageFilters} from 'sentry/types';
@@ -16,7 +17,7 @@ import {
 } from './customMeasurementsContext';
 
 type MeasurementsMetaResponse = {
-  [x: string]: {functions: string[]};
+  [x: string]: {functions: string[]; unit: string};
 };
 
 function fetchCustomMeasurements(
@@ -74,6 +75,8 @@ export function CustomMeasurementsProvider({
               key: customMeasurement,
               name: customMeasurement,
               functions: response[customMeasurement].functions,
+              unit: response[customMeasurement].unit,
+              fieldType: getFieldTypeFromUnit(response[customMeasurement].unit),
             };
             return acc;
           }, {});

+ 10 - 1
static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/eventsSearchBar.tsx

@@ -3,7 +3,10 @@ import styled from '@emotion/styled';
 import SearchBar, {SearchBarProps} from 'sentry/components/events/searchBar';
 import {MAX_QUERY_LENGTH} from 'sentry/constants';
 import {Organization, PageFilters, SavedSearchType} from 'sentry/types';
+import {generateAggregateFields} from 'sentry/utils/discover/fields';
+import useCustomMeasurements from 'sentry/utils/useCustomMeasurements';
 import {WidgetQuery} from 'sentry/views/dashboardsV2/types';
+import {eventViewFromWidget} from 'sentry/views/dashboardsV2/utils';
 import {
   MAX_MENU_HEIGHT,
   MAX_SEARCH_ITEMS,
@@ -22,7 +25,12 @@ export function EventsSearchBar({
   onClose,
   widgetQuery,
 }: Props) {
+  const {customMeasurements} = useCustomMeasurements();
   const projectIds = pageFilters.projects;
+  const eventView = eventViewFromWidget('', widgetQuery, pageFilters);
+  const fields = eventView.hasAggregateField()
+    ? generateAggregateFields(organization, eventView.fields)
+    : eventView.fields;
 
   return (
     <Search
@@ -30,13 +38,14 @@ export function EventsSearchBar({
       organization={organization}
       projectIds={projectIds}
       query={widgetQuery.conditions}
-      fields={[]}
+      fields={fields}
       onClose={onClose}
       useFormWrapper={false}
       maxQueryLength={MAX_QUERY_LENGTH}
       maxSearchItems={MAX_SEARCH_ITEMS}
       maxMenuHeight={MAX_MENU_HEIGHT}
       savedSearchType={SavedSearchType.EVENT}
+      customMeasurements={customMeasurements}
     />
   );
 }