From b57847df3888dcceec2c3da7c3e20215d9091664 Mon Sep 17 00:00:00 2001
From: Aaron Knudtson <87577305+knudtty@users.noreply.github.com>
Date: Wed, 27 May 2026 10:54:00 -0400
Subject: [PATCH] fix: don't wrap JSON filters in "toString" until query
rendering
---
.../add-to-filter-json-column-toString.md | 15 +++++++++++++++
.../src/components/DBRowJsonViewer.test.tsx | 19 +++++++++++++++++--
.../app/src/components/DBRowJsonViewer.tsx | 19 ++++---------------
packages/app/src/components/EventTag.tsx | 6 +++++-
.../src/__tests__/filters.test.ts | 16 ++++++++++++++++
.../src/__tests__/queryParser.test.ts | 13 +++++++++++++
6 files changed, 70 insertions(+), 18 deletions(-)
create mode 100644 .changeset/add-to-filter-json-column-toString.md
diff --git a/.changeset/add-to-filter-json-column-toString.md b/.changeset/add-to-filter-json-column-toString.md
new file mode 100644
index 0000000000..4b0b247962
--- /dev/null
+++ b/.changeset/add-to-filter-json-column-toString.md
@@ -0,0 +1,15 @@
+---
+"@hyperdx/app": patch
+---
+
+fix: "Add to Filters" on a JSON-typed ClickHouse column no longer produces an
+unparseable Lucene query
+
+Previously, clicking "Add to Filters" on a field under a JSON column wrapped
+the field path with `toString(...)` before handing it off as a Lucene filter
+key. Lucene's grammar forbids parentheses inside field names, so the resulting
+condition like `toString(JSONColumn.\`foo\`):"…"` failed to parse with
+`Expected … but ":" found.`
+
+The handler now passes the clean dot-notation path (e.g. `JSONColumn.foo`)
+to the filter setter.
diff --git a/packages/app/src/components/DBRowJsonViewer.test.tsx b/packages/app/src/components/DBRowJsonViewer.test.tsx
index 161a5abfe6..e889a6df69 100644
--- a/packages/app/src/components/DBRowJsonViewer.test.tsx
+++ b/packages/app/src/components/DBRowJsonViewer.test.tsx
@@ -53,10 +53,10 @@ describe('DBRowJsonViewer', () => {
});
// Helper to render component
- const renderComponent = (data: any) => {
+ const renderComponent = (data: any, jsonColumns?: string[]) => {
return renderWithMantine(
-
+
,
);
};
@@ -133,6 +133,21 @@ describe('DBRowJsonViewer', () => {
);
});
+ it('adds filters on JSON-typed column as plain dot notation (no toString, no backticks)', () => {
+ const jsonData = {
+ Metadata: {
+ endpoint: 'grpc://thingy:8888/Verify',
+ },
+ };
+ renderComponent(jsonData, ['Metadata']);
+ clickLineButton('endpoint', 'Add to Filters');
+
+ expect(mockOnPropertyAddClick).toHaveBeenCalledWith(
+ 'Metadata.endpoint',
+ 'grpc://thingy:8888/Verify',
+ );
+ });
+
it('toggles columns with correct path formatting', () => {
renderComponent(logData);
clickLineButton('field1', 'Column');
diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx
index 65abd56115..e6aea39c5f 100644
--- a/packages/app/src/components/DBRowJsonViewer.tsx
+++ b/packages/app/src/components/DBRowJsonViewer.tsx
@@ -27,6 +27,7 @@ import {
IconTextWrap,
} from '@tabler/icons-react';
+import { cleanClickHouseExpression } from '@/components/DBSearchPageFilters/utils';
import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson';
import { mergePath } from '@/utils';
import {
@@ -370,8 +371,6 @@ export function DBRowJsonViewer({
({ keyPath, value, isInParsedJson, parsedJsonRootPath }) => {
const actions: LineAction[] = [];
const fieldPath = mergePath(keyPath, jsonColumns);
- const isJsonColumn =
- keyPath.length > 0 && jsonColumns?.includes(keyPath[0]);
// Add to Filters action (strings only)
// FIXME: TOTAL HACK To disallow adding timestamp to filters
@@ -394,7 +393,6 @@ export function DBRowJsonViewer({
onClick: () => {
let filterFieldPath = fieldPath;
- // Handle parsed JSON from string columns using JSONExtractString
if (isInParsedJson && parsedJsonRootPath) {
const jsonQuery = buildJSONExtractQuery(
keyPath,
@@ -403,23 +401,14 @@ export function DBRowJsonViewer({
);
if (jsonQuery) {
filterFieldPath = jsonQuery;
- } else {
- // We're at the root of the parsed JSON, treat as string
- filterFieldPath = isJsonColumn
- ? `toString(${fieldPath})`
- : fieldPath;
}
- } else {
- // Regular JSON column or non-JSON field
- filterFieldPath = isJsonColumn
- ? `toString(${fieldPath})`
- : fieldPath;
}
- onPropertyAddClick(filterFieldPath, value);
+ const cleanedPath = cleanClickHouseExpression(filterFieldPath);
+ onPropertyAddClick(cleanedPath, value);
notifications.show({
color: 'green',
- message: `Added "${fieldPath} = ${value}" to filters`,
+ message: `Added "${cleanedPath} = ${value}" to filters`,
});
},
});
diff --git a/packages/app/src/components/EventTag.tsx b/packages/app/src/components/EventTag.tsx
index 9cb6177fb0..d2ae465604 100644
--- a/packages/app/src/components/EventTag.tsx
+++ b/packages/app/src/components/EventTag.tsx
@@ -5,6 +5,7 @@ import { SearchConditionLanguage } from '@hyperdx/common-utils/dist/types';
import { Button, Group, Popover, Stack, Text, Tooltip } from '@mantine/core';
import { IconCirclePlus, IconLink, IconSearch } from '@tabler/icons-react';
+import { cleanClickHouseExpression } from '@/components/DBSearchPageFilters/utils';
import { isLinkableUrl } from '@/utils/highlightedAttributes';
export default function EventTag({
@@ -105,7 +106,10 @@ export default function EventTag({
size="xs"
rightSection={}
onClick={() => {
- onPropertyAddClick(sqlExpression, value);
+ onPropertyAddClick(
+ cleanClickHouseExpression(sqlExpression),
+ value,
+ );
setOpened(false);
}}
>
diff --git a/packages/common-utils/src/__tests__/filters.test.ts b/packages/common-utils/src/__tests__/filters.test.ts
index d2ba8ebb2f..9ba03e7c30 100644
--- a/packages/common-utils/src/__tests__/filters.test.ts
+++ b/packages/common-utils/src/__tests__/filters.test.ts
@@ -457,6 +457,22 @@ describe('filters', () => {
expect(parsed?.[0].included).toEqual(['my-app']);
});
+ it('round-trips a JSON column dot-path with colons and slashes in value', () => {
+ const state: FilterState = {
+ 'Metadata.endpoint': {
+ included: new Set(['grpc://thingy:8888/Verify']),
+ excluded: new Set(),
+ },
+ };
+ const filters = filtersToQuery(state);
+ const condition = getCondition(filters[0]);
+ expect(condition).not.toMatch(/^toString\(/);
+ expect(condition).not.toMatch(/`/);
+ const parsed = parseLuceneFilter(condition);
+ expect(parsed?.[0].key).toBe('Metadata.endpoint');
+ expect(parsed?.[0].included).toEqual(['grpc://thingy:8888/Verify']);
+ });
+
it('round-trips mixed included and excluded for same field', () => {
const state: FilterState = {
env: {
diff --git a/packages/common-utils/src/__tests__/queryParser.test.ts b/packages/common-utils/src/__tests__/queryParser.test.ts
index dbc79c3e54..b223c6bb69 100644
--- a/packages/common-utils/src/__tests__/queryParser.test.ts
+++ b/packages/common-utils/src/__tests__/queryParser.test.ts
@@ -493,6 +493,19 @@ describe('CustomSchemaSQLSerializerV2 - json', () => {
"((hasToken(lower(concatWithSeparator(';',Body,OtherColumn)), lower('foo'))) AND (hasToken(lower(concatWithSeparator(';',Body,OtherColumn)), lower('bar'))))";
expect(actualSql).toBe(expectedSql);
});
+
+ it('"Add to Filters" repro: JSON column path + value with colons and slashes emits valid SQL', async () => {
+ const lucene =
+ 'ResourceAttributesJSON.endpoint:"grpc://auth-svc:50051/Verify"';
+ const builder = new SearchQueryBuilder(lucene, serializer);
+ const actualSql = await builder.build();
+ expect(actualSql).toContain(
+ 'toString(`ResourceAttributesJSON`.`endpoint`)',
+ );
+ expect(actualSql).toContain('grpc://auth-svc:50051/Verify');
+ expect(actualSql).not.toContain('http_COLON_');
+ expect(actualSql).not.toContain('HDX_COLON');
+ });
});
describe('CustomSchemaSQLSerializerV2 - bloom_filter tokens() indices', () => {