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
7 changes: 7 additions & 0 deletions .changeset/mcp-lucene-exact-match.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@hyperdx/api': patch
---

docs: MCP tool descriptions and the query guide now recommend the quoted Lucene
form `field:"value"` for exact matches, and bare terms or phrases for log body
text, so agents keep the primary key and the text index
7 changes: 6 additions & 1 deletion packages/api/src/mcp/__tests__/dashboards/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,13 @@ describe('MCP Dashboard Prompts', () => {
const luceneBody = prompt.slice(luceneIdx);
expect(luceneBody).toMatch(/SUBSTRING MATCHING/);
expect(luceneBody).toMatch(/ilike\(field, '%value%'\)/);
// The canonical fix is SQL with =.
// The canonical fix is SQL with =, or a quoted Lucene value.
expect(luceneBody).toMatch(/SpanKind = 'Server'/);
expect(luceneBody).toMatch(
/SpanKind:"Server" compiles to SpanKind = 'Server'/,
);
// Body text goes through the token/text index as a bare term, not Body:term.
expect(luceneBody).toMatch(/bare Lucene term or phrase/);
// Wildcard limitation is documented alongside substring matching.
expect(luceneBody).toMatch(/prefix-within-substring/);
expect(luceneBody).toMatch(/LIKE 'api%'/);
Expand Down
16 changes: 16 additions & 0 deletions packages/api/src/mcp/__tests__/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
getMetricSelectIssues,
mcpQuantileLevelSchema,
validateMetricSelectItems,
whereLanguageSchema,
whereSchema,
} from '@/mcp/tools/query/schemas';
import { resolveOrderBy } from '@/mcp/tools/query/table';
import { getErrorCategory } from '@/mcp/utils/errors';
Expand Down Expand Up @@ -1414,3 +1416,17 @@ describe('mcpQuantileLevelSchema', () => {
expect(optional.safeParse('0.97').success).toBe(false);
});
});

