Skip to content

Commit 253a690

Browse files
authored
docs(mcp): recommend quoted Lucene values for exact matches and bare terms for body text (#3548)
Refs #3538 ## What Only descriptions and the query guide change; Lucene processing is untouched. - **Query tool `where` / `whereLanguage` descriptions** (`packages/api/src/mcp/tools/query/schemas.ts`): - for an exact match, quote the value: `SpanKind:"Server"` compiles to `=`, so a sort-key column such as `ServiceName` can use the primary key (SQL `=` is still listed); - for text in the log body, write the term or phrase without a field name. That uses the source's token/text index; `Body:term`, `Body LIKE` and `Body ILIKE` read the whole column, and `Body:"…"` is exact equality on the whole body. - **Query guide prompt** (`packages/api/src/mcp/prompts/dashboards/content.ts`): the same two points in the SUBSTRING MATCHING section and in common mistake 15; the "Contains" example now uses `StatusMessage` instead of `Body LIKE '%…%'`. - `prompts.test.ts` asserts both points are in the guide. ## Why Agents read "for exact match, use SQL" and mostly kept writing `ServiceName:x`, which matches `x-jobs` too and skips the primary key (one hour of logs: 108.9M vs 1.65M rows read). For body text, the descriptions pointed to `LIKE`, which can't use the index (27.7 GiB vs 121 MiB for the same matches). Details in #3538. What each recommended form compiles to on `main`: | Lucene | SQL | |---|---| | `ServiceName:api` | `ServiceName ILIKE '%api%'` | | `ServiceName:"api"` | `ServiceName = 'api'` | | `timeout` | `hasAllTokens(lower(Body), lower('timeout'))` (with a text index) | | `"connection refused"` | `hasAllTokens(lower(Body), lower('connection refused')) AND lower(Body) LIKE lower('%connection refused%')` | | `Body:"connection refused"` | `Body = 'connection refused'` | The optional hint ("`ServiceName:api` matched 2 values") is not part of this PR; happy to follow up if you want it. ## Tests - `npx jest src/mcp --testPathIgnorePatterns '\.int\.test\.ts$'` in `packages/api`: 25 suites, 484 tests pass. - `tsc --noEmit`, `eslint` (no new warnings) and `prettier` clean.
1 parent 513b237 commit 253a690

5 files changed

Lines changed: 55 additions & 9 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@hyperdx/api': patch
3+
---
4+
5+
docs: MCP tool descriptions and the query guide now recommend the quoted Lucene
6+
form `field:"value"` for exact matches, and bare terms or phrases for log body
7+
text, so agents keep the primary key and the text index

‎packages/api/src/mcp/__tests__/dashboards/prompts.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,13 @@ describe('MCP Dashboard Prompts', () => {
175175
const luceneBody = prompt.slice(luceneIdx);
176176
expect(luceneBody).toMatch(/SUBSTRING MATCHING/);
177177
expect(luceneBody).toMatch(/ilike\(field, '%value%'\)/);
178-
// The canonical fix is SQL with =.
178+
// The canonical fix is SQL with =, or a quoted Lucene value.
179179
expect(luceneBody).toMatch(/SpanKind = 'Server'/);
180+
expect(luceneBody).toMatch(
181+
/SpanKind:"Server" compiles to SpanKind = 'Server'/,
182+
);
183+
// Body text goes through the token/text index as a bare term, not Body:term.
184+
expect(luceneBody).toMatch(/bare Lucene term or phrase/);
180185
// Wildcard limitation is documented alongside substring matching.
181186
expect(luceneBody).toMatch(/prefix-within-substring/);
182187
expect(luceneBody).toMatch(/LIKE 'api%'/);

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
getMetricSelectIssues,
2929
mcpQuantileLevelSchema,
3030
validateMetricSelectItems,
31+
whereLanguageSchema,
32+
whereSchema,
3133
} from '@/mcp/tools/query/schemas';
3234
import { resolveOrderBy } from '@/mcp/tools/query/table';
3335
import { getErrorCategory } from '@/mcp/utils/errors';
@@ -1414,3 +1416,17 @@ describe('mcpQuantileLevelSchema', () => {
14141416
expect(optional.safeParse('0.97').success).toBe(false);
14151417
});
14161418
});
1419+
1420+
describe('where / whereLanguage descriptions', () => {
1421+
it('steer exact matches to the quoted Lucene form and body text to bare terms', () => {
1422+
expect(whereSchema.description).toContain(
1423+
'quote the value: SpanKind:"Server"',
1424+
);
1425+
expect(whereSchema.description).toContain(
1426+
'BODY TEXT: write the term or phrase without a field name',
1427+
);
1428+
expect(whereLanguageSchema.description).toContain(
1429+
'quote the value (field:"value", case-sensitive)',
1430+
);
1431+
});
1432+
});

