diff --git a/static/app/views/dashboards/widgetCard/index.tsx b/static/app/views/dashboards/widgetCard/index.tsx index c5d91c5926e7a4..17f28bdd86e4a6 100644 --- a/static/app/views/dashboards/widgetCard/index.tsx +++ b/static/app/views/dashboards/widgetCard/index.tsx @@ -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[]; @@ -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, diff --git a/static/app/views/dashboards/widgetCard/widgetLLMContext.spec.tsx b/static/app/views/dashboards/widgetCard/widgetLLMContext.spec.tsx index ef531f3e34b1ab..2a5c3af3957519 100644 --- a/static/app/views/dashboards/widgetCard/widgetLLMContext.spec.tsx +++ b/static/app/views/dashboards/widgetCard/widgetLLMContext.spec.tsx @@ -1,6 +1,6 @@ import {DisplayType} from 'sentry/views/dashboards/types'; -import {getWidgetQueryLLMHint} from './widgetLLMContext'; +import {getSearchFiltersForLLM, getWidgetQueryLLMHint} from './widgetLLMContext'; describe('getWidgetQueryLLMHint', () => { it.each([ @@ -26,3 +26,105 @@ 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 — the ! is part of the value, not a negation operator + expect(getSearchFiltersForLLM('browser.name:!Firefox')).toEqual([ + {field: 'browser.name', op: 'is', value: '!Firefox'}, + ]); + }); + + it('parses != operator on numeric fields', () => { + expect(getSearchFiltersForLLM('count():!=100')).toEqual([ + {field: 'count()', op: 'is not', value: '100'}, + ]); + }); + + 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([]); + }); +}); diff --git a/static/app/views/dashboards/widgetCard/widgetLLMContext.tsx b/static/app/views/dashboards/widgetCard/widgetLLMContext.tsx index c792a7ed1b34cd..0c81294ae4d6ae 100644 --- a/static/app/views/dashboards/widgetCard/widgetLLMContext.tsx +++ b/static/app/views/dashboards/widgetCard/widgetLLMContext.tsx @@ -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 => 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] ?? f.operator}`, + 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.