import {useEffect, useMemo} from 'react'; import memoize from 'lodash/memoize'; import omit from 'lodash/omit'; import {fetchSpanFieldValues, fetchTagValues} from 'sentry/actionCreators/tags'; import type {SearchConfig} from 'sentry/components/searchSyntax/parser'; import {defaultConfig} from 'sentry/components/searchSyntax/parser'; import SmartSearchBar from 'sentry/components/smartSearchBar'; import type {TagCollection} from 'sentry/types/group'; import {SavedSearchType} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements'; import type {Field} from 'sentry/utils/discover/fields'; import {isAggregateField, isEquation, isMeasurement} from 'sentry/utils/discover/fields'; import { DiscoverDatasets, DiscoverDatasetsToDatasetMap, } from 'sentry/utils/discover/types'; import { DEVICE_CLASS_TAG_VALUES, FieldKey, FieldKind, isDeviceClass, } from 'sentry/utils/fields'; import Measurements from 'sentry/utils/measurements/measurements'; import useApi from 'sentry/utils/useApi'; import withTags from 'sentry/utils/withTags'; import {isCustomMeasurement} from 'sentry/views/dashboards/utils'; import { SEARCH_SPECIAL_CHARS_REGEXP, STATIC_FIELD_TAGS, STATIC_FIELD_TAGS_SET, STATIC_FIELD_TAGS_WITHOUT_ERROR_FIELDS, STATIC_FIELD_TAGS_WITHOUT_TRACING, STATIC_FIELD_TAGS_WITHOUT_TRANSACTION_FIELDS, STATIC_SEMVER_TAGS, STATIC_SPAN_TAGS, } from './searchBarFieldConstants'; const getFunctionTags = (fields: Readonly | undefined) => { if (!fields?.length) { return []; } return fields.reduce((acc, item) => { if ( !STATIC_FIELD_TAGS_SET.has(item.field) && !isEquation(item.field) && !isCustomMeasurement(item.field) ) { acc[item.field] = {key: item.field, name: item.field, kind: FieldKind.FUNCTION}; } return acc; }, {}); }; const getMeasurementTags = ( measurements: Parameters< React.ComponentProps['children'] >[0]['measurements'], customMeasurements: | Parameters['children']>[0]['measurements'] | undefined ) => { const measurementsWithKind = Object.keys(measurements).reduce((tags, key) => { tags[key] = { ...measurements[key], kind: FieldKind.MEASUREMENT, }; return tags; }, {}); if (!customMeasurements) { return measurementsWithKind; } return Object.keys(customMeasurements).reduce((tags, key) => { tags[key] = { ...customMeasurements[key], kind: FieldKind.MEASUREMENT, }; return tags; }, measurementsWithKind); }; const getSearchConfigFromCustomPerformanceMetrics = ( customPerformanceMetrics?: CustomMeasurementCollection ): Partial => { if (!customPerformanceMetrics) { return {}; } const searchConfigMap: Record = { sizeKeys: [...defaultConfig.sizeKeys], durationKeys: [...defaultConfig.durationKeys], percentageKeys: [...defaultConfig.percentageKeys], numericKeys: [...defaultConfig.numericKeys], }; 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; }; export const getHasTag = (tags: TagCollection) => ({ key: FieldKey.HAS, name: 'Has property', values: Object.keys(tags).sort((a, b) => { return a.toLowerCase().localeCompare(b.toLowerCase()); }), predefined: true, kind: FieldKind.FIELD, }); export type SearchBarProps = Omit, 'tags'> & { organization: Organization; tags: TagCollection; customMeasurements?: CustomMeasurementCollection; dataset?: DiscoverDatasets; fields?: Readonly; includeSessionTagsValues?: boolean; includeTransactions?: boolean; /** * Used to define the max height of the menu in px. */ maxMenuHeight?: number; maxSearchItems?: React.ComponentProps['maxSearchItems']; omitTags?: string[]; projectIds?: number[] | Readonly; savedSearchType?: SavedSearchType; supportedTags?: TagCollection | undefined; }; function SearchBar(props: SearchBarProps) { const { maxSearchItems, organization, tags, omitTags, fields, projectIds, includeSessionTagsValues, maxMenuHeight, customMeasurements, dataset, savedSearchType = SavedSearchType.EVENT, includeTransactions = true, } = props; const api = useApi(); const functionTags = useMemo(() => getFunctionTags(fields), [fields]); const tagsWithKind = useMemo(() => { return Object.keys(tags).reduce((acc, key) => { acc[key] = { ...tags[key], kind: FieldKind.TAG, }; return acc; }, {}); }, [tags]); useEffect(() => { // Clear memoized data on mount to make tests more consistent. getEventFieldValues.cache.clear?.(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectIds]); // Returns array of tag values that substring match `query`; invokes `callback` // with data when ready const getEventFieldValues = memoize( (tag, query, endpointParams): Promise => { const projectIdStrings = (projectIds as Readonly[])?.map(String); if (isAggregateField(tag.key) || isMeasurement(tag.key)) { // We can't really auto suggest values for aggregate fields // or measurements, so we simply don't return Promise.resolve([]); } // device.class is stored as "numbers" in snuba, but we want to suggest high, medium, // and low search filter values because discover maps device.class to these values. if (isDeviceClass(tag.key)) { return Promise.resolve(DEVICE_CLASS_TAG_VALUES); } const fetchPromise = dataset === DiscoverDatasets.SPANS_INDEXED ? fetchSpanFieldValues({ api, orgSlug: organization.slug, fieldKey: tag.key, search: query, projectIds: projectIdStrings, endpointParams, }) : fetchTagValues({ api, orgSlug: organization.slug, tagKey: tag.key, search: query, projectIds: projectIdStrings, endpointParams, // allows searching for tags on transactions as well includeTransactions: includeTransactions, // allows searching for tags on sessions as well includeSessions: includeSessionTagsValues, dataset: dataset ? DiscoverDatasetsToDatasetMap[dataset] : undefined, }); return fetchPromise.then( results => results.filter(({name}) => defined(name)).map(({name}) => name), () => { throw new Error('Unable to fetch event field values'); } ); }, ({key}, query) => `${key}-${query}` ); const getTagList = ( measurements: Parameters< React.ComponentProps['children'] >[0]['measurements'] ) => { const measurementsWithKind = getMeasurementTags(measurements, customMeasurements); const orgHasPerformanceView = organization.features.includes('performance-view'); const combinedTags: TagCollection = dataset === DiscoverDatasets.ERRORS ? Object.assign({}, functionTags, STATIC_FIELD_TAGS_WITHOUT_TRANSACTION_FIELDS) : dataset === DiscoverDatasets.TRANSACTIONS || dataset === DiscoverDatasets.METRICS_ENHANCED ? Object.assign( {}, measurementsWithKind, functionTags, STATIC_SPAN_TAGS, STATIC_FIELD_TAGS_WITHOUT_ERROR_FIELDS ) : orgHasPerformanceView ? Object.assign( {}, measurementsWithKind, functionTags, STATIC_SPAN_TAGS, STATIC_FIELD_TAGS ) : Object.assign({}, STATIC_FIELD_TAGS_WITHOUT_TRACING); Object.assign(combinedTags, tagsWithKind, STATIC_SEMVER_TAGS); combinedTags.has = getHasTag(combinedTags); const list = omitTags && omitTags.length > 0 ? omit(combinedTags, omitTags) : combinedTags; return list; }; const customPerformanceMetricsSearchConfig = useMemo( () => getSearchConfigFromCustomPerformanceMetrics(customMeasurements), [customMeasurements] ); return ( {({measurements}) => ( { // Prepare query string (e.g. strip special characters like negation operator) return query.replace(SEARCH_SPECIAL_CHARS_REGEXP, ''); }} maxSearchItems={maxSearchItems} excludedTags={[FieldKey.ENVIRONMENT, FieldKey.TOTAL_COUNT]} maxMenuHeight={maxMenuHeight ?? 300} {...customPerformanceMetricsSearchConfig} {...props} /> )} ); } export default withTags(SearchBar);