Skip to content

Commit 4e54d85

Browse files
authored
fix: show Map sub-fields in facet panel for non-LowCardinality value types (#2002)
## Summary When a table uses `Map(LowCardinality(String), String)` for columns like `LogAttributes` or `ResourceAttributes`, none of the Map sub-fields appear in the search page's facet/filter panel. This is because the filter logic extracts the Map **value** type (`String`) and checks it for `LowCardinality` to decide default visibility — so only `Map(LowCardinality(String), LowCardinality(String))` schemas worked. The fix adds an `isMapSubField` check (`path.length > 1`) so that all Map/JSON sub-fields are always included in the default filter set, regardless of the Map value type. Regular top-level `String` columns (like `Body`) remain hidden by default. ### How to test locally or on Vercel 1. Configure a ClickHouse source with a table that uses `Map(LowCardinality(String), String)` for LogAttributes or ResourceAttributes (e.g. the default ClickHouse Cloud OTel exporter schema) 2. Navigate to the search page for that source 3. Verify that LogAttributes and ResourceAttributes sub-fields now appear in the facet panel by default, without needing to click "More filters" ### References - Customer escalation
1 parent 9cfb7e9 commit 4e54d85

3 files changed

Lines changed: 229 additions & 1 deletion

File tree

‎.changeset/gentle-numbers-warn.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hyperdx/app": patch
3+
"@hyperdx/common-utils": patch
4+
---
5+
6+
fix: show Map sub-fields in facet panel for non-LowCardinality value types

‎packages/app/src/components/DBSearchPageFilters.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -960,12 +960,17 @@ const DBSearchPageFiltersComponent = ({
960960
// todo: add number type with sliders :D
961961
)
962962
.map(({ path, type }) => {
963-
return { type, path: mergePath(path, jsonColumns ?? []) };
963+
return {
964+
type,
965+
path: mergePath(path, jsonColumns ?? []),
966+
isMapSubField: path.length > 1,
967+
};
964968
})
965969
.filter(
966970
field =>
967971
showMoreFields ||
968972
field.type.includes('LowCardinality') || // query only low cardinality fields by default
973+
field.isMapSubField || // always include Map/JSON sub-fields (e.g. LogAttributes, ResourceAttributes keys)
969974
Object.keys(filterState).includes(field.path) || // keep selected fields
970975
isFieldPinned(field.path), // keep pinned fields
971976
)

‎packages/common-utils/src/__tests__/metadata.test.ts‎

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,223 @@ describe('Metadata', () => {
688688
});
689689
});
690690