describe('where / whereLanguage descriptions', () => {
it('steer exact matches to the quoted Lucene form and body text to bare terms', () => {
expect(whereSchema.description).toContain(
'quote the value: SpanKind:"Server"',
);
expect(whereSchema.description).toContain(
'BODY TEXT: write the term or phrase without a field name',
);
expect(whereLanguageSchema.description).toContain(
'quote the value (field:"value", case-sensitive)',
);
});
});
22 changes: 16 additions & 6 deletions packages/api/src/mcp/prompts/dashboards/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -862,7 +862,7 @@ Used in the "where" field of select items and search tiles.
OR: level:error OR level:fatal
NOT: NOT level:debug
Wildcard: ServiceName:front*
Phrase: Body:"connection refused"
Phrase: "connection refused" (default search column, e.g. the log body)
Exists: _exists_:http.route
Range (numeric): Duration:>1000000000
Range (inclusive): http.status_code:[400 TO 499]
Expand All @@ -883,13 +883,22 @@ Lucene field:value does NOT mean exact equality. It translates to ilike(field, '
ServiceName:api* does NOT mean "starts with api". It means "contains a substring starting with api".
In practice this often works for prefix matching, but it is NOT equivalent to SQL LIKE 'api%'.

For exact matching or true prefix/suffix/contains patterns, use SQL instead:
For exact matching in Lucene, quote the value: SpanKind:"Server" compiles to SpanKind = 'Server'
(case-sensitive, unlike the unquoted form).

For true prefix/suffix/contains patterns, use SQL instead:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — The new heading "For true prefix/suffix/contains patterns, use SQL instead" sits above a list that still starts with Exact:

Either move the Exact: SpanKind = 'Server' line next to the new quoted-Lucene paragraph above it, or change the heading back to "For exact matching or true prefix/suffix/contains patterns, use SQL". As written, the heading and the first example disagree.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

Exact: whereLanguage: "sql", where: "SpanKind = 'Server'"
Prefix: whereLanguage: "sql", where: "ServiceName LIKE 'api%'"
Contains: whereLanguage: "sql", where: "Body LIKE '%timeout%'"
Contains: whereLanguage: "sql", where: "StatusMessage LIKE '%timeout%'"
Regex: whereLanguage: "sql", where: "match(ServiceName, '^api-v[0-9]+')"

When Lucene substring matching is FINE: free-text columns like Body, StatusMessage where substring is the intent.
For text in the log body, write a bare Lucene term or phrase (timeout, "connection refused"). It searches the
source's default search column (the body for logs) and uses its text index when the source has one. With an
index it matches whole tokens (timeout does not match ConnectionTimeoutException); for a substring inside a word, use
SQL ILIKE. Body:timeout, Body LIKE and Body ILIKE read the whole column, and Body:"..." is exact equality on
the whole body.

When Lucene substring matching is FINE: free-text columns like StatusMessage where substring is the intent.
When Lucene substring matching is WRONG: enum-like columns (SpanKind, SeverityText, StatusCode, ServiceName) where you want exact or true prefix match.

*** MAP-ATTRIBUTE GOTCHA ***
Expand Down Expand Up @@ -923,7 +932,7 @@ Alternative to Lucene. Set whereLanguage: "sql" when using SQL syntax.
Basic: SeverityText = 'error'
AND/OR: ServiceName = 'api' AND StatusCode = 'STATUS_CODE_ERROR'
IN: ServiceName IN ('api', 'web', 'worker')
LIKE: Body LIKE '%timeout%'
LIKE: StatusMessage LIKE '%timeout%'
Comparison: Duration > 1000000000
Map access: SpanAttributes['http.status_code'] = '500'

Expand Down Expand Up @@ -1562,8 +1571,9 @@ Example: find top patterns for production services over the last 4 hours:
Wrong: where: "SpanKind:Server" (whereLanguage: "lucene"; translates to ilike(SpanKind, '%Server%') and matches "ServerStreaming", "InternalServer", etc.)
Wrong: where: "ServiceName:api*" (whereLanguage: "lucene"; NOT a true prefix match -- it is prefix-within-substring)
Correct: where: "SpanKind = 'Server'" (whereLanguage: "sql"; exact match)
Correct: where: 'SpanKind:"Server"' (whereLanguage: "lucene"; a quoted value is an exact match)
Correct: where: "ServiceName LIKE 'api%'" (whereLanguage: "sql"; true prefix match)
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')).
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')).

16. Forgetting that map-attribute values are often empty strings
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.
Expand Down
12 changes: 10 additions & 2 deletions packages/api/src/mcp/tools/query/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ const WHERE_DESCRIPTION =
'WRONG: level = "error" (SQL syntax with whereLanguage:"lucene")\n\n' +
'SUBSTRING TRAP: Lucene field:value matches ANY row containing "value" as a substring, not exact equality.\n' +
' SpanKind:Server matches "Server", "ServerStreaming", "InternalServer", etc.\n' +
" For exact match, use SQL: SpanKind = 'Server'";
' For exact match, quote the value: SpanKind:"Server" (compiles to a case-sensitive =, so a sort-key\n' +
" column such as ServiceName can use the primary key), or use SQL: SpanKind = 'Server'\n\n" +
'BODY TEXT: write the term or phrase without a field name, e.g. timeout or "connection refused".\n' +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — Bare-term advice drops substring matches that Body LIKE/Body:term returned, and the docs don't say so

Say that a bare term matches whole tokens. In packages/common-utils/src/queryParser.ts:1586-1623 a single bare term like timeout becomes hasAllTokens(...) (or hasAll(tokens(...))), so it no longer matches rows such as ConnectionTimeoutException or timeouts that Body LIKE '%timeout%' matched. Add a line to the description: for a true substring inside a word, use a wildcard (*timeout*) or SQL LIKE. Repeat the note in content.ts lines 892-894 and mistake 15.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

" That searches the source's default search column (the body for logs) and uses its text index\n" +
' when the source has one. With an index it matches whole tokens: timeout does not match ConnectionTimeoutException.\n' +
" For a substring inside a word, use SQL ILIKE '%timeout%' (reads the whole column).\n" +
' Body:timeout, Body LIKE and Body ILIKE read the whole column, and Body:"..." is exact equality on the\n' +
' whole body.';

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

Expand Down
Loading