Skip to content

Commit 8091099

Browse files
authored
feat(issues): add feature flags to issue stream search suggestions (#83968)
1 parent c62d076 commit 8091099

File tree

7 files changed

+199
-22
lines changed

7 files changed

+199
-22
lines changed

Diff for: static/app/actionCreators/tags.tsx

+66-1
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import AlertStore from 'sentry/stores/alertStore';
88
import TagStore from 'sentry/stores/tagStore';
99
import type {PageFilters} from 'sentry/types/core';
1010
import type {Tag, TagValue} from 'sentry/types/group';
11+
import type {Organization} from 'sentry/types/organization';
1112
import {
1213
type ApiQueryKey,
1314
keepPreviousData,
1415
useApiQuery,
1516
type UseApiQueryOptions,
1617
} from 'sentry/utils/queryClient';
17-
import type {Dataset} from 'sentry/views/alerts/rules/metric/types';
18+
import {Dataset} from 'sentry/views/alerts/rules/metric/types';
1819

1920
const MAX_TAGS = 1000;
2021

@@ -212,6 +213,67 @@ export function fetchSpanFieldValues({
212213
});
213214
}
214215

216+
/**
217+
* Fetch feature flag values for an organization. This is done by querying ERRORS with useFlagsBackend=1.
218+
* This only returns feature flags and not tags.
219+
*
220+
* The `projectIds` argument can be used to subset projects.
221+
*/
222+
export function fetchFeatureFlagValues({
223+
api,
224+
organization,
225+
tagKey,
226+
endpointParams,
227+
projectIds,
228+
search,
229+
sort,
230+
}: {
231+
api: Client;
232+
organization: Organization;
233+
tagKey: string;
234+
endpointParams?: Query;
235+
projectIds?: string[];
236+
search?: string;
237+
sort?: '-last_seen' | '-count';
238+
}): Promise<TagValue[]> {
239+
if (!organization.features.includes('feature-flag-autocomplete')) {
240+
return Promise.resolve([]);
241+
}
242+
243+
const url = `/organizations/${organization.slug}/tags/${tagKey}/values/`;
244+
245+
const query: Query = {
246+
dataset: Dataset.ERRORS,
247+
useFlagsBackend: '1',
248+
};
249+
250+
if (search) {
251+
query.query = search;
252+
}
253+
if (projectIds) {
254+
query.project = projectIds;
255+
}
256+
if (endpointParams) {
257+
if (endpointParams.start) {
258+
query.start = endpointParams.start;
259+
}
260+
if (endpointParams.end) {
261+
query.end = endpointParams.end;
262+
}
263+
if (endpointParams.statsPeriod) {
264+
query.statsPeriod = endpointParams.statsPeriod;
265+
}
266+
}
267+
if (sort) {
268+
query.sort = sort;
269+
}
270+
271+
return api.requestPromise(url, {
272+
method: 'GET',
273+
query,
274+
});
275+
}
276+
215277
type FetchOrganizationTagsParams = {
216278
orgSlug: string;
217279
dataset?: Dataset;
@@ -222,13 +284,15 @@ type FetchOrganizationTagsParams = {
222284
start?: string;
223285
statsPeriod?: string | null;
224286
useCache?: boolean;
287+
useFlagsBackend?: boolean;
225288
};
226289

227290
export const makeFetchOrganizationTags = ({
228291
orgSlug,
229292
dataset,
230293
projectIds,
231294
useCache = true,
295+
useFlagsBackend = false,
232296
statsPeriod,
233297
start,
234298
end,
@@ -238,6 +302,7 @@ export const makeFetchOrganizationTags = ({
238302
query.dataset = dataset;
239303
query.useCache = useCache ? '1' : '0';
240304
query.project = projectIds;
305+
query.useFlagsBackend = useFlagsBackend ? '1' : '0';
241306

242307
if (statsPeriod) {
243308
query.statsPeriod = statsPeriod;

Diff for: static/app/components/searchQueryBuilder/tokens/filterKeyListBox/keyDescription.tsx

+17-5
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,24 @@ type KeyDescriptionProps = {
1313
size?: 'sm' | 'md';
1414
};
1515

16-
function ValueType({fieldDefinition}: {fieldDefinition: FieldDefinition | null}) {
16+
function ValueType({
17+
fieldDefinition,
18+
fieldKind,
19+
}: {
20+
fieldDefinition: FieldDefinition | null;
21+
fieldKind?: FieldKind;
22+
}) {
23+
const defaultType =
24+
fieldKind === FieldKind.FEATURE_FLAG ? FieldValueType.BOOLEAN : FieldValueType.STRING;
1725
if (!fieldDefinition) {
18-
return toTitleCase(FieldValueType.STRING);
26+
return toTitleCase(defaultType);
1927
}
2028

2129
if (fieldDefinition.parameterDependentValueType) {
2230
return t('Dynamic');
2331
}
2432

25-
return toTitleCase(fieldDefinition?.valueType ?? FieldValueType.STRING);
33+
return toTitleCase(fieldDefinition?.valueType ?? defaultType);
2634
}
2735

2836
export function KeyDescription({size = 'sm', tag}: KeyDescriptionProps) {
@@ -32,7 +40,11 @@ export function KeyDescription({size = 'sm', tag}: KeyDescriptionProps) {
3240

3341
const description =
3442
fieldDefinition?.desc ??
35-
(tag.kind === FieldKind.TAG ? t('A tag sent with one or more events') : null);
43+
(tag.kind === FieldKind.TAG
44+
? t('A tag sent with one or more events')
45+
: tag.kind === FieldKind.FEATURE_FLAG
46+
? t('A feature flag evaluated before an error event')
47+
: null);
3648

3749
return (
3850
<DescriptionWrapper size={size}>
@@ -44,7 +56,7 @@ export function KeyDescription({size = 'sm', tag}: KeyDescriptionProps) {
4456
<DescriptionList>
4557
<Term>{t('Type')}</Term>
4658
<Details>
47-
<ValueType fieldDefinition={fieldDefinition} />
59+
<ValueType fieldDefinition={fieldDefinition} fieldKind={tag.kind} />
4860
</Details>
4961
</DescriptionList>
5062
</DescriptionWrapper>

Diff for: static/app/utils/fields/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {CONDITIONS_ARGUMENTS, WEB_VITALS_QUALITY} from '../discover/types';
77

88
export enum FieldKind {
99
TAG = 'tag',
10+
FEATURE_FLAG = 'feature_flag',
1011
MEASUREMENT = 'measurement',
1112
BREAKDOWN = 'breakdown',
1213
FIELD = 'field',

Diff for: static/app/views/issueList/searchBar.tsx

+46-10
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import {useCallback, useMemo} from 'react';
22
import orderBy from 'lodash/orderBy';
33

4-
import {fetchTagValues} from 'sentry/actionCreators/tags';
4+
import {fetchFeatureFlagValues, fetchTagValues} from 'sentry/actionCreators/tags';
55
import {
66
SearchQueryBuilder,
77
type SearchQueryBuilderProps,
88
} from 'sentry/components/searchQueryBuilder';
99
import type {FilterKeySection} from 'sentry/components/searchQueryBuilder/types';
1010
import {t} from 'sentry/locale';
11-
import {SavedSearchType, type Tag, type TagCollection} from 'sentry/types/group';
11+
import {
12+
SavedSearchType,
13+
type Tag,
14+
type TagCollection,
15+
type TagValue,
16+
} from 'sentry/types/group';
1217
import type {Organization} from 'sentry/types/organization';
1318
import {getUtcDateString} from 'sentry/utils/dates';
1419
import {FieldKind} from 'sentry/utils/fields';
@@ -19,25 +24,37 @@ import {mergeAndSortTagValues} from 'sentry/views/issueDetails/utils';
1924
import {makeGetIssueTagValues} from 'sentry/views/issueList/utils/getIssueTagValues';
2025
import {useIssueListFilterKeys} from 'sentry/views/issueList/utils/useIssueListFilterKeys';
2126

22-
const getFilterKeySections = (tags: TagCollection): FilterKeySection[] => {
27+
const getFilterKeySections = (
28+
tags: TagCollection,
29+
organization: Organization
30+
): FilterKeySection[] => {
2331
const allTags: Tag[] = Object.values(tags).filter(
2432
tag => !EXCLUDED_TAGS.includes(tag.key)
2533
);
34+
2635
const issueFields = orderBy(
2736
allTags.filter(tag => tag.kind === FieldKind.ISSUE_FIELD),
2837
['key']
2938
).map(tag => tag.key);
39+
3040
const eventFields = orderBy(
3141
allTags.filter(tag => tag.kind === FieldKind.EVENT_FIELD),
3242
['key']
3343
).map(tag => tag.key);
44+
3445
const eventTags = orderBy(
3546
allTags.filter(tag => tag.kind === FieldKind.TAG),
3647
['totalValues', 'key'],
3748
['desc', 'asc']
3849
).map(tag => tag.key);
3950

40-
return [
51+
const eventFeatureFlags = orderBy(
52+
allTags.filter(tag => tag.kind === FieldKind.FEATURE_FLAG),
53+
['totalValues', 'key'],
54+
['desc', 'asc']
55+
).map(tag => tag.key);
56+
57+
const sections = [
4158
{
4259
value: FieldKind.ISSUE_FIELD,
4360
label: t('Issues'),
@@ -54,6 +71,16 @@ const getFilterKeySections = (tags: TagCollection): FilterKeySection[] => {
5471
children: eventTags,
5572
},
5673
];
74+
75+
if (organization.features.includes('feature-flag-autocomplete')) {
76+
sections.push({
77+
value: FieldKind.FEATURE_FLAG,
78+
label: t('Flags'), // Keeping this short so the tabs stay on 1 line.
79+
children: eventFeatureFlags,
80+
});
81+
}
82+
83+
return sections;
5784
};
5885

5986
interface Props extends Partial<SearchQueryBuilderProps> {
@@ -72,9 +99,9 @@ function IssueListSearchBar({
7299
const {selection: pageFilters} = usePageFilters();
73100
const filterKeys = useIssueListFilterKeys();
74101

102+
// Fetches the unique values seen for a tag key and query string. Result is sorted by count.
75103
const tagValueLoader = useCallback(
76-
async (key: string, search: string) => {
77-
const orgSlug = organization.slug;
104+
async (key: string, search: string): Promise<TagValue[]> => {
78105
const projectIds = pageFilters.projects.map(id => id.toString());
79106
const endpointParams = {
80107
start: pageFilters.datetime.start
@@ -88,14 +115,22 @@ function IssueListSearchBar({
88115

89116
const fetchTagValuesPayload = {
90117
api,
91-
orgSlug,
118+
orgSlug: organization.slug,
92119
tagKey: key,
93120
search,
94121
projectIds,
95122
endpointParams,
96123
sort: '-count' as const,
97124
};
98125

126+
// For now feature flags are treated like tags, but the api query is slightly different.
127+
if (filterKeys[key]?.kind === FieldKind.FEATURE_FLAG) {
128+
return await fetchFeatureFlagValues({
129+
...fetchTagValuesPayload,
130+
organization,
131+
});
132+
}
133+
99134
const [eventsDatasetValues, issuePlatformDatasetValues] = await Promise.all([
100135
fetchTagValues({
101136
...fetchTagValuesPayload,
@@ -115,7 +150,8 @@ function IssueListSearchBar({
115150
},
116151
[
117152
api,
118-
organization.slug,
153+
filterKeys,
154+
organization,
119155
pageFilters.datetime.end,
120156
pageFilters.datetime.period,
121157
pageFilters.datetime.start,
@@ -129,8 +165,8 @@ function IssueListSearchBar({
129165
);
130166

131167
const filterKeySections = useMemo(() => {
132-
return getFilterKeySections(filterKeys);
133-
}, [filterKeys]);
168+
return getFilterKeySections(filterKeys, organization);
169+
}, [filterKeys, organization]);
134170

135171
return (
136172
<SearchQueryBuilder

Diff for: static/app/views/issueList/utils/getIssueTagValues.tsx

+10-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {Tag, TagValue} from 'sentry/types/group';
2-
import {DEVICE_CLASS_TAG_VALUES, isDeviceClass} from 'sentry/utils/fields';
2+
import {DEVICE_CLASS_TAG_VALUES, FieldKind, isDeviceClass} from 'sentry/utils/fields';
33

44
/**
55
* Returns a function that fetches tag values for a given tag key. Useful as
@@ -11,12 +11,19 @@ export function makeGetIssueTagValues(
1111
tagValueLoader: (key: string, search: string) => Promise<TagValue[]>
1212
) {
1313
return async (tag: Tag, query: string): Promise<string[]> => {
14+
// Strip quotes for feature flags, which may be used to escape special characters in the search bar.
15+
const charsToStrip = '"';
16+
const key =
17+
tag.kind === FieldKind.FEATURE_FLAG
18+
? tag.key.replace(new RegExp(`^[${charsToStrip}]+|[${charsToStrip}]+$`, 'g'), '')
19+
: tag.key;
20+
1421
// device.class is stored as "numbers" in snuba, but we want to suggest high, medium,
1522
// and low search filter values because discover maps device.class to these values.
16-
if (isDeviceClass(tag.key)) {
23+
if (isDeviceClass(key)) {
1724
return DEVICE_CLASS_TAG_VALUES;
1825
}
19-
const values = await tagValueLoader(tag.key, query);
26+
const values = await tagValueLoader(key, query);
2027
return values.map(({value}) => {
2128
// Truncate results to 5000 characters to avoid exceeding the max url query length
2229
// The message attribute for example can be 8192 characters.

0 commit comments

Comments
 (0)