Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/mcp-event-patterns-drilldown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@hyperdx/api': patch
'@hyperdx/common-utils': patch
---

fix: make clickstack_event_patterns drill-down snippets executable

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.

`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.
1 change: 1 addition & 0 deletions packages/api/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ export const bulkInsertLogs = async (
ServiceName: string;
SeverityText: string;
Timestamp: Date;
LogAttributes?: Record<string, string>;
}[],
) => {
if (!config.IS_CI) {
Expand Down
26 changes: 26 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,30 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => {
};
};

// clickstack_search has no variable context, so each snippet must carry
// the expanded selection and return only the selected service's rows.
const expectDrilldownServices = async (text: string, service: string) => {
const { patterns } = JSON.parse(text);
expect(patterns.length).toBeGreaterThan(0);
for (const p of patterns) {
expect(p.whereSnippet).not.toContain('$__filter');
expect(p.whereSnippet).not.toContain('$service');
const search = await callTool(ctx.client!, 'clickstack_search', {
sourceId: logSourceId,
startTime,
endTime,
where: p.whereSnippet,
whereLanguage: 'sql',
columns: 'ServiceName',
});
expect(search.isError).toBeFalsy();
const rows: { ServiceName: string }[] = JSON.parse(getFirstText(search))
.result.data;
expect(rows.length).toBeGreaterThan(0);
for (const r of rows) expect(r.ServiceName).toBe(service);
}
};

it('expands $__filter to a no-op on an event_patterns tile with nothing selected', async () => {
const dashboard = await saveDashboard([eventPatternsTile()]);

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

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

it('reports an event_patterns macro naming a variable the dashboard does not declare', async () => {
Expand Down
55 changes: 55 additions & 0 deletions packages/api/src/mcp/__tests__/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
INCREASE_TOP_N_CAP,
isServerError,
mergeWhereIntoSelectItems,
normalizeBodyExpression,
parseTimeRange,
} from '@/mcp/tools/query/helpers';
import {
Expand Down Expand Up @@ -109,6 +110,60 @@ describe('parseTimeRange', () => {

// ─── mergeWhereIntoSelectItems ───────────────────────────────────────────────

describe('normalizeBodyExpression', () => {
it.each([
['Body', 'Body'],
['SpanName', 'SpanName'],
["SpanAttributes['http.url']", "SpanAttributes['http.url']"],
[
"JSONExtractString(Body, 'message')",
"JSONExtractString(Body, 'message')",
],
[
"JSONExtractString(Body,'error', 'message')",
"JSONExtractString(Body, 'error', 'message')",
],
[
"JSONExtractString(Body, 'app.user.currency')",
"JSONExtractString(Body, 'app.user.currency')",
],
[
"JSONExtractString(LogAttributes['object'], 'regarding', 'kind')",
"JSONExtractString(LogAttributes['object'], 'regarding', 'kind')",
],
[
String.raw`JSONExtractString(Body, 'it\'s, ok')`,
String.raw`JSONExtractString(Body, 'it\'s, ok')`,
],
[
"JSONExtractString(Body, 'it''s, ok')",
"JSONExtractString(Body, 'it''s, ok')",
],
])('accepts %s', (expr, expected) => {
expect(normalizeBodyExpression(expr)).toBe(expected);
});

it.each([
'Body) OR (1=1',
'lower(Body)',
"JSONExtract(Body, 'message', 'String')",
'JSONExtractString(Body)',
'JSONExtractString(Body, message)',
"JSONExtractString(lower(Body), 'message')",
"JSONExtractString(Body, 'm') OR 1=1",
"JSONExtractString(Body, 'm'), Body",
"JSONExtractString(Body, 'm' || 'x')",
"JSONExtractString(Body, 'm')) OR (lower(Body)",
"JSONExtractString(LogAttributes['a'], 'm') OR (1=1",
"JSONExtractString(Body, 'm', (SELECT 1))",
// A stray quote in the column would pair with a key's quotes.
"JSONExtractString(Body', ' OR 1=1 --')",
"JSONExtractString(Body, 'm'' OR 1=1)",
])('rejects %s', expr => {
expect(normalizeBodyExpression(expr)).toBeNull();
});
});

describe('mergeWhereIntoSelectItems', () => {
it('should return items unchanged when topWhere is empty', () => {
const items = [{ where: 'level:error', whereLanguage: 'lucene' as const }];
Expand Down
Loading
Loading