Skip to content

Commit 02e6956

Browse files
Mihir-MavalankarClaude Opus 4.6
authored andcommitted
fix(seer): Make widget conditions readable for the Seer Explorer agent (#112502)
+ Widget query conditions use internal \uf00d Unicode markers around wildcard operators (e.g. Contains, StartsWith), which produce garbled strings like Containsqueue.task.taskworker when rendered to markdown for the Seer agent — causing the agent to make broken tool calls with literal operator text as search values. + Add readableConditions() that replaces the 6 \uf00d-delimited operators with readable labels (e.g. contains, starts with) via simple string replacement. All other query structure — AND, OR, parentheses, free text, negation, comparison operators — passes through unchanged. + Update widget query hints to describe what each widget type visualizes (timeseries chart, table, single number) and direct the agent to understand intent from the query config, instead of referencing internal API parameters (y_axes, group_by) that the natural-language tool doesn't use. + Add 20 tests covering all 6 operator types, partial match safety, mixed operators, repeated operators, OR with parens, free text passthrough, literal "Contains" without markers, and a real-world dashboard widget query. --------- Co-authored-by: Claude Opus 4.6 <noreply@example.com>
1 parent 0478932 commit 02e6956

File tree

4 files changed

+155
-19
lines changed

4 files changed

+155
-19
lines changed

static/app/views/dashboards/dashboard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ function DashboardInner({
129129
// Push dashboard metadata into the LLM context tree for Seer Explorer.
130130
useLLMContext({
131131
contextHint:
132-
'This is a Sentry dashboard. The dateRange, environments, and projects below are global page filters that scope every widget query. Each child widget node contains its own query config that can be used with the telemetry_live_search tool to fetch or drill into its data.',
132+
'This is a Sentry dashboard. The dateRange, environments, and projects below are global page filters that scope every widget query. Each child widget node contains its own query config that can be used with tools like telemetry_live_search and telemetry_index_list_nodes to fetch data for that widget and dig deeper. Based on the user question, data might be needed from multiple widgets.',
133133
title: dashboard.title,
134134
widgetCount: dashboard.widgets.length,
135135
filters: dashboard.filters,

static/app/views/dashboards/widgetCard/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import {
6464
useTransactionsDeprecationWarning,
6565
} from './widgetCardContextMenu';
6666
import {WidgetFrame} from './widgetFrame';
67-
import {getWidgetQueryLLMHint} from './widgetLLMContext';
67+
import {getWidgetQueryLLMHint, readableConditions} from './widgetLLMContext';
6868

6969
export type OnDataFetchedParams = {
7070
tableResults?: TableDataWithTitle[];
@@ -165,7 +165,7 @@ function WidgetCard(props: Props) {
165165
queryHint: getWidgetQueryLLMHint(resolvedDisplayType),
166166
queries: props.widget.queries.map(q => ({
167167
name: q.name,
168-
conditions: q.conditions,
168+
conditions: readableConditions(q.conditions),
169169
aggregates: q.aggregates,
170170
columns: q.columns,
171171
orderby: q.orderby,
Lines changed: 132 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,150 @@
11
import {DisplayType} from 'sentry/views/dashboards/types';
22

3-
import {getWidgetQueryLLMHint} from './widgetLLMContext';
3+
import {getWidgetQueryLLMHint, readableConditions} from './widgetLLMContext';
44

55
describe('getWidgetQueryLLMHint', () => {
66
it.each([
7-
[DisplayType.LINE, 'timeseries'],
8-
[DisplayType.AREA, 'timeseries'],
9-
[DisplayType.BAR, 'timeseries'],
7+
[DisplayType.LINE, 'timeseries chart'],
8+
[DisplayType.AREA, 'timeseries chart'],
9+
[DisplayType.BAR, 'timeseries chart'],
1010
])('returns timeseries hint for %s', (displayType, expected) => {
1111
expect(getWidgetQueryLLMHint(displayType)).toContain(expected);
1212
});
1313

1414
it('returns table hint for TABLE', () => {
15-
expect(getWidgetQueryLLMHint(DisplayType.TABLE)).toContain('table query');
15+
expect(getWidgetQueryLLMHint(DisplayType.TABLE)).toContain('shows a table');
1616
});
1717

18-
it('returns single aggregate hint for BIG_NUMBER', () => {
19-
expect(getWidgetQueryLLMHint(DisplayType.BIG_NUMBER)).toContain('single aggregate');
18+
it('returns single number hint for BIG_NUMBER', () => {
19+
expect(getWidgetQueryLLMHint(DisplayType.BIG_NUMBER)).toContain('single number');
2020
expect(getWidgetQueryLLMHint(DisplayType.BIG_NUMBER)).toContain(
21-
'value is included below'
21+
'current value is included below'
2222
);
2323
});
2424

25-
it('returns table hint as default for unknown types', () => {
26-
expect(getWidgetQueryLLMHint(DisplayType.WHEEL)).toContain('table query');
25+
it('returns generic hint for unknown types', () => {
26+
expect(getWidgetQueryLLMHint(DisplayType.WHEEL)).toContain('shows data');
27+
});
28+
});
29+
30+
describe('readableConditions', () => {
31+
it('replaces Contains operator with readable label', () => {
32+
expect(readableConditions('span.name:\uf00dContains\uf00dfoo')).toBe(
33+
'span.name: contains foo'
34+
);
35+
});
36+
37+
it('replaces Contains with IN list brackets', () => {
38+
expect(readableConditions('span.name:\uf00dContains\uf00d[a,b,c]')).toBe(
39+
'span.name: contains [a,b,c]'
40+
);
41+
});
42+
43+
it('replaces DoesNotContain operator', () => {
44+
expect(readableConditions('key:\uf00dDoesNotContain\uf00dval')).toBe(
45+
'key: does not contain val'
46+
);
47+
});
48+
49+
it('replaces StartsWith and EndsWith operators', () => {
50+
expect(readableConditions('key:\uf00dStartsWith\uf00d/api')).toBe(
51+
'key: starts with /api'
52+
);
53+
expect(readableConditions('key:\uf00dEndsWith\uf00d.json')).toBe(
54+
'key: ends with .json'
55+
);
56+
});
57+
58+
it('replaces DoesNotStartWith and DoesNotEndWith operators', () => {
59+
expect(readableConditions('key:\uf00dDoesNotStartWith\uf00d/api')).toBe(
60+
'key: does not start with /api'
61+
);
62+
expect(readableConditions('key:\uf00dDoesNotEndWith\uf00d.json')).toBe(
63+
'key: does not end with .json'
64+
);
65+
});
66+
67+
it('preserves negated filter prefix', () => {
68+
expect(readableConditions('!path:\uf00dContains\uf00dfoo')).toBe(
69+
'!path: contains foo'
70+
);
71+
});
72+
73+
it('replaces multiple operators in one string', () => {
74+
const input =
75+
'span.name:\uf00dContains\uf00dqueue.task !trigger_path:\uf00dContains\uf00dold_seer';
76+
expect(readableConditions(input)).toBe(
77+
'span.name: contains queue.task !trigger_path: contains old_seer'
78+
);
79+
});
80+
81+
it('passes through plain filters unchanged', () => {
82+
expect(readableConditions('browser.name:Firefox')).toBe('browser.name:Firefox');
83+
});
84+
85+
it('passes through free text unchanged', () => {
86+
expect(readableConditions('some free text')).toBe('some free text');
87+
});
88+
89+
it('passes through empty string', () => {
90+
expect(readableConditions('')).toBe('');
91+
});
92+
93+
it('preserves OR and parentheses', () => {
94+
expect(readableConditions('(a:1 OR b:2) error')).toBe('(a:1 OR b:2) error');
95+
});
96+
97+
it('preserves comparison operators', () => {
98+
expect(readableConditions('count():>100 duration:<=5s')).toBe(
99+
'count():>100 duration:<=5s'
100+
);
101+
});
102+
103+
it('handles real-world widget query', () => {
104+
const input =
105+
'span.description:\uf00dContains\uf00d[sentry.tasks.autofix.generate_issue_summary_only,sentry.tasks.autofix.run_automation_only_task] span.name:\uf00dContains\uf00dqueue.task.taskworker !trigger_path:\uf00dContains\uf00dold_seer_automation';
106+
expect(readableConditions(input)).toBe(
107+
'span.description: contains [sentry.tasks.autofix.generate_issue_summary_only,sentry.tasks.autofix.run_automation_only_task] span.name: contains queue.task.taskworker !trigger_path: contains old_seer_automation'
108+
);
109+
});
110+
111+
it('does not replace DoesNotContain partially as Contains', () => {
112+
// DoesNotContain must be replaced before Contains to avoid partial match
113+
expect(readableConditions('key:\uf00dDoesNotContain\uf00dval')).toBe(
114+
'key: does not contain val'
115+
);
116+
// Should NOT produce "key: does not contains val" or "key:DoesNot contains val"
117+
expect(readableConditions('key:\uf00dDoesNotContain\uf00dval')).not.toContain(
118+
'\uf00d'
119+
);
120+
});
121+
122+
it('handles mixed operator types in one query', () => {
123+
const input =
124+
'url:\uf00dStartsWith\uf00d/api span.description:\uf00dContains\uf00dfoo !path:\uf00dDoesNotEndWith\uf00d.js';
125+
expect(readableConditions(input)).toBe(
126+
'url: starts with /api span.description: contains foo !path: does not end with .js'
127+
);
128+
});
129+
130+
it('handles the same operator appearing multiple times', () => {
131+
const input =
132+
'a:\uf00dContains\uf00dfoo b:\uf00dContains\uf00dbar c:\uf00dContains\uf00dbaz';
133+
expect(readableConditions(input)).toBe(
134+
'a: contains foo b: contains bar c: contains baz'
135+
);
136+
});
137+
138+
it('preserves OR with wildcard operators inside parens', () => {
139+
const input =
140+
'(span.name:\uf00dContains\uf00dfoo OR span.name:\uf00dContains\uf00dbar)';
141+
expect(readableConditions(input)).toBe(
142+
'(span.name: contains foo OR span.name: contains bar)'
143+
);
144+
});
145+
146+
it('does not replace literal "Contains" text without unicode markers', () => {
147+
// The word "Contains" in a value or free text should NOT be replaced
148+
expect(readableConditions('message:Contains error')).toBe('message:Contains error');
27149
});
28150
});
Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,34 @@
1+
import {OP_LABELS} from 'sentry/components/searchQueryBuilder/tokens/filter/utils';
12
import {DisplayType} from 'sentry/views/dashboards/types';
23

34
/**
4-
* Returns a hint for the Seer Explorer agent describing how to re-query this
5-
* widget's data using a tool call, if the user wants to dig deeper.
5+
* Replace internal \uf00d-delimited wildcard operators with readable labels
6+
* so the Seer Explorer agent can understand widget filter conditions.
7+
*
8+
* All other query structure (AND, OR, parens, free text, comparison operators)
9+
* passes through unchanged since only wildcard operators use \uf00d markers.
10+
*/
11+
export function readableConditions(query: string): string {
12+
return Object.entries(OP_LABELS)
13+
.filter(([key]) => key.includes('\uf00d'))
14+
.reduce((s, [key, label]) => s.replaceAll(key, ` ${label} `), query);
15+
}
16+
17+
/**
18+
* Returns a hint for the Seer Explorer agent describing what this widget
19+
* visualizes so it can understand the intent from the query config.
620
*/
721
export function getWidgetQueryLLMHint(displayType: DisplayType): string {
822
switch (displayType) {
923
case DisplayType.LINE:
1024
case DisplayType.AREA:
1125
case DisplayType.BAR:
12-
return 'To dig deeper into this widget, run a timeseries query using y_axes (aggregates) + group_by (columns) + query (conditions)';
26+
return 'This widget shows a timeseries chart. The aggregates are the y-axis metrics, columns are the group-by breakdowns, and conditions filter the data. Understand the intent from the query config below.';
1327
case DisplayType.TABLE:
14-
return 'To dig deeper into this widget, run a table query using fields (aggregates + columns) + query (conditions) + sort (orderby)';
28+
return 'This widget shows a table. The aggregates and columns define the visible fields, orderby is the sort, and conditions filter the data. Understand the intent from the query config below.';
1529
case DisplayType.BIG_NUMBER:
16-
return 'To dig deeper into this widget, run a single aggregate query using fields (aggregates) + query (conditions); current value is included below';
30+
return 'This widget shows a single number. The aggregate is the metric, conditions filter the data, and the current value is included below. Understand the intent from the query config below.';
1731
default:
18-
return 'To dig deeper into this widget, run a table query using fields (aggregates + columns) + query (conditions)';
32+
return 'This widget shows data. The aggregates, columns, and conditions define what is displayed. Understand the intent from the query config below.';
1933
}
2034
}

0 commit comments

Comments
 (0)