diff --git a/.changeset/cascading-dashboard-filters.md b/.changeset/cascading-dashboard-filters.md
new file mode 100644
index 0000000000..5ad16612ff
--- /dev/null
+++ b/.changeset/cascading-dashboard-filters.md
@@ -0,0 +1,16 @@
+---
+'@hyperdx/app': patch
+---
+
+feat(dashboards): opt-in linked (faceted) filter values
+
+Dashboard and Kubernetes filter bars gain a "link filters" toggle (the
+bidirectional-arrow button at the end of the bar). When enabled, each filter
+dropdown only shows values that co-occur with the other current selections —
+e.g. picking a `cluster` narrows the `namespace` dropdown to namespaces in that
+cluster (the K8s bar also factors in the free-text search). A filter never
+constrains its own options, so multi-select still works. It is off by default
+because contingent value lookups can't use the cheap per-key rollups and are
+more expensive at scale; when on, all of a source's facets are computed in a
+single `groupUniqArrayIf` scan rather than one query per filter. Search-page
+filters are unaffected.
diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx
index f92ac131c8..275c1c8bd2 100644
--- a/packages/app/src/DashboardFilters.tsx
+++ b/packages/app/src/DashboardFilters.tsx
@@ -1,8 +1,10 @@
+import { useState } from 'react';
import { FilterState } from '@hyperdx/common-utils/dist/filters';
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
import { Group, Stack, Text, Tooltip } from '@mantine/core';
import { IconAlertTriangle, IconHelp, IconRefresh } from '@tabler/icons-react';
+import { FilterLinkToggle } from './components/FilterLinkToggle';
import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect';
import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';
@@ -63,10 +65,10 @@ const DashboardFilterSelect = ({
placeholder={value.length === 0 ? filter.name : undefined}
values={value}
data={sortedValues}
- // Disable only while values are genuinely loading. A completed query
- // that returned no rows (or failed) must stay interactive so the user
- // can still clear/adjust the selection instead of being stuck.
- disabled={isLoading}
+ // Surface loading as a dropdown hint rather than disabling the control,
+ // so a completed/empty/failed query stays interactive and the user can
+ // still clear or adjust the selection.
+ loading={isLoading}
onChange={onChange}
data-testid={`dashboard-filter-select-${filter.name}`}
/>
@@ -88,6 +90,12 @@ const DashboardFilters = ({
filterValues,
onSetFilterValue,
}: DashboardFilterProps) => {
+ // "Link" mode (opt-in, off by default): each dropdown's values are narrowed by
+ // the others' selections. Off by default because contingent value lookups
+ // can't use the cheap per-key rollups and are more expensive at scale. When
+ // on, all of a source's facets are computed in a single groupUniqArrayIf scan.
+ const [linked, setLinked] = useState(false);
+
const {
data: filterValuesById,
erroredFilterIds,
@@ -95,6 +103,8 @@ const DashboardFilters = ({
} = useDashboardFilterValues({
filters,
dateRange,
+ // Only narrow by sibling selections when linked.
+ filterValues: linked ? filterValues : {},
});
return (
@@ -107,7 +117,7 @@ const DashboardFilters = ({
: [];
// Fall back to the hook-level fetching state only until this filter's
// query has produced an entry; once it has (even with empty values),
- // honor its own loading flag so a finished query never stays disabled.
+ // honor its own loading flag.
const isLoadingValues = queriedFilterValues
? queriedFilterValues.isLoading
: isFetching;
@@ -123,6 +133,19 @@ const DashboardFilters = ({
/>
);
})}
+ {filters.length >= 2 && (
+
+ {/* Spacer to align the toggle with the inputs (filters have a label row above). */}
+
+
+
+
+
+ )}
{isFetching && }
);
diff --git a/packages/app/src/components/FilterLinkToggle.tsx b/packages/app/src/components/FilterLinkToggle.tsx
new file mode 100644
index 0000000000..7c53464f6e
--- /dev/null
+++ b/packages/app/src/components/FilterLinkToggle.tsx
@@ -0,0 +1,56 @@
+import type { CSSProperties } from 'react';
+import { ActionIcon, Tooltip } from '@mantine/core';
+import { IconArrowsLeftRight } from '@tabler/icons-react';
+
+type FilterLinkToggleProps = {
+ linked: boolean;
+ onChange: (linked: boolean) => void;
+ 'data-testid'?: string;
+};
+
+/**
+ * Opt-in toggle that "links" a set of filter dropdowns so each one's selectable
+ * values are narrowed by the others' current selections (faceted / filter-aware
+ * values). Off by default because contingent value lookups can't be served from
+ * the cheap per-key rollups and are far more expensive at scale.
+ */
+export function FilterLinkToggle({
+ linked,
+ onChange,
+ 'data-testid': dataTestId = 'filter-link-toggle',
+}: FilterLinkToggleProps) {
+ return (
+
+ onChange(!linked)}
+ aria-label="Link filters"
+ aria-pressed={linked}
+ data-testid={dataTestId}
+ >
+
+
+
+ );
+}
diff --git a/packages/app/src/components/KubernetesFilters.tsx b/packages/app/src/components/KubernetesFilters.tsx
index 3367ad6359..770035c515 100644
--- a/packages/app/src/components/KubernetesFilters.tsx
+++ b/packages/app/src/components/KubernetesFilters.tsx
@@ -1,12 +1,18 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { escapeRegExp } from 'lodash';
import { useForm, useWatch } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
+import {
+ FilterState,
+ filtersToQuery,
+} from '@hyperdx/common-utils/dist/filters';
import {
BuilderChartConfigWithDateRange,
TMetricSource,
} from '@hyperdx/common-utils/dist/types';
import { Box, Group, Select } from '@mantine/core';
+import { FilterLinkToggle } from '@/components/FilterLinkToggle';
import SearchInputV2 from '@/components/SearchInput/SearchInputV2';
import { useGetKeyValues } from '@/hooks/useMetadata';
@@ -17,44 +23,84 @@ type KubernetesFiltersProps = {
setSearchQuery: (query: string) => void;
};
+// The bundled Kubernetes filter dropdowns, in display order.
+const K8S_FILTER_FIELDS = [
+ {
+ field: 'k8s.pod.name',
+ placeholder: 'Pod',
+ dataTestId: 'pod-filter-select',
+ },
+ {
+ field: 'k8s.deployment.name',
+ placeholder: 'Deployment',
+ dataTestId: 'deployment-filter-select',
+ },
+ {
+ field: 'k8s.node.name',
+ placeholder: 'Node',
+ dataTestId: 'node-filter-select',
+ },
+ {
+ field: 'k8s.namespace.name',
+ placeholder: 'Namespace',
+ dataTestId: 'namespace-filter-select',
+ },
+ {
+ field: 'k8s.cluster.name',
+ placeholder: 'Cluster',
+ dataTestId: 'cluster-filter-select',
+ },
+] as const;
+
type FilterSelectProps = {
- metricSource: TMetricSource;
placeholder: string;
- fieldName: string;
value: string | null;
onChange: (value: string | null) => void;
- chartConfig: BuilderChartConfigWithDateRange;
+ options: string[];
+ loading?: boolean;
dataTestId?: string;
};
+// Removes a single field's `resourceAttr.field:"..."` clause from a Lucene
+// query string, leaving every other clause (and free-text search) intact. Used
+// both to rewrite the query when a dropdown changes and to build the faceted
+// free-text `where` for the dropdown value lookup.
+export const stripFieldClause = (
+ query: string,
+ resourceAttr: string,
+ field: string,
+): string => {
+ const fullAttribute = `${resourceAttr}.${field}`;
+ const regex = new RegExp(`${escapeRegExp(fullAttribute)}:"[^"]*"`, 'g');
+ // Replace with a space and collapse runs of whitespace so removing a clause
+ // from the middle of the query doesn't leave a double space.
+ return query.replace(regex, ' ').replace(/\s+/g, ' ').trim();
+};
+
const FilterSelect: React.FC = ({
- metricSource,
placeholder,
- fieldName,
value,
onChange,
- chartConfig,
+ options,
+ loading,
dataTestId,
}) => {
- const { data, isLoading } = useGetKeyValues({
- chartConfig,
- keys: [`${metricSource.resourceAttributesExpression}['${fieldName}']`],
- disableRowLimit: true,
- limit: 1000000,
- });
-
- const options = useMemo(
- () =>
- data?.[0]?.value
- .map(value => ({ value, label: value }))
- .sort((a, b) => a.value.localeCompare(b.value)) || [], // Sort alphabetically for better search results
- [data],
- );
+ const data = useMemo(() => {
+ const opts = options
+ .map(v => ({ value: v, label: v }))
+ .sort((a, b) => a.value.localeCompare(b.value)); // Sort alphabetically for better search results
+ // Keep the current selection visible even when it isn't in the (faceted)
+ // value list, so the control still reflects what's applied.
+ if (value && !opts.some(o => o.value === value)) {
+ opts.unshift({ value, label: value });
+ }
+ return opts;
+ }, [options, value]);
return (
= ({
allowDeselect
size="xs"
maxDropdownHeight={280}
- disabled={isLoading}
variant="filled"
w={200}
limit={100} // Show up to 100 search results
@@ -84,6 +129,27 @@ export const KubernetesFilters: React.FC = ({
const [namespaceName, setNamespaceName] = useState(null);
const [clusterName, setClusterName] = useState(null);
+ // "Link" mode (opt-in, off by default): narrow each dropdown's values by the
+ // other selections + the free-text search. Off by default because contingent
+ // value lookups can't use the cheap per-key rollups and cost far more at scale.
+ const [linked, setLinked] = useState(false);
+
+ const resourceAttr = metricSource.resourceAttributesExpression;
+ const valueByField: Record = {
+ 'k8s.pod.name': podName,
+ 'k8s.deployment.name': deploymentName,
+ 'k8s.node.name': nodeName,
+ 'k8s.namespace.name': namespaceName,
+ 'k8s.cluster.name': clusterName,
+ };
+ const setterByField: Record void> = {
+ 'k8s.pod.name': setPodName,
+ 'k8s.deployment.name': setDeploymentName,
+ 'k8s.node.name': setNodeName,
+ 'k8s.namespace.name': setNamespaceName,
+ 'k8s.cluster.name': setClusterName,
+ };
+
const { control, setValue } = useForm({
defaultValues: {
searchQuery: searchQuery,
@@ -115,8 +181,9 @@ export const KubernetesFilters: React.FC = ({
resourceAttr: string = '',
attribute: string,
) => {
+ const fullAttribute = `${resourceAttr}.${attribute}`;
const match = searchQuery.match(
- new RegExp(`${resourceAttr}\\.${attribute}:"([^"]+)"`, 'i'),
+ new RegExp(`${escapeRegExp(fullAttribute)}:"([^"]+)"`, 'i'),
);
return match ? match[1] : null;
};
@@ -124,51 +191,102 @@ export const KubernetesFilters: React.FC = ({
// Initialize filter values from search query
useEffect(() => {
if (searchQuery) {
- const resourceAttr = metricSource.resourceAttributesExpression;
-
- setPodName(
- extractValueFromSearchQuery(searchQuery, resourceAttr, 'k8s.pod.name'),
- );
- setDeploymentName(
- extractValueFromSearchQuery(
- searchQuery,
- resourceAttr,
- 'k8s.deployment.name',
- ),
- );
- setNodeName(
- extractValueFromSearchQuery(searchQuery, resourceAttr, 'k8s.node.name'),
- );
- setNamespaceName(
- extractValueFromSearchQuery(
- searchQuery,
- resourceAttr,
- 'k8s.namespace.name',
- ),
- );
- setClusterName(
- extractValueFromSearchQuery(
- searchQuery,
- resourceAttr,
- 'k8s.cluster.name',
- ),
- );
+ for (const { field } of K8S_FILTER_FIELDS) {
+ setterByField[field](
+ extractValueFromSearchQuery(searchQuery, resourceAttr, field),
+ );
+ }
}
- }, [searchQuery, metricSource.resourceAttributesExpression]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchQuery, resourceAttr]);
- // Create chart config for fetching key values
- const chartConfig: BuilderChartConfigWithDateRange = {
- from: {
- databaseName: metricSource.from.databaseName,
- tableName: metricSource.metricTables?.gauge || '',
- },
- where: '',
- whereLanguage: 'sql',
- select: '',
- timestampValueExpression: metricSource.timestampValueExpression || '',
- connection: metricSource.connection,
- dateRange,
- };
+ const keys = useMemo(
+ () => K8S_FILTER_FIELDS.map(({ field }) => `${resourceAttr}['${field}']`),
+ [resourceAttr],
+ );
+
+ // Faceted (linked) lookups: build a per-field SQL predicate from the OTHER
+ // selected fields (exclude-self) so all five value lists are computed in a
+ // single `groupUniqArrayIf` scan. The free-text search is applied as a shared
+ // WHERE. When unlinked there are no constraints and every dropdown lists all
+ // values (still a single batched query).
+ const keyConditions = useMemo(() => {
+ if (!linked) return undefined;
+ return K8S_FILTER_FIELDS.map(({ field }) => {
+ const others: FilterState = {};
+ for (const { field: otherField } of K8S_FILTER_FIELDS) {
+ const value = valueByField[otherField];
+ if (otherField !== field && value) {
+ others[`${resourceAttr}['${otherField}']`] = {
+ included: new Set([value]),
+ excluded: new Set(),
+ };
+ }
+ }
+ // filtersToQuery only emits `sql` filters (which carry `condition`); the
+ // `in` guard narrows away the `sql_ast` member of the Filter union.
+ const predicates = filtersToQuery(others, {
+ stringifyKeys: false,
+ }).flatMap(f => ('condition' in f ? [f.condition] : []));
+ return predicates.length
+ ? predicates.map(c => `(${c})`).join(' AND ')
+ : undefined;
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ linked,
+ resourceAttr,
+ podName,
+ deploymentName,
+ nodeName,
+ namespaceName,
+ clusterName,
+ ]);
+
+ // Free-text portion of the search (everything except the structured field
+ // clauses), applied as a shared WHERE so the dropdowns honor it when linked.
+ const facetWhere = useMemo(
+ () =>
+ linked
+ ? K8S_FILTER_FIELDS.reduce(
+ (query, { field }) => stripFieldClause(query, resourceAttr, field),
+ searchQuery,
+ )
+ : '',
+ [linked, resourceAttr, searchQuery],
+ );
+
+ const chartConfig: BuilderChartConfigWithDateRange = useMemo(
+ () => ({
+ from: {
+ databaseName: metricSource.from.databaseName,
+ tableName: metricSource.metricTables?.gauge || '',
+ },
+ where: facetWhere,
+ whereLanguage: 'lucene',
+ select: '',
+ timestampValueExpression: metricSource.timestampValueExpression || '',
+ connection: metricSource.connection,
+ dateRange,
+ }),
+ [metricSource, facetWhere, dateRange],
+ );
+
+ const { data, isLoading } = useGetKeyValues({
+ chartConfig,
+ keys,
+ keyConditions,
+ disableRowLimit: true,
+ limit: 1000000,
+ });
+
+ const valuesByKey = useMemo(() => {
+ const map = new Map();
+ for (const entry of data ?? []) {
+ map.set(entry.key, entry.value as string[]);
+ }
+ return map;
+ }, [data]);
// Helper function to update search query
const updateSearchQuery = (
@@ -178,13 +296,10 @@ export const KubernetesFilters: React.FC = ({
) => {
setter(value);
- const resourceAttr = metricSource.resourceAttributesExpression;
const fullAttribute = `${resourceAttr}.${attribute}`;
// Remove existing filter for this attribute if it exists
- let newQuery = searchQuery;
- const regex = new RegExp(`${fullAttribute}:"[^"]*"`, 'g');
- newQuery = newQuery.replace(regex, '').trim();
+ let newQuery = stripFieldClause(searchQuery, resourceAttr, attribute);
// Add new filter if value is not null
if (value) {
@@ -196,62 +311,23 @@ export const KubernetesFilters: React.FC = ({
return (
- updateSearchQuery('k8s.pod.name', value, setPodName)}
- chartConfig={chartConfig}
- dataTestId="pod-filter-select"
- />
-
-
- updateSearchQuery('k8s.deployment.name', value, setDeploymentName)
- }
- chartConfig={chartConfig}
- dataTestId="deployment-filter-select"
- />
-
-
- updateSearchQuery('k8s.node.name', value, setNodeName)
- }
- chartConfig={chartConfig}
- dataTestId="node-filter-select"
- />
-
-
- updateSearchQuery('k8s.namespace.name', value, setNamespaceName)
- }
- chartConfig={chartConfig}
- dataTestId="namespace-filter-select"
- />
-
-
- updateSearchQuery('k8s.cluster.name', value, setClusterName)
- }
- chartConfig={chartConfig}
- dataTestId="cluster-filter-select"
+ {K8S_FILTER_FIELDS.map(({ field, placeholder, dataTestId }) => (
+
+ updateSearchQuery(field, value, setterByField[field])
+ }
+ options={valuesByKey.get(`${resourceAttr}['${field}']`) ?? []}
+ loading={isLoading}
+ dataTestId={dataTestId}
+ />
+ ))}
+
void;
@@ -30,6 +32,7 @@ type VirtualMultiSelectProps = {
export function VirtualMultiSelect({
data,
disabled,
+ loading,
placeholder,
values,
onChange,
@@ -188,7 +191,9 @@ export function VirtualMultiSelect({
) : (
- Nothing found...
+
+ {loading ? 'Loading…' : 'Nothing found...'}
+
)}
diff --git a/packages/app/src/components/__tests__/KubernetesFilters.test.ts b/packages/app/src/components/__tests__/KubernetesFilters.test.ts
new file mode 100644
index 0000000000..f5a63c998b
--- /dev/null
+++ b/packages/app/src/components/__tests__/KubernetesFilters.test.ts
@@ -0,0 +1,59 @@
+import { stripFieldClause } from '../KubernetesFilters';
+
+describe('stripFieldClause', () => {
+ const resourceAttr = 'ResourceAttributes';
+
+ it('removes only the target field clause, keeping other filters and free text', () => {
+ const query =
+ 'ResourceAttributes.k8s.cluster.name:"prod" ResourceAttributes.k8s.namespace.name:"api" error';
+
+ // Building the faceted `where` for the namespace dropdown drops the
+ // namespace clause but keeps the cluster selection and free-text search,
+ // so the namespace options are narrowed to the selected cluster.
+ expect(stripFieldClause(query, resourceAttr, 'k8s.namespace.name')).toBe(
+ 'ResourceAttributes.k8s.cluster.name:"prod" error',
+ );
+ });
+
+ it('returns an empty string when the query only contains the target clause', () => {
+ expect(
+ stripFieldClause(
+ 'ResourceAttributes.k8s.cluster.name:"prod"',
+ resourceAttr,
+ 'k8s.cluster.name',
+ ),
+ ).toBe('');
+ });
+
+ it('leaves the query unchanged when the target field is absent', () => {
+ const query = 'ResourceAttributes.k8s.cluster.name:"prod"';
+ expect(stripFieldClause(query, resourceAttr, 'k8s.namespace.name')).toBe(
+ query,
+ );
+ });
+
+ it('does not strip a sibling field that shares a path prefix', () => {
+ // `k8s.pod.name` must not match `k8s.pod.uid`.
+ const query =
+ 'ResourceAttributes.k8s.pod.name:"a" ResourceAttributes.k8s.pod.uid:"b"';
+ expect(stripFieldClause(query, resourceAttr, 'k8s.pod.name')).toBe(
+ 'ResourceAttributes.k8s.pod.uid:"b"',
+ );
+ });
+
+ it('treats dots in the attribute as literal characters, not regex wildcards', () => {
+ // Unescaped, the dots would act as wildcards and wrongly strip this clause
+ // where the path separators are different characters. Escaping keeps them
+ // literal, so a non-dotted lookalike is left untouched.
+ const query = 'ResourceAttributesXk8sXpodXname:"a"';
+ expect(stripFieldClause(query, resourceAttr, 'k8s.pod.name')).toBe(query);
+ });
+
+ it('does not throw when the resource attribute contains regex metacharacters', () => {
+ // An unescaped `(` would produce an "unterminated group" SyntaxError.
+ expect(() =>
+ stripFieldClause('foo', 'attr(', 'k8s.pod.name'),
+ ).not.toThrow();
+ expect(stripFieldClause('foo', 'attr(', 'k8s.pod.name')).toBe('foo');
+ });
+});
diff --git a/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx b/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx
index 6384fb7540..0af87f8d1d 100644
--- a/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx
+++ b/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx
@@ -1,7 +1,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
import React from 'react';
-import { optimizeGetKeyValuesCalls } from '@hyperdx/common-utils/dist/core/materializedViews';
+import {
+ optimizeFacetedKeyValuesConfig,
+ optimizeGetKeyValuesCalls,
+} from '@hyperdx/common-utils/dist/core/materializedViews';
import { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
+import { FilterState } from '@hyperdx/common-utils/dist/filters';
import {
DashboardFilter,
MetricsDataType,
@@ -25,6 +29,10 @@ jest.mock('@hyperdx/common-utils/dist/core/materializedViews', () => ({
.mockImplementation(async ({ keys, chartConfig }) => [
{ keys, chartConfig },
]),
+ // Default: no covering MV → faceted query runs against the raw config.
+ optimizeFacetedKeyValuesConfig: jest
+ .fn()
+ .mockImplementation(async ({ chartConfig }) => chartConfig),
}));
describe('useDashboardFilterValues', () => {
@@ -1057,4 +1065,251 @@ describe('useDashboardFilterValues', () => {
}),
);
});
+
+ describe('faceted filtering (cascading filters)', () => {
+ // Returns the single arg object passed to the MOST RECENT getKeyValues call
+ // whose `keys` exactly match, or undefined if no such call was made. (Most
+ // recent matters when a rerender issues a fresh call for the same keys.)
+ const callForKeys = (keys: string[]) =>
+ (mockMetadata.getKeyValues.mock.calls as any[])
+ .filter(([arg]) => JSON.stringify(arg.keys) === JSON.stringify(keys))
+ .at(-1)?.[0];
+
+ const envAndStatus: DashboardFilter[] = [
+ {
+ id: 'filter1',
+ type: 'QUERY_EXPRESSION',
+ name: 'Environment',
+ expression: 'environment',
+ source: 'logs-source',
+ },
+ {
+ id: 'filter2',
+ type: 'QUERY_EXPRESSION',
+ name: 'Status',
+ expression: 'status',
+ source: 'logs-source',
+ },
+ ];
+
+ beforeEach(() => {
+ jest.spyOn(sourceModule, 'useSources').mockReturnValue({
+ data: mockSources,
+ isLoading: false,
+ } as any);
+ // Unconstrained groups still go through the optimizer; restore its
+ // passthrough (clearAllMocks resets call data but not implementations).
+ jest
+ .mocked(optimizeGetKeyValuesCalls)
+ .mockImplementation(async ({ keys, chartConfig }) => [
+ { keys, chartConfig },
+ ]);
+ // Default: faceted queries run against the raw config (no covering MV).
+ jest
+ .mocked(optimizeFacetedKeyValuesConfig)
+ .mockImplementation(async ({ chartConfig }) => chartConfig);
+ });
+
+ it('resolves every key in one faceted scan, constraining each by the others (exclude-self)', async () => {
+ const { result } = renderHook(
+ () =>
+ useDashboardFilterValues({
+ filters: envAndStatus,
+ dateRange: mockDateRange,
+ filterValues: {
+ environment: {
+ included: new Set(['production']),
+ excluded: new Set(),
+ },
+ },
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ // A single scan for the whole source — not one query per filter.
+ expect(mockMetadata.getKeyValues).toHaveBeenCalledTimes(1);
+ // `status` is constrained by the environment selection; `environment` is
+ // NOT constrained by its own selection (exclude-self → undefined).
+ expect(callForKeys(['environment', 'status'])?.keyConditions).toEqual([
+ undefined,
+ "(environment IN ('production'))",
+ ]);
+ });
+
+ it('runs one unconstrained query when nothing is selected', async () => {
+ const { result } = renderHook(
+ () =>
+ useDashboardFilterValues({
+ filters: envAndStatus,
+ dateRange: mockDateRange,
+ filterValues: {},
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ expect(mockMetadata.getKeyValues).toHaveBeenCalledTimes(1);
+ // No conditions → plain groupUniqArray (no keyConditions passed).
+ expect(
+ callForKeys(['environment', 'status'])?.keyConditions,
+ ).toBeUndefined();
+ });
+
+ it('still uses a single scan for many filters when one is selected', async () => {
+ const filters: DashboardFilter[] = [
+ ...envAndStatus,
+ {
+ id: 'filter3',
+ type: 'QUERY_EXPRESSION',
+ name: 'Log Level',
+ expression: 'log_level',
+ source: 'logs-source',
+ },
+ ];
+
+ const { result } = renderHook(
+ () =>
+ useDashboardFilterValues({
+ filters,
+ dateRange: mockDateRange,
+ filterValues: {
+ environment: {
+ included: new Set(['production']),
+ excluded: new Set(),
+ },
+ },
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ // One faceted scan regardless of how many filters/selections.
+ expect(mockMetadata.getKeyValues).toHaveBeenCalledTimes(1);
+ expect(
+ callForKeys(['environment', 'status', 'log_level'])?.keyConditions,
+ ).toEqual([
+ undefined,
+ "(environment IN ('production'))",
+ "(environment IN ('production'))",
+ ]);
+ });
+
+ it('does not apply a selection from one source to filters on another source', async () => {
+ const filters: DashboardFilter[] = [
+ ...envAndStatus,
+ {
+ id: 'filter3',
+ type: 'QUERY_EXPRESSION',
+ name: 'Service',
+ expression: 'service.name',
+ source: 'traces-source',
+ },
+ ];
+
+ const { result } = renderHook(
+ () =>
+ useDashboardFilterValues({
+ filters,
+ dateRange: mockDateRange,
+ filterValues: {
+ environment: {
+ included: new Set(['production']),
+ excluded: new Set(),
+ },
+ },
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ // The logs group is faceted (status narrowed by env)...
+ expect(callForKeys(['environment', 'status'])?.keyConditions).toEqual([
+ undefined,
+ "(environment IN ('production'))",
+ ]);
+ // ...but the traces filter is never constrained by the logs selection.
+ expect(callForKeys(['service.name'])?.keyConditions).toBeUndefined();
+ });
+
+ it('refetches with updated conditions when a selection changes', async () => {
+ const { result, rerender } = renderHook(
+ ({ filterValues }) =>
+ useDashboardFilterValues({
+ filters: envAndStatus,
+ dateRange: mockDateRange,
+ filterValues,
+ }),
+ {
+ wrapper,
+ initialProps: { filterValues: {} as FilterState },
+ },
+ );
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+ expect(
+ callForKeys(['environment', 'status'])?.keyConditions,
+ ).toBeUndefined();
+
+ rerender({
+ filterValues: {
+ environment: {
+ included: new Set(['production']),
+ excluded: new Set(),
+ },
+ },
+ });
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ expect(callForKeys(['environment', 'status'])?.keyConditions).toEqual([
+ undefined,
+ "(environment IN ('production'))",
+ ]);
+ });
+
+ it('runs the faceted scan against a covering materialized view when one is found', async () => {
+ // Simulate a covering MV: the resolver points the faceted query at the
+ // rollup table.
+ jest
+ .mocked(optimizeFacetedKeyValuesConfig)
+ .mockImplementation(async ({ chartConfig }) => ({
+ ...chartConfig,
+ from: { databaseName: 'telemetry', tableName: 'logs_rollup_1m' },
+ }));
+
+ const { result } = renderHook(
+ () =>
+ useDashboardFilterValues({
+ filters: envAndStatus,
+ dateRange: mockDateRange,
+ filterValues: {
+ environment: {
+ included: new Set(['production']),
+ excluded: new Set(),
+ },
+ },
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isFetching).toBe(false));
+
+ const call = callForKeys(['environment', 'status']);
+ // The single faceted scan targets the rollup, still carrying the per-key
+ // conditions.
+ expect(call?.chartConfig?.from).toEqual({
+ databaseName: 'telemetry',
+ tableName: 'logs_rollup_1m',
+ });
+ expect(call?.keyConditions).toEqual([
+ undefined,
+ "(environment IN ('production'))",
+ ]);
+ });
+ });
});
diff --git a/packages/app/src/hooks/useDashboardFilterValues.tsx b/packages/app/src/hooks/useDashboardFilterValues.tsx
index 710eecb930..93bd91198b 100644
--- a/packages/app/src/hooks/useDashboardFilterValues.tsx
+++ b/packages/app/src/hooks/useDashboardFilterValues.tsx
@@ -2,8 +2,13 @@ import { useMemo } from 'react';
import { pick } from 'lodash';
import {
GetKeyValueCall,
+ optimizeFacetedKeyValuesConfig,
optimizeGetKeyValuesCalls,
} from '@hyperdx/common-utils/dist/core/materializedViews';
+import {
+ FilterState,
+ filtersToQuery,
+} from '@hyperdx/common-utils/dist/filters';
import {
BuilderChartConfigWithDateRange,
DashboardFilter,
@@ -37,49 +42,108 @@ const filterToKey = (filter: DashboardFilter): string =>
whereLanguage: filter.whereLanguage ?? 'sql',
} satisfies FilterSourceKey);
-const filterFromKey = (key: string): FilterSourceKey =>
- JSON.parse(key) as FilterSourceKey;
-
type EnrichedCall = GetKeyValueCall & {
/** filterIds[i] = array of filter IDs whose values come from keys[i] */
filterIds: string[][];
+ /** Per-key SQL predicate for faceted lookups, aligned with `keys`. */
+ keyConditions?: (string | undefined)[];
};
function useOptimizedKeyValuesCalls({
filters,
dateRange,
+ filterValues,
}: {
filters: DashboardFilter[];
dateRange: [Date, Date];
+ filterValues: FilterState;
}) {
const clickhouseClient = useClickhouseClient();
const metadata = useMetadataWithSettings();
const { data: sources, isLoading: isLoadingSources } = useSources();
- // Group filters by (source, metricType, where, whereLanguage) so that we can test each group for MV compatibility separately.
+ // Faceted filtering: each filter's selectable values are narrowed by the
+ // CURRENT selections of its sibling filters. For every filter, build a SQL
+ // predicate from the selections of the OTHER filters that target the same
+ // source + metric type (so the constrained columns exist in the queried
+ // table), EXCLUDING the filter's own expression (otherwise a multi-select
+ // would collapse to only its already-selected values). Expressing it as a
+ // per-key predicate lets all of a source's filters resolve in one
+ // `groupUniqArrayIf` scan instead of one query per filter.
+ const conditionByFilterId = useMemo(() => {
+ const byId = new Map();
+ for (const filter of filters) {
+ const prunedState: FilterState = {};
+ for (const sibling of filters) {
+ if (
+ sibling.source !== filter.source ||
+ sibling.sourceMetricType !== filter.sourceMetricType ||
+ // Exclude-self: FilterState is keyed by expression, so a sibling that
+ // shares this filter's expression carries this filter's own selection.
+ sibling.expression === filter.expression
+ ) {
+ continue;
+ }
+ const selection = filterValues[sibling.expression];
+ if (
+ selection &&
+ (selection.included.size > 0 ||
+ selection.excluded.size > 0 ||
+ selection.range != null)
+ ) {
+ prunedState[sibling.expression] = selection;
+ }
+ }
+ const predicates = filtersToQuery(prunedState, {
+ stringifyKeys: false,
+ // filtersToQuery only emits `sql` filters (which carry `condition`); the
+ // `in` guard narrows away the `sql_ast` member of the Filter union.
+ }).flatMap(f => ('condition' in f ? [f.condition] : []));
+ byId.set(
+ filter.id,
+ predicates.length
+ ? predicates.map(c => `(${c})`).join(' AND ')
+ : undefined,
+ );
+ }
+ return byId;
+ }, [filters, filterValues]);
+
+ // Group filters by (source, metricType, where, whereLanguage). Every filter in
+ // a group is resolved together: an unconstrained group goes through the MV
+ // optimizer (one batched, rollup-eligible query); a constrained group runs a
+ // single faceted `groupUniqArrayIf` scan carrying a per-key condition.
const filtersByGroupKey = useMemo(() => {
- const filtersByGroupKey = new Map();
+ const byGroupKey = new Map();
for (const filter of filters) {
const key = filterToKey(filter);
- if (!filtersByGroupKey.has(key)) {
- filtersByGroupKey.set(key, [filter]);
+ const existing = byGroupKey.get(key);
+ if (existing) {
+ existing.push(filter);
} else {
- filtersByGroupKey.get(key)!.push(filter);
+ byGroupKey.set(key, [filter]);
}
}
- return filtersByGroupKey;
+ return byGroupKey;
}, [filters]);
const results: UseQueryResult[] = useQueries({
- queries: Array.from(filtersByGroupKey.entries())
- .filter(([key]) =>
- sources?.some(s => s.id === filterFromKey(key).sourceId),
+ queries: Array.from(filtersByGroupKey.values())
+ .filter(filtersInGroup =>
+ sources?.some(s => s.id === filtersInGroup[0].source),
)
- .map(([key, filtersInGroup]) => {
- const { sourceId, metricType, where, whereLanguage } =
- filterFromKey(key);
+ .map(filtersInGroup => {
+ const representative = filtersInGroup[0];
+ const sourceId = representative.source;
+ const metricType = representative.sourceMetricType;
+ const where = representative.where ?? '';
+ const whereLanguage = representative.whereLanguage ?? 'sql';
const source = sources!.find(s => s.id === sourceId)!;
const keyExpressions = filtersInGroup.map(f => f.expression);
+ const keyConditions = filtersInGroup.map(f =>
+ conditionByFilterId.get(f.id),
+ );
+ const isFaceted = keyConditions.some(c => c != null);
const tableName = getMetricTableName(source, metricType) ?? '';
const chartConfig: BuilderChartConfigWithDateRange = {
@@ -107,6 +171,13 @@ function useOptimizedKeyValuesCalls({
select: '',
};
+ const filterIdsForKeys = (keys: string[]) =>
+ keys.map(expression =>
+ filtersInGroup
+ .filter(f => f.expression === expression)
+ .map(f => f.id),
+ );
+
return {
queryKey: [
'dashboard-filters-key-value-calls',
@@ -116,10 +187,35 @@ function useOptimizedKeyValuesCalls({
keyExpressions,
where,
whereLanguage,
+ keyConditions,
],
enabled: !isLoadingSources,
staleTime: 1000 * 60 * 5, // Cache every 5 min
- queryFn: async ({ signal }) => {
+ queryFn: async ({ signal }): Promise => {
+ // Constrained: resolve every key in one faceted scan
+ // (groupUniqArrayIf). A per-key condition can't be split across
+ // single-key MVs, but it can run against one MV whose dimensions
+ // cover every filter column (else the raw table).
+ if (isFaceted) {
+ const facetedConfig = await optimizeFacetedKeyValuesConfig({
+ chartConfig,
+ keys: keyExpressions,
+ keyConditions,
+ source,
+ clickhouseClient,
+ metadata,
+ signal,
+ });
+ return [
+ {
+ chartConfig: facetedConfig,
+ keys: keyExpressions,
+ keyConditions,
+ filterIds: filterIdsForKeys(keyExpressions),
+ },
+ ];
+ }
+ // Unconstrained: let the MV optimizer batch / route to rollups.
const calls = await optimizeGetKeyValuesCalls({
chartConfig,
source,
@@ -128,14 +224,9 @@ function useOptimizedKeyValuesCalls({
keys: keyExpressions,
signal,
});
- // Enrich each call with the filter IDs that correspond to each key expression
return calls.map(call => ({
...call,
- filterIds: call.keys.map(expression =>
- filtersInGroup
- .filter(f => f.expression === expression)
- .map(f => f.id),
- ),
+ filterIds: filterIdsForKeys(call.keys),
}));
},
};
@@ -152,9 +243,11 @@ function useOptimizedKeyValuesCalls({
export function useDashboardFilterValues({
filters,
dateRange,
+ filterValues = {},
}: {
filters: DashboardFilter[];
dateRange: [Date, Date];
+ filterValues?: FilterState;
}) {
const metadata = useMetadataWithSettings();
const {
@@ -164,6 +257,7 @@ export function useDashboardFilterValues({
} = useOptimizedKeyValuesCalls({
filters,
dateRange,
+ filterValues,
});
const { data: sources, isLoading: isSourcesLoading } = useSources();
@@ -173,7 +267,7 @@ export function useDashboardFilterValues({
type TQueryData = { key: string; value: string[] }[];
const results: UseQueryResult[] = useQueries({
- queries: calls.map(({ chartConfig, keys }) => {
+ queries: calls.map(({ chartConfig, keys, keyConditions }) => {
// Construct a query key prefix which will allow us to use placeholder data from the previous query for the same keys
const queryKeyPrefix = [
'dashboard-filter-key-values',
@@ -184,7 +278,7 @@ export function useDashboardFilterValues({
const source = sourcesLookup.get(chartConfig.source);
return {
- queryKey: [...queryKeyPrefix, chartConfig],
+ queryKey: [...queryKeyPrefix, chartConfig, keyConditions],
placeholderData: () => {
// Use placeholder data from the most recently cached query with the same key prefix
const cached = queryClient
@@ -206,6 +300,7 @@ export function useDashboardFilterValues({
metadata.getKeyValues({
chartConfig,
keys,
+ keyConditions,
limit: 10000,
disableRowLimit: true,
signal,
diff --git a/packages/app/src/hooks/useMetadata.tsx b/packages/app/src/hooks/useMetadata.tsx
index 2f170d980f..7b31f15130 100644
--- a/packages/app/src/hooks/useMetadata.tsx
+++ b/packages/app/src/hooks/useMetadata.tsx
@@ -227,6 +227,7 @@ export function useMultipleGetKeyValues(
{
chartConfigs,
keys,
+ keyConditions,
limit,
disableRowLimit,
mode = 'exact',
@@ -236,6 +237,8 @@ export function useMultipleGetKeyValues(
| BuilderChartConfigWithDateRange
| BuilderChartConfigWithDateRange[];
keys: string[];
+ /** Per-key SQL predicates for faceted ('exact' mode) value lookups. */
+ keyConditions?: (string | undefined)[];
limit?: number;
disableRowLimit?: boolean;
mode?: 'all' | 'exact';
@@ -261,6 +264,7 @@ export function useMultipleGetKeyValues(
metadataMVsOverride,
...chartConfigsArr.map(cc => ({ ...cc })),
...keys,
+ keyConditions,
disableRowLimit,
maxKeys,
],
@@ -306,6 +310,7 @@ export function useMultipleGetKeyValues(
return metadata.getKeyValuesWithMVs({
chartConfig,
keys: keys.slice(0, maxKeys),
+ keyConditions: keyConditions?.slice(0, maxKeys),
limit,
disableRowLimit,
source,
@@ -366,6 +371,7 @@ export function useGetKeyValues(
{
chartConfig,
keys,
+ keyConditions,
limit,
disableRowLimit,
mode,
@@ -373,6 +379,8 @@ export function useGetKeyValues(
}: {
chartConfig?: BuilderChartConfigWithDateRange;
keys: string[];
+ /** Per-key SQL predicates for faceted value lookups (groupUniqArrayIf). */
+ keyConditions?: (string | undefined)[];
limit?: number;
disableRowLimit?: boolean;
mode?: 'all' | 'exact';
@@ -384,6 +392,7 @@ export function useGetKeyValues(
{
chartConfigs: chartConfig ? [chartConfig] : [],
keys,
+ keyConditions,
limit,
disableRowLimit,
mode,
diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts
index bbd40781ef..b6691fb515 100644
--- a/packages/common-utils/src/__tests__/metadata.test.ts
+++ b/packages/common-utils/src/__tests__/metadata.test.ts
@@ -485,6 +485,34 @@ describe('Metadata', () => {
);
});
+ it('uses groupUniqArrayIf for keys that have a condition (faceted lookup)', async () => {
+ const renderChartConfigSpy = jest.spyOn(
+ renderChartConfigModule,
+ 'renderChartConfig',
+ );
+
+ await metadata.getKeyValues({
+ chartConfig: mockChartConfig,
+ keys: ['colA', 'colB'],
+ keyConditions: [undefined, "(colA IN ('x'))"],
+ limit: 10,
+ disableRowLimit: true,
+ source,
+ });
+
+ const actualConfig = renderChartConfigSpy.mock.calls.at(-1)![0];
+ if (!isBuilderChartConfig(actualConfig))
+ throw new Error('Expected builder config');
+ // No condition → plain groupUniqArray; condition → groupUniqArrayIf so
+ // both facets resolve in a single scan.
+ expect(actualConfig.select).toContain(
+ 'groupUniqArray(10)(colA) AS param0',
+ );
+ expect(actualConfig.select).toContain(
+ "groupUniqArrayIf(10)(colB, (colA IN ('x'))) AS param1",
+ );
+ });
+
it('should apply row limit by default when disableRowLimit is not specified', async () => {
await metadata.getKeyValues({
chartConfig: mockChartConfig,
diff --git a/packages/common-utils/src/clickhouse/__tests__/materializedViews.test.ts b/packages/common-utils/src/clickhouse/__tests__/materializedViews.test.ts
index 361673e280..6a63bbe453 100644
--- a/packages/common-utils/src/clickhouse/__tests__/materializedViews.test.ts
+++ b/packages/common-utils/src/clickhouse/__tests__/materializedViews.test.ts
@@ -1,5 +1,6 @@
import {
isUnsupportedCountFunction,
+ optimizeFacetedKeyValuesConfig,
optimizeGetKeyValuesCalls,
tryConvertConfigToMaterializedViewSelect,
tryOptimizeConfigWithMaterializedView,
@@ -2545,4 +2546,113 @@ describe('materializedViews', () => {
expect(result[0].keys).toEqual(['environment', 'service']);
});
});
+
+ describe('optimizeFacetedKeyValuesConfig', () => {
+ const mockClickHouseClient = {
+ testChartConfigValidity: jest.fn(),
+ } as unknown as jest.Mocked;
+
+ const facetedChartConfig: ChartConfigWithOptDateRange = {
+ from: { databaseName: 'default', tableName: 'otel_spans' },
+ select: '',
+ where: '',
+ connection: 'test-connection',
+ };
+
+ const facetedSource = {
+ kind: SourceKind.Log,
+ from: { databaseName: 'default', tableName: 'otel_spans' },
+ materializedViews: [MV_CONFIG_METRIC_ROLLUP_1M],
+ } as TLogSource;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockClickHouseClient.testChartConfigValidity.mockResolvedValue({
+ isValid: true,
+ rowEstimate: 1000,
+ error: undefined,
+ });
+ });
+
+ it('routes to a covering MV and EXPLAINs the faceted select', async () => {
+ const result = await optimizeFacetedKeyValuesConfig({
+ chartConfig: facetedChartConfig,
+ keys: ['ServiceName', 'StatusCode'],
+ keyConditions: [undefined, "(ServiceName IN ('x'))"],
+ source: facetedSource,
+ clickhouseClient: mockClickHouseClient,
+ metadata,
+ });
+
+ // The faceted query runs against the rollup, using its timestamp column.
+ expect(result.from).toEqual({
+ databaseName: 'default',
+ tableName: 'metrics_rollup_1m',
+ });
+ expect(result.timestampValueExpression).toBe('Timestamp');
+ // Validation used groupUniqArrayIf only for the constrained key.
+ expect(mockClickHouseClient.testChartConfigValidity).toHaveBeenCalledWith(
+ expect.objectContaining({
+ config: expect.objectContaining({
+ from: { databaseName: 'default', tableName: 'metrics_rollup_1m' },
+ select:
+ "groupUniqArray(1)(ServiceName) AS param0, groupUniqArrayIf(1)(StatusCode, (ServiceName IN ('x'))) AS param1",
+ }),
+ }),
+ );
+ });
+
+ it('falls back to the raw table when a key is not an MV dimension', async () => {
+ const result = await optimizeFacetedKeyValuesConfig({
+ chartConfig: facetedChartConfig,
+ keys: ['ServiceName', 'NotADimension'],
+ keyConditions: [undefined, "(ServiceName IN ('x'))"],
+ source: facetedSource,
+ clickhouseClient: mockClickHouseClient,
+ metadata,
+ });
+
+ expect(result).toBe(facetedChartConfig);
+ expect(
+ mockClickHouseClient.testChartConfigValidity,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('falls back to the raw table when the MV EXPLAIN is invalid', async () => {
+ mockClickHouseClient.testChartConfigValidity.mockResolvedValue({
+ isValid: false,
+ error: '',
+ });
+
+ const result = await optimizeFacetedKeyValuesConfig({
+ chartConfig: facetedChartConfig,
+ keys: ['ServiceName', 'StatusCode'],
+ keyConditions: [undefined, "(ServiceName IN ('x'))"],
+ source: facetedSource,
+ clickhouseClient: mockClickHouseClient,
+ metadata,
+ });
+
+ expect(result).toBe(facetedChartConfig);
+ });
+
+ it('returns the raw table for a source without materialized views', async () => {
+ const result = await optimizeFacetedKeyValuesConfig({
+ chartConfig: facetedChartConfig,
+ keys: ['ServiceName'],
+ keyConditions: [undefined],
+ source: {
+ kind: SourceKind.Log,
+ from: { databaseName: 'default', tableName: 'otel_spans' },
+ } as TLogSource,
+ clickhouseClient: mockClickHouseClient,
+ metadata,
+ });
+
+ expect(result).toBe(facetedChartConfig);
+ expect(
+ mockClickHouseClient.testChartConfigValidity,
+ ).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/packages/common-utils/src/core/materializedViews.ts b/packages/common-utils/src/core/materializedViews.ts
index 810dd79063..c62ad221c2 100644
--- a/packages/common-utils/src/core/materializedViews.ts
+++ b/packages/common-utils/src/core/materializedViews.ts
@@ -899,3 +899,99 @@ export async function optimizeGetKeyValuesCalls<
return calls;
}
+
+/**
+ * Resolve which table a *faceted* key-values lookup should scan. A faceted
+ * lookup applies a different predicate per key (`groupUniqArrayIf`), so it can't
+ * be split across single-key rollups like `optimizeGetKeyValuesCalls` — but it
+ * CAN run against one materialized view whose dimension columns cover every
+ * filter column. Returns an MV-pointed chartConfig when a covering MV validates
+ * via EXPLAIN (cheapest row estimate wins), otherwise the original (raw) config.
+ */
+export async function optimizeFacetedKeyValuesConfig<
+ C extends BuilderChartConfigWithOptDateRange,
+>({
+ chartConfig,
+ keys,
+ keyConditions,
+ source,
+ clickhouseClient,
+ metadata,
+ signal,
+}: {
+ chartConfig: C;
+ keys: string[];
+ /** Per-key SQL predicate aligned with `keys` (undefined = unconstrained). */
+ keyConditions: (string | undefined)[];
+ source: TSource | undefined;
+ clickhouseClient: BaseClickhouseClient;
+ metadata: Metadata;
+ signal?: AbortSignal;
+}): Promise {
+ const mvs =
+ source && (isTraceSource(source) || isLogSource(source))
+ ? (source.materializedViews ?? [])
+ : [];
+ if (mvs.length === 0) return chartConfig;
+
+ // A covering MV must expose every filter column as a dimension. The per-key
+ // conditions only reference other keys, so requiring all `keys` covers them
+ // too; anything else (e.g. the static `where`) is caught by the EXPLAIN below.
+ const coveringMvs = mvs.filter(mv => {
+ const intervalsInDateRange = chartConfig.dateRange
+ ? countIntervalsInDateRange(chartConfig.dateRange, mv.minGranularity)
+ : Infinity;
+ if (
+ !mvConfigSupportsDateRange(mv, chartConfig) ||
+ intervalsInDateRange < 3
+ ) {
+ return false;
+ }
+ const dimensionColumns = splitAndTrimWithBracket(mv.dimensionColumns);
+ return keys.every(k => dimensionColumns.includes(k));
+ });
+ if (coveringMvs.length === 0) return chartConfig;
+
+ // Build + EXPLAIN-validate a faceted config against each covering MV. The
+ // EXPLAIN only needs the same columns, so the limit is irrelevant here.
+ const explainResults = await Promise.all(
+ coveringMvs.map(async mv => {
+ const config = {
+ ...structuredClone(chartConfig),
+ timestampValueExpression: mv.timestampColumn,
+ from: { databaseName: mv.databaseName, tableName: mv.tableName },
+ select: keys
+ .map((k, i) => {
+ const condition = keyConditions[i];
+ return condition
+ ? `groupUniqArrayIf(1)(${k}, ${condition}) AS param${i}`
+ : `groupUniqArray(1)(${k}) AS param${i}`;
+ })
+ .join(', '),
+ };
+ const { isValid, rowEstimate = Number.POSITIVE_INFINITY } =
+ await clickhouseClient.testChartConfigValidity({
+ config,
+ metadata,
+ opts: { abort_signal: signal },
+ querySettings: source?.querySettings,
+ });
+ return { mv, isValid, rowEstimate };
+ }),
+ );
+
+ const best = explainResults
+ .filter(r => r.isValid)
+ .sort((a, b) => a.rowEstimate - b.rowEstimate)[0];
+ if (!best) return chartConfig;
+
+ const optimizedConfig: C = {
+ ...structuredClone(chartConfig),
+ timestampValueExpression: best.mv.timestampColumn,
+ from: {
+ databaseName: best.mv.databaseName,
+ tableName: best.mv.tableName,
+ },
+ };
+ return optimizedConfig;
+}
diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts
index a80feb1fad..370ab5829d 100644
--- a/packages/common-utils/src/core/metadata.ts
+++ b/packages/common-utils/src/core/metadata.ts
@@ -23,6 +23,7 @@ import { isLogSource, isTraceSource, SourceKind } from '@/types';
import { ClickHouseVersion, parseClickHouseVersion } from './clickhouseVersion';
import {
+ optimizeFacetedKeyValuesConfig,
optimizeGetKeyValuesCalls,
renderStartOfBucketExpr,
} from './materializedViews';
@@ -1579,6 +1580,7 @@ export class Metadata {
async getKeyValues({
chartConfig,
keys,
+ keyConditions,
limit = 20,
disableRowLimit = false,
signal,
@@ -1586,6 +1588,14 @@ export class Metadata {
}: {
chartConfig: BuilderChartConfigWithDateRange;
keys: string[];
+ /**
+ * Optional per-key SQL predicate, aligned with `keys`. When provided for a
+ * key, its values are gathered with `groupUniqArrayIf(key, condition)` so
+ * several "faceted" value lists (each constrained by a different condition)
+ * can be computed in a single table scan. Only applied when
+ * `disableRowLimit` is true (the path used by filter dropdowns).
+ */
+ keyConditions?: (string | undefined)[];
limit?: number;
disableRowLimit?: boolean;
signal?: AbortSignal;
@@ -1603,6 +1613,7 @@ export class Metadata {
'filters',
]),
keys,
+ keyConditions,
disableRowLimit,
};
return this.cache.getOrFetch(
@@ -1616,7 +1627,15 @@ export class Metadata {
? {
...chartConfig,
select: keys
- .map((k, i) => `groupUniqArray(${limit})(${k}) AS param${i}`)
+ .map((k, i) => {
+ const condition = keyConditions?.[i];
+ // `groupUniqArrayIf` lets each key be constrained by its own
+ // predicate, so multiple faceted value lists are computed in a
+ // single scan instead of one query per key.
+ return condition
+ ? `groupUniqArrayIf(${limit})(${k}, ${condition}) AS param${i}`
+ : `groupUniqArray(${limit})(${k}) AS param${i}`;
+ })
.join(', '),
}
: await (async () => {
@@ -1698,6 +1717,7 @@ export class Metadata {
async getKeyValuesWithMVs({
chartConfig,
keys,
+ keyConditions,
source,
limit = 20,
disableRowLimit,
@@ -1705,6 +1725,8 @@ export class Metadata {
}: {
chartConfig: BuilderChartConfigWithDateRange;
keys: string[];
+ /** Per-key SQL predicates for faceted value lookups; see getKeyValues. */
+ keyConditions?: (string | undefined)[];
source: TSource | undefined;
limit?: number;
disableRowLimit?: boolean;
@@ -1720,6 +1742,7 @@ export class Metadata {
'filters',
]),
keys,
+ keyConditions,
disableRowLimit,
};
return this.cache.getOrFetch(
@@ -1727,6 +1750,31 @@ export class Metadata {
async () => {
if (keys.length === 0) return [];
+ // Faceted lookups apply a different predicate per key, so they can't be
+ // split across single-key materialized views — but they can run as one
+ // scan against a materialized view whose dimensions cover every filter
+ // column (else fall back to the raw table).
+ if (keyConditions && keyConditions.some(c => c != null)) {
+ const facetedConfig = await optimizeFacetedKeyValuesConfig({
+ chartConfig,
+ keys,
+ keyConditions,
+ source,
+ clickhouseClient: this.clickhouseClient,
+ metadata: this,
+ signal,
+ });
+ return this.getKeyValues({
+ chartConfig: facetedConfig,
+ keys,
+ keyConditions,
+ limit,
+ disableRowLimit,
+ signal,
+ source,
+ });
+ }
+
const defaultKeyValueCall = { chartConfig, keys };
const canHaveMVs =
source &&