diff --git a/.changeset/gpu-metrics-infra-panel.md b/.changeset/gpu-metrics-infra-panel.md new file mode 100644 index 0000000000..f992580a05 --- /dev/null +++ b/.changeset/gpu-metrics-infra-panel.md @@ -0,0 +1,9 @@ +--- +'@hyperdx/app': minor +--- + +Show GPU utilization and GPU memory utilization charts in the log/span side +panel Infrastructure section when `hw.gpu.*` metrics (OTel hardware semconv) +exist for the correlated host/node. Multiple GPUs on a host render as separate +series grouped by `hw.id`. The section is fully hidden when no GPU metrics are +present and partially rendered when only one metric is available. diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index 2d6587e37e..b72fea0cee 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -412,6 +412,11 @@ export const K8S_MEM_NUMBER_FORMAT: NumberFormat = { output: 'byte', }; +export const GPU_UTILIZATION_NUMBER_FORMAT: NumberFormat = { + output: 'percent', + mantissa: 1, +}; + function inferValueColumns( meta: Array<{ name: string; type: string }>, excluded: Set, diff --git a/packages/app/src/components/DBInfraPanel.tsx b/packages/app/src/components/DBInfraPanel.tsx index d0342dcbed..9cc04fc016 100644 --- a/packages/app/src/components/DBInfraPanel.tsx +++ b/packages/app/src/components/DBInfraPanel.tsx @@ -6,8 +6,11 @@ import { Granularity, } from '@hyperdx/common-utils/dist/core/utils'; import { + BuilderChartConfigWithDateRange, + DisplayType, isLogSource, isTraceSource, + MetricsDataType, SourceKind, TMetricSource, TSource, @@ -27,35 +30,101 @@ import { } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; -import { convertV1ChartConfigToV2 } from '@/ChartUtils'; import { TableSourceForm } from '@/components/Sources/SourceForm'; import { IS_LOCAL_MODE } from '@/config'; +import { useAvailableMetricNames } from '@/hooks/useAvailableMetricNames'; +import { getMetricNameSql } from '@/otelSemanticConventions'; import { useSource } from '@/source'; import { DBTimeChart } from './DBTimeChart'; import { getActiveInfraCorrelations, InfraChartSpec, + InfraCorrelation, } from './infraCorrelations'; import { KubeTimeline } from './KubeComponents'; -const InfraSubpanelGroup = ({ - charts, +function metricNameFor(fieldPrefix: string, chart: InfraChartSpec) { + return `${fieldPrefix}${chart.field}`; +} + +export function buildChartConfig({ + chart, fieldPrefix, - metricSource, - timestamp, - title, where, + metricSource, + dateRange, + granularity, }: { - charts: readonly InfraChartSpec[]; + chart: InfraChartSpec; fieldPrefix: string; - metricSource: TMetricSource; - timestamp: any; - title: string; where: string; + metricSource: TMetricSource; + dateRange: [Date, Date]; + granularity: Granularity; +}): BuilderChartConfigWithDateRange { + const metricName = metricNameFor(fieldPrefix, chart); + return { + displayType: DisplayType.Line, + select: [ + { + aggFn: 'avg', + metricType: chart.metricType ?? MetricsDataType.Gauge, + metricName, + // Matches both names across the k8s cpu.utilization -> cpu.usage + // semconv rename; undefined for metrics with no migration. + metricNameSql: getMetricNameSql(metricName), + // The metric branch of the renderer replaces this with the bucketed + // value column; the schema still requires a string. + valueExpression: 'Value', + aggConditionLanguage: 'lucene', + aggCondition: chart.where ? `(${where}) AND (${chart.where})` : where, + }, + ], + from: metricSource.from, + where: '', + whereLanguage: 'lucene', + groupBy: chart.groupBy?.join(', ') ?? '', + metricTables: metricSource.metricTables, + timestampValueExpression: metricSource.timestampValueExpression, + connection: metricSource.connection, + numberFormat: chart.numberFormat, + granularity, + dateRange, + }; +} + +/** + * One correlation group (Pod / Node / GPU): the metric chart grid plus, for + * Pod on log sources, the Kubernetes event timeline. + * + * Owns its wrapper element so that a group with nothing to show renders no + * DOM at all. Returning `null` from here — rather than an empty wrapper — is + * what keeps the parent `Stack`'s 40px gap from being applied to a group that + * is not visible (a rendered-but-empty div is still a flex item). + */ +const InfraCorrelationGroup = ({ + correlation, + logSource, + metricSource, + resourceAttributes, + timestamp, +}: { + correlation: InfraCorrelation; + logSource: TSource; + metricSource: TMetricSource | undefined; + resourceAttributes: Record | undefined; + timestamp: number; }) => { const [range, setRange] = useState<'30m' | '1h' | '1d'>('30m'); const [size, setSize] = useState<'sm' | 'md' | 'lg'>('sm'); + const { charts, fieldPrefix, requiresMetricAvailability, title } = + correlation; + + const correlateValue = resourceAttributes?.[correlation.correlateAttribute]; + const where = metricSource + ? `${metricSource.resourceAttributesExpression}.${correlation.correlateAttribute}:"${correlateValue}"` + : ''; const dateRange = useMemo<[Date, Date]>(() => { const duration = { @@ -70,6 +139,35 @@ const InfraSubpanelGroup = ({ ]; }, [timestamp, range]); + // Wider than the chart window: this only answers "does this host emit these + // metrics at all?", and a narrow window would make the section flap in and + // out as the user scrubs across a gap in the series. + const availabilityDateRange = useMemo<[Date, Date]>( + () => [ + sub(new Date(timestamp), { days: 1 }), + add(new Date(timestamp), { days: 1 }), + ], + [timestamp], + ); + + const candidateMetricNames = useMemo( + () => + requiresMetricAvailability + ? charts.map(chart => metricNameFor(fieldPrefix, chart)) + : [], + [charts, fieldPrefix, requiresMetricAvailability], + ); + + const isGated = requiresMetricAvailability === true; + const { availableMetrics, isLoading: isLoadingAvailability } = + useAvailableMetricNames({ + metricSource, + correlationWhere: where, + metricNames: candidateMetricNames, + dateRange: availabilityDateRange, + enabled: isGated, + }); + const { cols, height } = useMemo(() => { switch (size) { case 'sm': @@ -85,69 +183,109 @@ const InfraSubpanelGroup = ({ return convertDateRangeToGranularityString(dateRange); }, [dateRange]); + const visibleCharts = useMemo(() => { + if (!isGated) { + return charts; + } + return charts.filter(chart => + availableMetrics.has(metricNameFor(fieldPrefix, chart)), + ); + }, [charts, fieldPrefix, isGated, availableMetrics]); + + // The tab gate admits a correlate attribute that is present but empty; an + // empty value would correlate to nothing, so the whole group is dropped. + const showCharts = + metricSource != null && + visibleCharts.length > 0 && + // Only the gated groups wait on the existence query; ungated groups must + // not be held back by it. + (!isGated || !isLoadingAvailability); + const showTimeline = + correlation.timeline != null && logSource.kind === SourceKind.Log; + + if (!correlateValue || (!showCharts && !showTimeline)) { + return null; + } + return ( -
- - -

{title}

- setRange(value as any)} - /> -
- - setSize(value as any)} - /> - -
- - {charts.map(chart => ( - - - + {showCharts && metricSource && ( +
+ + +

{title}

+ setRange(value as '30m' | '1h' | '1d')} + /> +
+ + setSize(value as 'sm' | 'md' | 'lg')} /> - - - ))} - + +
+ + {visibleCharts.map(chart => ( + + + + + + ))} + +
+ )} + {showTimeline && correlation.timeline && ( + + + {title} Timeline + + + + + This Event
, + timestamp: new Date(timestamp).toISOString(), + }} + /> + + + + + )} ); }; @@ -214,69 +352,16 @@ export default ({ )} )} - {activeCorrelations.map(correlation => { - const value = resourceAttributes?.[correlation.correlateAttribute]; - // Truthiness guard, mirroring the previous Pod/Node render blocks - // (which gated on the attribute value with `&&`); the tab gate uses - // != null. detect and correlate are the same attribute for the - // built-in k8s descriptors, so this stays byte-identical. A future - // descriptor that splits the two decides here how an empty correlate - // value should render. - if (!value) { - return null; - } - const showTimeline = - correlation.timeline != null && source.kind === SourceKind.Log; - // Skip rendering an empty container when neither the metric group nor - // the timeline has anything to show (e.g. no metric source configured - // on a non-Log source). - if (!metricSource && !showTimeline) { - return null; - } - return ( -
- {metricSource && ( - - )} - {correlation.timeline && source.kind === SourceKind.Log && ( - - - {correlation.title} Timeline - - - - - This Event
, - timestamp: new Date(timestamp).toISOString(), - }} - /> - - - - - )} - - ); - })} + {activeCorrelations.map(correlation => ( + + ))} ); }; diff --git a/packages/app/src/components/__tests__/DBInfraPanel.buildChartConfig.test.ts b/packages/app/src/components/__tests__/DBInfraPanel.buildChartConfig.test.ts new file mode 100644 index 0000000000..42c8c3d269 --- /dev/null +++ b/packages/app/src/components/__tests__/DBInfraPanel.buildChartConfig.test.ts @@ -0,0 +1,120 @@ +import { Granularity } from '@hyperdx/common-utils/dist/core/utils'; +import { + DisplayType, + MetricsDataType, + TMetricSource, +} from '@hyperdx/common-utils/dist/types'; + +import { buildChartConfig } from '@/components/DBInfraPanel'; +import { INFRA_CORRELATIONS } from '@/components/infraCorrelations'; + +jest.mock('@/components/DBTimeChart', () => ({ DBTimeChart: () => null })); +jest.mock('@/components/Sources/SourceForm', () => ({ + TableSourceForm: () => null, +})); +jest.mock('@/components/KubeComponents', () => ({ KubeTimeline: () => null })); + +const METRIC_SOURCE = { + id: 'metric-source-1', + kind: 'metric', + name: 'Metrics', + connection: 'conn-1', + from: { databaseName: 'default', tableName: '' }, + timestampValueExpression: 'TimeUnix', + resourceAttributesExpression: 'ResourceAttributes', + metricTables: { + gauge: 'otel_metrics_gauge', + sum: 'otel_metrics_sum', + histogram: 'otel_metrics_histogram', + summary: 'otel_metrics_summary', + 'exponential histogram': 'otel_metrics_exponential_histogram', + }, +} as unknown as TMetricSource; + +const DATE_RANGE: [Date, Date] = [ + new Date('2026-01-01T00:00:00Z'), + new Date('2026-01-01T01:00:00Z'), +]; + +const gpu = INFRA_CORRELATIONS.find(c => c.title === 'GPU')!; +const node = INFRA_CORRELATIONS.find(c => c.title === 'Node')!; + +function build(correlation: typeof gpu, cardTestId: string, where: string) { + const chart = correlation.charts.find(c => c.cardTestId === cardTestId)!; + return buildChartConfig({ + chart, + fieldPrefix: correlation.fieldPrefix, + where, + metricSource: METRIC_SOURCE, + dateRange: DATE_RANGE, + granularity: Granularity.OneMinute, + }); +} + +describe('buildChartConfig', () => { + const where = 'ResourceAttributes.k8s.node.name:"gpu-node-1"'; + + it('builds a gauge metric select with the fully-qualified metric name', () => { + const config = build(gpu, 'gpu-memory-utilization-card', where); + expect(config.select).toEqual([ + { + aggFn: 'avg', + metricType: MetricsDataType.Gauge, + metricName: 'hw.gpu.memory.utilization', + metricNameSql: undefined, + valueExpression: 'Value', + aggConditionLanguage: 'lucene', + aggCondition: where, + }, + ]); + }); + + it('ANDs the per-chart where onto the correlation filter', () => { + const config = build(gpu, 'gpu-utilization-card', where); + expect(Array.isArray(config.select) && config.select[0].aggCondition).toBe( + `(${where}) AND (hw.gpu.task:"general" OR NOT hw.gpu.task:*)`, + ); + }); + + it('passes the GPU groupBy through as raw SQL', () => { + const config = build(gpu, 'gpu-utilization-card', where); + expect(config.groupBy).toContain("Attributes['hw.id']"); + }); + + it('leaves groupBy empty for charts that do not define one', () => { + const config = build(node, 'cpu-usage-card', where); + expect(config.groupBy).toBe(''); + }); + + it('threads source wiring and render settings onto the config', () => { + const config = build(gpu, 'gpu-utilization-card', where); + expect(config).toMatchObject({ + displayType: DisplayType.Line, + from: METRIC_SOURCE.from, + where: '', + whereLanguage: 'lucene', + metricTables: METRIC_SOURCE.metricTables, + timestampValueExpression: 'TimeUnix', + connection: 'conn-1', + granularity: Granularity.OneMinute, + dateRange: DATE_RANGE, + }); + expect(config.numberFormat).toMatchObject({ output: 'percent' }); + }); + + it('emits the semconv rename matcher for migrated k8s CPU metrics', () => { + const config = build(node, 'cpu-usage-card', where); + // k8s.node.cpu.utilization was renamed to k8s.node.cpu.usage; both must + // match or the Node CPU chart silently empties on newer collectors. + expect(Array.isArray(config.select) && config.select[0].metricNameSql).toBe( + "MetricName IN ('k8s.node.cpu.utilization', 'k8s.node.cpu.usage')", + ); + }); + + it('leaves metricNameSql undefined for metrics with no rename', () => { + const config = build(node, 'memory-usage-card', where); + expect( + Array.isArray(config.select) && config.select[0].metricNameSql, + ).toBeUndefined(); + }); +}); diff --git a/packages/app/src/components/__tests__/infraCorrelations.test.ts b/packages/app/src/components/__tests__/infraCorrelations.test.ts index 4cde31a4c3..4732f71910 100644 --- a/packages/app/src/components/__tests__/infraCorrelations.test.ts +++ b/packages/app/src/components/__tests__/infraCorrelations.test.ts @@ -1,3 +1,5 @@ +import { MetricsDataType } from '@hyperdx/common-utils/dist/types'; + import { getActiveInfraCorrelations, INFRA_CORRELATIONS, @@ -9,17 +11,17 @@ describe('getActiveInfraCorrelations', () => { expect(active.map(c => c.title)).toEqual(['Pod']); }); - it('returns the Node group when only k8s.node.name is present', () => { + it('returns the Node and GPU groups when only k8s.node.name is present', () => { const active = getActiveInfraCorrelations({ 'k8s.node.name': 'node-1' }); - expect(active.map(c => c.title)).toEqual(['Node']); + expect(active.map(c => c.title)).toEqual(['Node', 'GPU']); }); - it('returns both groups in render order when both attributes are present', () => { + it('returns Pod, Node, and GPU when both attributes are present', () => { const active = getActiveInfraCorrelations({ 'k8s.pod.uid': 'pod-abc', 'k8s.node.name': 'node-1', }); - expect(active.map(c => c.title)).toEqual(['Pod', 'Node']); + expect(active.map(c => c.title)).toEqual(['Pod', 'Node', 'GPU']); }); it('returns no groups when no detect attribute is present', () => { @@ -40,7 +42,6 @@ describe('getActiveInfraCorrelations', () => { expect(getActiveInfraCorrelations(null)).toEqual([]); }); - // The gate uses != null, not truthiness, matching the prior hardcoded gate. it('treats a detect attribute explicitly set to null as absent', () => { expect(getActiveInfraCorrelations({ 'k8s.pod.uid': null })).toEqual([]); }); @@ -62,16 +63,34 @@ describe('INFRA_CORRELATIONS built-ins', () => { correlateAttribute: 'k8s.node.name', fieldPrefix: 'k8s.node.', }, + { + title: 'GPU', + detectAttribute: 'k8s.node.name', + correlateAttribute: 'k8s.node.name', + fieldPrefix: 'hw.gpu.', + requiresMetricAvailability: true, + }, ]); }); + it('gates only the GPU group on metric availability', () => { + for (const correlation of INFRA_CORRELATIONS) { + expect(!!correlation.requiresMetricAvailability).toBe( + correlation.title === 'GPU', + ); + } + }); + it('keeps the Pod Timeline only on the Pod group', () => { - const node = INFRA_CORRELATIONS.find(c => c.title === 'Node'); - expect(node?.timeline).toBeUndefined(); + expect( + INFRA_CORRELATIONS.filter(c => c.timeline != null).map(c => c.title), + ).toEqual(['Pod']); }); - it('keeps the three k8s metric fields and card test ids on every group', () => { - for (const correlation of INFRA_CORRELATIONS) { + it('keeps the three k8s metric fields on Pod and Node groups', () => { + for (const correlation of INFRA_CORRELATIONS.filter( + c => c.title === 'Pod' || c.title === 'Node', + )) { expect(correlation.charts.map(c => [c.cardTestId, c.field])).toEqual([ ['cpu-usage-card', 'cpu.utilization'], ['memory-usage-card', 'memory.usage'], @@ -79,4 +98,78 @@ describe('INFRA_CORRELATIONS built-ins', () => { ]); } }); + + it('produces the expected fully-qualified metric names per group', () => { + const names = INFRA_CORRELATIONS.map(c => ({ + title: c.title, + metrics: c.charts.map(chart => `${c.fieldPrefix}${chart.field}`), + })); + expect(names).toEqual([ + { + title: 'Pod', + metrics: [ + 'k8s.pod.cpu.utilization', + 'k8s.pod.memory.usage', + 'k8s.pod.filesystem.available', + ], + }, + { + title: 'Node', + metrics: [ + 'k8s.node.cpu.utilization', + 'k8s.node.memory.usage', + 'k8s.node.filesystem.available', + ], + }, + { + title: 'GPU', + metrics: ['hw.gpu.utilization', 'hw.gpu.memory.utilization'], + }, + ]); + }); +}); + +describe('GPU chart specs', () => { + const gpuCorrelation = INFRA_CORRELATIONS.find(c => c.title === 'GPU')!; + + it('defines utilization and memory utilization charts', () => { + expect(gpuCorrelation.charts.map(c => c.cardTestId)).toEqual([ + 'gpu-utilization-card', + 'gpu-memory-utilization-card', + ]); + }); + + it('uses field:* for the task existence check, not _exists_', () => { + const utilizationChart = gpuCorrelation.charts.find( + c => c.cardTestId === 'gpu-utilization-card', + ); + expect(utilizationChart?.where).toBe( + 'hw.gpu.task:"general" OR NOT hw.gpu.task:*', + ); + }); + + it('does not filter the memory chart on hw.gpu.task', () => { + const memChart = gpuCorrelation.charts.find( + c => c.cardTestId === 'gpu-memory-utilization-card', + ); + expect(memChart?.where).toBeUndefined(); + }); + + it('includes hw.id/hw.name/hw.model in the groupBy expression', () => { + for (const chart of gpuCorrelation.charts) { + expect(chart.groupBy).toHaveLength(1); + const expr = chart.groupBy![0]; + expect(expr).toContain("Attributes['hw.id']"); + expect(expr).toContain("Attributes['hw.name']"); + expect(expr).toContain("Attributes['hw.model']"); + } + }); + + it('reads GPU metrics from the gauge table', () => { + for (const chart of gpuCorrelation.charts) { + expect(chart.metricType ?? MetricsDataType.Gauge).toBe( + MetricsDataType.Gauge, + ); + } + }); }); diff --git a/packages/app/src/components/infraCorrelations.ts b/packages/app/src/components/infraCorrelations.ts index 0e0c4484e0..0c8faddd75 100644 --- a/packages/app/src/components/infraCorrelations.ts +++ b/packages/app/src/components/infraCorrelations.ts @@ -1,19 +1,28 @@ +import { MetricsDataType } from '@hyperdx/common-utils/dist/types'; + import { + GPU_UTILIZATION_NUMBER_FORMAT, K8S_CPU_PERCENTAGE_NUMBER_FORMAT, K8S_FILESYSTEM_NUMBER_FORMAT, K8S_MEM_NUMBER_FORMAT, } from '@/ChartUtils'; import { NumberFormat } from '@/types'; -// One metric chart inside an infrastructure correlation group. The rendered -// metric field is `${fieldPrefix}${field} - Gauge` (see DBInfraPanel), so -// `field` is the metric name without the resource prefix or the type suffix. +// One metric chart inside an infrastructure correlation group. The queried +// metric name is `${fieldPrefix}${field}` (see DBInfraPanel), so `field` is +// the metric name without the resource prefix. export type InfraChartSpec = { readonly title: string; // data-testid for the chart card; the e2e suite selects on these. readonly cardTestId: string; readonly field: string; readonly numberFormat: NumberFormat; + // Per-chart Lucene WHERE condition ANDed with the correlation filter. + readonly where?: string; + // Per-chart groupBy SQL expressions (passed through as raw SQL). + readonly groupBy?: readonly string[]; + // Defaults to Gauge. + readonly metricType?: MetricsDataType; }; // A declarative infrastructure correlation group. `detectAttribute` decides @@ -33,6 +42,9 @@ export type InfraCorrelation = { readonly timeline?: { readonly queryAttribute: string; }; + // When true, charts in this group are individually gated on metric existence. + // The entire group is hidden if none of its metrics are available. + readonly requiresMetricAvailability?: boolean; }; // Pod and Node render the same three charts; only the field prefix and the @@ -58,8 +70,33 @@ const K8S_CHART_SPECS: readonly InfraChartSpec[] = [ }, ]; +// GroupBy expression that labels each series with the GPU device identity. +// Concatenates hw.id with hw.name or hw.model when available. +const GPU_GROUP_BY_EXPR = + `concat(Attributes['hw.id'], ` + + `if(Attributes['hw.name'] != '', concat(' ', Attributes['hw.name']), ` + + `if(Attributes['hw.model'] != '', concat(' ', Attributes['hw.model']), '')))`; + +const GPU_CHART_SPECS: readonly InfraChartSpec[] = [ + { + title: 'GPU utilization', + cardTestId: 'gpu-utilization-card', + field: 'utilization', + numberFormat: GPU_UTILIZATION_NUMBER_FORMAT, + where: 'hw.gpu.task:"general" OR NOT hw.gpu.task:*', + groupBy: [GPU_GROUP_BY_EXPR], + }, + { + title: 'GPU memory utilization', + cardTestId: 'gpu-memory-utilization-card', + field: 'memory.utilization', + numberFormat: GPU_UTILIZATION_NUMBER_FORMAT, + groupBy: [GPU_GROUP_BY_EXPR], + }, +]; + // Built-in correlation groups. Array order is the render order in the -// Infrastructure panel (Pod, then Node), matching the prior hardcoding. +// Infrastructure panel (Pod, then Node, then GPU). export const INFRA_CORRELATIONS: readonly InfraCorrelation[] = [ { title: 'Pod', @@ -76,6 +113,14 @@ export const INFRA_CORRELATIONS: readonly InfraCorrelation[] = [ fieldPrefix: 'k8s.node.', charts: K8S_CHART_SPECS, }, + { + title: 'GPU', + detectAttribute: 'k8s.node.name', + correlateAttribute: 'k8s.node.name', + fieldPrefix: 'hw.gpu.', + charts: GPU_CHART_SPECS, + requiresMetricAvailability: true, + }, ]; // Returns the built-in correlation groups whose detect attribute is present diff --git a/packages/app/src/hooks/useAvailableMetricNames.ts b/packages/app/src/hooks/useAvailableMetricNames.ts new file mode 100644 index 0000000000..a1c2ea561b --- /dev/null +++ b/packages/app/src/hooks/useAvailableMetricNames.ts @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import { + MetricsDataType, + TMetricSource, +} from '@hyperdx/common-utils/dist/types'; + +import { useGetKeyValues } from '@/hooks/useMetadata'; + +/** + * Resolves which of `metricNames` actually exist in the metric source for a + * correlated resource, so a chart group can hide the charts it has no data for. + * + * The query asks only about the candidate names rather than enumerating every + * distinct MetricName on the host. That matters: the metadata layer aggregates + * values with `groupUniqArray(limit)`, so an open-ended lookup can silently + * drop the name we are looking for on a metric-heavy host and hide a chart + * that does have data. Bounding the universe to the candidates — and sizing + * the limit to match — makes truncation impossible. + * + * Results are cached by useGetKeyValues (5 min staleTime), so reopening the + * panel does not re-query. + */ +export function useAvailableMetricNames({ + metricSource, + correlationWhere, + metricNames, + dateRange, + enabled = true, +}: { + metricSource: TMetricSource | undefined; + correlationWhere: string; + metricNames: readonly string[]; + dateRange: [Date, Date]; + enabled?: boolean; +}): { availableMetrics: Set; isLoading: boolean } { + const gaugeTable = metricSource?.metricTables?.[MetricsDataType.Gauge]; + + // Callers pass a memoized `metricNames`, so this rebuilds only when the + // candidate set actually changes rather than on every render. + const chartConfig = useMemo(() => { + if (!metricSource || !gaugeTable || metricNames.length === 0) { + return undefined; + } + const nameFilter = metricNames.map(n => `MetricName:"${n}"`).join(' OR '); + return { + // Empty select: only the grouped MetricName values are needed. + select: [] as [], + from: { + databaseName: metricSource.from.databaseName, + tableName: gaugeTable, + }, + where: correlationWhere + ? `(${correlationWhere}) AND (${nameFilter})` + : nameFilter, + whereLanguage: 'lucene' as const, + groupBy: '', + timestampValueExpression: metricSource.timestampValueExpression ?? '', + connection: metricSource.connection, + dateRange, + }; + }, [metricSource, gaugeTable, correlationWhere, metricNames, dateRange]); + + const { data, isLoading } = useGetKeyValues( + { + chartConfig, + keys: ['MetricName'], + // The value universe is exactly the candidate list, so this cannot cut + // off a name we asked about. + limit: metricNames.length, + disableRowLimit: true, + }, + { enabled: enabled && !!chartConfig }, + ); + + return useMemo( + () => ({ + availableMetrics: new Set(data?.[0]?.value ?? []), + isLoading, + }), + [data, isLoading], + ); +} diff --git a/scripts/ci/ratchet-baseline.json b/scripts/ci/ratchet-baseline.json index 6181bdaaf9..3a1a2ee7fa 100644 --- a/scripts/ci/ratchet-baseline.json +++ b/scripts/ci/ratchet-baseline.json @@ -5,7 +5,7 @@ "eslint-disable": 29 }, "app": { - "as-any": 215, + "as-any": 213, "ts-ignore": 11, "eslint-disable": 143 },