From 43ca848263e0dba6ac4e7bd566b428da6e479c27 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 22:47:22 +0000 Subject: [PATCH 1/6] feat(app): show GPU metrics in log side panel infrastructure section Add GPU utilization and GPU memory utilization charts to the Infrastructure tab of the log/span side panel, using OTel hardware semantic conventions (hw.gpu.*). - Add useGpuMetricsAvailability hook for cheap metric existence check (queries MetricName values from gauge table, cached 5 min) - Add GpuInfraSection component with per-GPU series via hw.id groupBy - Add getGpuCorrelationWhere to build resource correlation filter (prefers k8s.node.name, falls back to host.name) - Section is fully hidden when no GPU metrics exist for the correlated resource; partial availability renders only available charts - GPU utilization chart filters to hw.gpu.task:general (or unset) to avoid mixing encoder/decoder series HDX-5102 Co-authored-by: Mike Shi --- .changeset/gpu-metrics-infra-panel.md | 9 + packages/app/src/components/DBInfraPanel.tsx | 17 ++ .../app/src/components/GpuInfraSection.tsx | 183 ++++++++++++++++++ .../__tests__/infraCorrelations.test.ts | 51 +++++ .../app/src/components/infraCorrelations.ts | 26 +++ .../src/hooks/useGpuMetricsAvailability.ts | 83 ++++++++ 6 files changed, 369 insertions(+) create mode 100644 .changeset/gpu-metrics-infra-panel.md create mode 100644 packages/app/src/components/GpuInfraSection.tsx create mode 100644 packages/app/src/hooks/useGpuMetricsAvailability.ts 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/components/DBInfraPanel.tsx b/packages/app/src/components/DBInfraPanel.tsx index d0342dcbed..cb85bd6d10 100644 --- a/packages/app/src/components/DBInfraPanel.tsx +++ b/packages/app/src/components/DBInfraPanel.tsx @@ -33,8 +33,10 @@ import { IS_LOCAL_MODE } from '@/config'; import { useSource } from '@/source'; import { DBTimeChart } from './DBTimeChart'; +import { GpuInfraSection } from './GpuInfraSection'; import { getActiveInfraCorrelations, + getGpuCorrelationWhere, InfraChartSpec, } from './infraCorrelations'; import { KubeTimeline } from './KubeComponents'; @@ -179,6 +181,14 @@ export default ({ const timestamp = new Date(rowData?.__hdx_timestamp).getTime(); + const gpuWhere = useMemo( + () => + metricSource + ? getGpuCorrelationWhere(metricSource, resourceAttributes) + : undefined, + [metricSource, resourceAttributes], + ); + return ( {!metricSource && !isLoadingMetricSource && ( @@ -277,6 +287,13 @@ export default ({ ); })} + {metricSource && gpuWhere && ( + + )} ); }; diff --git a/packages/app/src/components/GpuInfraSection.tsx b/packages/app/src/components/GpuInfraSection.tsx new file mode 100644 index 0000000000..4f21260c7f --- /dev/null +++ b/packages/app/src/components/GpuInfraSection.tsx @@ -0,0 +1,183 @@ +import { useMemo, useState } from 'react'; +import { add, min, sub } from 'date-fns'; +import { + convertDateRangeToGranularityString, + Granularity, +} from '@hyperdx/common-utils/dist/core/utils'; +import { TMetricSource } from '@hyperdx/common-utils/dist/types'; +import { Card, Group, SegmentedControl, SimpleGrid } from '@mantine/core'; + +import { convertV1ChartConfigToV2 } from '@/ChartUtils'; +import { + GPU_METRIC_NAMES, + useGpuMetricsAvailability, +} from '@/hooks/useGpuMetricsAvailability'; +import { NumberFormat } from '@/types'; + +import { DBTimeChart } from './DBTimeChart'; + +const GPU_UTILIZATION_NUMBER_FORMAT: NumberFormat = { + output: 'percent', + mantissa: 1, +}; + +type GpuChartDef = { + title: string; + cardTestId: string; + metricName: string; + numberFormat: NumberFormat; + where?: string; +}; + +const GPU_CHARTS: GpuChartDef[] = [ + { + title: 'GPU utilization', + cardTestId: 'gpu-utilization-card', + metricName: GPU_METRIC_NAMES.utilization, + numberFormat: GPU_UTILIZATION_NUMBER_FORMAT, + where: 'hw.gpu.task:"general" OR NOT _exists_:hw.gpu.task', + }, + { + title: 'GPU memory utilization', + cardTestId: 'gpu-memory-utilization-card', + metricName: GPU_METRIC_NAMES.memoryUtilization, + numberFormat: GPU_UTILIZATION_NUMBER_FORMAT, + }, +]; + +function isChartAvailable( + chart: GpuChartDef, + availability: { hasUtilization: boolean; hasMemoryUtilization: boolean }, +): boolean { + if (chart.metricName === GPU_METRIC_NAMES.utilization) { + return availability.hasUtilization; + } + if (chart.metricName === GPU_METRIC_NAMES.memoryUtilization) { + return availability.hasMemoryUtilization; + } + return false; +} + +export function GpuInfraSection({ + metricSource, + where, + timestamp, +}: { + metricSource: TMetricSource; + where: string; + timestamp: number; +}) { + const [range, setRange] = useState<'30m' | '1h' | '1d'>('30m'); + const [size, setSize] = useState<'sm' | 'md' | 'lg'>('sm'); + + const dateRange = useMemo<[Date, Date]>(() => { + const duration = { + '30m': { minutes: 15 }, + '1h': { minutes: 30 }, + '1d': { hours: 12 }, + }[range]; + return [ + sub(new Date(timestamp), duration), + // eslint-disable-next-line no-restricted-syntax + min([add(new Date(timestamp), duration), new Date()]), + ]; + }, [timestamp, range]); + + const availability = useGpuMetricsAvailability({ + metricSource, + where, + dateRange, + }); + + const { cols, height } = useMemo(() => { + switch (size) { + case 'sm': + return { cols: 3, height: 200 }; + case 'md': + return { cols: 2, height: 250 }; + case 'lg': + return { cols: 1, height: 320 }; + } + }, [size]); + + const granularity = useMemo(() => { + return convertDateRangeToGranularityString(dateRange); + }, [dateRange]); + + const visibleCharts = useMemo( + () => GPU_CHARTS.filter(chart => isChartAvailable(chart, availability)), + [availability], + ); + + if (availability.isLoading || !availability.hasAny) { + return null; + } + + return ( +
+ + +

GPU

+ setRange(value as any)} + /> +
+ + setSize(value as any)} + /> + +
+ + {visibleCharts.map(chart => ( + + + + + + ))} + +
+ ); +} diff --git a/packages/app/src/components/__tests__/infraCorrelations.test.ts b/packages/app/src/components/__tests__/infraCorrelations.test.ts index 4cde31a4c3..5846f501ac 100644 --- a/packages/app/src/components/__tests__/infraCorrelations.test.ts +++ b/packages/app/src/components/__tests__/infraCorrelations.test.ts @@ -1,5 +1,6 @@ import { getActiveInfraCorrelations, + getGpuCorrelationWhere, INFRA_CORRELATIONS, } from '@/components/infraCorrelations'; @@ -80,3 +81,53 @@ describe('INFRA_CORRELATIONS built-ins', () => { } }); }); + +describe('getGpuCorrelationWhere', () => { + const metricSource = { + resourceAttributesExpression: 'ResourceAttributes', + } as any; + + it('returns where clause using k8s.node.name when present', () => { + const result = getGpuCorrelationWhere(metricSource, { + 'k8s.node.name': 'gpu-node-1', + 'host.name': 'host-1', + }); + expect(result).toBe('ResourceAttributes.k8s.node.name:"gpu-node-1"'); + }); + + it('falls back to host.name when k8s.node.name is absent', () => { + const result = getGpuCorrelationWhere(metricSource, { + 'host.name': 'gpu-host-1', + }); + expect(result).toBe('ResourceAttributes.host.name:"gpu-host-1"'); + }); + + it('returns undefined when no correlatable attribute is present', () => { + const result = getGpuCorrelationWhere(metricSource, { + 'service.name': 'api', + 'k8s.pod.uid': 'pod-123', + }); + expect(result).toBeUndefined(); + }); + + it('returns undefined for null resource attributes', () => { + expect(getGpuCorrelationWhere(metricSource, null)).toBeUndefined(); + expect(getGpuCorrelationWhere(metricSource, undefined)).toBeUndefined(); + }); + + it('skips empty string attribute values', () => { + const result = getGpuCorrelationWhere(metricSource, { + 'k8s.node.name': '', + 'host.name': 'fallback-host', + }); + expect(result).toBe('ResourceAttributes.host.name:"fallback-host"'); + }); + + it('returns undefined when all correlatable attributes are empty', () => { + const result = getGpuCorrelationWhere(metricSource, { + 'k8s.node.name': '', + 'host.name': '', + }); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/app/src/components/infraCorrelations.ts b/packages/app/src/components/infraCorrelations.ts index 0e0c4484e0..28858e3226 100644 --- a/packages/app/src/components/infraCorrelations.ts +++ b/packages/app/src/components/infraCorrelations.ts @@ -1,3 +1,5 @@ +import type { TMetricSource } from '@hyperdx/common-utils/dist/types'; + import { K8S_CPU_PERCENTAGE_NUMBER_FORMAT, K8S_FILESYSTEM_NUMBER_FORMAT, @@ -92,3 +94,27 @@ export function getActiveInfraCorrelations( correlation => resourceAttributes[correlation.detectAttribute] != null, ); } + +// Resource attributes used to correlate GPU metrics to the selected log/span. +// GPU metrics carry host/pod identity as resource attributes on the metric +// (per OTel hardware semconv), so we correlate via the same node/host +// attributes. Preference order: k8s.node.name > host.name. +const GPU_CORRELATE_ATTRIBUTES = ['k8s.node.name', 'host.name'] as const; + +/** + * Builds the Lucene WHERE clause to correlate GPU metrics to the selected + * log/span's host. Returns undefined if no correlatable attribute is found. + */ +export function getGpuCorrelationWhere( + metricSource: TMetricSource, + resourceAttributes: Record | null | undefined, +): string | undefined { + if (!resourceAttributes) return undefined; + for (const attr of GPU_CORRELATE_ATTRIBUTES) { + const value = resourceAttributes[attr]; + if (value != null && value !== '') { + return `${metricSource.resourceAttributesExpression}.${attr}:"${value}"`; + } + } + return undefined; +} diff --git a/packages/app/src/hooks/useGpuMetricsAvailability.ts b/packages/app/src/hooks/useGpuMetricsAvailability.ts new file mode 100644 index 0000000000..cd4ded9648 --- /dev/null +++ b/packages/app/src/hooks/useGpuMetricsAvailability.ts @@ -0,0 +1,83 @@ +import { useMemo } from 'react'; +import { + MetricsDataType, + TMetricSource, +} from '@hyperdx/common-utils/dist/types'; + +import { useGetKeyValues } from '@/hooks/useMetadata'; + +export const GPU_METRIC_NAMES = { + utilization: 'hw.gpu.utilization', + memoryUtilization: 'hw.gpu.memory.utilization', +} as const; + +export type GpuMetricsAvailability = { + hasUtilization: boolean; + hasMemoryUtilization: boolean; + hasAny: boolean; + isLoading: boolean; +}; + +/** + * Checks whether GPU metrics exist in the given metric source, scoped to a + * correlated resource (by host/node). Queries distinct MetricName values from + * the gauge table filtered by the resource correlation WHERE clause. + * + * Results are cached (staleTime 5 min by useGetKeyValues) so reopening the + * panel does not re-query. + */ +export function useGpuMetricsAvailability({ + metricSource, + where, + dateRange, + enabled = true, +}: { + metricSource: TMetricSource | undefined; + where: string; + dateRange: [Date, Date]; + enabled?: boolean; +}): GpuMetricsAvailability { + const gaugeTable = metricSource?.metricTables?.[MetricsDataType.Gauge]; + + const chartConfig = useMemo(() => { + if (!metricSource || !gaugeTable) return undefined; + return { + select: [] as [], + from: { + databaseName: metricSource.from.databaseName, + tableName: gaugeTable, + }, + where, + whereLanguage: 'lucene' as const, + groupBy: '', + timestampValueExpression: metricSource.timestampValueExpression ?? '', + connection: metricSource.connection, + dateRange, + }; + }, [metricSource, gaugeTable, where, dateRange]); + + const { data, isLoading } = useGetKeyValues( + { + chartConfig, + keys: ['MetricName'], + limit: 50, + disableRowLimit: true, + }, + { + enabled: enabled && !!chartConfig, + }, + ); + + return useMemo(() => { + const metricNames: string[] = data?.[0]?.value ?? []; + const gpuNames = metricNames.filter(name => name.startsWith('hw.gpu.')); + return { + hasUtilization: gpuNames.includes(GPU_METRIC_NAMES.utilization), + hasMemoryUtilization: gpuNames.includes( + GPU_METRIC_NAMES.memoryUtilization, + ), + hasAny: gpuNames.length > 0, + isLoading, + }; + }, [data, isLoading]); +} From 0ef3ffd23160e6138670932c9a773b2d540e1382 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 22:53:12 +0000 Subject: [PATCH 2/6] fix(app): resolve lint warnings in GPU infra section Use specific type assertions instead of 'as any' to stay within the max-warnings threshold. Fix import sort order in test file. Co-authored-by: Mike Shi --- packages/app/src/components/GpuInfraSection.tsx | 4 ++-- .../app/src/components/__tests__/infraCorrelations.test.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/GpuInfraSection.tsx b/packages/app/src/components/GpuInfraSection.tsx index 4f21260c7f..c68d2ec9d8 100644 --- a/packages/app/src/components/GpuInfraSection.tsx +++ b/packages/app/src/components/GpuInfraSection.tsx @@ -126,7 +126,7 @@ export function GpuInfraSection({ { label: '1d', value: '1d' }, ]} value={range} - onChange={value => setRange(value as any)} + onChange={value => setRange(value as '30m' | '1h' | '1d')} /> @@ -138,7 +138,7 @@ export function GpuInfraSection({ { label: 'LG', value: 'lg' }, ]} value={size} - onChange={value => setSize(value as any)} + onChange={value => setSize(value as 'sm' | 'md' | 'lg')} /> diff --git a/packages/app/src/components/__tests__/infraCorrelations.test.ts b/packages/app/src/components/__tests__/infraCorrelations.test.ts index 5846f501ac..8cb349ae27 100644 --- a/packages/app/src/components/__tests__/infraCorrelations.test.ts +++ b/packages/app/src/components/__tests__/infraCorrelations.test.ts @@ -1,3 +1,5 @@ +import type { TMetricSource } from '@hyperdx/common-utils/dist/types'; + import { getActiveInfraCorrelations, getGpuCorrelationWhere, @@ -85,7 +87,7 @@ describe('INFRA_CORRELATIONS built-ins', () => { describe('getGpuCorrelationWhere', () => { const metricSource = { resourceAttributesExpression: 'ResourceAttributes', - } as any; + } as unknown as TMetricSource; it('returns where clause using k8s.node.name when present', () => { const result = getGpuCorrelationWhere(metricSource, { From 7b45585bba450947343d2fc9577b688f91f1e64e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 22:56:50 +0000 Subject: [PATCH 3/6] fix(app): use NOW constant to avoid lint ratchet violation Use the stable NOW constant from config instead of new Date() in the GPU infra section's date range calculation, matching the project's date hygiene rules. Co-authored-by: Mike Shi --- packages/app/src/components/GpuInfraSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/GpuInfraSection.tsx b/packages/app/src/components/GpuInfraSection.tsx index c68d2ec9d8..abbe527505 100644 --- a/packages/app/src/components/GpuInfraSection.tsx +++ b/packages/app/src/components/GpuInfraSection.tsx @@ -8,6 +8,7 @@ import { TMetricSource } from '@hyperdx/common-utils/dist/types'; import { Card, Group, SegmentedControl, SimpleGrid } from '@mantine/core'; import { convertV1ChartConfigToV2 } from '@/ChartUtils'; +import { NOW } from '@/config'; import { GPU_METRIC_NAMES, useGpuMetricsAvailability, @@ -78,8 +79,7 @@ export function GpuInfraSection({ }[range]; return [ sub(new Date(timestamp), duration), - // eslint-disable-next-line no-restricted-syntax - min([add(new Date(timestamp), duration), new Date()]), + min([add(new Date(timestamp), duration), new Date(NOW)]), ]; }, [timestamp, range]); From 7c880b45dff2491b8770a85e66cb9f232ebe84d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 04:34:28 +0000 Subject: [PATCH 4/6] refactor(app): address review feedback on GPU infra panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural: - Extend InfraChartSpec with optional groupBy, where, metricType, and fallback fields so GPU registers as a descriptor rather than a separate component - Delete GpuInfraSection.tsx; InfraSubpanelGroup now handles both k8s and GPU charts via the descriptor data - Add requiresMetricAvailability flag to InfraCorrelation; gated groups only render when metric existence is confirmed - Add AvailabilityGatedGroup wrapper that checks availability before rendering Bug fixes: - Fix _exists_ syntax (unsupported) → hw.gpu.task:* per query parser - Push MetricName:hw.gpu.* prefix filter into the availability query so limit doesn't produce false negatives on metric-heavy nodes - Check both gauge and sum tables for availability - Drop host.name fallback (unreachable: tab requires k8s attributes) Acceptance criteria: - Series grouped by concat(hw.id, hw.name, hw.model) for richer labels - hw.gpu.memory.usage / hw.gpu.memory.limit fallback via ratio chart when hw.gpu.memory.utilization isn't emitted - resolveChartAvailability tested for primary, fallback, none, and partial cases Minor: - Use live new Date() (eslint-disable) matching sibling InfraSubpanelGroup - Move GPU_UTILIZATION_NUMBER_FORMAT to ChartUtils for consistency - Remove empty select comment (now annotated in the hook) HDX-5102 Co-authored-by: Mike Shi --- packages/app/src/ChartUtils.tsx | 5 + packages/app/src/components/DBInfraPanel.tsx | 238 +++++++++++++----- .../app/src/components/GpuInfraSection.tsx | 183 -------------- .../__tests__/infraCorrelations.test.ts | 183 ++++++++++---- .../app/src/components/infraCorrelations.ts | 90 ++++--- .../src/hooks/useGpuMetricsAvailability.ts | 132 +++++++--- scripts/ci/ratchet-baseline.json | 2 +- 7 files changed, 475 insertions(+), 358 deletions(-) delete mode 100644 packages/app/src/components/GpuInfraSection.tsx 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 cb85bd6d10..5f8aa955da 100644 --- a/packages/app/src/components/DBInfraPanel.tsx +++ b/packages/app/src/components/DBInfraPanel.tsx @@ -30,17 +30,88 @@ import { useDisclosure } from '@mantine/hooks'; import { convertV1ChartConfigToV2 } from '@/ChartUtils'; import { TableSourceForm } from '@/components/Sources/SourceForm'; import { IS_LOCAL_MODE } from '@/config'; +import { + GpuMetricsAvailability, + resolveChartAvailability, + useGpuMetricsAvailability, +} from '@/hooks/useGpuMetricsAvailability'; import { useSource } from '@/source'; import { DBTimeChart } from './DBTimeChart'; -import { GpuInfraSection } from './GpuInfraSection'; import { getActiveInfraCorrelations, - getGpuCorrelationWhere, InfraChartSpec, + InfraCorrelation, } from './infraCorrelations'; import { KubeTimeline } from './KubeComponents'; +function buildChartConfig( + chart: InfraChartSpec, + fieldPrefix: string, + where: string, + metricSource: TMetricSource, + dateRange: [Date, Date], + granularity: Granularity, + mode: 'primary' | 'fallback', +) { + const metricType = chart.metricType ?? 'Gauge'; + + if (mode === 'fallback' && chart.fallback) { + const [numField, denField] = chart.fallback.fields; + const fallbackType = chart.fallback.metricType; + const seriesWhere = chart.where ? `(${where}) AND (${chart.where})` : where; + return convertV1ChartConfigToV2( + { + dateRange, + granularity, + seriesReturnType: 'ratio', + series: [ + { + type: 'time', + where: seriesWhere, + groupBy: chart.groupBy ? [...chart.groupBy] : [], + aggFn: 'avg', + field: `${fieldPrefix}${numField} - ${fallbackType}`, + table: 'metrics', + numberFormat: chart.fallback.numberFormat, + }, + { + type: 'time', + where: seriesWhere, + groupBy: chart.groupBy ? [...chart.groupBy] : [], + aggFn: 'avg', + field: `${fieldPrefix}${denField} - ${fallbackType}`, + table: 'metrics', + numberFormat: chart.fallback.numberFormat, + }, + ], + }, + { metric: metricSource }, + ); + } + + const seriesWhere = chart.where ? `(${where}) AND (${chart.where})` : where; + return convertV1ChartConfigToV2( + { + dateRange, + granularity, + seriesReturnType: 'column', + series: [ + { + type: 'time', + where: seriesWhere, + groupBy: chart.groupBy ? [...chart.groupBy] : [], + aggFn: 'avg', + field: `${fieldPrefix}${chart.field} - ${metricType}`, + table: 'metrics', + numberFormat: chart.numberFormat, + }, + ], + }, + { metric: metricSource }, + ); +} + const InfraSubpanelGroup = ({ charts, fieldPrefix, @@ -48,13 +119,15 @@ const InfraSubpanelGroup = ({ timestamp, title, where, + availability, }: { charts: readonly InfraChartSpec[]; fieldPrefix: string; metricSource: TMetricSource; - timestamp: any; + timestamp: number; title: string; where: string; + availability?: GpuMetricsAvailability; }) => { const [range, setRange] = useState<'30m' | '1h' | '1d'>('30m'); const [size, setSize] = useState<'sm' | 'md' | 'lg'>('sm'); @@ -87,6 +160,26 @@ const InfraSubpanelGroup = ({ return convertDateRangeToGranularityString(dateRange); }, [dateRange]); + // When availability is provided, resolve each chart to primary/fallback/none. + const resolvedCharts = useMemo(() => { + if (!availability) { + return charts.map(chart => ({ chart, mode: 'primary' as const })); + } + return charts.reduce< + { chart: InfraChartSpec; mode: 'primary' | 'fallback' }[] + >((acc, chart) => { + const mode = resolveChartAvailability(fieldPrefix, chart, availability); + if (mode !== 'none') { + acc.push({ chart, mode }); + } + return acc; + }, []); + }, [charts, availability, fieldPrefix]); + + if (resolvedCharts.length === 0) { + return null; + } + return (
@@ -100,7 +193,7 @@ const InfraSubpanelGroup = ({ { label: '1d', value: '1d' }, ]} value={range} - onChange={value => setRange(value as any)} + onChange={value => setRange(value as '30m' | '1h' | '1d')} /> @@ -112,36 +205,24 @@ const InfraSubpanelGroup = ({ { label: 'LG', value: 'lg' }, ]} value={size} - onChange={value => setSize(value as any)} + onChange={value => setSize(value as 'sm' | 'md' | 'lg')} /> - {charts.map(chart => ( + {resolvedCharts.map(({ chart, mode }) => ( { + // Wide window for the existence check — we only need a boolean "are there + // any GPU metrics for this host?" answer, not precise time-aligned data. + const dateRange = useMemo<[Date, Date]>( + () => [ + sub(new Date(timestamp), { days: 1 }), + add(new Date(timestamp), { days: 1 }), + ], + [timestamp], + ); + + const availability = useGpuMetricsAvailability({ + metricSource, + correlationWhere: where, + dateRange, + }); + + if (availability.isLoading || !availability.hasAny) { + return null; + } + + return ( + + ); +}; + export default ({ rowData, source, @@ -181,14 +310,6 @@ export default ({ const timestamp = new Date(rowData?.__hdx_timestamp).getTime(); - const gpuWhere = useMemo( - () => - metricSource - ? getGpuCorrelationWhere(metricSource, resourceAttributes) - : undefined, - [metricSource, resourceAttributes], - ); - return ( {!metricSource && !isLoadingMetricSource && ( @@ -226,35 +347,39 @@ 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; } + + const correlationWhere = metricSource + ? `${metricSource.resourceAttributesExpression}.${correlation.correlateAttribute}:"${value}"` + : ''; + return (
- {metricSource && ( - - )} + {metricSource && + (correlation.requiresMetricAvailability ? ( + + ) : ( + + ))} {correlation.timeline && source.kind === SourceKind.Log && ( @@ -287,13 +412,6 @@ export default ({
); })} - {metricSource && gpuWhere && ( - - )}
); }; diff --git a/packages/app/src/components/GpuInfraSection.tsx b/packages/app/src/components/GpuInfraSection.tsx deleted file mode 100644 index abbe527505..0000000000 --- a/packages/app/src/components/GpuInfraSection.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useMemo, useState } from 'react'; -import { add, min, sub } from 'date-fns'; -import { - convertDateRangeToGranularityString, - Granularity, -} from '@hyperdx/common-utils/dist/core/utils'; -import { TMetricSource } from '@hyperdx/common-utils/dist/types'; -import { Card, Group, SegmentedControl, SimpleGrid } from '@mantine/core'; - -import { convertV1ChartConfigToV2 } from '@/ChartUtils'; -import { NOW } from '@/config'; -import { - GPU_METRIC_NAMES, - useGpuMetricsAvailability, -} from '@/hooks/useGpuMetricsAvailability'; -import { NumberFormat } from '@/types'; - -import { DBTimeChart } from './DBTimeChart'; - -const GPU_UTILIZATION_NUMBER_FORMAT: NumberFormat = { - output: 'percent', - mantissa: 1, -}; - -type GpuChartDef = { - title: string; - cardTestId: string; - metricName: string; - numberFormat: NumberFormat; - where?: string; -}; - -const GPU_CHARTS: GpuChartDef[] = [ - { - title: 'GPU utilization', - cardTestId: 'gpu-utilization-card', - metricName: GPU_METRIC_NAMES.utilization, - numberFormat: GPU_UTILIZATION_NUMBER_FORMAT, - where: 'hw.gpu.task:"general" OR NOT _exists_:hw.gpu.task', - }, - { - title: 'GPU memory utilization', - cardTestId: 'gpu-memory-utilization-card', - metricName: GPU_METRIC_NAMES.memoryUtilization, - numberFormat: GPU_UTILIZATION_NUMBER_FORMAT, - }, -]; - -function isChartAvailable( - chart: GpuChartDef, - availability: { hasUtilization: boolean; hasMemoryUtilization: boolean }, -): boolean { - if (chart.metricName === GPU_METRIC_NAMES.utilization) { - return availability.hasUtilization; - } - if (chart.metricName === GPU_METRIC_NAMES.memoryUtilization) { - return availability.hasMemoryUtilization; - } - return false; -} - -export function GpuInfraSection({ - metricSource, - where, - timestamp, -}: { - metricSource: TMetricSource; - where: string; - timestamp: number; -}) { - const [range, setRange] = useState<'30m' | '1h' | '1d'>('30m'); - const [size, setSize] = useState<'sm' | 'md' | 'lg'>('sm'); - - const dateRange = useMemo<[Date, Date]>(() => { - const duration = { - '30m': { minutes: 15 }, - '1h': { minutes: 30 }, - '1d': { hours: 12 }, - }[range]; - return [ - sub(new Date(timestamp), duration), - min([add(new Date(timestamp), duration), new Date(NOW)]), - ]; - }, [timestamp, range]); - - const availability = useGpuMetricsAvailability({ - metricSource, - where, - dateRange, - }); - - const { cols, height } = useMemo(() => { - switch (size) { - case 'sm': - return { cols: 3, height: 200 }; - case 'md': - return { cols: 2, height: 250 }; - case 'lg': - return { cols: 1, height: 320 }; - } - }, [size]); - - const granularity = useMemo(() => { - return convertDateRangeToGranularityString(dateRange); - }, [dateRange]); - - const visibleCharts = useMemo( - () => GPU_CHARTS.filter(chart => isChartAvailable(chart, availability)), - [availability], - ); - - if (availability.isLoading || !availability.hasAny) { - return null; - } - - return ( -
- - -

GPU

- setRange(value as '30m' | '1h' | '1d')} - /> -
- - setSize(value as 'sm' | 'md' | 'lg')} - /> - -
- - {visibleCharts.map(chart => ( - - - - - - ))} - -
- ); -} diff --git a/packages/app/src/components/__tests__/infraCorrelations.test.ts b/packages/app/src/components/__tests__/infraCorrelations.test.ts index 8cb349ae27..92c8afcc5a 100644 --- a/packages/app/src/components/__tests__/infraCorrelations.test.ts +++ b/packages/app/src/components/__tests__/infraCorrelations.test.ts @@ -1,10 +1,9 @@ -import type { TMetricSource } from '@hyperdx/common-utils/dist/types'; - import { getActiveInfraCorrelations, - getGpuCorrelationWhere, INFRA_CORRELATIONS, } from '@/components/infraCorrelations'; +import type { GpuMetricsAvailability } from '@/hooks/useGpuMetricsAvailability'; +import { resolveChartAvailability } from '@/hooks/useGpuMetricsAvailability'; describe('getActiveInfraCorrelations', () => { it('returns the Pod group when only k8s.pod.uid is present', () => { @@ -12,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', () => { @@ -43,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([]); }); @@ -65,16 +63,27 @@ 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('keeps the Pod Timeline only on the Pod group', () => { const node = INFRA_CORRELATIONS.find(c => c.title === 'Node'); expect(node?.timeline).toBeUndefined(); + const gpu = INFRA_CORRELATIONS.find(c => c.title === 'GPU'); + expect(gpu?.timeline).toBeUndefined(); }); - 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'], @@ -84,52 +93,132 @@ describe('INFRA_CORRELATIONS built-ins', () => { }); }); -describe('getGpuCorrelationWhere', () => { - const metricSource = { - resourceAttributesExpression: 'ResourceAttributes', - } as unknown as TMetricSource; +describe('GPU chart specs', () => { + const gpuCorrelation = INFRA_CORRELATIONS.find(c => c.title === 'GPU')!; - it('returns where clause using k8s.node.name when present', () => { - const result = getGpuCorrelationWhere(metricSource, { - 'k8s.node.name': 'gpu-node-1', - 'host.name': 'host-1', - }); - expect(result).toBe('ResourceAttributes.k8s.node.name:"gpu-node-1"'); + it('defines utilization and memory utilization charts', () => { + expect(gpuCorrelation.charts.map(c => c.cardTestId)).toEqual([ + 'gpu-utilization-card', + 'gpu-memory-utilization-card', + ]); }); - it('falls back to host.name when k8s.node.name is absent', () => { - const result = getGpuCorrelationWhere(metricSource, { - 'host.name': 'gpu-host-1', - }); - expect(result).toBe('ResourceAttributes.host.name:"gpu-host-1"'); + it('uses correct where clause for utilization (no _exists_ syntax)', () => { + 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('returns undefined when no correlatable attribute is present', () => { - const result = getGpuCorrelationWhere(metricSource, { - 'service.name': 'api', - 'k8s.pod.uid': 'pod-123', + it('provides a fallback for memory utilization from sum table', () => { + const memChart = gpuCorrelation.charts.find( + c => c.cardTestId === 'gpu-memory-utilization-card', + ); + expect(memChart?.fallback).toEqual({ + fields: ['memory.usage', 'memory.limit'], + metricType: 'Sum', + numberFormat: expect.objectContaining({ output: 'percent' }), }); - expect(result).toBeUndefined(); }); - it('returns undefined for null resource attributes', () => { - expect(getGpuCorrelationWhere(metricSource, null)).toBeUndefined(); - expect(getGpuCorrelationWhere(metricSource, undefined)).toBeUndefined(); - }); - - it('skips empty string attribute values', () => { - const result = getGpuCorrelationWhere(metricSource, { - 'k8s.node.name': '', - 'host.name': 'fallback-host', - }); - expect(result).toBe('ResourceAttributes.host.name:"fallback-host"'); + it('includes hw.id/hw.name/hw.model in 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('returns undefined when all correlatable attributes are empty', () => { - const result = getGpuCorrelationWhere(metricSource, { - 'k8s.node.name': '', - 'host.name': '', - }); - expect(result).toBeUndefined(); +describe('resolveChartAvailability', () => { + const fieldPrefix = 'hw.gpu.'; + + const makeAvailability = ( + gauge: string[] = [], + sum: string[] = [], + ): GpuMetricsAvailability => ({ + gaugeMetrics: new Set(gauge), + sumMetrics: new Set(sum), + hasAny: gauge.length > 0 || sum.length > 0, + isLoading: false, + }); + + it('returns primary when gauge metric exists', () => { + const chart = { field: 'utilization' }; + const availability = makeAvailability(['hw.gpu.utilization']); + expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( + 'primary', + ); + }); + + it('returns none when neither primary nor fallback exists', () => { + const chart = { field: 'utilization' }; + const availability = makeAvailability(); + expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( + 'none', + ); + }); + + it('returns fallback when primary is absent but fallback fields exist in sum', () => { + const chart = { + field: 'memory.utilization', + fallback: { + fields: ['memory.usage', 'memory.limit'] as [string, string], + metricType: 'Sum' as const, + numberFormat: { output: 'percent' as const }, + }, + }; + const availability = makeAvailability( + [], + ['hw.gpu.memory.usage', 'hw.gpu.memory.limit'], + ); + expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( + 'fallback', + ); + }); + + it('prefers primary over fallback even if both exist', () => { + const chart = { + field: 'memory.utilization', + fallback: { + fields: ['memory.usage', 'memory.limit'] as [string, string], + metricType: 'Sum' as const, + numberFormat: { output: 'percent' as const }, + }, + }; + const availability = makeAvailability( + ['hw.gpu.memory.utilization'], + ['hw.gpu.memory.usage', 'hw.gpu.memory.limit'], + ); + expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( + 'primary', + ); + }); + + it('returns none when only one fallback field exists', () => { + const chart = { + field: 'memory.utilization', + fallback: { + fields: ['memory.usage', 'memory.limit'] as [string, string], + metricType: 'Sum' as const, + numberFormat: { output: 'percent' as const }, + }, + }; + const availability = makeAvailability([], ['hw.gpu.memory.usage']); + expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( + 'none', + ); + }); + + it('handles Sum primary metric type', () => { + const chart = { field: 'some.counter', metricType: 'Sum' as const }; + const availability = makeAvailability([], ['hw.gpu.some.counter']); + expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( + 'primary', + ); }); }); diff --git a/packages/app/src/components/infraCorrelations.ts b/packages/app/src/components/infraCorrelations.ts index 28858e3226..9b5f7cd479 100644 --- a/packages/app/src/components/infraCorrelations.ts +++ b/packages/app/src/components/infraCorrelations.ts @@ -1,6 +1,5 @@ -import type { TMetricSource } from '@hyperdx/common-utils/dist/types'; - import { + GPU_UTILIZATION_NUMBER_FORMAT, K8S_CPU_PERCENTAGE_NUMBER_FORMAT, K8S_FILESYSTEM_NUMBER_FORMAT, K8S_MEM_NUMBER_FORMAT, @@ -8,14 +7,30 @@ import { 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. +// metric field is `${fieldPrefix}${field} - ${metricType}` (see DBInfraPanel), +// so `field` is the metric name without the resource prefix or the type suffix. 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[]; + // Metric data type suffix; defaults to 'Gauge'. + readonly metricType?: 'Gauge' | 'Sum'; + // Fallback chart rendered when the primary field is unavailable but the + // fallback fields exist. The fields are rendered as a ratio (numerator / + // denominator) with the given metric type. + readonly fallback?: InfraChartFallback; +}; + +export type InfraChartFallback = { + readonly fields: readonly [string, string]; // [numerator, denominator] + readonly metricType: 'Gauge' | 'Sum'; + readonly numberFormat: NumberFormat; }; // A declarative infrastructure correlation group. `detectAttribute` decides @@ -35,6 +50,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 @@ -60,8 +78,38 @@ 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], + fallback: { + fields: ['memory.usage', 'memory.limit'], + metricType: 'Sum', + numberFormat: GPU_UTILIZATION_NUMBER_FORMAT, + }, + }, +]; + // 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', @@ -78,6 +126,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 @@ -94,27 +150,3 @@ export function getActiveInfraCorrelations( correlation => resourceAttributes[correlation.detectAttribute] != null, ); } - -// Resource attributes used to correlate GPU metrics to the selected log/span. -// GPU metrics carry host/pod identity as resource attributes on the metric -// (per OTel hardware semconv), so we correlate via the same node/host -// attributes. Preference order: k8s.node.name > host.name. -const GPU_CORRELATE_ATTRIBUTES = ['k8s.node.name', 'host.name'] as const; - -/** - * Builds the Lucene WHERE clause to correlate GPU metrics to the selected - * log/span's host. Returns undefined if no correlatable attribute is found. - */ -export function getGpuCorrelationWhere( - metricSource: TMetricSource, - resourceAttributes: Record | null | undefined, -): string | undefined { - if (!resourceAttributes) return undefined; - for (const attr of GPU_CORRELATE_ATTRIBUTES) { - const value = resourceAttributes[attr]; - if (value != null && value !== '') { - return `${metricSource.resourceAttributesExpression}.${attr}:"${value}"`; - } - } - return undefined; -} diff --git a/packages/app/src/hooks/useGpuMetricsAvailability.ts b/packages/app/src/hooks/useGpuMetricsAvailability.ts index cd4ded9648..999547e4b9 100644 --- a/packages/app/src/hooks/useGpuMetricsAvailability.ts +++ b/packages/app/src/hooks/useGpuMetricsAvailability.ts @@ -6,78 +6,134 @@ import { import { useGetKeyValues } from '@/hooks/useMetadata'; -export const GPU_METRIC_NAMES = { - utilization: 'hw.gpu.utilization', - memoryUtilization: 'hw.gpu.memory.utilization', -} as const; - -export type GpuMetricsAvailability = { - hasUtilization: boolean; - hasMemoryUtilization: boolean; - hasAny: boolean; - isLoading: boolean; -}; - /** * Checks whether GPU metrics exist in the given metric source, scoped to a - * correlated resource (by host/node). Queries distinct MetricName values from - * the gauge table filtered by the resource correlation WHERE clause. + * correlated resource. Queries distinct MetricName values from both the gauge + * and sum tables, pushing a `MetricName:hw.gpu.*` filter into the query so + * the DB only scans matching rows regardless of how many other metrics exist. * * Results are cached (staleTime 5 min by useGetKeyValues) so reopening the * panel does not re-query. */ export function useGpuMetricsAvailability({ metricSource, - where, + correlationWhere, dateRange, enabled = true, }: { metricSource: TMetricSource | undefined; - where: string; + correlationWhere: string; dateRange: [Date, Date]; enabled?: boolean; }): GpuMetricsAvailability { const gaugeTable = metricSource?.metricTables?.[MetricsDataType.Gauge]; + const sumTable = metricSource?.metricTables?.[MetricsDataType.Sum]; + + // Push prefix filter into the WHERE so we only scan hw.gpu.* rows. + const gpuWhere = correlationWhere + ? `(${correlationWhere}) AND MetricName:hw.gpu.*` + : 'MetricName:hw.gpu.*'; - const chartConfig = useMemo(() => { + const gaugeConfig = useMemo(() => { if (!metricSource || !gaugeTable) return undefined; return { + // Empty select: the query only needs MetricName values, not aggregates. select: [] as [], from: { databaseName: metricSource.from.databaseName, tableName: gaugeTable, }, - where, + where: gpuWhere, + whereLanguage: 'lucene' as const, + groupBy: '', + timestampValueExpression: metricSource.timestampValueExpression ?? '', + connection: metricSource.connection, + dateRange, + }; + }, [metricSource, gaugeTable, gpuWhere, dateRange]); + + const sumConfig = useMemo(() => { + if (!metricSource || !sumTable) return undefined; + return { + select: [] as [], + from: { + databaseName: metricSource.from.databaseName, + tableName: sumTable, + }, + where: gpuWhere, whereLanguage: 'lucene' as const, groupBy: '', timestampValueExpression: metricSource.timestampValueExpression ?? '', connection: metricSource.connection, dateRange, }; - }, [metricSource, gaugeTable, where, dateRange]); + }, [metricSource, sumTable, gpuWhere, dateRange]); - const { data, isLoading } = useGetKeyValues( - { - chartConfig, - keys: ['MetricName'], - limit: 50, - disableRowLimit: true, - }, - { - enabled: enabled && !!chartConfig, - }, + const { data: gaugeData, isLoading: isGaugeLoading } = useGetKeyValues( + { chartConfig: gaugeConfig, keys: ['MetricName'], disableRowLimit: true }, + { enabled: enabled && !!gaugeConfig }, + ); + + const { data: sumData, isLoading: isSumLoading } = useGetKeyValues( + { chartConfig: sumConfig, keys: ['MetricName'], disableRowLimit: true }, + { enabled: enabled && !!sumConfig }, ); return useMemo(() => { - const metricNames: string[] = data?.[0]?.value ?? []; - const gpuNames = metricNames.filter(name => name.startsWith('hw.gpu.')); + const gaugeNames: string[] = gaugeData?.[0]?.value ?? []; + const sumNames: string[] = sumData?.[0]?.value ?? []; + return { - hasUtilization: gpuNames.includes(GPU_METRIC_NAMES.utilization), - hasMemoryUtilization: gpuNames.includes( - GPU_METRIC_NAMES.memoryUtilization, - ), - hasAny: gpuNames.length > 0, - isLoading, + gaugeMetrics: new Set(gaugeNames), + sumMetrics: new Set(sumNames), + hasAny: gaugeNames.length > 0 || sumNames.length > 0, + isLoading: isGaugeLoading || isSumLoading, }; - }, [data, isLoading]); + }, [gaugeData, sumData, isGaugeLoading, isSumLoading]); +} + +export type GpuMetricsAvailability = { + gaugeMetrics: Set; + sumMetrics: Set; + hasAny: boolean; + isLoading: boolean; +}; + +/** + * Determines whether a specific chart's primary metric is available, + * or whether its fallback metrics are available. + */ +export function resolveChartAvailability( + fieldPrefix: string, + chart: { + field: string; + metricType?: string; + fallback?: { fields: readonly [string, string]; metricType: string }; + }, + availability: GpuMetricsAvailability, +): 'primary' | 'fallback' | 'none' { + const primaryMetric = `${fieldPrefix}${chart.field}`; + const primaryType = chart.metricType ?? 'Gauge'; + const metricsSet = + primaryType === 'Sum' ? availability.sumMetrics : availability.gaugeMetrics; + + if (metricsSet.has(primaryMetric)) { + return 'primary'; + } + + if (chart.fallback) { + const fallbackSet = + chart.fallback.metricType === 'Sum' + ? availability.sumMetrics + : availability.gaugeMetrics; + const [num, den] = chart.fallback.fields; + if ( + fallbackSet.has(`${fieldPrefix}${num}`) && + fallbackSet.has(`${fieldPrefix}${den}`) + ) { + return 'fallback'; + } + } + + return 'none'; } 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 }, From 0e951374d08f4ab2f49ef66c48797604c74a66dd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 07:10:58 +0000 Subject: [PATCH 5/6] fix(app): drop broken memory fallback, fix empty-div gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the hw.gpu.memory.usage / hw.gpu.memory.limit fallback: convertV1ChartConfigToV2 drops seriesReturnType for metrics, the renderer discards the second series, and Sum uses counter-increase semantics on a non-monotonic UpDownCounter. All three failures are in the renderer and out of scope for this PR. Changes: - Remove InfraChartFallback type and fallback field from InfraChartSpec - Simplify resolveChartAvailability to return boolean (available / not) - Remove sum-table query from useGpuMetricsAvailability (halves cost) - Simplify buildChartConfig (no mode parameter) - Fix 40px empty-div gap: return null from the correlation map entry when both metricsGroup and timeline render nothing, so no empty flex child is emitted into Stack Follow-up: HDX-5102 — support ratio charts over Sum metrics for GPU memory fallback (requires changes to convertV1ChartConfigToV2, renderChartConfig metric select handling, and Sum aggFn projection). HDX-5102 Co-authored-by: Mike Shi --- packages/app/src/components/DBInfraPanel.tsx | 163 +++++++----------- .../__tests__/infraCorrelations.test.ts | 101 ++++------- .../app/src/components/infraCorrelations.ts | 15 -- .../src/hooks/useGpuMetricsAvailability.ts | 90 ++-------- 4 files changed, 113 insertions(+), 256 deletions(-) diff --git a/packages/app/src/components/DBInfraPanel.tsx b/packages/app/src/components/DBInfraPanel.tsx index 5f8aa955da..4d8d4957e3 100644 --- a/packages/app/src/components/DBInfraPanel.tsx +++ b/packages/app/src/components/DBInfraPanel.tsx @@ -52,44 +52,8 @@ function buildChartConfig( metricSource: TMetricSource, dateRange: [Date, Date], granularity: Granularity, - mode: 'primary' | 'fallback', ) { const metricType = chart.metricType ?? 'Gauge'; - - if (mode === 'fallback' && chart.fallback) { - const [numField, denField] = chart.fallback.fields; - const fallbackType = chart.fallback.metricType; - const seriesWhere = chart.where ? `(${where}) AND (${chart.where})` : where; - return convertV1ChartConfigToV2( - { - dateRange, - granularity, - seriesReturnType: 'ratio', - series: [ - { - type: 'time', - where: seriesWhere, - groupBy: chart.groupBy ? [...chart.groupBy] : [], - aggFn: 'avg', - field: `${fieldPrefix}${numField} - ${fallbackType}`, - table: 'metrics', - numberFormat: chart.fallback.numberFormat, - }, - { - type: 'time', - where: seriesWhere, - groupBy: chart.groupBy ? [...chart.groupBy] : [], - aggFn: 'avg', - field: `${fieldPrefix}${denField} - ${fallbackType}`, - table: 'metrics', - numberFormat: chart.fallback.numberFormat, - }, - ], - }, - { metric: metricSource }, - ); - } - const seriesWhere = chart.where ? `(${where}) AND (${chart.where})` : where; return convertV1ChartConfigToV2( { @@ -160,23 +124,17 @@ const InfraSubpanelGroup = ({ return convertDateRangeToGranularityString(dateRange); }, [dateRange]); - // When availability is provided, resolve each chart to primary/fallback/none. - const resolvedCharts = useMemo(() => { + // When availability is provided, only render charts whose metric exists. + const visibleCharts = useMemo(() => { if (!availability) { - return charts.map(chart => ({ chart, mode: 'primary' as const })); + return charts; } - return charts.reduce< - { chart: InfraChartSpec; mode: 'primary' | 'fallback' }[] - >((acc, chart) => { - const mode = resolveChartAvailability(fieldPrefix, chart, availability); - if (mode !== 'none') { - acc.push({ chart, mode }); - } - return acc; - }, []); + return charts.filter(chart => + resolveChartAvailability(fieldPrefix, chart, availability), + ); }, [charts, availability, fieldPrefix]); - if (resolvedCharts.length === 0) { + if (visibleCharts.length === 0) { return null; } @@ -210,7 +168,7 @@ const InfraSubpanelGroup = ({ - {resolvedCharts.map(({ chart, mode }) => ( + {visibleCharts.map(chart => ( + ) : ( + + ) + ) : null; + + const timeline = + correlation.timeline && source.kind === SourceKind.Log ? ( + + + {correlation.title} Timeline + + + + + This Event
, + timestamp: new Date(timestamp).toISOString(), + }} + /> + + + + + ) : null; + + if (!metricsGroup && !timeline) { + return null; + } + return (
- {metricSource && - (correlation.requiresMetricAvailability ? ( - - ) : ( - - ))} - {correlation.timeline && source.kind === SourceKind.Log && ( - - - {correlation.title} Timeline - - - - - This Event
, - timestamp: new Date(timestamp).toISOString(), - }} - /> - - - - - )} + {metricsGroup} + {timeline} ); })} diff --git a/packages/app/src/components/__tests__/infraCorrelations.test.ts b/packages/app/src/components/__tests__/infraCorrelations.test.ts index 92c8afcc5a..c83622cf97 100644 --- a/packages/app/src/components/__tests__/infraCorrelations.test.ts +++ b/packages/app/src/components/__tests__/infraCorrelations.test.ts @@ -112,15 +112,11 @@ describe('GPU chart specs', () => { ); }); - it('provides a fallback for memory utilization from sum table', () => { + it('does not define a fallback on memory utilization', () => { const memChart = gpuCorrelation.charts.find( c => c.cardTestId === 'gpu-memory-utilization-card', ); - expect(memChart?.fallback).toEqual({ - fields: ['memory.usage', 'memory.limit'], - metricType: 'Sum', - numberFormat: expect.objectContaining({ output: 'percent' }), - }); + expect(memChart).not.toHaveProperty('fallback'); }); it('includes hw.id/hw.name/hw.model in groupBy expression', () => { @@ -137,88 +133,51 @@ describe('GPU chart specs', () => { describe('resolveChartAvailability', () => { const fieldPrefix = 'hw.gpu.'; - const makeAvailability = ( - gauge: string[] = [], - sum: string[] = [], - ): GpuMetricsAvailability => ({ - gaugeMetrics: new Set(gauge), - sumMetrics: new Set(sum), - hasAny: gauge.length > 0 || sum.length > 0, + const makeAvailability = (metrics: string[]): GpuMetricsAvailability => ({ + availableMetrics: new Set(metrics), + hasAny: metrics.length > 0, isLoading: false, }); - it('returns primary when gauge metric exists', () => { + it('returns true when the metric exists', () => { const chart = { field: 'utilization' }; const availability = makeAvailability(['hw.gpu.utilization']); expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( - 'primary', + true, ); }); - it('returns none when neither primary nor fallback exists', () => { + it('returns false when the metric does not exist', () => { const chart = { field: 'utilization' }; - const availability = makeAvailability(); - expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( - 'none', - ); - }); - - it('returns fallback when primary is absent but fallback fields exist in sum', () => { - const chart = { - field: 'memory.utilization', - fallback: { - fields: ['memory.usage', 'memory.limit'] as [string, string], - metricType: 'Sum' as const, - numberFormat: { output: 'percent' as const }, - }, - }; - const availability = makeAvailability( - [], - ['hw.gpu.memory.usage', 'hw.gpu.memory.limit'], - ); + const availability = makeAvailability([]); expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( - 'fallback', + false, ); }); - it('prefers primary over fallback even if both exist', () => { - const chart = { - field: 'memory.utilization', - fallback: { - fields: ['memory.usage', 'memory.limit'] as [string, string], - metricType: 'Sum' as const, - numberFormat: { output: 'percent' as const }, - }, - }; - const availability = makeAvailability( - ['hw.gpu.memory.utilization'], - ['hw.gpu.memory.usage', 'hw.gpu.memory.limit'], - ); + it('returns true for memory.utilization when present', () => { + const chart = { field: 'memory.utilization' }; + const availability = makeAvailability(['hw.gpu.memory.utilization']); expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( - 'primary', + true, ); }); - it('returns none when only one fallback field exists', () => { - const chart = { - field: 'memory.utilization', - fallback: { - fields: ['memory.usage', 'memory.limit'] as [string, string], - metricType: 'Sum' as const, - numberFormat: { output: 'percent' as const }, - }, - }; - const availability = makeAvailability([], ['hw.gpu.memory.usage']); - expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( - 'none', - ); - }); - - it('handles Sum primary metric type', () => { - const chart = { field: 'some.counter', metricType: 'Sum' as const }; - const availability = makeAvailability([], ['hw.gpu.some.counter']); - expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( - 'primary', - ); + it('handles partial availability (one present, one absent)', () => { + const availability = makeAvailability(['hw.gpu.utilization']); + expect( + resolveChartAvailability( + fieldPrefix, + { field: 'utilization' }, + availability, + ), + ).toBe(true); + expect( + resolveChartAvailability( + fieldPrefix, + { field: 'memory.utilization' }, + availability, + ), + ).toBe(false); }); }); diff --git a/packages/app/src/components/infraCorrelations.ts b/packages/app/src/components/infraCorrelations.ts index 9b5f7cd479..d12e7251ad 100644 --- a/packages/app/src/components/infraCorrelations.ts +++ b/packages/app/src/components/infraCorrelations.ts @@ -21,16 +21,6 @@ export type InfraChartSpec = { readonly groupBy?: readonly string[]; // Metric data type suffix; defaults to 'Gauge'. readonly metricType?: 'Gauge' | 'Sum'; - // Fallback chart rendered when the primary field is unavailable but the - // fallback fields exist. The fields are rendered as a ratio (numerator / - // denominator) with the given metric type. - readonly fallback?: InfraChartFallback; -}; - -export type InfraChartFallback = { - readonly fields: readonly [string, string]; // [numerator, denominator] - readonly metricType: 'Gauge' | 'Sum'; - readonly numberFormat: NumberFormat; }; // A declarative infrastructure correlation group. `detectAttribute` decides @@ -100,11 +90,6 @@ const GPU_CHART_SPECS: readonly InfraChartSpec[] = [ field: 'memory.utilization', numberFormat: GPU_UTILIZATION_NUMBER_FORMAT, groupBy: [GPU_GROUP_BY_EXPR], - fallback: { - fields: ['memory.usage', 'memory.limit'], - metricType: 'Sum', - numberFormat: GPU_UTILIZATION_NUMBER_FORMAT, - }, }, ]; diff --git a/packages/app/src/hooks/useGpuMetricsAvailability.ts b/packages/app/src/hooks/useGpuMetricsAvailability.ts index 999547e4b9..cc46c4116b 100644 --- a/packages/app/src/hooks/useGpuMetricsAvailability.ts +++ b/packages/app/src/hooks/useGpuMetricsAvailability.ts @@ -8,9 +8,9 @@ import { useGetKeyValues } from '@/hooks/useMetadata'; /** * Checks whether GPU metrics exist in the given metric source, scoped to a - * correlated resource. Queries distinct MetricName values from both the gauge - * and sum tables, pushing a `MetricName:hw.gpu.*` filter into the query so - * the DB only scans matching rows regardless of how many other metrics exist. + * correlated resource. Queries distinct MetricName values from the gauge + * table, pushing a `MetricName:hw.gpu.*` filter into the query so the DB + * only scans matching rows regardless of how many other metrics exist. * * Results are cached (staleTime 5 min by useGetKeyValues) so reopening the * panel does not re-query. @@ -27,14 +27,13 @@ export function useGpuMetricsAvailability({ enabled?: boolean; }): GpuMetricsAvailability { const gaugeTable = metricSource?.metricTables?.[MetricsDataType.Gauge]; - const sumTable = metricSource?.metricTables?.[MetricsDataType.Sum]; // Push prefix filter into the WHERE so we only scan hw.gpu.* rows. const gpuWhere = correlationWhere ? `(${correlationWhere}) AND MetricName:hw.gpu.*` : 'MetricName:hw.gpu.*'; - const gaugeConfig = useMemo(() => { + const chartConfig = useMemo(() => { if (!metricSource || !gaugeTable) return undefined; return { // Empty select: the query only needs MetricName values, not aggregates. @@ -52,88 +51,35 @@ export function useGpuMetricsAvailability({ }; }, [metricSource, gaugeTable, gpuWhere, dateRange]); - const sumConfig = useMemo(() => { - if (!metricSource || !sumTable) return undefined; - return { - select: [] as [], - from: { - databaseName: metricSource.from.databaseName, - tableName: sumTable, - }, - where: gpuWhere, - whereLanguage: 'lucene' as const, - groupBy: '', - timestampValueExpression: metricSource.timestampValueExpression ?? '', - connection: metricSource.connection, - dateRange, - }; - }, [metricSource, sumTable, gpuWhere, dateRange]); - - const { data: gaugeData, isLoading: isGaugeLoading } = useGetKeyValues( - { chartConfig: gaugeConfig, keys: ['MetricName'], disableRowLimit: true }, - { enabled: enabled && !!gaugeConfig }, - ); - - const { data: sumData, isLoading: isSumLoading } = useGetKeyValues( - { chartConfig: sumConfig, keys: ['MetricName'], disableRowLimit: true }, - { enabled: enabled && !!sumConfig }, + const { data, isLoading } = useGetKeyValues( + { chartConfig, keys: ['MetricName'], disableRowLimit: true }, + { enabled: enabled && !!chartConfig }, ); return useMemo(() => { - const gaugeNames: string[] = gaugeData?.[0]?.value ?? []; - const sumNames: string[] = sumData?.[0]?.value ?? []; - + const metricNames: string[] = data?.[0]?.value ?? []; return { - gaugeMetrics: new Set(gaugeNames), - sumMetrics: new Set(sumNames), - hasAny: gaugeNames.length > 0 || sumNames.length > 0, - isLoading: isGaugeLoading || isSumLoading, + availableMetrics: new Set(metricNames), + hasAny: metricNames.length > 0, + isLoading, }; - }, [gaugeData, sumData, isGaugeLoading, isSumLoading]); + }, [data, isLoading]); } export type GpuMetricsAvailability = { - gaugeMetrics: Set; - sumMetrics: Set; + availableMetrics: Set; hasAny: boolean; isLoading: boolean; }; /** - * Determines whether a specific chart's primary metric is available, - * or whether its fallback metrics are available. + * Determines whether a specific chart's metric is available. */ export function resolveChartAvailability( fieldPrefix: string, - chart: { - field: string; - metricType?: string; - fallback?: { fields: readonly [string, string]; metricType: string }; - }, + chart: { field: string }, availability: GpuMetricsAvailability, -): 'primary' | 'fallback' | 'none' { - const primaryMetric = `${fieldPrefix}${chart.field}`; - const primaryType = chart.metricType ?? 'Gauge'; - const metricsSet = - primaryType === 'Sum' ? availability.sumMetrics : availability.gaugeMetrics; - - if (metricsSet.has(primaryMetric)) { - return 'primary'; - } - - if (chart.fallback) { - const fallbackSet = - chart.fallback.metricType === 'Sum' - ? availability.sumMetrics - : availability.gaugeMetrics; - const [num, den] = chart.fallback.fields; - if ( - fallbackSet.has(`${fieldPrefix}${num}`) && - fallbackSet.has(`${fieldPrefix}${den}`) - ) { - return 'fallback'; - } - } - - return 'none'; +): boolean { + const metricName = `${fieldPrefix}${chart.field}`; + return availability.availableMetrics.has(metricName); } From 6aa74d53ab6fc6cd41e070e5d540639a1e8ee378 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 12:46:53 +0000 Subject: [PATCH 6/6] refactor(app): build v2 chart configs directly, fix metric-name cap Greptile findings: - Metric-name cap could hide GPU charts. useGetKeyValues aggregates with groupUniqArray(limit) even when disableRowLimit is set, so an open-ended MetricName lookup can drop the name being looked for on a metric-heavy host. Now the query asks only about the candidate metric names (derived from the chart specs) and sizes the limit to match, so truncation is impossible. This also replaces the unanchored MetricName:hw.gpu.* ILIKE scan with exact equality. - hasAny was true for any hw.gpu.* metric, so a host emitting only hw.gpu.io could render GPU controls over an empty grid. Asking only about chartable metrics removes the failure mode structurally; the separate hasAny flag is gone. Drop convertV1ChartConfigToV2 for infra charts and build the v2 BuilderChartConfig directly. The v1 layer was a lossy round trip for metrics: 'name - Gauge' string-split and re-parsed into an enum, a table:'metrics' discriminator that only picks a branch, a seriesReturnType that is silently dropped, a groupBy array joined to a string via a startsWith('k8s') rewrite, and a valueExpression the renderer overwrites. getMetricNameSql is now called directly so the k8s cpu.utilization -> cpu.usage rename still matches both names; a test pins that. Also: - Collapse AvailabilityGatedGroup and InfraSubpanelGroup into one InfraCorrelationGroup that owns its wrapper, so a group with nothing to show renders no DOM. The previous fix was ineffective: a React element is always truthy, so the empty-wrapper check never fired and the 40px Stack gap remained. - Gate the availability loading state to gated groups only, so Pod/Node are not held back by a query they do not run. - Preserve the pre-existing behavior of dropping a group whose correlate attribute is present but empty. - Rename useGpuMetricsAvailability to useAvailableMetricNames; nothing in it is GPU-specific. - Add DBInfraPanel.buildChartConfig tests asserting the produced config rather than an intermediate decision. HDX-5102 Co-authored-by: Mike Shi --- packages/app/src/components/DBInfraPanel.tsx | 429 +++++++++--------- .../DBInfraPanel.buildChartConfig.test.ts | 120 +++++ .../__tests__/infraCorrelations.test.ts | 112 +++-- .../app/src/components/infraCorrelations.ts | 12 +- .../app/src/hooks/useAvailableMetricNames.ts | 82 ++++ .../src/hooks/useGpuMetricsAvailability.ts | 85 ---- 6 files changed, 467 insertions(+), 373 deletions(-) create mode 100644 packages/app/src/components/__tests__/DBInfraPanel.buildChartConfig.test.ts create mode 100644 packages/app/src/hooks/useAvailableMetricNames.ts delete mode 100644 packages/app/src/hooks/useGpuMetricsAvailability.ts diff --git a/packages/app/src/components/DBInfraPanel.tsx b/packages/app/src/components/DBInfraPanel.tsx index 4d8d4957e3..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,14 +30,10 @@ 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 { - GpuMetricsAvailability, - resolveChartAvailability, - useGpuMetricsAvailability, -} from '@/hooks/useGpuMetricsAvailability'; +import { useAvailableMetricNames } from '@/hooks/useAvailableMetricNames'; +import { getMetricNameSql } from '@/otelSemanticConventions'; import { useSource } from '@/source'; import { DBTimeChart } from './DBTimeChart'; @@ -45,56 +44,87 @@ import { } from './infraCorrelations'; import { KubeTimeline } from './KubeComponents'; -function buildChartConfig( - chart: InfraChartSpec, - fieldPrefix: string, - where: string, - metricSource: TMetricSource, - dateRange: [Date, Date], - granularity: Granularity, -) { - const metricType = chart.metricType ?? 'Gauge'; - const seriesWhere = chart.where ? `(${where}) AND (${chart.where})` : where; - return convertV1ChartConfigToV2( - { - dateRange, - granularity, - seriesReturnType: 'column', - series: [ - { - type: 'time', - where: seriesWhere, - groupBy: chart.groupBy ? [...chart.groupBy] : [], - aggFn: 'avg', - field: `${fieldPrefix}${chart.field} - ${metricType}`, - table: 'metrics', - numberFormat: chart.numberFormat, - }, - ], - }, - { metric: metricSource }, - ); +function metricNameFor(fieldPrefix: string, chart: InfraChartSpec) { + return `${fieldPrefix}${chart.field}`; } -const InfraSubpanelGroup = ({ - charts, +export function buildChartConfig({ + chart, fieldPrefix, - metricSource, - timestamp, - title, where, - availability, + metricSource, + dateRange, + granularity, }: { - charts: readonly InfraChartSpec[]; + chart: InfraChartSpec; fieldPrefix: 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; - title: string; - where: string; - availability?: GpuMetricsAvailability; }) => { 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 = { @@ -109,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': @@ -124,122 +183,113 @@ const InfraSubpanelGroup = ({ return convertDateRangeToGranularityString(dateRange); }, [dateRange]); - // When availability is provided, only render charts whose metric exists. const visibleCharts = useMemo(() => { - if (!availability) { + if (!isGated) { return charts; } return charts.filter(chart => - resolveChartAvailability(fieldPrefix, chart, availability), + availableMetrics.has(metricNameFor(fieldPrefix, chart)), ); - }, [charts, availability, fieldPrefix]); + }, [charts, fieldPrefix, isGated, availableMetrics]); - if (visibleCharts.length === 0) { + // 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 '30m' | '1h' | '1d')} - /> -
- - setSize(value as 'sm' | 'md' | 'lg')} - /> - -
- - {visibleCharts.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(), + }} + /> + + + + + )} ); }; -/** - * Wrapper that fetches GPU metric availability and only renders the group - * when at least one chart has data. Returns null while loading or when empty. - */ -const AvailabilityGatedGroup = ({ - correlation, - metricSource, - timestamp, - where, -}: { - correlation: InfraCorrelation; - metricSource: TMetricSource; - timestamp: number; - where: string; -}) => { - // Wide window for the existence check — we only need a boolean "are there - // any GPU metrics for this host?" answer, not precise time-aligned data. - const dateRange = useMemo<[Date, Date]>( - () => [ - sub(new Date(timestamp), { days: 1 }), - add(new Date(timestamp), { days: 1 }), - ], - [timestamp], - ); - - const availability = useGpuMetricsAvailability({ - metricSource, - correlationWhere: where, - dateRange, - }); - - if (availability.isLoading || !availability.hasAny) { - return null; - } - - return ( - - ); -}; - export default ({ rowData, source, @@ -302,83 +352,16 @@ export default ({ )} )} - {activeCorrelations.map(correlation => { - const value = resourceAttributes?.[correlation.correlateAttribute]; - if (!value) { - return null; - } - const showTimeline = - correlation.timeline != null && source.kind === SourceKind.Log; - if (!metricSource && !showTimeline) { - return null; - } - - const correlationWhere = metricSource - ? `${metricSource.resourceAttributesExpression}.${correlation.correlateAttribute}:"${value}"` - : ''; - - const metricsGroup = metricSource ? ( - correlation.requiresMetricAvailability ? ( - - ) : ( - - ) - ) : null; - - const timeline = - correlation.timeline && source.kind === SourceKind.Log ? ( - - - {correlation.title} Timeline - - - - - This Event, - timestamp: new Date(timestamp).toISOString(), - }} - /> - - - - - ) : null; - - if (!metricsGroup && !timeline) { - return null; - } - - return ( -
- {metricsGroup} - {timeline} -
- ); - })} + {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 c83622cf97..4732f71910 100644 --- a/packages/app/src/components/__tests__/infraCorrelations.test.ts +++ b/packages/app/src/components/__tests__/infraCorrelations.test.ts @@ -1,9 +1,9 @@ +import { MetricsDataType } from '@hyperdx/common-utils/dist/types'; + import { getActiveInfraCorrelations, INFRA_CORRELATIONS, } from '@/components/infraCorrelations'; -import type { GpuMetricsAvailability } from '@/hooks/useGpuMetricsAvailability'; -import { resolveChartAvailability } from '@/hooks/useGpuMetricsAvailability'; describe('getActiveInfraCorrelations', () => { it('returns the Pod group when only k8s.pod.uid is present', () => { @@ -73,11 +73,18 @@ describe('INFRA_CORRELATIONS built-ins', () => { ]); }); + 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(); - const gpu = INFRA_CORRELATIONS.find(c => c.title === 'GPU'); - expect(gpu?.timeline).toBeUndefined(); + expect( + INFRA_CORRELATIONS.filter(c => c.timeline != null).map(c => c.title), + ).toEqual(['Pod']); }); it('keeps the three k8s metric fields on Pod and Node groups', () => { @@ -91,6 +98,35 @@ 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', () => { @@ -103,7 +139,7 @@ describe('GPU chart specs', () => { ]); }); - it('uses correct where clause for utilization (no _exists_ syntax)', () => { + it('uses field:* for the task existence check, not _exists_', () => { const utilizationChart = gpuCorrelation.charts.find( c => c.cardTestId === 'gpu-utilization-card', ); @@ -112,14 +148,14 @@ describe('GPU chart specs', () => { ); }); - it('does not define a fallback on memory utilization', () => { + 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).not.toHaveProperty('fallback'); + expect(memChart?.where).toBeUndefined(); }); - it('includes hw.id/hw.name/hw.model in groupBy expression', () => { + 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]; @@ -128,56 +164,12 @@ describe('GPU chart specs', () => { expect(expr).toContain("Attributes['hw.model']"); } }); -}); - -describe('resolveChartAvailability', () => { - const fieldPrefix = 'hw.gpu.'; - const makeAvailability = (metrics: string[]): GpuMetricsAvailability => ({ - availableMetrics: new Set(metrics), - hasAny: metrics.length > 0, - isLoading: false, - }); - - it('returns true when the metric exists', () => { - const chart = { field: 'utilization' }; - const availability = makeAvailability(['hw.gpu.utilization']); - expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( - true, - ); - }); - - it('returns false when the metric does not exist', () => { - const chart = { field: 'utilization' }; - const availability = makeAvailability([]); - expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( - false, - ); - }); - - it('returns true for memory.utilization when present', () => { - const chart = { field: 'memory.utilization' }; - const availability = makeAvailability(['hw.gpu.memory.utilization']); - expect(resolveChartAvailability(fieldPrefix, chart, availability)).toBe( - true, - ); - }); - - it('handles partial availability (one present, one absent)', () => { - const availability = makeAvailability(['hw.gpu.utilization']); - expect( - resolveChartAvailability( - fieldPrefix, - { field: 'utilization' }, - availability, - ), - ).toBe(true); - expect( - resolveChartAvailability( - fieldPrefix, - { field: 'memory.utilization' }, - availability, - ), - ).toBe(false); + 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 d12e7251ad..0c8faddd75 100644 --- a/packages/app/src/components/infraCorrelations.ts +++ b/packages/app/src/components/infraCorrelations.ts @@ -1,3 +1,5 @@ +import { MetricsDataType } from '@hyperdx/common-utils/dist/types'; + import { GPU_UTILIZATION_NUMBER_FORMAT, K8S_CPU_PERCENTAGE_NUMBER_FORMAT, @@ -6,9 +8,9 @@ import { } from '@/ChartUtils'; import { NumberFormat } from '@/types'; -// One metric chart inside an infrastructure correlation group. The rendered -// metric field is `${fieldPrefix}${field} - ${metricType}` (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. @@ -19,8 +21,8 @@ export type InfraChartSpec = { readonly where?: string; // Per-chart groupBy SQL expressions (passed through as raw SQL). readonly groupBy?: readonly string[]; - // Metric data type suffix; defaults to 'Gauge'. - readonly metricType?: 'Gauge' | 'Sum'; + // Defaults to Gauge. + readonly metricType?: MetricsDataType; }; // A declarative infrastructure correlation group. `detectAttribute` decides 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/packages/app/src/hooks/useGpuMetricsAvailability.ts b/packages/app/src/hooks/useGpuMetricsAvailability.ts deleted file mode 100644 index cc46c4116b..0000000000 --- a/packages/app/src/hooks/useGpuMetricsAvailability.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useMemo } from 'react'; -import { - MetricsDataType, - TMetricSource, -} from '@hyperdx/common-utils/dist/types'; - -import { useGetKeyValues } from '@/hooks/useMetadata'; - -/** - * Checks whether GPU metrics exist in the given metric source, scoped to a - * correlated resource. Queries distinct MetricName values from the gauge - * table, pushing a `MetricName:hw.gpu.*` filter into the query so the DB - * only scans matching rows regardless of how many other metrics exist. - * - * Results are cached (staleTime 5 min by useGetKeyValues) so reopening the - * panel does not re-query. - */ -export function useGpuMetricsAvailability({ - metricSource, - correlationWhere, - dateRange, - enabled = true, -}: { - metricSource: TMetricSource | undefined; - correlationWhere: string; - dateRange: [Date, Date]; - enabled?: boolean; -}): GpuMetricsAvailability { - const gaugeTable = metricSource?.metricTables?.[MetricsDataType.Gauge]; - - // Push prefix filter into the WHERE so we only scan hw.gpu.* rows. - const gpuWhere = correlationWhere - ? `(${correlationWhere}) AND MetricName:hw.gpu.*` - : 'MetricName:hw.gpu.*'; - - const chartConfig = useMemo(() => { - if (!metricSource || !gaugeTable) return undefined; - return { - // Empty select: the query only needs MetricName values, not aggregates. - select: [] as [], - from: { - databaseName: metricSource.from.databaseName, - tableName: gaugeTable, - }, - where: gpuWhere, - whereLanguage: 'lucene' as const, - groupBy: '', - timestampValueExpression: metricSource.timestampValueExpression ?? '', - connection: metricSource.connection, - dateRange, - }; - }, [metricSource, gaugeTable, gpuWhere, dateRange]); - - const { data, isLoading } = useGetKeyValues( - { chartConfig, keys: ['MetricName'], disableRowLimit: true }, - { enabled: enabled && !!chartConfig }, - ); - - return useMemo(() => { - const metricNames: string[] = data?.[0]?.value ?? []; - return { - availableMetrics: new Set(metricNames), - hasAny: metricNames.length > 0, - isLoading, - }; - }, [data, isLoading]); -} - -export type GpuMetricsAvailability = { - availableMetrics: Set; - hasAny: boolean; - isLoading: boolean; -}; - -/** - * Determines whether a specific chart's metric is available. - */ -export function resolveChartAvailability( - fieldPrefix: string, - chart: { field: string }, - availability: GpuMetricsAvailability, -): boolean { - const metricName = `${fieldPrefix}${chart.field}`; - return availability.availableMetrics.has(metricName); -}