Skip to content

Commit 5584f84

Browse files
fix(mcp): make event_patterns drill-down snippets executable (#3426)
## Summary `clickstack_event_patterns` returned a `whereSnippet` per pattern that agents were told to pass to `clickstack_search`, but it often matched nothing. A template like `Sending <*> rows` became `Body:"Sending rows"` (a phrase that never occurs), and map-field body expressions produced Lucene that failed to parse. Agents hit dead ends on the main drill-down path. Changes: - **Executable drill-down.** `whereSnippet` is now a SQL `match()` predicate (`whereLanguage: "sql"`) built from the template: literal tokens in order, one non-whitespace token per `<*>`, whitespace matching Drain's JS `\s` tokenization. It already ANDs in the caller's `where` (rendered to SQL, dashboard variables expanded) and the source's table filter. If the caller filter can't be rendered as SQL, `summary.drilldown.includesFilters` is `false` with a warning. Templates over 4000 chars get `drilldownUnavailable` instead of an empty snippet that would match everything. - **Source table filter.** Pattern mining (`event_patterns`, `emerging_signals`, dashboard pattern tiles) now applies the log source's `tableFilterExpression`. Counts can drop for sources that set one. - **`bodyExpression`** also accepts `JSONExtractString(<column or map ref>, '<key>', ...)`. It is rebuilt from validated args, so the injection allowlist still holds. - **Sampling transparency.** Response reports `sampleCoverage`, and `summary.trend` gives the real bucket interval and boundaries (granularity rounding means fewer or wider buckets than requested). Tool description and usage text explain that counts are extrapolated and a 0 bucket doesn't mean no events. - **`minePatterns` template bug.** Each cluster now reports its own template. Before, it re-matched the first member's body and could pick up a different same-length cluster's template that other members didn't fit, so the drill-down missed some of the pattern's own events. ### How to reproduce **Starting point:** `yarn dev`, MCP client connected to the local API, a log source with data. **On `main`:** 1. Call `clickstack_event_patterns` on a log source. 2. Pass a pattern containing `<*>` as `where` to `clickstack_search`. It returns no rows, or a parse error when `bodyExpression` is a map field. **On this branch:** 1. Same call. Each pattern has a SQL `whereSnippet` and `whereLanguage: "sql"`. 2. Pass it to `clickstack_search` with `whereLanguage: "sql"`. It returns that pattern's events. Covered by new unit tests (`patternDrilldown.test.ts`, `mine-patterns.test.ts`, `query.test.ts`) and integration tests in `queryTool.int.test.ts` / `queryTile.int.test.ts` that run each snippet through search. ### References HDX-5748
1 parent a13c80e commit 5584f84

18 files changed

Lines changed: 1401 additions & 151 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@hyperdx/api': patch
3+
'@hyperdx/common-utils': patch
4+
---
5+
6+
fix: make clickstack_event_patterns drill-down snippets executable
7+
8+
Each pattern's `whereSnippet` is now a SQL predicate (`whereLanguage: "sql"`) that keeps the template's token order and variable positions and includes the caller's filter and the source table filter, so passing it to `clickstack_search` returns the pattern's events. Before, a template like `Sending <*> rows` produced `Body:"Sending rows"`, which matched nothing, and map fields produced Lucene that failed to parse. `bodyExpression` now also accepts `JSONExtractString(<column>, '<key>', ...)` to mine a field inside JSON bodies, and the tool reports sample coverage and the actual trend bucket interval and boundaries.
9+
10+
`clickstack_event_patterns`, `clickstack_emerging_signals` and dashboard event-pattern tiles now apply the log source's `tableFilterExpression`, so their counts can drop for sources that set one. `minePatterns` now reports each cluster's own template; before, it could report the template of a different same-length cluster that some members did not fit.

‎packages/api/src/fixtures.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ export const bulkInsertLogs = async (
434434
ServiceName: string;
435435
SeverityText: string;
436436
Timestamp: Date;
437+
LogAttributes?: Record<string, string>;
437438
}[],
438439
) => {
439440
if (!config.IS_CI) {

‎packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,30 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => {
891891
};
892892
};
893893

894+
// clickstack_search has no variable context, so each snippet must carry
895+
// the expanded selection and return only the selected service's rows.
896+
const expectDrilldownServices = async (text: string, service: string) => {
897+
const { patterns } = JSON.parse(text);
898+
expect(patterns.length).toBeGreaterThan(0);
899+
for (const p of patterns) {
900+
expect(p.whereSnippet).not.toContain('$__filter');
901+
expect(p.whereSnippet).not.toContain('$service');
902+
const search = await callTool(ctx.client!, 'clickstack_search', {
903+
sourceId: logSourceId,
904+
startTime,
905+
endTime,
906+
where: p.whereSnippet,
907+
whereLanguage: 'sql',
908+
columns: 'ServiceName',
909+
});
910+
expect(search.isError).toBeFalsy();
911+
const rows: { ServiceName: string }[] = JSON.parse(getFirstText(search))
912+
.result.data;
913+
expect(rows.length).toBeGreaterThan(0);
914+
for (const r of rows) expect(r.ServiceName).toBe(service);
915+
}
916+
};
917+
894918
it('expands $__filter to a no-op on an event_patterns tile with nothing selected', async () => {
895919
const dashboard = await saveDashboard([eventPatternsTile()]);
896920

@@ -931,6 +955,7 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => {
931955
expect(sampledCount).toBe(2);
932956
expect(bodies.some(b => b.includes('checkout'))).toBe(true);
933957
expect(bodies.some(b => b.includes('payments'))).toBe(false);
958+
await expectDrilldownServices(getFirstText(result), 'checkout');
934959
});
935960

936961
it('expands a Lucene variable reference on an event_patterns tile', async () => {
@@ -953,6 +978,7 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => {
953978
expect(totalCount).toBe(1);
954979
expect(bodies.some(b => b.includes('checkout'))).toBe(false);
955980
expect(bodies.some(b => b.includes('payments'))).toBe(true);
981+
await expectDrilldownServices(getFirstText(result), 'payments');
956982
});
957983

958984
it('reports an event_patterns macro naming a variable the dashboard does not declare', async () => {

‎packages/api/src/mcp/__tests__/query.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
INCREASE_TOP_N_CAP,
2222
isServerError,
2323
mergeWhereIntoSelectItems,
24+
normalizeBodyExpression,
2425
parseTimeRange,
2526
} from '@/mcp/tools/query/helpers';
2627
import {
@@ -109,6 +110,60 @@ describe('parseTimeRange', () => {
109110

110111
// ─── mergeWhereIntoSelectItems ───────────────────────────────────────────────
111112

113+
describe('normalizeBodyExpression', () => {
114+
it.each([
115+
['Body', 'Body'],
116+
['SpanName', 'SpanName'],
117+
["SpanAttributes['http.url']", "SpanAttributes['http.url']"],
118+
[
119+
"JSONExtractString(Body, 'message')",
120+
"JSONExtractString(Body, 'message')",
121+
],
122+
[
123+
"JSONExtractString(Body,'error', 'message')",
124+
"JSONExtractString(Body, 'error', 'message')",
125+
],
126+
[
127+
"JSONExtractString(Body, 'app.user.currency')",
128+
"JSONExtractString(Body, 'app.user.currency')",
129+
],
130+
[
131+
"JSONExtractString(LogAttributes['object'], 'regarding', 'kind')",
132+
"JSONExtractString(LogAttributes['object'], 'regarding', 'kind')",
133+
],
134+
[
135+
String.raw`JSONExtractString(Body, 'it\'s, ok')`,
136+
String.raw`JSONExtractString(Body, 'it\'s, ok')`,
137+
],
138+
[
139+
"JSONExtractString(Body, 'it''s, ok')",
140+
"JSONExtractString(Body, 'it''s, ok')",
141+
],
142+
])('accepts %s', (expr, expected) => {
143+
expect(normalizeBodyExpression(expr)).toBe(expected);
144+
});
145+
146+
it.each([
147+
'Body) OR (1=1',
148+
'lower(Body)',
149+
"JSONExtract(Body, 'message', 'String')",
150+
'JSONExtractString(Body)',
151+
'JSONExtractString(Body, message)',
152+
"JSONExtractString(lower(Body), 'message')",
153+
"JSONExtractString(Body, 'm') OR 1=1",
154+
"JSONExtractString(Body, 'm'), Body",
155+
"JSONExtractString(Body, 'm' || 'x')",
156+
"JSONExtractString(Body, 'm')) OR (lower(Body)",
157+
"JSONExtractString(LogAttributes['a'], 'm') OR (1=1",
158+
"JSONExtractString(Body, 'm', (SELECT 1))",
159+
// A stray quote in the column would pair with a key's quotes.
160+
"JSONExtractString(Body', ' OR 1=1 --')",
161+
"JSONExtractString(Body, 'm'' OR 1=1)",
162+
])('rejects %s', expr => {
163+
expect(normalizeBodyExpression(expr)).toBeNull();
164+
});
165+
});
166+
112167
describe('mergeWhereIntoSelectItems', () => {
113168
it('should return items unchanged when topWhere is empty', () => {
114169
const items = [{ where: 'level:error', whereLanguage: 'lucene' as const }];

0 commit comments

Comments
 (0)