‎packages/api/src/mcp/prompts/dashboards/content.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,7 @@ Used in the "where" field of select items and search tiles.
862862
OR: level:error OR level:fatal
863863
NOT: NOT level:debug
864864
Wildcard: ServiceName:front*
865-
Phrase: Body:"connection refused"
865+
Phrase: "connection refused" (default search column, e.g. the log body)
866866
Exists: _exists_:http.route
867867
Range (numeric): Duration:>1000000000
868868
Range (inclusive): http.status_code:[400 TO 499]
@@ -883,13 +883,22 @@ Lucene field:value does NOT mean exact equality. It translates to ilike(field, '
883883
ServiceName:api* does NOT mean "starts with api". It means "contains a substring starting with api".
884884
In practice this often works for prefix matching, but it is NOT equivalent to SQL LIKE 'api%'.
885885
886-
For exact matching or true prefix/suffix/contains patterns, use SQL instead:
886+
For exact matching in Lucene, quote the value: SpanKind:"Server" compiles to SpanKind = 'Server'
887+
(case-sensitive, unlike the unquoted form).
888+
889+
For true prefix/suffix/contains patterns, use SQL instead:
887890
Exact: whereLanguage: "sql", where: "SpanKind = 'Server'"
888891
Prefix: whereLanguage: "sql", where: "ServiceName LIKE 'api%'"
889-
Contains: whereLanguage: "sql", where: "Body LIKE '%timeout%'"
892+
Contains: whereLanguage: "sql", where: "StatusMessage LIKE '%timeout%'"
890893
Regex: whereLanguage: "sql", where: "match(ServiceName, '^api-v[0-9]+')"
891894
892-
When Lucene substring matching is FINE: free-text columns like Body, StatusMessage where substring is the intent.
895+
For text in the log body, write a bare Lucene term or phrase (timeout, "connection refused"). It searches the
896+
source's default search column (the body for logs) and uses its text index when the source has one. With an
897+
index it matches whole tokens (timeout does not match ConnectionTimeoutException); for a substring inside a word, use
898+
SQL ILIKE. Body:timeout, Body LIKE and Body ILIKE read the whole column, and Body:"..." is exact equality on
899+
the whole body.
900+
901+
When Lucene substring matching is FINE: free-text columns like StatusMessage where substring is the intent.
893902
When Lucene substring matching is WRONG: enum-like columns (SpanKind, SeverityText, StatusCode, ServiceName) where you want exact or true prefix match.
894903
895904
*** MAP-ATTRIBUTE GOTCHA ***
@@ -923,7 +932,7 @@ Alternative to Lucene. Set whereLanguage: "sql" when using SQL syntax.
923932
Basic: SeverityText = 'error'
924933
AND/OR: ServiceName = 'api' AND StatusCode = 'STATUS_CODE_ERROR'
925934
IN: ServiceName IN ('api', 'web', 'worker')
926-
LIKE: Body LIKE '%timeout%'
935+
LIKE: StatusMessage LIKE '%timeout%'
927936
Comparison: Duration > 1000000000
928937
Map access: SpanAttributes['http.status_code'] = '500'
929938
@@ -1562,8 +1571,9 @@ Example: find top patterns for production services over the last 4 hours:
15621571
Wrong: where: "SpanKind:Server" (whereLanguage: "lucene"; translates to ilike(SpanKind, '%Server%') and matches "ServerStreaming", "InternalServer", etc.)
15631572
Wrong: where: "ServiceName:api*" (whereLanguage: "lucene"; NOT a true prefix match -- it is prefix-within-substring)
15641573
Correct: where: "SpanKind = 'Server'" (whereLanguage: "sql"; exact match)
1574+
Correct: where: 'SpanKind:"Server"' (whereLanguage: "lucene"; a quoted value is an exact match)
15651575
Correct: where: "ServiceName LIKE 'api%'" (whereLanguage: "sql"; true prefix match)
1566-
Lucene field:value is a substring match (ilike with surrounding wildcards), not exact equality. Lucene field:value* is prefix-within-substring, not SQL LIKE 'value%'. Fine for free-text columns (Body, StatusMessage); wrong for enum-like columns where exact match matters (SpanKind, SeverityText, StatusCode) and wrong when true prefix matching is needed. Use SQL for exact match (=), prefix (LIKE 'x%'), contains (LIKE '%x%'), or regex (match(col, 'pattern')).
1576+
Lucene field:value is a substring match (ilike with surrounding wildcards), not exact equality. Lucene field:value* is prefix-within-substring, not SQL LIKE 'value%'. Fine for free-text columns (StatusMessage; for Body use a bare term or phrase, which uses the index); wrong for enum-like columns where exact match matters (SpanKind, SeverityText, StatusCode) and wrong when true prefix matching is needed. Use SQL for exact match (=), prefix (LIKE 'x%'), contains (LIKE '%x%'), or regex (match(col, 'pattern')).
15671577
15681578
16. Forgetting that map-attribute values are often empty strings
15691579
Wrong: groupBy on SpanAttributes['db.collection.name'] without filtering empty values; the result has an empty-string bucket alongside the real ones, which renders as a blank row in tables and an unlabelled slice in pies.

‎packages/api/src/mcp/tools/query/schemas.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ const WHERE_DESCRIPTION =
2020
'WRONG: level = "error" (SQL syntax with whereLanguage:"lucene")\n\n' +
2121
'SUBSTRING TRAP: Lucene field:value matches ANY row containing "value" as a substring, not exact equality.\n' +
2222
' SpanKind:Server matches "Server", "ServerStreaming", "InternalServer", etc.\n' +
23-
" For exact match, use SQL: SpanKind = 'Server'";
23+
' For exact match, quote the value: SpanKind:"Server" (compiles to a case-sensitive =, so a sort-key\n' +
24+
" column such as ServiceName can use the primary key), or use SQL: SpanKind = 'Server'\n\n" +
25+
'BODY TEXT: write the term or phrase without a field name, e.g. timeout or "connection refused".\n' +
26+
" That searches the source's default search column (the body for logs) and uses its text index\n" +
27+
' when the source has one. With an index it matches whole tokens: timeout does not match ConnectionTimeoutException.\n' +
28+
" For a substring inside a word, use SQL ILIKE '%timeout%' (reads the whole column).\n" +
29+
' Body:timeout, Body LIKE and Body ILIKE read the whole column, and Body:"..." is exact equality on the\n' +
30+
' whole body.';
2431

2532
const WHERE_LANGUAGE_DESCRIPTION =
2633
'Language of the "where" filter. The syntax in "where" MUST match this setting.\n' +
@@ -30,7 +37,8 @@ const WHERE_LANGUAGE_DESCRIPTION =
3037
'and map attributes via dot notation. Use "sql" for IN(...) lists, complex expressions, or function calls.\n' +
3138
'IMPORTANT: Lucene field:value is a SUBSTRING match (ilike), not exact equality. ' +
3239
'field:val* is prefix-within-substring, not a true prefix match. ' +
33-
"For exact matching or reliable wildcards, use SQL: WHERE field = 'value' or WHERE field LIKE 'val%'.\n\n" +
40+
'For exact matching, quote the value (field:"value", case-sensitive) or use SQL: WHERE field = \'value\'. ' +
41+
"For reliable wildcards, use SQL: WHERE field LIKE 'val%'.\n\n" +
3442
'Common mistake: writing Column:value (Lucene) but setting whereLanguage to "sql". ' +
3543
'If your filter uses colon syntax, use "lucene".';
3644

0 commit comments

Comments
 (0)