Skip to content

Commit 77d7620

Browse files
Mihir-MavalankarClaude Opus 4.6
andcommitted
feat(seer): Add widget-level LLM context to dashboard widgets
Register each dashboard widget as a child node in the LLM context tree so the Seer Explorer agent gets per-widget metadata: title, display type, widget type, query config, and tool-use hints that map directly to Seer's telemetry_live_search parameters. Big number widgets also include their fetched data value. Capitalize node type headings in the backend markdown renderer for readability. Co-Authored-By: Claude Opus 4.6 <noreply@example.com>
1 parent da920f8 commit 77d7620

File tree

4 files changed

+103
-8
lines changed

4 files changed

+103
-8
lines changed

src/sentry/seer/explorer/client_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ def poll_until_done(
345345
def _render_node(node: dict[str, Any], depth: int) -> str:
346346
"""Recursively render an LLMContextSnapshot node and its children as markdown."""
347347
heading = "#" * min(depth + 1, 6)
348-
lines = [f"{heading} {node.get('nodeType', 'unknown')}"]
348+
lines = [f"{heading} {node.get('nodeType', 'unknown').capitalize()}"]
349349

350350
data = node.get("data")
351351
if isinstance(data, dict):

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,3 +1032,48 @@ describe('Dashboards > WidgetCard', () => {
10321032
});
10331033
});
10341034
});
1035+
1036+
describe('getQueryHint', () => {
1037+
const {getQueryHint} = require('sentry/views/dashboards/widgetCard');
1038+
1039+
it.each([
1040+
[DisplayType.LINE, 'timeseries'],
1041+
[DisplayType.AREA, 'timeseries'],
1042+
[DisplayType.BAR, 'timeseries'],
1043+
])('returns timeseries hint for %s', (displayType, expected) => {
1044+
expect(getQueryHint(displayType)).toContain(expected);
1045+
});
1046+
1047+
it('returns table hint for TABLE', () => {
1048+
expect(getQueryHint(DisplayType.TABLE)).toContain('table query');
1049+
});
1050+
1051+
it('returns single aggregate hint for BIG_NUMBER', () => {
1052+
expect(getQueryHint(DisplayType.BIG_NUMBER)).toContain('single aggregate');
1053+
expect(getQueryHint(DisplayType.BIG_NUMBER)).toContain('value is included below');
1054+
});
1055+
1056+
it('returns table hint as default for unknown types', () => {
1057+
expect(getQueryHint(DisplayType.WHEEL)).toContain('table query');
1058+
});
1059+
});
1060+
1061+
describe('getWidgetData', () => {
1062+
const {getWidgetData} = require('sentry/views/dashboards/widgetCard');
1063+
1064+
it('returns table data for BIG_NUMBER when data is available', () => {
1065+
const tableData = [{count: 42}];
1066+
const data = {tableResults: [{title: 'test', data: tableData}]};
1067+
expect(getWidgetData(DisplayType.BIG_NUMBER, data)).toEqual({data: tableData});
1068+
});
1069+
1070+
it('returns empty object for BIG_NUMBER when no data', () => {
1071+
expect(getWidgetData(DisplayType.BIG_NUMBER, undefined)).toEqual({});
1072+
});
1073+
1074+
it('returns empty object for non-BIG_NUMBER types even with data', () => {
1075+
const data = {tableResults: [{title: 'test', data: [{count: 42}]}]};
1076+
expect(getWidgetData(DisplayType.LINE, data)).toEqual({});
1077+
expect(getWidgetData(DisplayType.TABLE, data)).toEqual({});
1078+
});
1079+
});

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ import {WidgetCardChartContainer} from 'sentry/views/dashboards/widgetCard/widge
5252
import type {WidgetLegendSelectionState} from 'sentry/views/dashboards/widgetLegendSelectionState';
5353
import type {TabularColumn} from 'sentry/views/dashboards/widgets/common/types';
5454
import {Widget} from 'sentry/views/dashboards/widgets/widget/widget';
55+
import {useLLMContext} from 'sentry/views/seerExplorer/contexts/llmContext';
56+
import {registerLLMContext} from 'sentry/views/seerExplorer/contexts/registerLLMContext';
5557

5658
import {useDashboardsMEPContext} from './dashboardsMEPContext';
5759
import {VisualizationWidget} from './visualizationWidget';
@@ -140,13 +142,58 @@ type Data = {
140142
totalIssuesCount?: string;
141143
};
142144

