Browse Source

feat(replays): Create a Replay specific SearchBox for the index page (#40683)

Fixes #37968
Ryan Albrecht 2 years ago
parent
commit
1447ab68c0

+ 132 - 4
static/app/utils/fields/index.ts

@@ -69,6 +69,7 @@ export enum FieldKey {
   RELEASE_PACKAGE = 'release.package',
   RELEASE_STAGE = 'release.stage',
   RELEASE_VERSION = 'release.version',
+  REPLAY_ID = 'replayId',
   SDK_NAME = 'sdk.name',
   SDK_VERSION = 'sdk.version',
   STACK_ABS_PATH = 'stack.abs_path',
@@ -433,13 +434,13 @@ export const SPAN_OP_FIELDS: Record<SpanOpBreakdown, FieldDefinition> = {
   },
 };
 
-type AllFieldKeys =
+type AllEventFieldKeys =
   | keyof typeof AGGREGATION_FIELDS
   | keyof typeof MEASUREMENT_FIELDS
   | keyof typeof SPAN_OP_FIELDS
   | FieldKey;
 
-const FIELD_DEFINITIONS: Record<AllFieldKeys, FieldDefinition> = {
+const EVENT_FIELD_DEFINITIONS: Record<AllEventFieldKeys, FieldDefinition> = {
   ...AGGREGATION_FIELDS,
   ...MEASUREMENT_FIELDS,
   ...SPAN_OP_FIELDS,
@@ -732,6 +733,11 @@ const FIELD_DEFINITIONS: Record<AllFieldKeys, FieldDefinition> = {
     kind: FieldKind.FIELD,
     valueType: FieldValueType.STRING,
   },
+  [FieldKey.REPLAY_ID]: {
+    desc: t('The ID of an associated Session Replay'),
+    kind: FieldKind.TAG,
+    valueType: FieldValueType.STRING,
+  },
   [FieldKey.SDK_NAME]: {
     desc: t('Name of the platform that sent the event'),
     kind: FieldKind.FIELD,
@@ -1041,6 +1047,128 @@ export const DISCOVER_FIELDS = [
   SpanOpBreakdown.SpansUi,
 ];
 
-export const getFieldDefinition = (key: string): FieldDefinition | null => {
-  return FIELD_DEFINITIONS[key] ?? null;
+enum ReplayFieldKey {
+  BROWSER_NAME = 'browser.name',
+  BROWSER_VERSION = 'browser.version',
+  COUNT_ERRORS = 'countErrors',
+  COUNT_SEGMENTS = 'countSegments',
+  // COUNT_URLS = 'countUrls',
+  DEVICE_MODEL = 'device.model',
+  DURATION = 'duration',
+  // ERROR_IDS = 'errorIds',
+  // LONGEST_TRANSACTION = 'longestTransaction',
+  OS_NAME = 'os.name',
+  OS_VERSION = 'os.version',
+  RELEASES = 'releases',
+  // TRACE_IDS = 'traceIds',
+  URLS = 'urls',
+  USER_IP_ADDRESS = 'user.ipAddress',
+  USER_NAME = 'user.name',
+}
+
+export const REPLAY_FIELDS = [
+  ReplayFieldKey.BROWSER_NAME,
+  ReplayFieldKey.BROWSER_VERSION,
+  ReplayFieldKey.COUNT_ERRORS,
+  ReplayFieldKey.COUNT_SEGMENTS,
+  FieldKey.DEVICE_BRAND,
+  FieldKey.DEVICE_FAMILY,
+  ReplayFieldKey.DEVICE_MODEL,
+  FieldKey.DEVICE_NAME,
+  FieldKey.DIST,
+  ReplayFieldKey.DURATION,
+  FieldKey.ID,
+  ReplayFieldKey.OS_NAME,
+  ReplayFieldKey.OS_VERSION,
+  FieldKey.PLATFORM,
+  ReplayFieldKey.RELEASES,
+  FieldKey.SDK_NAME,
+  FieldKey.SDK_VERSION,
+  ReplayFieldKey.URLS,
+  FieldKey.USER_EMAIL,
+  FieldKey.USER_ID,
+  ReplayFieldKey.USER_IP_ADDRESS,
+  ReplayFieldKey.USER_NAME,
+];
+
+const REPLAY_FIELD_DEFINITIONS: Record<ReplayFieldKey, FieldDefinition> = {
+  [ReplayFieldKey.BROWSER_NAME]: {
+    desc: t('Name of the brower'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.STRING,
+  },
+  [ReplayFieldKey.BROWSER_VERSION]: {
+    desc: t('Version number of the Browser'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.STRING,
+  },
+  [ReplayFieldKey.COUNT_ERRORS]: {
+    desc: t('Number of errors in the replay'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.INTEGER,
+  },
+  [ReplayFieldKey.COUNT_SEGMENTS]: {
+    desc: t('Number of segments in the replay'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.INTEGER,
+  },
+  [ReplayFieldKey.DEVICE_MODEL]: {
+    desc: t('Model of device'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.STRING,
+  },
+  [ReplayFieldKey.DURATION]: {
+    desc: t('Duration of the replay, in seconds'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.DURATION,
+  },
+  [ReplayFieldKey.OS_NAME]: {
+    desc: t('Name of the Operating System'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.STRING,
+  },
+  [ReplayFieldKey.OS_VERSION]: {
+    desc: t('Version number of the Operating System'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.STRING,
+  },
+  [ReplayFieldKey.RELEASES]: {
+    desc: t('Releases this Replay spans across'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.STRING,
+  },
+  [ReplayFieldKey.URLS]: {
+    desc: t('List of urls that were visited within the Replay'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.STRING,
+  },
+  [ReplayFieldKey.USER_IP_ADDRESS]: {
+    desc: t('IP Address of the user'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.STRING,
+  },
+  [ReplayFieldKey.USER_NAME]: {
+    desc: t('Name of the user'),
+    kind: FieldKind.FIELD,
+    valueType: FieldValueType.STRING,
+  },
+};
+
+export const getFieldDefinition = (
+  key: string,
+  type: 'event' | 'replay' = 'event'
+): FieldDefinition | null => {
+  switch (type) {
+    case 'replay':
+      if (key in REPLAY_FIELD_DEFINITIONS) {
+        return REPLAY_FIELD_DEFINITIONS[key];
+      }
+      if (REPLAY_FIELDS.includes(key as FieldKey)) {
+        return EVENT_FIELD_DEFINITIONS[key];
+      }
+      return null;
+    case 'event':
+    default:
+      return EVENT_FIELD_DEFINITIONS[key] ?? null;
+  }
 };

+ 6 - 4
static/app/utils/replays/replayDataUtils.tsx

@@ -41,16 +41,18 @@ export function mapResponseToReplayRecord(apiResponse: any): ReplayRecord {
   // Add missing tags to the response
   const unorderedTags: ReplayRecord['tags'] = {
     ...apiResponse.tags,
-    ...(apiResponse.os?.name ? {'os.name': [apiResponse.os.name]} : {}),
-    ...(apiResponse.os?.version ? {'os.version': [apiResponse.os.version]} : {}),
     ...(apiResponse.browser?.name ? {'browser.name': [apiResponse.browser.name]} : {}),
     ...(apiResponse.browser?.version
       ? {'browser.version': [apiResponse.browser.version]}
       : {}),
-    ...(apiResponse.device?.name ? {'device.name': [apiResponse.device.name]} : {}),
-    ...(apiResponse.device?.family ? {'device.family': [apiResponse.device.family]} : {}),
     ...(apiResponse.device?.brand ? {'device.brand': [apiResponse.device.brand]} : {}),
+    ...(apiResponse.device?.family ? {'device.family': [apiResponse.device.family]} : {}),
     ...(apiResponse.device?.model ? {'device.model': [apiResponse.device.model]} : {}),
+    ...(apiResponse.device?.name ? {'device.name': [apiResponse.device.name]} : {}),
+    ...(apiResponse.platform ? {platform: [apiResponse.platform]} : {}),
+    ...(apiResponse.releases ? {releases: [apiResponse.releases]} : {}),
+    ...(apiResponse.os?.name ? {'os.name': [apiResponse.os.name]} : {}),
+    ...(apiResponse.os?.version ? {'os.version': [apiResponse.os.version]} : {}),
     ...(apiResponse.sdk?.name ? {'sdk.name': [apiResponse.sdk.name]} : {}),
     ...(apiResponse.sdk?.version ? {'sdk.version': [apiResponse.sdk.version]} : {}),
     ...(apiResponse.user?.ip_address

+ 25 - 14
static/app/views/replays/filters.tsx

@@ -1,21 +1,24 @@
+import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 
 import DatePageFilter from 'sentry/components/datePageFilter';
 import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
-import SearchBar from 'sentry/components/events/searchBar';
 import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
 import ProjectPageFilter from 'sentry/components/projectPageFilter';
-import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {Organization} from 'sentry/types';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import ReplaySearchBar from 'sentry/views/replays/replaySearchBar';
 
-type Props = {
-  handleSearchQuery: (query: string) => void;
-  organization: Organization;
-  query: string;
-};
+function ReplaysFilters() {
+  const {selection} = usePageFilters();
+  const location = useLocation();
+  const organization = useOrganization();
+
+  const {pathname, query} = location;
 
-function ReplaysFilters({organization, handleSearchQuery, query}: Props) {
   return (
     <FilterContainer>
       <PageFilterBar condensed>
@@ -23,12 +26,20 @@ function ReplaysFilters({organization, handleSearchQuery, query}: Props) {
         <EnvironmentPageFilter resetParamsOnChange={['cursor']} />
         <DatePageFilter alignDropdown="left" resetParamsOnChange={['cursor']} />
       </PageFilterBar>
-      <SearchBar
+      <ReplaySearchBar
         organization={organization}
-        defaultQuery=""
-        query={query}
-        placeholder={t('Search')}
-        onSearch={handleSearchQuery}
+        pageFilters={selection}
+        defaultQuery={decodeScalar(location.query?.query, '')}
+        onSearch={searchQuery => {
+          browserHistory.push({
+            pathname,
+            query: {
+              ...query,
+              cursor: undefined,
+              query: searchQuery.trim(),
+            },
+          });
+        }}
       />
     </FilterContainer>
   );

+ 59 - 0
static/app/views/replays/replaySearchBar.tsx

@@ -0,0 +1,59 @@
+import SmartSearchBar from 'sentry/components/smartSearchBar';
+import {MAX_QUERY_LENGTH, NEGATION_OPERATOR, SEARCH_WILDCARD} from 'sentry/constants';
+import {t} from 'sentry/locale';
+import {Organization, PageFilters, SavedSearchType, TagCollection} from 'sentry/types';
+import {getFieldDefinition, REPLAY_FIELDS} from 'sentry/utils/fields';
+
+const SEARCH_SPECIAL_CHARS_REGEXP = new RegExp(
+  `^${NEGATION_OPERATOR}|\\${SEARCH_WILDCARD}`,
+  'g'
+);
+
+/**
+ * Prepare query string (e.g. strip special characters like negation operator)
+ */
+function prepareQuery(searchQuery: string) {
+  return searchQuery.replace(SEARCH_SPECIAL_CHARS_REGEXP, '');
+}
+const getReplayFieldDefinition = (key: string) => getFieldDefinition(key, 'replay');
+
+function fieldDefinitionsToTagCollection(fieldKeys: string[]): TagCollection {
+  return Object.fromEntries(
+    fieldKeys.map(key => [
+      key,
+      {
+        key,
+        name: key,
+        kind: getReplayFieldDefinition(key)?.kind,
+      },
+    ])
+  );
+}
+
+const REPLAY_TAGS = fieldDefinitionsToTagCollection(REPLAY_FIELDS);
+
+type Props = React.ComponentProps<typeof SmartSearchBar> & {
+  organization: Organization;
+  pageFilters: PageFilters;
+};
+
+function SearchBar(props: Props) {
+  return (
+    <SmartSearchBar
+      {...props}
+      onGetTagValues={undefined}
+      supportedTags={REPLAY_TAGS}
+      placeholder={t('Search for users, duration, countErrors, and more')}
+      prepareQuery={prepareQuery}
+      maxQueryLength={MAX_QUERY_LENGTH}
+      searchSource="replay_index"
+      savedSearchType={SavedSearchType.REPLAY}
+      maxMenuHeight={500}
+      hasRecentSearches
+      highlightUnsupportedTags
+      fieldDefinitionGetter={getReplayFieldDefinition}
+    />
+  );
+}
+
+export default SearchBar;

+ 1 - 15
static/app/views/replays/replays.tsx

@@ -46,7 +46,6 @@ function Replays({location}: Props) {
     );
   }, [location]);
 
-  const {pathname, query} = location;
   const {replays, pageLinks, isFetching, fetchError} = useReplayList({
     organization,
     eventView,
@@ -61,20 +60,7 @@ function Replays({location}: Props) {
       </StyledPageHeader>
       <PageFiltersContainer>
         <StyledPageContent>
-          <ReplaysFilters
-            query={query.query || ''}
-            organization={organization}
-            handleSearchQuery={searchQuery => {
-              browserHistory.push({
-                pathname,
-                query: {
-                  ...query,
-                  cursor: undefined,
-                  query: searchQuery.trim(),
-                },
-              });
-            }}
-          />
+          <ReplaysFilters />
           <ReplayTable
             isFetching={isFetching}
             fetchError={fetchError}