Skip to content
Closed
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
104 changes: 103 additions & 1 deletion static/app/views/dashboards/widgetCard/widgetLLMContext.spec.tsx
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,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([]);
});
});
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] ?? f.operator}`,
value: f.value.text,
}));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Filter parsing drops query logic

High Severity

getSearchFiltersForLLM keeps only Token.FILTER entries and discards non-filter tokens. Queries containing OR, parentheses, or mixed free text lose their original semantics when converted into conditions, so the LLM context can represent a different query than the widget actually runs.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 602b5c0. Configure here.

} 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