Skip to content

Commit c94c7d2

Browse files
authored
feat(explore): Auto add columns when searching (#91476)
When searching on attributes, try to auto add columns to the table. This happens in the following cases: - adding a negation filter - adding an array filter - adding a wildcard filter Closes EXP-235
1 parent d6c6348 commit c94c7d2

File tree

4 files changed

+283
-5
lines changed

4 files changed

+283
-5
lines changed

static/app/views/explore/logs/logsTab.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent
1616
import {DiscoverDatasets} from 'sentry/utils/discover/types';
1717
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
1818
import usePageFilters from 'sentry/utils/usePageFilters';
19+
import usePrevious from 'sentry/utils/usePrevious';
1920
import SchemaHintsList, {
2021
SchemaHintsSection,
2122
} from 'sentry/views/explore/components/schemaHints/schemaHintsList';
@@ -43,6 +44,7 @@ import {usePersistentLogsPageParameters} from 'sentry/views/explore/logs/usePers
4344
import {ColumnEditorModal} from 'sentry/views/explore/tables/columnEditorModal';
4445
import {TraceItemDataset} from 'sentry/views/explore/types';
4546
import type {PickableDays} from 'sentry/views/explore/utils';
47+
import {findSuggestedColumns} from 'sentry/views/explore/utils';
4648
import {useSortedTimeSeries} from 'sentry/views/insights/common/queries/useSortedTimeSeries';
4749

4850
type LogsTabProps = PickableDays;
@@ -60,6 +62,8 @@ export function LogsTabContent({
6062
const pageFilters = usePageFilters();
6163
usePersistentLogsPageParameters(); // persist the columns you chose last time
6264

65+
const oldLogsSearch = usePrevious(logsSearch);
66+
6367
const columnEditorButtonRef = useRef<HTMLButtonElement>(null);
6468
// always use the smallest interval possible (the most bars)
6569
const interval = getIntervalOptionsForPageFilter(pageFilters.selection.datetime)?.[0]
@@ -88,9 +92,18 @@ export function LogsTabContent({
8892
initialQuery: logsSearch.formatString(),
8993
searchSource: 'ourlogs',
9094
onSearch: (newQuery: string) => {
91-
const mutableQuery = new MutableSearch(newQuery);
95+
const newSearch = new MutableSearch(newQuery);
96+
const suggestedColumns = findSuggestedColumns(newSearch, oldLogsSearch, {
97+
numberAttributes,
98+
stringAttributes,
99+
});
100+
101+
const existingFields = new Set(fields);
102+
const newColumns = suggestedColumns.filter(col => !existingFields.has(col));
103+
92104
setLogsPageParams({
93-
search: mutableQuery,
105+
search: newSearch,
106+
fields: newColumns.length ? [...fields, ...newColumns] : undefined,
94107
});
95108
},
96109
numberAttributes,

static/app/views/explore/spans/spansTab.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,17 @@ import {
2929
} from 'sentry/utils/fields';
3030
import {chonkStyled} from 'sentry/utils/theme/theme.chonk';
3131
import {withChonk} from 'sentry/utils/theme/withChonk';
32+
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
3233
import useOrganization from 'sentry/utils/useOrganization';
3334
import usePageFilters from 'sentry/utils/usePageFilters';
35+
import usePrevious from 'sentry/utils/usePrevious';
3436
import {ExploreCharts} from 'sentry/views/explore/charts';
3537
import SchemaHintsList, {
3638
SchemaHintsSection,
3739
} from 'sentry/views/explore/components/schemaHints/schemaHintsList';
3840
import {SchemaHintsSources} from 'sentry/views/explore/components/schemaHints/schemaHintsUtils';
3941
import {
42+
useExploreFields,
4043
useExploreId,
4144
useExploreMode,
4245
useExploreQuery,
@@ -57,7 +60,11 @@ import {useVisitQuery} from 'sentry/views/explore/hooks/useVisitQuery';
5760
import {ExploreSpansTour, ExploreSpansTourContext} from 'sentry/views/explore/spans/tour';
5861
import {ExploreTables} from 'sentry/views/explore/tables';
5962
import {ExploreToolbar} from 'sentry/views/explore/toolbar';
60-
import {combineConfidenceForSeries, type PickableDays} from 'sentry/views/explore/utils';
63+
import {
64+
combineConfidenceForSeries,
65+
findSuggestedColumns,
66+
type PickableDays,
67+
} from 'sentry/views/explore/utils';
6168
import {Onboarding} from 'sentry/views/performance/onboarding';
6269

6370
// eslint-disable-next-line no-restricted-imports
@@ -134,18 +141,32 @@ interface SpanTabSearchSectionProps {
134141

135142
function SpanTabSearchSection({datePageFilterProps}: SpanTabSearchSectionProps) {
136143
const mode = useExploreMode();
144+
const fields = useExploreFields();
137145
const query = useExploreQuery();
138146
const setExplorePageParams = useSetExplorePageParams();
139147

140148
const {tags: numberTags, isLoading: numberTagsLoading} = useSpanTags('number');
141149
const {tags: stringTags, isLoading: stringTagsLoading} = useSpanTags('string');
142150

151+
const search = useMemo(() => new MutableSearch(query), [query]);
152+
const oldSearch = usePrevious(search);
153+
143154
const eapSpanSearchQueryBuilderProps = useMemo(
144155
() => ({
145156
initialQuery: query,
146157
onSearch: (newQuery: string) => {
158+
const newSearch = new MutableSearch(newQuery);
159+
const suggestedColumns = findSuggestedColumns(newSearch, oldSearch, {
160+
numberAttributes: numberTags,
161+
stringAttributes: stringTags,
162+
});
163+
164+
const existingFields = new Set(fields);
165+
const newColumns = suggestedColumns.filter(col => !existingFields.has(col));
166+
147167
setExplorePageParams({
148168
query: newQuery,
169+
fields: newColumns.length ? [...fields, ...newColumns] : undefined,
149170
});
150171
},
151172
searchSource: 'explore',
@@ -165,7 +186,7 @@ function SpanTabSearchSection({datePageFilterProps}: SpanTabSearchSectionProps)
165186
numberTags,
166187
stringTags,
167188
}),
168-
[mode, query, setExplorePageParams, numberTags, stringTags]
189+
[fields, mode, query, setExplorePageParams, numberTags, stringTags, oldSearch]
169190
);
170191

171192
const eapSpanSearchQueryProviderProps = useEAPSpanSearchQueryBuilderProps(

static/app/views/explore/utils.spec.tsx

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {LocationFixture} from 'sentry-fixture/locationFixture';
22
import {ProjectFixture} from 'sentry-fixture/project';
33

4+
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
45
import {Visualize} from 'sentry/views/explore/contexts/pageParamsContext/visualizes';
5-
import {viewSamplesTarget} from 'sentry/views/explore/utils';
6+
import {findSuggestedColumns, viewSamplesTarget} from 'sentry/views/explore/utils';
67

78
describe('viewSamplesTarget', function () {
89
const project = ProjectFixture();
@@ -156,3 +157,128 @@ describe('viewSamplesTarget', function () {
156157
});
157158
});
158159
});
160+
161+
describe('findSuggestedColumns', function () {
162+
it.each([
163+
{
164+
cols: [],
165+
oldQuery: '',
166+
newQuery: '',
167+
},
168+
{
169+
cols: [],
170+
oldQuery: '',
171+
newQuery: 'key:value',
172+
},
173+
{
174+
cols: ['key'],
175+
oldQuery: 'key:value1',
176+
newQuery: 'key:[value1,value2]',
177+
},
178+
{
179+
cols: ['key'],
180+
oldQuery: '',
181+
newQuery: 'key:[value1,value2]',
182+
},
183+
{
184+
cols: ['key'],
185+
oldQuery: '',
186+
newQuery: '!key:value',
187+
},
188+
{
189+
cols: ['key'],
190+
oldQuery: '',
191+
newQuery: 'key:*',
192+
},
193+
{
194+
cols: ['key'],
195+
oldQuery: '',
196+
newQuery: 'key:v*',
197+
},
198+
{
199+
cols: [],
200+
oldQuery: '',
201+
newQuery: 'key:\\*',
202+
},
203+
{
204+
cols: [],
205+
oldQuery: '',
206+
newQuery: 'key:v\\*',
207+
},
208+
{
209+
cols: ['key'],
210+
oldQuery: '',
211+
newQuery: 'key:\\\\*',
212+
},
213+
{
214+
cols: ['key'],
215+
oldQuery: '',
216+
newQuery: 'key:v\\\\*',
217+
},
218+
{
219+
cols: [],
220+
oldQuery: '',
221+
newQuery: 'key:\\\\\\*',
222+
},
223+
{
224+
cols: [],
225+
oldQuery: '',
226+
newQuery: 'key:v\\\\\\*',
227+
},
228+
{
229+
cols: ['key'],
230+
oldQuery: '',
231+
newQuery: 'has:key',
232+
},
233+
{
234+
cols: [],
235+
oldQuery: 'key:value',
236+
newQuery: 'has:key',
237+
},
238+
{
239+
cols: [],
240+
oldQuery: '',
241+
newQuery: 'key:value has:key',
242+
},
243+
{
244+
cols: ['key'],
245+
oldQuery: '',
246+
newQuery: 'key:[value1,value2] has:key',
247+
},
248+
{
249+
cols: [],
250+
oldQuery: '',
251+
newQuery: '!has:a',
252+
},
253+
{
254+
cols: ['num'],
255+
oldQuery: '',
256+
newQuery: 'num:>0',
257+
},
258+
{
259+
cols: [],
260+
oldQuery: '',
261+
newQuery: 'foo:[a,b]',
262+
},
263+
{
264+
cols: [],
265+
oldQuery: '',
266+
newQuery: 'count():>0',
267+
},
268+
])(
269+
'should inject $cols when changing from `$oldQuery` to `$newQuery`',
270+
function ({cols, oldQuery, newQuery}) {
271+
const oldSearch = new MutableSearch(oldQuery);
272+
const newSearch = new MutableSearch(newQuery);
273+
const suggestion = findSuggestedColumns(newSearch, oldSearch, {
274+
numberAttributes: {
275+
num: {key: 'num', name: 'num'},
276+
},
277+
stringAttributes: {
278+
key: {key: 'key', name: 'key'},
279+
},
280+
});
281+
expect(new Set(suggestion)).toEqual(new Set(cols));
282+
}
283+
);
284+
});

static/app/views/explore/utils.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import HookOrDefault from 'sentry/components/hookOrDefault';
99
import {IconBusiness} from 'sentry/icons/iconBusiness';
1010
import {t} from 'sentry/locale';
1111
import type {PageFilters} from 'sentry/types/core';
12+
import type {TagCollection} from 'sentry/types/group';
1213
import type {Confidence, Organization} from 'sentry/types/organization';
1314
import type {Project} from 'sentry/types/project';
1415
import {defined} from 'sentry/utils';
@@ -457,3 +458,120 @@ export function confirmDeleteSavedQuery({
457458
onConfirm: handleDelete,
458459
});
459460
}
461+
462+
export function findSuggestedColumns(
463+
newSearch: MutableSearch,
464+
oldSearch: MutableSearch,
465+
attributes: {
466+
numberAttributes: TagCollection;
467+
stringAttributes: TagCollection;
468+
}
469+
): string[] {
470+
const oldFilters = oldSearch.filters;
471+
const newFilters = newSearch.filters;
472+
473+
const keys: Set<string> = new Set();
474+
475+
for (const [key, value] of Object.entries(newFilters)) {
476+
if (key === 'has' || key === '!has') {
477+
// special key to be handled last
478+
continue;
479+
}
480+
481+
const isStringAttribute = key.startsWith('!')
482+
? attributes.stringAttributes.hasOwnProperty(key.slice(1))
483+
: attributes.stringAttributes.hasOwnProperty(key);
484+
const isNumberAttribute = key.startsWith('!')
485+
? attributes.numberAttributes.hasOwnProperty(key.slice(1))
486+
: attributes.numberAttributes.hasOwnProperty(key);
487+
488+
// guard against unknown keys and aggregate keys
489+
if (!isStringAttribute && !isNumberAttribute) {
490+
continue;
491+
}
492+
493+
if (isSimpleFilter(key, value, attributes)) {
494+
continue;
495+
}
496+
497+
if (
498+
!oldFilters.hasOwnProperty(key) || // new filter key
499+
isSimpleFilter(key, oldFilters[key] || [], attributes) // existing filter key turned complex
500+
) {
501+
keys.add(normalizeKey(key));
502+
break;
503+
}
504+
}
505+
506+
const oldHas = new Set(oldFilters.has);
507+
for (const key of newFilters.has || []) {
508+
if (oldFilters.hasOwnProperty(key) || oldHas.has(key)) {
509+
// old condition, don't add column
510+
continue;
511+
}
512+
513+
// if there's a simple filter on the key, don't add column
514+
if (
515+
newFilters.hasOwnProperty(key) &&
516+
isSimpleFilter(key, newFilters[key] || [], attributes)
517+
) {
518+
continue;
519+
}
520+
521+
keys.add(normalizeKey(key));
522+
}
523+
524+
return [...keys];
525+
}
526+
527+
const PREFIX_WILDCARD_PATTERN = /^(\\\\)*\*/;
528+
const INFIX_WILDCARD_PATTERN = /[^\\](\\\\)*\*/;
529+
530+
function isSimpleFilter(
531+
key: string,
532+
value: string[],
533+
attributes: {
534+
numberAttributes: TagCollection;
535+
stringAttributes: TagCollection;
536+
}
537+
): boolean {
538+
// negation filters are always considered non trivial
539+
// because it matches on multiple values
540+
if (key.startsWith('!')) {
541+
return false;
542+
}
543+
544+
// all number attributes are considered non trivial because they
545+
// almost always match on a range of values
546+
if (attributes.numberAttributes.hasOwnProperty(key)) {
547+
return false;
548+
}
549+
550+
if (value.length === 1) {
551+
const v = value[0]!;
552+
// if the value is wrapped in `[...]`, then it's an array value
553+
if (v.startsWith('[') && v.endsWith(']')) {
554+
return false;
555+
}
556+
557+
// if is wild card search, return false
558+
if (v.startsWith('*')) {
559+
return false;
560+
}
561+
562+
if (PREFIX_WILDCARD_PATTERN.test(v) || INFIX_WILDCARD_PATTERN.test(v)) {
563+
return false;
564+
}
565+
}
566+
567+
// if there is more than 1 possible value
568+
if (value.length > 1) {
569+
return false;
570+
}
571+
572+
return true;
573+
}
574+
575+
function normalizeKey(key: string): string {
576+
return key.startsWith('!') ? key.slice(1) : key;
577+
}

0 commit comments

Comments
 (0)