mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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
This commit is contained in:
parent
9cfb7e9c42
commit
4e54d85061
3 changed files with 229 additions and 1 deletions
6
.changeset/gentle-numbers-warn.md
Normal file
6
.changeset/gentle-numbers-warn.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
"@hyperdx/common-utils": patch
|
||||
---
|
||||
|
||||
fix: show Map sub-fields in facet panel for non-LowCardinality value types
|
||||
|
|
@ -960,12 +960,17 @@ const DBSearchPageFiltersComponent = ({
|
|||
// todo: add number type with sliders :D
|
||||
)
|
||||
.map(({ path, type }) => {
|
||||
return { type, path: mergePath(path, jsonColumns ?? []) };
|
||||
return {
|
||||
type,
|
||||
path: mergePath(path, jsonColumns ?? []),
|
||||
isMapSubField: path.length > 1,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
field =>
|
||||
showMoreFields ||
|
||||
field.type.includes('LowCardinality') || // query only low cardinality fields by default
|
||||
field.isMapSubField || // always include Map/JSON sub-fields (e.g. LogAttributes, ResourceAttributes keys)
|
||||
Object.keys(filterState).includes(field.path) || // keep selected fields
|
||||
isFieldPinned(field.path), // keep pinned fields
|
||||
)
|
||||
|
|
|
|||
|
|
@ -688,6 +688,223 @@ describe('Metadata', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getAllFields', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should extract LowCardinality(String) type for Map sub-fields when value type is LowCardinality', async () => {
|
||||
// Simulate: Map(LowCardinality(String), LowCardinality(String))
|
||||
// This is the "working" case — sub-fields get type LowCardinality(String)
|
||||
const realCache = new (
|
||||
jest.requireActual('../core/metadata') as any
|
||||
).MetadataCache();
|
||||
const md = new Metadata(mockClickhouseClient, realCache);
|
||||
|
||||
// Mock getColumns → returns one Map column
|
||||
(mockClickhouseClient.query as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
// DESCRIBE TABLE
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
name: 'LogAttributes',
|
||||
type: 'Map(LowCardinality(String), LowCardinality(String))',
|
||||
default_type: '',
|
||||
default_expression: '',
|
||||
comment: '',
|
||||
codec_expression: '',
|
||||
ttl_expression: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
// lowCardinalityKeys query for LogAttributes
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [{ key: 'http.method' }, { key: 'http.status_code' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const fields = await md.getAllFields({
|
||||
databaseName: 'otel',
|
||||
tableName: 'otel_logs',
|
||||
connectionId: 'conn-1',
|
||||
});
|
||||
|
||||
// The Map column itself should be present
|
||||
const mapField = fields.find(
|
||||
f => f.path.length === 1 && f.path[0] === 'LogAttributes',
|
||||
);
|
||||
expect(mapField).toBeDefined();
|
||||
expect(mapField!.type).toBe(
|
||||
'Map(LowCardinality(String), LowCardinality(String))',
|
||||
);
|
||||
|
||||
// Sub-fields should have LowCardinality(String) as their type
|
||||
const httpMethod = fields.find(
|
||||
f =>
|
||||
f.path.length === 2 &&
|
||||
f.path[0] === 'LogAttributes' &&
|
||||
f.path[1] === 'http.method',
|
||||
);
|
||||
expect(httpMethod).toBeDefined();
|
||||
expect(httpMethod!.type).toBe('LowCardinality(String)');
|
||||
// This type includes 'LowCardinality', so the UI filter check passes
|
||||
expect(httpMethod!.type.includes('LowCardinality')).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract String type for Map sub-fields when value type is plain String — BUG: fields excluded from default filters', async () => {
|
||||
// Simulate: Map(LowCardinality(String), String)
|
||||
// This is the customer's schema (Constructor.io) — sub-fields get type "String"
|
||||
// which causes them to be filtered out of the default facet panel
|
||||
const realCache = new (
|
||||
jest.requireActual('../core/metadata') as any
|
||||
).MetadataCache();
|
||||
const md = new Metadata(mockClickhouseClient, realCache);
|
||||
|
||||
(mockClickhouseClient.query as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
// DESCRIBE TABLE
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
name: 'LogAttributes',
|
||||
type: 'Map(LowCardinality(String), String)',
|
||||
default_type: '',
|
||||
default_expression: '',
|
||||
comment: '',
|
||||
codec_expression: '',
|
||||
ttl_expression: '',
|
||||
},
|
||||
{
|
||||
name: 'ResourceAttributes',
|
||||
type: 'Map(LowCardinality(String), String)',
|
||||
default_type: '',
|
||||
default_expression: '',
|
||||
comment: '',
|
||||
codec_expression: '',
|
||||
ttl_expression: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
// lowCardinalityKeys query for LogAttributes
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [{ key: 'io.constructor.message' }, { key: 'severity' }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
// lowCardinalityKeys query for ResourceAttributes
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [{ key: 'log.index' }, { key: 'service.name' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const fields = await md.getAllFields({
|
||||
databaseName: 'otel',
|
||||
tableName: 'otel_logs',
|
||||
connectionId: 'conn-1',
|
||||
});
|
||||
|
||||
// Sub-fields for LogAttributes
|
||||
const logAttrField = fields.find(
|
||||
f =>
|
||||
f.path[0] === 'LogAttributes' &&
|
||||
f.path[1] === 'io.constructor.message',
|
||||
);
|
||||
expect(logAttrField).toBeDefined();
|
||||
// BUG: The extracted type is "String" (the Map VALUE type), NOT "LowCardinality(String)"
|
||||
expect(logAttrField!.type).toBe('String');
|
||||
// This means the UI's LowCardinality check FAILS, hiding the field by default
|
||||
expect(logAttrField!.type.includes('LowCardinality')).toBe(false);
|
||||
|
||||
// Same issue for ResourceAttributes
|
||||
const resAttrField = fields.find(
|
||||
f => f.path[0] === 'ResourceAttributes' && f.path[1] === 'log.index',
|
||||
);
|
||||
expect(resAttrField).toBeDefined();
|
||||
expect(resAttrField!.type).toBe('String');
|
||||
expect(resAttrField!.type.includes('LowCardinality')).toBe(false);
|
||||
});
|
||||
|
||||
it('demonstrates that Map sub-fields with plain String type are included via isMapSubField check', async () => {
|
||||
// This test simulates the fixed keysToFetch filtering logic from DBSearchPageFilters.tsx
|
||||
const fields = [
|
||||
// Regular LowCardinality column — always shown
|
||||
{
|
||||
path: ['SeverityText'],
|
||||
type: 'LowCardinality(String)',
|
||||
jsType: 'string' as const,
|
||||
},
|
||||
{
|
||||
path: ['ServiceName'],
|
||||
type: 'LowCardinality(String)',
|
||||
jsType: 'string' as const,
|
||||
},
|
||||
// Map(LowCardinality(String), LowCardinality(String)) sub-fields — shown (type has LowCardinality)
|
||||
{
|
||||
path: ['SpanAttributes', 'http.method'],
|
||||
type: 'LowCardinality(String)',
|
||||
jsType: 'string' as const,
|
||||
},
|
||||
// Map(LowCardinality(String), String) sub-fields — now shown via isMapSubField
|
||||
{
|
||||
path: ['LogAttributes', 'io.constructor.message'],
|
||||
type: 'String',
|
||||
jsType: 'string' as const,
|
||||
},
|
||||
{
|
||||
path: ['ResourceAttributes', 'log.index'],
|
||||
type: 'String',
|
||||
jsType: 'string' as const,
|
||||
},
|
||||
// Regular String column (not a Map sub-field) — still hidden by default
|
||||
{ path: ['Body'], type: 'String', jsType: 'string' as const },
|
||||
];
|
||||
|
||||
// Simulate the fixed filter logic from DBSearchPageFilters.tsx
|
||||
const showMoreFields = false; // default state
|
||||
const filterState: Record<string, unknown> = {};
|
||||
const isFieldPinned = () => false;
|
||||
|
||||
const keysToFetch = fields
|
||||
.filter(field => field.jsType && ['string'].includes(field.jsType))
|
||||
.map(({ path, type }) => ({
|
||||
type,
|
||||
path: path.join('.'),
|
||||
isMapSubField: path.length > 1,
|
||||
}))
|
||||
.filter(
|
||||
field =>
|
||||
showMoreFields ||
|
||||
field.type.includes('LowCardinality') ||
|
||||
field.isMapSubField || // Fix: always include Map/JSON sub-fields
|
||||
Object.keys(filterState).includes(field.path) ||
|
||||
isFieldPinned(),
|
||||
)
|
||||
.map(f => f.path);
|
||||
|
||||
// LowCardinality columns still shown
|
||||
expect(keysToFetch).toContain('SeverityText');
|
||||
expect(keysToFetch).toContain('ServiceName');
|
||||
expect(keysToFetch).toContain('SpanAttributes.http.method');
|
||||
|
||||
// Map(LowCardinality(String), String) sub-fields NOW included
|
||||
expect(keysToFetch).toContain('LogAttributes.io.constructor.message');
|
||||
expect(keysToFetch).toContain('ResourceAttributes.log.index');
|
||||
|
||||
// Regular non-LowCardinality columns still hidden by default
|
||||
expect(keysToFetch).not.toContain('Body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTokensExpression', () => {
|
||||
it.each([
|
||||
// Test cases without tokens
|
||||
|
|
|
|||
Loading…
Reference in a new issue