From faeb0eea34450ad97a872a902f0eabb688064803 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 27 Aug 2024 15:31:34 -0700 Subject: [PATCH] feat(replay): implement search bar key sections and fetch tags from IP instead of discover (#76276) Follow up to https://github.com/getsentry/sentry/pull/75552. Implements sections to make the typeahead easier to navigate. [Screen recording](https://drive.google.com/file/d/1HxV87JgZWaHHuA74EEdIOdbL1sDxNTzd/view?usp=sharing) "Suggested" and "Click Fields" combined is = to our documented search properties: https://docs.sentry.io/concepts/search/searchable-properties/session-replay. We always want to show these. "Tags" are tags found in the Issue Platform dataset, sorted by times seen in the dataset (popularity). Previously we were fetching these from the TagStore which queries Discover. IP has a narrower set of more relevant tags. Some more context is at https://github.com/getsentry/sentry/pull/75878 --- static/app/utils/fields/index.ts | 7 + .../views/replays/list/replaySearchBar.tsx | 152 ++++++++++++------ 2 files changed, 113 insertions(+), 46 deletions(-) diff --git a/static/app/utils/fields/index.ts b/static/app/utils/fields/index.ts index e5f6c8f785b984..cdf06726d46b09 100644 --- a/static/app/utils/fields/index.ts +++ b/static/app/utils/fields/index.ts @@ -1754,6 +1754,7 @@ export enum ReplayFieldKey { OS_VERSION = 'os.version', SEEN_BY_ME = 'seen_by_me', URLS = 'urls', + URL = 'url', VIEWED_BY_ME = 'viewed_by_me', } @@ -1807,6 +1808,7 @@ export const REPLAY_FIELDS = [ ReplayFieldKey.SEEN_BY_ME, FieldKey.TRACE, ReplayFieldKey.URLS, + ReplayFieldKey.URL, FieldKey.USER_EMAIL, FieldKey.USER_ID, FieldKey.USER_IP, @@ -1880,6 +1882,11 @@ const REPLAY_FIELD_DEFINITIONS: Record = { kind: FieldKind.FIELD, valueType: FieldValueType.BOOLEAN, }, + [ReplayFieldKey.URL]: { + desc: t('A url visited within the replay'), + kind: FieldKind.FIELD, + valueType: FieldValueType.STRING, + }, [ReplayFieldKey.URLS]: { desc: t('List of urls that were visited within the replay'), kind: FieldKind.FIELD, diff --git a/static/app/views/replays/list/replaySearchBar.tsx b/static/app/views/replays/list/replaySearchBar.tsx index ac6d22f9c801ae..a16a9bfcf426f4 100644 --- a/static/app/views/replays/list/replaySearchBar.tsx +++ b/static/app/views/replays/list/replaySearchBar.tsx @@ -1,7 +1,9 @@ -import {useCallback, useEffect, useMemo} from 'react'; +import {useCallback, useMemo} from 'react'; +import orderBy from 'lodash/orderBy'; -import {fetchTagValues, loadOrganizationTags} from 'sentry/actionCreators/tags'; +import {fetchTagValues, useFetchOrganizationTags} from 'sentry/actionCreators/tags'; import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder'; +import type {FilterKeySection} from 'sentry/components/searchQueryBuilder/types'; import SmartSearchBar from 'sentry/components/smartSearchBar'; import {MAX_QUERY_LENGTH, NEGATION_OPERATOR, SEARCH_WILDCARD} from 'sentry/constants'; import {t} from 'sentry/locale'; @@ -20,7 +22,7 @@ import { } from 'sentry/utils/fields'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import useApi from 'sentry/utils/useApi'; -import useTags from 'sentry/utils/useTags'; +import {Dataset} from 'sentry/views/alerts/rules/metric/types'; const SEARCH_SPECIAL_CHARS_REGEXP = new RegExp( `^${NEGATION_OPERATOR}|\\${SEARCH_WILDCARD}`, @@ -50,33 +52,74 @@ function fieldDefinitionsToTagCollection(fieldKeys: string[]): TagCollection { const REPLAY_FIELDS_AS_TAGS = fieldDefinitionsToTagCollection(REPLAY_FIELDS); const REPLAY_CLICK_FIELDS_AS_TAGS = fieldDefinitionsToTagCollection(REPLAY_CLICK_FIELDS); +/** + * Excluded from the display but still valid search queries. browser.name, + * device.name, etc are effectively the same and included from REPLAY_FIELDS. + * Displaying these would be redundant and confusing. + */ +const EXCLUDED_TAGS = ['browser', 'device', 'os', 'user']; /** - * Merges a list of supported tags and replay search fields into one collection. + * Merges a list of supported tags and replay search properties + * (https://docs.sentry.io/concepts/search/searchable-properties/session-replay/) + * into one collection. */ -function getReplaySearchTags(supportedTags: TagCollection): TagCollection { - const allTags = { +function getReplayFilterKeys(supportedTags: TagCollection): TagCollection { + return { ...REPLAY_FIELDS_AS_TAGS, ...REPLAY_CLICK_FIELDS_AS_TAGS, ...Object.fromEntries( - Object.keys(supportedTags).map(key => [ - key, - { - ...supportedTags[key], - kind: getReplayFieldDefinition(key)?.kind ?? FieldKind.TAG, - }, - ]) + Object.keys(supportedTags) + .filter(key => !EXCLUDED_TAGS.includes(key)) + .map(key => [ + key, + { + ...supportedTags[key], + kind: getReplayFieldDefinition(key)?.kind ?? FieldKind.TAG, + }, + ]) ), }; - - // A hack used to "sort" the dictionary for SearchQueryBuilder. - // Technically dicts are unordered but this works in dev. - // To guarantee ordering, we need to implement filterKeySections. - const keys = Object.keys(allTags); - keys.sort(); - return Object.fromEntries(keys.map(key => [key, allTags[key]])); } +const getFilterKeySections = ( + tags: TagCollection, + organization: Organization +): FilterKeySection[] => { + if (!organization.features.includes('search-query-builder-replays')) { + return []; + } + + const customTags: Tag[] = Object.values(tags).filter( + tag => + !EXCLUDED_TAGS.includes(tag.key) && + !REPLAY_FIELDS.map(String).includes(tag.key) && + !REPLAY_CLICK_FIELDS.map(String).includes(tag.key) + ); + + const orderedTagKeys = orderBy(customTags, ['totalValues', 'key'], ['desc', 'asc']).map( + tag => tag.key + ); + + return [ + { + value: 'replay_field', + label: t('Suggested'), + children: Object.keys(REPLAY_FIELDS_AS_TAGS), + }, + { + value: 'replay_click_field', + label: t('Click Fields'), + children: Object.keys(REPLAY_CLICK_FIELDS_AS_TAGS), + }, + { + value: FieldKind.TAG, + label: t('Tags'), + children: orderedTagKeys, + }, + ]; +}; + type Props = React.ComponentProps & { organization: Organization; pageFilters: PageFilters; @@ -86,15 +129,43 @@ function ReplaySearchBar(props: Props) { const {organization, pageFilters} = props; const api = useApi(); const projectIds = pageFilters.projects; - const organizationTags = useTags(); - useEffect(() => { - loadOrganizationTags(api, organization.slug, pageFilters); - }, [api, organization.slug, pageFilters]); - - const replayTags = useMemo( - () => getReplaySearchTags(organizationTags), - [organizationTags] + const start = pageFilters.datetime.start + ? getUtcDateString(pageFilters.datetime.start) + : undefined; + const end = pageFilters.datetime.end + ? getUtcDateString(pageFilters.datetime.end) + : undefined; + const statsPeriod = pageFilters.datetime.period; + + const tagQuery = useFetchOrganizationTags( + { + orgSlug: organization.slug, + projectIds: projectIds.map(String), + dataset: Dataset.ISSUE_PLATFORM, + useCache: true, + enabled: true, + keepPreviousData: false, + start: start, + end: end, + statsPeriod: statsPeriod, + }, + {} + ); + const issuePlatformTags: TagCollection = useMemo(() => { + return (tagQuery.data ?? []).reduce((acc, tag) => { + acc[tag.key] = {...tag, kind: FieldKind.TAG}; + return acc; + }, {}); + }, [tagQuery]); + // tagQuery.isLoading and tagQuery.isError are not used + + const filterKeys = useMemo( + () => getReplayFilterKeys(issuePlatformTags), + [issuePlatformTags] ); + const filterKeySections = useMemo(() => { + return getFilterKeySections(issuePlatformTags, organization); + }, [issuePlatformTags, organization]); const getTagValues = useCallback( (tag: Tag, searchQuery: string): Promise => { @@ -105,13 +176,9 @@ function ReplaySearchBar(props: Props) { } const endpointParams = { - start: pageFilters.datetime.start - ? getUtcDateString(pageFilters.datetime.start) - : undefined, - end: pageFilters.datetime.end - ? getUtcDateString(pageFilters.datetime.end) - : undefined, - statsPeriod: pageFilters.datetime.period, + start: start, + end: end, + statsPeriod: statsPeriod, }; return fetchTagValues({ @@ -129,14 +196,7 @@ function ReplaySearchBar(props: Props) { } ); }, - [ - api, - organization.slug, - projectIds, - pageFilters.datetime.end, - pageFilters.datetime.period, - pageFilters.datetime.start, - ] + [api, organization.slug, projectIds, start, end, statsPeriod] ); const onSearch = props.onSearch; @@ -164,8 +224,8 @@ function ReplaySearchBar(props: Props) { disallowLogicalOperators={undefined} // ^ className={props.className} fieldDefinitionGetter={getReplayFieldDefinition} - filterKeys={replayTags} - filterKeySections={undefined} + filterKeys={filterKeys} + filterKeySections={filterKeySections} getTagValues={getTagValues} initialQuery={props.query ?? props.defaultQuery ?? ''} onSearch={onSearchWithAnalytics} @@ -183,7 +243,7 @@ function ReplaySearchBar(props: Props) {