691+
describe('getAllFields', () => {
692+
beforeEach(() => {
693+
jest.clearAllMocks();
694+
});
695+
696+
it('should extract LowCardinality(String) type for Map sub-fields when value type is LowCardinality', async () => {
697+
// Simulate: Map(LowCardinality(String), LowCardinality(String))
698+
// This is the "working" case — sub-fields get type LowCardinality(String)
699+
const realCache = new (
700+
jest.requireActual('../core/metadata') as any
701+
).MetadataCache();
702+
const md = new Metadata(mockClickhouseClient, realCache);
703+
704+
// Mock getColumns → returns one Map column
705+
(mockClickhouseClient.query as jest.Mock)
706+
.mockResolvedValueOnce({
707+
// DESCRIBE TABLE
708+
json: () =>
709+
Promise.resolve({
710+
data: [
711+
{
712+
name: 'LogAttributes',
713+
type: 'Map(LowCardinality(String), LowCardinality(String))',
714+
default_type: '',
715+
default_expression: '',
716+
comment: '',
717+
codec_expression: '',
718+
ttl_expression: '',
719+
},
720+
],
721+
}),
722+
})
723+
.mockResolvedValueOnce({
724+
// lowCardinalityKeys query for LogAttributes
725+
json: () =>
726+
Promise.resolve({
727+
data: [{ key: 'http.method' }, { key: 'http.status_code' }],
728+
}),
729+
});
730+
731+
const fields = await md.getAllFields({
732+
databaseName: 'otel',
733+
tableName: 'otel_logs',
734+
connectionId: 'conn-1',
735+
});
736+
737+
// The Map column itself should be present
738+
const mapField = fields.find(
739+
f => f.path.length === 1 && f.path[0] === 'LogAttributes',
740+
);
741+
expect(mapField).toBeDefined();
742+
expect(mapField!.type).toBe(
743+
'Map(LowCardinality(String), LowCardinality(String))',
744+
);
745+
746+
// Sub-fields should have LowCardinality(String) as their type
747+
const httpMethod = fields.find(
748+
f =>
749+
f.path.length === 2 &&
750+
f.path[0] === 'LogAttributes' &&
751+
f.path[1] === 'http.method',
752+
);
753+
expect(httpMethod).toBeDefined();
754+
expect(httpMethod!.type).toBe('LowCardinality(String)');
755+
// This type includes 'LowCardinality', so the UI filter check passes
756+
expect(httpMethod!.type.includes('LowCardinality')).toBe(true);
757+
});
758+
759+
it('should extract String type for Map sub-fields when value type is plain String — BUG: fields excluded from default filters', async () => {
760+
// Simulate: Map(LowCardinality(String), String)
761+
// This is the customer's schema (Constructor.io) — sub-fields get type "String"
762+
// which causes them to be filtered out of the default facet panel
763+
const realCache = new (
764+
jest.requireActual('../core/metadata') as any
765+
).MetadataCache();
766+
const md = new Metadata(mockClickhouseClient, realCache);
767+
768+
(mockClickhouseClient.query as jest.Mock)
769+
.mockResolvedValueOnce({
770+
// DESCRIBE TABLE
771+
json: () =>
772+
Promise.resolve({
773+
data: [
774+
{
775+
name: 'LogAttributes',
776+
type: 'Map(LowCardinality(String), String)',
777+
default_type: '',
778+
default_expression: '',
779+
comment: '',
780+
codec_expression: '',
781+
ttl_expression: '',
782+
},
783+
{
784+
name: 'ResourceAttributes',
785+
type: 'Map(LowCardinality(String), String)',
786+
default_type: '',
787+
default_expression: '',
788+
comment: '',
789+
codec_expression: '',
790+
ttl_expression: '',
791+
},
792+
],
793+
}),
794+
})
795+
.mockResolvedValueOnce({
796+
// lowCardinalityKeys query for LogAttributes
797+
json: () =>
798+
Promise.resolve({
799+
data: [{ key: 'io.constructor.message' }, { key: 'severity' }],
800+
}),
801+
})
802+
.mockResolvedValueOnce({
803+
// lowCardinalityKeys query for ResourceAttributes
804+
json: () =>
805+
Promise.resolve({
806+
data: [{ key: 'log.index' }, { key: 'service.name' }],
807+
}),
808+
});
809+
810+
const fields = await md.getAllFields({
811+
databaseName: 'otel',
812+
tableName: 'otel_logs',
813+
connectionId: 'conn-1',
814+
});
815+
816+
// Sub-fields for LogAttributes
817+
const logAttrField = fields.find(
818+
f =>
819+
f.path[0] === 'LogAttributes' &&
820+
f.path[1] === 'io.constructor.message',
821+
);
822+
expect(logAttrField).toBeDefined();
823+
// BUG: The extracted type is "String" (the Map VALUE type), NOT "LowCardinality(String)"
824+
expect(logAttrField!.type).toBe('String');
825+
// This means the UI's LowCardinality check FAILS, hiding the field by default
826+
expect(logAttrField!.type.includes('LowCardinality')).toBe(false);
827+
828+
// Same issue for ResourceAttributes
829+
const resAttrField = fields.find(
830+
f => f.path[0] === 'ResourceAttributes' && f.path[1] === 'log.index',
831+
);
832+
expect(resAttrField).toBeDefined();
833+
expect(resAttrField!.type).toBe('String');
834+
expect(resAttrField!.type.includes('LowCardinality')).toBe(false);
835+
});
836+
837+
it('demonstrates that Map sub-fields with plain String type are included via isMapSubField check', async () => {
838+
// This test simulates the fixed keysToFetch filtering logic from DBSearchPageFilters.tsx
839+
const fields = [
840+
// Regular LowCardinality column — always shown
841+
{
842+
path: ['SeverityText'],
843+
type: 'LowCardinality(String)',
844+
jsType: 'string' as const,
845+
},
846+
{
847+
path: ['ServiceName'],
848+
type: 'LowCardinality(String)',
849+
jsType: 'string' as const,
850+
},
851+
// Map(LowCardinality(String), LowCardinality(String)) sub-fields — shown (type has LowCardinality)
852+
{
853+
path: ['SpanAttributes', 'http.method'],
854+
type: 'LowCardinality(String)',
855+
jsType: 'string' as const,
856+
},
857+
// Map(LowCardinality(String), String) sub-fields — now shown via isMapSubField
858+
{
859+
path: ['LogAttributes', 'io.constructor.message'],
860+
type: 'String',
861+
jsType: 'string' as const,
862+
},
863+
{
864+
path: ['ResourceAttributes', 'log.index'],
865+
type: 'String',
866+
jsType: 'string' as const,
867+
},
868+
// Regular String column (not a Map sub-field) — still hidden by default
869+
{ path: ['Body'], type: 'String', jsType: 'string' as const },
870+
];
871+
872+
// Simulate the fixed filter logic from DBSearchPageFilters.tsx
873+
const showMoreFields = false; // default state
874+
const filterState: Record<string, unknown> = {};
875+
const isFieldPinned = () => false;
876+
877+
const keysToFetch = fields
878+
.filter(field => field.jsType && ['string'].includes(field.jsType))
879+
.map(({ path, type }) => ({
880+
type,
881+
path: path.join('.'),
882+
isMapSubField: path.length > 1,
883+
}))
884+
.filter(
885+
field =>
886+
showMoreFields ||
887+
field.type.includes('LowCardinality') ||
888+
field.isMapSubField || // Fix: always include Map/JSON sub-fields
889+
Object.keys(filterState).includes(field.path) ||
890+
isFieldPinned(),
891+
)
892+
.map(f => f.path);
893+
894+
// LowCardinality columns still shown
895+
expect(keysToFetch).toContain('SeverityText');
896+
expect(keysToFetch).toContain('ServiceName');
897+
expect(keysToFetch).toContain('SpanAttributes.http.method');
898+
899+
// Map(LowCardinality(String), String) sub-fields NOW included
900+
expect(keysToFetch).toContain('LogAttributes.io.constructor.message');
901+
expect(keysToFetch).toContain('ResourceAttributes.log.index');
902+
903+
// Regular non-LowCardinality columns still hidden by default
904+
expect(keysToFetch).not.toContain('Body');
905+
});
906+
});
907+
691908
describe('parseTokensExpression', () => {
692909
it.each([
693910
// Test cases without tokens

0 commit comments

Comments
 (0)