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(issues): add feature flags to issue stream search suggestions #83968

Merged
merged 15 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion static/app/actionCreators/tags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import AlertStore from 'sentry/stores/alertStore';
import TagStore from 'sentry/stores/tagStore';
import type {PageFilters} from 'sentry/types/core';
import type {Tag, TagValue} from 'sentry/types/group';
import type {Organization} from 'sentry/types/organization';
import {
type ApiQueryKey,
keepPreviousData,
useApiQuery,
type UseApiQueryOptions,
} from 'sentry/utils/queryClient';
import type {Dataset} from 'sentry/views/alerts/rules/metric/types';
import {Dataset} from 'sentry/views/alerts/rules/metric/types';

const MAX_TAGS = 1000;

Expand Down Expand Up @@ -212,6 +213,67 @@ export function fetchSpanFieldValues({
});
}

/**
* Fetch feature flag values for an organization. This is done by querying ERRORS with useFlagsBackend=1.
* This only returns feature flags and not tags.
*
* The `projectIds` argument can be used to subset projects.
*/
export function fetchFeatureFlagValues({
api,
organization,
tagKey,
endpointParams,
projectIds,
search,
sort,
}: {
api: Client;
organization: Organization;
tagKey: string;
endpointParams?: Query;
projectIds?: string[];
search?: string;
sort?: '-last_seen' | '-count';
}): Promise<TagValue[]> {
if (!organization.features.includes('feature-flag-autocomplete')) {
return Promise.resolve([]);
}

const url = `/organizations/${organization.slug}/tags/${tagKey}/values/`;

const query: Query = {
dataset: Dataset.ERRORS,
useFlagsBackend: '1',
};

if (search) {
query.query = search;
}
if (projectIds) {
query.project = projectIds;
}
if (endpointParams) {
if (endpointParams.start) {
query.start = endpointParams.start;
}
if (endpointParams.end) {
query.end = endpointParams.end;
}
if (endpointParams.statsPeriod) {
query.statsPeriod = endpointParams.statsPeriod;
}
}
if (sort) {
query.sort = sort;
}

return api.requestPromise(url, {
method: 'GET',
query,
});
}

