Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7cafad7
feat(search): Add async filter key validation
nsdeschenes Mar 20, 2026
4b9bead
feat(explore): Validate trace item search attributes
nsdeschenes Mar 20, 2026
a2823e0
Resolve knip issues
nsdeschenes Mar 20, 2026
361abd7
Fix up import issues
nsdeschenes Mar 20, 2026
1eee06f
test(search): Add mock for trace-items attributes validate endpoint
nsdeschenes Mar 20, 2026
f3f59ee
ref(search): Replace validateFilterKeys with invalidFilterKeys prop
nsdeschenes Mar 26, 2026
e507b86
ref(explore): Move filter key validation to parent via useMutation
nsdeschenes Mar 26, 2026
024969c
Remove sort
nsdeschenes Mar 26, 2026
30c132e
ref(explore): Skip validation API call when query has no filter keys
nsdeschenes Mar 26, 2026
71b076e
ref(explore): Also skip validation when parsedQuery is null
nsdeschenes Mar 26, 2026
7f18d3b
ref(explore): Skip validation when no filter keys are found
nsdeschenes Mar 26, 2026
65f08ca
feat(explore): Validate filter keys on initial render
nsdeschenes Mar 26, 2026
3d56779
ref(explore): Extract extractFilterKeys helper from validation hook
nsdeschenes Mar 26, 2026
8abcb48
ref(explore): Migrate filter key validation from useMutation to useAp…
nsdeschenes Mar 26, 2026
a06fd6c
test(explore): Add tests for useAsyncAttributeValidation hook
nsdeschenes Mar 26, 2026
d7659ff
ref(explore): Rename useAsyncAttributeValidation to useAttributeValid…
nsdeschenes Mar 26, 2026
063ee79
ref(explore): Move attribute validation to utils and inline into comp…
nsdeschenes Mar 27, 2026
985ede1
ref(explore): Extract useAttributeValidation hook and move to hooks d…
nsdeschenes Mar 27, 2026
0819552
ref(explore): Make validateAttributesQueryOptions private, simplify t…
nsdeschenes Mar 27, 2026
70558e4
fix(explore): Fix initial query validation running more than once
nsdeschenes Mar 27, 2026
ff53f17
fix(explore): Cancel stale validation queries before fetching
nsdeschenes Mar 27, 2026
809a4b6
fix(explore): Re-validate query when page filters change
nsdeschenes Mar 27, 2026
a053e45
ref(explore): Accept selection in validateQuery, deduplicate via cache
nsdeschenes Mar 27, 2026
e062a47
Fix up linting issues
nsdeschenes Mar 27, 2026
f62ed1b
fix(explore): Scope validation query cancellation by itemType
nsdeschenes Mar 27, 2026
7508035
ref(explore): Replace imperative validateQuery with declarative useQuery
nsdeschenes Mar 27, 2026
61c2b5f
Remove unused export
nsdeschenes Mar 27, 2026
74cdd0f
fix(explore): Recurse into logic groups when extracting filter keys
nsdeschenes Mar 27, 2026
4aded1a
fix(explore): Update validation tests to match declarative useQuery r…
nsdeschenes Mar 27, 2026
aeb47e2
feat(explore): Gate search key validation behind feature flag
nsdeschenes Mar 30, 2026
05180af
fix(explore): Fix attribute validation enabled condition and test fix…
nsdeschenes Mar 30, 2026
c5cec3e
fix(explore): Move itemType from request body to query param in attri…
nsdeschenes Mar 30, 2026
0ca1e62
fix(explore): Guard against missing attributes in validation response
nsdeschenes Mar 30, 2026
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
9 changes: 9 additions & 0 deletions static/app/components/searchQueryBuilder/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ interface SearchQueryBuilderContextData {
getSuggestedFilterKey: (key: string) => string | null;
getTagValues: GetTagValues;
handleSearch: (query: string) => void;
invalidFilterKeys: string[];
parseQuery: (query: string) => ParseResult | null;
parsedQuery: ParseResult | null;
query: string;
Expand Down Expand Up @@ -128,6 +129,7 @@ export function SearchQueryBuilderProvider({
filterKeyAliases,
caseInsensitive,
onCaseInsensitiveClick,
invalidFilterKeys,
}: SearchQueryBuilderProps & {children: React.ReactNode}) {
const wrapperRef = useRef<HTMLDivElement>(null);
const actionBarRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -198,6 +200,11 @@ export function SearchQueryBuilderProvider({

const parsedQuery = useMemo(() => parseQuery(state.query), [parseQuery, state.query]);

const stableInvalidFilterKeys = useMemo(
() => invalidFilterKeys ?? [],
[invalidFilterKeys]
);

const previousQuery = usePrevious(state.query);
const firstRender = useRef(true);
useEffect(() => {
Expand Down Expand Up @@ -242,6 +249,7 @@ export function SearchQueryBuilderProvider({
return {
...state,
aiSearchBadgeType,
invalidFilterKeys: stableInvalidFilterKeys,
disabled,
disallowFreeText: Boolean(disallowFreeText),
disallowLogicalOperators: Boolean(disallowLogicalOperators),
Expand Down Expand Up @@ -300,6 +308,7 @@ export function SearchQueryBuilderProvider({
getTagKeys,
getTagValues,
handleSearch,
stableInvalidFilterKeys,
matchKeySuggestions,
onCaseInsensitiveClick,
parseQuery,
Expand Down
8 changes: 7 additions & 1 deletion static/app/components/searchQueryBuilder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ export interface SearchQueryBuilderProps {
* and display the returned keys alongside any static filterKeys.
*/
getTagKeys?: GetTagKeys;
/**
* List of filter key strings that are invalid.
* When provided, tokens with matching keys will display a warning state.
* The parent component is responsible for fetching and determining invalid keys.
*/
invalidFilterKeys?: string[];

/**
* Allows for customization of the invalid token messages.
Expand Down Expand Up @@ -174,11 +180,11 @@ export interface SearchQueryBuilderProps {
*/
portalTarget?: HTMLElement | null;
queryInterface?: QueryInterfaceType;

/**
* If provided, saves and displays recent searches of the given type.
*/
recentSearches?: SavedSearchType;

/**
* When set, provided keys will override default raw search capabilities, while
* replacing it with options that include the provided keys, and the user's input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export function SearchQueryBuilderFilter({item, state, token}: SearchQueryTokenP

const isFocused = item.key === state.selectionManager.focusedKey;

const {dispatch} = useSearchQueryBuilder();
const {dispatch, invalidFilterKeys} = useSearchQueryBuilder();
const {rowProps, gridCellProps} = useQueryBuilderGridItem(item, state, ref);

const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
Expand All @@ -227,12 +227,19 @@ export function SearchQueryBuilderFilter({item, state, token}: SearchQueryTokenP

const tokenHasError = 'invalid' in token && defined(token.invalid);
const tokenHasWarning = 'warning' in token && defined(token.warning);
const isInvalidFilterKey = invalidFilterKeys.includes(getKeyName(token.key));

return (
<FilterWrapper
aria-label={token.text}
aria-invalid={tokenHasError}
state={tokenHasWarning ? 'warning' : tokenHasError ? 'invalid' : 'valid'}
state={
tokenHasWarning || isInvalidFilterKey
? 'warning'
: tokenHasError
? 'invalid'
: 'valid'
}
Comment thread
nsdeschenes marked this conversation as resolved.
ref={ref}
{...modifiedRowProps}
>
Expand All @@ -243,6 +250,11 @@ export function SearchQueryBuilderFilter({item, state, token}: SearchQueryTokenP
columnCount={4}
containerDisplayMode="grid"
forceVisible={filterMenuOpen ? false : undefined}
warning={
isInvalidFilterKey
? t('Invalid key. "%s" is not a supported search key.', getKeyName(token.key))
: undefined
}
>
{token.filter === FilterType.IS || token.filter === FilterType.HAS ? null : (
<BaseGridCell {...gridCellProps}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,23 @@ interface InvalidTokenTooltipProps extends Omit<TooltipProps, 'title'> {
item: Node<ParseResultToken>;
state: ListState<ParseResultToken>;
token: ParseResultToken;
warning?: ReactNode;
}

function getForceVisible({
isFocused,
isInvalid,
hasTokenWarning,
hasWarning,
forceVisible,
}: {
hasTokenWarning: boolean;
hasWarning: boolean;
isFocused: boolean;
isInvalid: boolean;
forceVisible?: boolean;
}) {
if (!isInvalid && !hasWarning) {
if (!isInvalid && !hasTokenWarning && !hasWarning) {
return false;
}

Expand All @@ -44,11 +47,13 @@ export function InvalidTokenTooltip({
state,
item,
forceVisible,
warning,
...tooltipProps
}: InvalidTokenTooltipProps) {
const invalid = 'invalid' in token ? token.invalid : null;
const warning = 'warning' in token ? token.warning : null;
const tokenWarning = 'warning' in token ? token.warning : null;

const hasTokenWarning = Boolean(tokenWarning);
const hasWarning = Boolean(warning);
const isInvalid = Boolean(invalid);
const isFocused =
Expand All @@ -57,9 +62,15 @@ export function InvalidTokenTooltip({
return (
<Tooltip
skipWrapper
forceVisible={getForceVisible({isFocused, isInvalid, hasWarning, forceVisible})}
forceVisible={getForceVisible({
isFocused,
isInvalid,
hasTokenWarning,
hasWarning,
forceVisible,
})}
position="bottom"
title={warning ?? invalid?.reason ?? t('This token is invalid')}
title={warning ?? tokenWarning ?? invalid?.reason ?? t('This token is invalid')}
{...tooltipProps}
>
{children}
Expand All @@ -68,7 +79,6 @@ export function InvalidTokenTooltip({
}

type GridInvalidTokenTooltipProps = InvalidTokenTooltipProps & {
children: React.ReactNode;
columnCount: number;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ describe('SpansSearchBar', () => {
mockSpanTagValues({type: 'number', tagKey: 'span.op', mockedValues: []});

mockSpanTags({type: 'boolean', mockedTags: []});

MockApiClient.addMockResponse({
url: `/organizations/org-slug/trace-items/attributes/validate/`,
method: 'POST',
body: {attributes: {}},
});
});

it('renders the initial query conditions', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ describe('NewMetricDetectorForm', () => {
url: '/organizations/org-slug/trace-items/attributes/',
body: [],
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/trace-items/attributes/validate/',
method: 'POST',
body: {attributes: {}},
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/recent-searches/',
body: [],
Expand Down
5 changes: 5 additions & 0 deletions static/app/views/detectors/edit.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ describe('DetectorEdit', () => {
url: `/organizations/${organization.slug}/trace-items/attributes/`,
body: [],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/trace-items/attributes/validate/`,
method: 'POST',
body: {attributes: {}},
});

MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/workflows/`,
Expand Down
5 changes: 5 additions & 0 deletions static/app/views/detectors/new-setting.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ describe('DetectorEdit', () => {
url: `/organizations/${organization.slug}/trace-items/attributes/`,
body: [],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/trace-items/attributes/validate/`,
method: 'POST',
body: {attributes: {}},
});

MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/workflows/`,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {OrganizationFixture} from 'sentry-fixture/organization';

import {renderHookWithProviders} from 'sentry-test/reactTestingLibrary';
import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';

import {PageFiltersStore} from 'sentry/components/pageFilters/store';
import {FieldKind} from 'sentry/utils/fields';
Expand All @@ -22,7 +22,7 @@ const defaultInitialProps: TraceItemSearchQueryBuilderProps = {
searchSource: 'test',
};
const organization = OrganizationFixture({
features: [],
features: ['search-query-attribute-validation'],
});

describe('useTraceItemSearchQueryBuilderProps', () => {
Expand Down Expand Up @@ -247,4 +247,88 @@ describe('useTraceItemSearchQueryBuilderProps', () => {
'log.flag',
]);
});

it('calls validateQuery when filter keys change', async () => {
const validateMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/trace-items/attributes/validate/',
method: 'POST',
body: {attributes: {'span.op': {valid: true}}},
});

renderHookWithProviders(useTraceItemSearchQueryBuilderProps, {
initialProps: {
...defaultInitialProps,
initialQuery: 'span.op:db',
},
organization,
});

await waitFor(() => {
expect(validateMock).toHaveBeenCalledTimes(1);
});
});

it('does not call validateQuery when only filter values change', async () => {
const validateMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/trace-items/attributes/validate/',
method: 'POST',
body: {attributes: {'span.op': {valid: true}}},
});

const {rerender} = renderHookWithProviders(useTraceItemSearchQueryBuilderProps, {
initialProps: {
...defaultInitialProps,
initialQuery: 'span.op:db',
},
organization,
});

await waitFor(() => {
expect(validateMock).toHaveBeenCalledTimes(1);
});

rerender({
...defaultInitialProps,
initialQuery: 'span.op:web',
});

// Still only 1 call — value changed but keys didn't (React Query deduplicates)
await waitFor(() => {
expect(validateMock).toHaveBeenCalledTimes(1);
});
});

it('calls validateQuery when a new filter key is added', async () => {
const validateMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/trace-items/attributes/validate/',
method: 'POST',
body: {
attributes: {
'span.op': {valid: true},
'other.key': {valid: true},
},
},
});

const {rerender} = renderHookWithProviders(useTraceItemSearchQueryBuilderProps, {
initialProps: {
...defaultInitialProps,
initialQuery: 'span.op:db',
},
organization,
});

await waitFor(() => {
expect(validateMock).toHaveBeenCalledTimes(1);
});

rerender({
...defaultInitialProps,
initialQuery: 'span.op:db other.key:val',
});

await waitFor(() => {
expect(validateMock).toHaveBeenCalledTimes(2);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {useMemo} from 'react';

import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
import type {SpanSearchQueryBuilderProps} from 'sentry/components/performance/spanSearchQueryBuilder';
import {
SearchQueryBuilder,
Expand All @@ -11,6 +12,7 @@ import {SavedSearchType, type TagCollection} from 'sentry/types/group';
import type {AggregationKey} from 'sentry/utils/fields';
import {FieldKind, getFieldDefinition} from 'sentry/utils/fields';
import {getHasTag} from 'sentry/utils/tag';
import {useAttributeValidation} from 'sentry/views/explore/hooks/useAttributeValidation';
import {useExploreSuggestedAttribute} from 'sentry/views/explore/hooks/useExploreSuggestedAttribute';
import {useGetTraceItemAttributeTagKeys} from 'sentry/views/explore/hooks/useGetTraceItemAttributeTagKeys';
import {useGetTraceItemAttributeValues} from 'sentry/views/explore/hooks/useGetTraceItemAttributeValues';
Expand Down Expand Up @@ -105,6 +107,19 @@ export function useTraceItemSearchQueryBuilderProps({
disableRecentSearches,
}: TraceItemSearchQueryBuilderProps) {
const placeholderText = itemTypeToDefaultPlaceholder(itemType);

const {selection} = usePageFilters();
const effectiveProjects = projects ?? selection.projects;
const validationSelection = useMemo(
() => ({datetime: selection.datetime, projects: effectiveProjects}),
[selection.datetime, effectiveProjects]
);

const {invalidFilterKeys} = useAttributeValidation(
itemType,
initialQuery ?? '',
validationSelection
);
Comment thread
nsdeschenes marked this conversation as resolved.
const functionTags = useFunctionTags(itemType, supportedAggregates);
const filterKeySections = useFilterKeySections(itemType, stringAttributes);
const filterTags = useFilterTags({
Expand Down Expand Up @@ -166,6 +181,7 @@ export function useTraceItemSearchQueryBuilderProps({
},
caseInsensitive,
onCaseInsensitiveClick,
invalidFilterKeys,
}),
[
booleanSecondaryAliases,
Expand All @@ -180,6 +196,7 @@ export function useTraceItemSearchQueryBuilderProps({
getTagKeys,
getTraceItemAttributeValues,
initialQuery,
invalidFilterKeys,
itemType,
matchKeySuggestions,
namespace,
Expand Down
Loading
Loading