Skip to content
Draft
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
4 changes: 2 additions & 2 deletions static/app/views/dashboards/widgetCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import {
useTransactionsDeprecationWarning,
} from './widgetCardContextMenu';
import {WidgetFrame} from './widgetFrame';
import {getWidgetQueryLLMHint} from './widgetLLMContext';
import {getSearchFiltersForLLM, getWidgetQueryLLMHint} from './widgetLLMContext';

export type OnDataFetchedParams = {
tableResults?: TableDataWithTitle[];
Expand Down Expand Up @@ -165,7 +165,7 @@ function WidgetCard(props: Props) {
queryHint: getWidgetQueryLLMHint(resolvedDisplayType),
queries: props.widget.queries.map(q => ({
name: q.name,
conditions: q.conditions,
conditions: getSearchFiltersForLLM(q.conditions),
aggregates: q.aggregates,
columns: q.columns,
orderby: q.orderby,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {DisplayType} from 'sentry/views/dashboards/types';

import {getWidgetQueryLLMHint} from './widgetLLMContext';
import {getSearchFiltersForLLM, getWidgetQueryLLMHint} from './widgetLLMContext';

describe('getWidgetQueryLLMHint', () => {
it.each([
Expand All @@ -26,3 +26,99 @@ describe('getWidgetQueryLLMHint', () => {
expect(getWidgetQueryLLMHint(DisplayType.WHEEL)).toContain('table query');
});
});

describe('getSearchFiltersForLLM', () => {
it('parses a simple key:value filter', () => {
expect(getSearchFiltersForLLM('browser.name:Firefox')).toEqual([
{field: 'browser.name', op: 'is', value: 'Firefox'},
]);
});

it('parses a Contains wildcard filter with readable operator', () => {
// Raw syntax: span.name:\uf00dContains\uf00dqueue.task
// The search bar produces this internally for "span.name contains queue.task"
expect(
getSearchFiltersForLLM('span.name:\uf00dContains\uf00dqueue.task.taskworker')
).toEqual([{field: 'span.name', op: 'contains', value: 'queue.task.taskworker'}]);
});

it('parses a negated filter with ! prefix', () => {
expect(getSearchFiltersForLLM('!browser.name:Firefox')).toEqual([
{field: 'browser.name', op: 'NOT is', value: 'Firefox'},
]);
});

it('parses a negated Contains wildcard filter', () => {
expect(
getSearchFiltersForLLM('!trigger_path:\uf00dContains\uf00dold_seer_automation')
).toEqual([
{field: 'trigger_path', op: 'NOT contains', value: 'old_seer_automation'},
]);
});

it('parses multiple filters separated by spaces', () => {
const result = getSearchFiltersForLLM(
'browser.name:Firefox os.name:Windows level:error'
);
expect(result).toEqual([
{field: 'browser.name', op: 'is', value: 'Firefox'},
{field: 'os.name', op: 'is', value: 'Windows'},
{field: 'level', op: 'is', value: 'error'},
]);
});

it('parses an IN list filter (bracket syntax)', () => {
const result = getSearchFiltersForLLM('browser.name:[Firefox,Chrome,Safari]');
expect(result).toEqual([
{field: 'browser.name', op: 'is', value: '[Firefox,Chrome,Safari]'},
]);
});

it('parses comparison operators', () => {
expect(getSearchFiltersForLLM('count():>100')).toEqual([
{field: 'count()', op: '>', value: '100'},
]);
});

it('parses negation-in-value syntax (key:!value)', () => {
// browser.name:!Firefox uses NOT_EQUAL operator, not ! prefix negation
expect(getSearchFiltersForLLM('browser.name:!Firefox')).toEqual([
{field: 'browser.name', op: 'is', value: '!Firefox'},
]);
});

it('returns empty array for empty string', () => {
expect(getSearchFiltersForLLM('')).toEqual([]);
expect(getSearchFiltersForLLM(' ')).toEqual([]);
});

it('falls back to raw string for unparseable input', () => {
// Malformed query that parseSearch can't handle
expect(getSearchFiltersForLLM('(((')).toBe('(((');
});

it('falls back to raw string when only free text (no key:value filters)', () => {
expect(getSearchFiltersForLLM('just some free text')).toBe('just some free text');
});

it('parses a real dashboard widget query with Contains IN list + single Contains + negated Contains', () => {
// Real query from a dashboard widget — \uf00d markers are invisible but present
const conditions =
'span.description:\uf00dContains\uf00d[sentry.tasks.autofix.generate_issue_summary_only,sentry.tasks.autofix.run_automation_only_task,sentry.tasks.autofix.generate_summary_and_run_automation] span.name:\uf00dContains\uf00dqueue.task.taskworker !trigger_path:\uf00dContains\uf00dold_seer_automation';
expect(getSearchFiltersForLLM(conditions)).toEqual([
{
field: 'span.description',
op: 'contains',
value:
'[sentry.tasks.autofix.generate_issue_summary_only,sentry.tasks.autofix.run_automation_only_task,sentry.tasks.autofix.generate_summary_and_run_automation]',
},
{field: 'span.name', op: 'contains', value: 'queue.task.taskworker'},
{field: 'trigger_path', op: 'NOT contains', value: 'old_seer_automation'},
]);
});

it('handles empty conditions from a widget with no filters', () => {
// First query from the user's example — aggregates only, no conditions
expect(getSearchFiltersForLLM('')).toEqual([]);
});
});
37 changes: 37 additions & 0 deletions static/app/views/dashboards/widgetCard/widgetLLMContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
import {OP_LABELS} from 'sentry/components/searchQueryBuilder/tokens/filter/utils';
import {
parseSearch,
Token,
type TokenResult,
} from 'sentry/components/searchSyntax/parser';
import {DisplayType} from 'sentry/views/dashboards/types';

/**
* Wraps parseSearch to return LLM-friendly structured filters with readable
* operators and negation. Falls back to the raw string if parsing fails.
*/
export function getSearchFiltersForLLM(
query: string
): Array<{field: string; op: string; value: string}> | string {
if (!query.trim()) {
return [];
}
try {
const tokens = parseSearch(query);
if (!tokens) {
return query;
}
const filters = tokens.filter(
(t): t is TokenResult<Token.FILTER> => t.type === Token.FILTER
);
if (filters.length === 0) {
return query;
}
return filters.map(f => ({
field: f.key.text,
op: `${f.negated ? 'NOT ' : ''}${OP_LABELS[f.operator as keyof typeof OP_LABELS] ?? f.operator}`,

Check failure on line 32 in static/app/views/dashboards/widgetCard/widgetLLMContext.tsx

View workflow job for this annotation

GitHub Actions / eslint

This assertion is unnecessary since it does not change the type of the expression
value: f.value.text,
}));
} catch {
return query;
}
}

/**
* Returns a hint for the Seer Explorer agent describing how to re-query this
* widget's data using a tool call, if the user wants to dig deeper.
Expand Down
Loading