type FetchOrganizationTagsParams = {
orgSlug: string;
dataset?: Dataset;
Expand All @@ -222,13 +284,15 @@ type FetchOrganizationTagsParams = {
start?: string;
statsPeriod?: string | null;
useCache?: boolean;
useFlagsBackend?: boolean;
};

export const makeFetchOrganizationTags = ({
orgSlug,
dataset,
projectIds,
useCache = true,
useFlagsBackend = false,
statsPeriod,
start,
end,
Expand All @@ -238,6 +302,7 @@ export const makeFetchOrganizationTags = ({
query.dataset = dataset;
query.useCache = useCache ? '1' : '0';
query.project = projectIds;
query.useFlagsBackend = useFlagsBackend ? '1' : '0';

if (statsPeriod) {
query.statsPeriod = statsPeriod;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,24 @@ type KeyDescriptionProps = {
size?: 'sm' | 'md';
};

function ValueType({fieldDefinition}: {fieldDefinition: FieldDefinition | null}) {
function ValueType({
fieldDefinition,
fieldKind,
}: {
fieldDefinition: FieldDefinition | null;
fieldKind?: FieldKind;
}) {
const defaultType =
fieldKind === FieldKind.FEATURE_FLAG ? FieldValueType.BOOLEAN : FieldValueType.STRING;
if (!fieldDefinition) {
return toTitleCase(FieldValueType.STRING);
return toTitleCase(defaultType);
}

if (fieldDefinition.parameterDependentValueType) {
return t('Dynamic');
}

return toTitleCase(fieldDefinition?.valueType ?? FieldValueType.STRING);
return toTitleCase(fieldDefinition?.valueType ?? defaultType);
}

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

const description =
fieldDefinition?.desc ??
(tag.kind === FieldKind.TAG ? t('A tag sent with one or more events') : null);
(tag.kind === FieldKind.TAG
? t('A tag sent with one or more events')
: tag.kind === FieldKind.FEATURE_FLAG
? t('A feature flag evaluated before an error event')
: null);

return (
<DescriptionWrapper size={size}>
Expand All @@ -44,7 +56,7 @@ export function KeyDescription({size = 'sm', tag}: KeyDescriptionProps) {
<DescriptionList>
<Term>{t('Type')}</Term>
<Details>
<ValueType fieldDefinition={fieldDefinition} />
<ValueType fieldDefinition={fieldDefinition} fieldKind={tag.kind} />
</Details>
</DescriptionList>
</DescriptionWrapper>
Expand Down
1 change: 1 addition & 0 deletions static/app/utils/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {CONDITIONS_ARGUMENTS, WEB_VITALS_QUALITY} from '../discover/types';

export enum FieldKind {
TAG = 'tag',
FEATURE_FLAG = 'feature_flag',
MEASUREMENT = 'measurement',
BREAKDOWN = 'breakdown',
FIELD = 'field',
Expand Down
56 changes: 46 additions & 10 deletions static/app/views/issueList/searchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import {useCallback, useMemo} from 'react';
import orderBy from 'lodash/orderBy';

import {fetchTagValues} from 'sentry/actionCreators/tags';
import {fetchFeatureFlagValues, fetchTagValues} from 'sentry/actionCreators/tags';
import {
SearchQueryBuilder,
type SearchQueryBuilderProps,
} from 'sentry/components/searchQueryBuilder';
import type {FilterKeySection} from 'sentry/components/searchQueryBuilder/types';
import {t} from 'sentry/locale';
import {SavedSearchType, type Tag, type TagCollection} from 'sentry/types/group';
import {
SavedSearchType,
type Tag,
type TagCollection,
type TagValue,
} from 'sentry/types/group';
import type {Organization} from 'sentry/types/organization';
import {getUtcDateString} from 'sentry/utils/dates';
import {FieldKind} from 'sentry/utils/fields';
Expand All @@ -19,25 +24,37 @@ import {mergeAndSortTagValues} from 'sentry/views/issueDetails/utils';
import {makeGetIssueTagValues} from 'sentry/views/issueList/utils/getIssueTagValues';
import {useIssueListFilterKeys} from 'sentry/views/issueList/utils/useIssueListFilterKeys';

const getFilterKeySections = (tags: TagCollection): FilterKeySection[] => {
const getFilterKeySections = (
tags: TagCollection,
organization: Organization
): FilterKeySection[] => {
const allTags: Tag[] = Object.values(tags).filter(
tag => !EXCLUDED_TAGS.includes(tag.key)
);

const issueFields = orderBy(
allTags.filter(tag => tag.kind === FieldKind.ISSUE_FIELD),
['key']
).map(tag => tag.key);

const eventFields = orderBy(
allTags.filter(tag => tag.kind === FieldKind.EVENT_FIELD),
['key']
).map(tag => tag.key);

const eventTags = orderBy(
allTags.filter(tag => tag.kind === FieldKind.TAG),
['totalValues', 'key'],
['desc', 'asc']
).map(tag => tag.key);

return [
const eventFeatureFlags = orderBy(
allTags.filter(tag => tag.kind === FieldKind.FEATURE_FLAG),
['totalValues', 'key'],
['desc', 'asc']
).map(tag => tag.key);

const sections = [
{
value: FieldKind.ISSUE_FIELD,
label: t('Issues'),
Expand All @@ -54,6 +71,16 @@ const getFilterKeySections = (tags: TagCollection): FilterKeySection[] => {
children: eventTags,
},
];

if (organization.features.includes('feature-flag-autocomplete')) {
sections.push({
value: FieldKind.FEATURE_FLAG,
label: t('Flags'), // Keeping this short so the tabs stay on 1 line.
children: eventFeatureFlags,
});
}

return sections;
};

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

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

const fetchTagValuesPayload = {
api,
orgSlug,
orgSlug: organization.slug,
tagKey: key,
search,
projectIds,
endpointParams,
sort: '-count' as const,
};

// For now feature flags are treated like tags, but the api query is slightly different.
if (filterKeys[key]?.kind === FieldKind.FEATURE_FLAG) {
return await fetchFeatureFlagValues({
...fetchTagValuesPayload,
organization,
});
}

const [eventsDatasetValues, issuePlatformDatasetValues] = await Promise.all([
fetchTagValues({
...fetchTagValuesPayload,
Expand All @@ -115,7 +150,8 @@ function IssueListSearchBar({
},
[
api,
organization.slug,
filterKeys,
organization,
pageFilters.datetime.end,
pageFilters.datetime.period,
pageFilters.datetime.start,
Expand All @@ -129,8 +165,8 @@ function IssueListSearchBar({
);

const filterKeySections = useMemo(() => {
return getFilterKeySections(filterKeys);
}, [filterKeys]);
return getFilterKeySections(filterKeys, organization);
}, [filterKeys, organization]);

return (
<SearchQueryBuilder
Expand Down
13 changes: 10 additions & 3 deletions static/app/views/issueList/utils/getIssueTagValues.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {Tag, TagValue} from 'sentry/types/group';
import {DEVICE_CLASS_TAG_VALUES, isDeviceClass} from 'sentry/utils/fields';
import {DEVICE_CLASS_TAG_VALUES, FieldKind, isDeviceClass} from 'sentry/utils/fields';

/**
* Returns a function that fetches tag values for a given tag key. Useful as
Expand All @@ -11,12 +11,19 @@ export function makeGetIssueTagValues(
tagValueLoader: (key: string, search: string) => Promise<TagValue[]>
) {
return async (tag: Tag, query: string): Promise<string[]> => {
// Strip quotes for feature flags, which may be used to escape special characters in the search bar.
const charsToStrip = '"';
const key =
tag.kind === FieldKind.FEATURE_FLAG
Copy link
Member

Choose a reason for hiding this comment

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

This is questionable to me because tags could have colons too but its fine we're not breaking something. We could always extend this later.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sounds good, @malwilley specifically requested this just to be safe, so the changes are FF only

Copy link
Member

@malwilley malwilley Feb 4, 2025

Choose a reason for hiding this comment

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

I asked for this to be added because there's a risk that this new stripping logic might interfere with existing tags. Agree that the final solution should be generic

? tag.key.replace(new RegExp(`^[${charsToStrip}]+|[${charsToStrip}]+$`, 'g'), '')
: tag.key;

// device.class is stored as "numbers" in snuba, but we want to suggest high, medium,
// and low search filter values because discover maps device.class to these values.
if (isDeviceClass(tag.key)) {
if (isDeviceClass(key)) {
return DEVICE_CLASS_TAG_VALUES;
}
const values = await tagValueLoader(tag.key, query);
const values = await tagValueLoader(key, query);
return values.map(({value}) => {
// Truncate results to 5000 characters to avoid exceeding the max url query length
// The message attribute for example can be 8192 characters.
Expand Down
Loading
Loading