145+
/**
146+
* Returns a hint for the Seer Explorer agent describing how to re-query this
147+
* widget's data using a tool call, if the user wants to dig deeper.
148+
*/
149+
export function getQueryHint(displayType: DisplayType): string {
150+
switch (displayType) {
151+
case DisplayType.LINE:
152+
case DisplayType.AREA:
153+
case DisplayType.BAR:
154+
return 'To dig deeper into this widget, run a timeseries query using y_axes (aggregates) + group_by (columns) + query (conditions)';
155+
case DisplayType.TABLE:
156+
return 'To dig deeper into this widget, run a table query using fields (aggregates + columns) + query (conditions) + sort (orderby)';
157+
case DisplayType.BIG_NUMBER:
158+
return 'To dig deeper into this widget, run a single aggregate query using fields (aggregates) + query (conditions); current value is included below';
159+
default:
160+
return 'To dig deeper into this widget, run a table query using fields (aggregates + columns) + query (conditions)';
161+
}
162+
}
163+
164+
export function getWidgetData(
165+
displayType: DisplayType,
166+
data: Data | undefined
167+
): Record<string, unknown> {
168+
if (displayType === DisplayType.BIG_NUMBER && data?.tableResults?.[0]) {
169+
return {data: data.tableResults[0].data};
170+
}
171+
return {};
172+
}
173+
143174
function WidgetCard(props: Props) {
144175
const [data, setData] = useState<Data>();
145176
const [isLoadingTextVisible, setIsLoadingTextVisible] = useState(false);
146177
const navigate = useNavigate();
147178
const {dashboardId: currentDashboardId} = useParams<{dashboardId: string}>();
148179
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
149180

181+
// Push widget metadata into the LLM context tree for Seer Explorer.
182+
useLLMContext({
183+
title: props.widget.title,
184+
displayType: props.widget.displayType,
185+
widgetType: props.widget.widgetType,
186+
queryHint: getQueryHint(props.widget.displayType),
187+
queries: props.widget.queries.map(q => ({
188+
name: q.name,
189+
conditions: q.conditions,
190+
aggregates: q.aggregates,
191+
columns: q.columns,
192+
orderby: q.orderby,
193+
})),
194+
...getWidgetData(props.widget.displayType, data),
195+
});
196+
150197
const onDataFetched = (newData: Data) => {
151198
if (props.onDataFetched) {
152199
props.onDataFetched({
@@ -435,7 +482,10 @@ function WidgetCard(props: Props) {
435482
);
436483
}
437484

438-
export default withApi(withOrganization(withPageFilters(withSentryRouter(WidgetCard))));
485+
export default registerLLMContext(
486+
'widget',
487+
withApi(withOrganization(withPageFilters(withSentryRouter(WidgetCard))))
488+
);
439489

440490
function useOnDemandWarning(props: {widget: TWidget}): string | null {
441491
const organization = useOrganization();

tests/sentry/seer/explorer/test_client_utils.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def test_single_node(self) -> None:
200200
],
201201
}
202202
result = snapshot_to_markdown(snapshot)
203-
assert "# dashboard" in result
203+
assert "# Dashboard" in result
204204
assert '- **title**: "Backend Health"' in result
205205
assert "- **widgetCount**: 3" in result
206206

@@ -228,9 +228,9 @@ def test_nested_nodes(self) -> None:
228228
],
229229
}
230230
result = snapshot_to_markdown(snapshot)
231-
assert "# dashboard" in result
232-
assert "## widget" in result
233-
assert "### chart" in result
231+
assert "# Dashboard" in result
232+
assert "## Widget" in result
233+
assert "### Chart" in result
234234
assert '- **query**: "count()"' in result
235235

236236
def test_empty_nodes(self) -> None:
@@ -242,7 +242,7 @@ def test_node_with_no_data(self) -> None:
242242
"nodes": [{"nodeType": "dashboard", "data": None, "children": []}],
243243
}
244244
result = snapshot_to_markdown(snapshot)
245-
assert "# dashboard" in result
245+
assert "# Dashboard" in result
246246
assert "not an exact screenshot" in result
247247

248248
def test_node_with_non_dict_data(self) -> None:
@@ -251,5 +251,5 @@ def test_node_with_non_dict_data(self) -> None:
251251
"nodes": [{"nodeType": "widget", "data": "some string", "children": []}],
252252
}
253253
result = snapshot_to_markdown(snapshot)
254-
assert "# widget" in result
254+
assert "# Widget" in result
255255
assert '- "some string"' in result

0 commit comments

Comments
 (0)