Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(replay): implement search bar key sections and fetch tags from IP instead of discover #76276

Merged
merged 9 commits into from
Aug 27, 2024
7 changes: 7 additions & 0 deletions static/app/utils/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1398,6 +1398,7 @@ export enum ReplayFieldKey {
OS_VERSION = 'os.version',
SEEN_BY_ME = 'seen_by_me',
URLS = 'urls',
URL = 'url',
VIEWED_BY_ME = 'viewed_by_me',
}

Expand Down Expand Up @@ -1451,6 +1452,7 @@ export const REPLAY_FIELDS = [
ReplayFieldKey.SEEN_BY_ME,
FieldKey.TRACE,
ReplayFieldKey.URLS,
ReplayFieldKey.URL,
FieldKey.USER_EMAIL,
FieldKey.USER_ID,
FieldKey.USER_IP,
Expand Down Expand Up @@ -1524,6 +1526,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,
Expand Down
152 changes: 106 additions & 46 deletions static/app/views/replays/list/replaySearchBar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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}`,
Expand Down Expand Up @@ -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'];

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are these excluded?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already have browser.name, device.name, etc. They're effectively the same

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe user is actually user.id, but either way they're confusing

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ohh gotcha, could we add a comment to clarify that? just for posterity

/**
* 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;
Expand All @@ -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[]> => {
Expand All @@ -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({
Expand All @@ -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;
Expand Down Expand Up @@ -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}
Expand All @@ -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')
Expand Down
Loading