Browse Source

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
Andrew Liu 6 months ago
parent
commit
faeb0eea34
2 changed files with 113 additions and 46 deletions
  1. 7 0
      static/app/utils/fields/index.ts
  2. 106 46
      static/app/views/replays/list/replaySearchBar.tsx

+ 7 - 0
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<ReplayFieldKey, FieldDefinition> = {
     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,

+ 106 - 46
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<typeof SmartSearchBar> & {
   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<TagCollection>((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<string[]> => {
@@ -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) {
     <SmartSearchBar
       {...props}
       onGetTagValues={getTagValues}
-      supportedTags={replayTags}
+      supportedTags={filterKeys}
       placeholder={
         props.placeholder ??
         t('Search for users, duration, clicked elements, count_errors, and more')