diff --git a/.changeset/gpu-metrics-infra-panel.md b/.changeset/gpu-metrics-infra-panel.md new file mode 100644 index 0000000000..5f9156f8f6 --- /dev/null +++ b/.changeset/gpu-metrics-infra-panel.md @@ -0,0 +1,21 @@ +--- +'@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`, and utilization is split per GPU engine by +`hw.gpu.task` (general/encoder/decoder) so a node saturated on video encode is +still visible; a missing task is reported as `general`. The section is fully +hidden when no GPU metrics are present and partially rendered when only one +metric is available. + +The Infrastructure tab now also treats a Kubernetes resource attribute that is +present but empty (for example `k8s.node.name: ""`) as absent. Such rows +previously surfaced an Infrastructure tab that could render nothing. + +Fix GPU chart availability leaking across rows: switching the side panel to a +row on a different host briefly rendered the previous host's set of GPU +charts, because the availability query keeps the prior result readable while +the new one runs. diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index 3e787dca1f..e639584565 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -439,6 +439,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..5e7529826e 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,108 @@ 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: 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]; + // `getActiveInfraCorrelations` already rejects a falsy *detect* attribute, + // but a descriptor is free to detect on one attribute and correlate on + // another, so the correlate side is checked here too. Never blank `where` + // instead: `useAvailableMetricNames` treats an empty correlation filter as + // "no filter" and would probe every host, reporting metrics as available + // for a host that has none. + const hasCorrelateValue = Boolean(correlateValue); + const where = metricSource + ? `${metricSource.resourceAttributesExpression}.${correlation.correlateAttribute}:"${correlateValue}"` + : ''; const dateRange = useMemo<[Date, Date]>(() => { const duration = { @@ -70,6 +146,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 && hasCorrelateValue, + }); + const { cols, height } = useMemo(() => { switch (size) { case 'sm': @@ -85,69 +190,107 @@ 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]); + + 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 (!hasCorrelateValue || (!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 +357,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..7e2ebdd16d --- /dev/null +++ b/packages/app/src/components/__tests__/DBInfraPanel.buildChartConfig.test.ts @@ -0,0 +1,128 @@ +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('uses the correlation filter verbatim as the agg condition', () => { + const config = build(gpu, 'gpu-utilization-card', where); + expect(Array.isArray(config.select) && config.select[0].aggCondition).toBe( + where, + ); + }); + + it('joins multiple GPU groupBy expressions into raw SQL', () => { + const config = build(gpu, 'gpu-utilization-card', where); + expect(config.groupBy).toContain("Attributes['hw.id']"); + expect(config.groupBy).toContain("Attributes['hw.gpu.task']"); + // Comma-joined so ClickHouse sees two group columns, which DBTimeChart + // renders as " · ". + expect(config.groupBy).toBe( + gpu.charts + .find(c => c.cardTestId === 'gpu-utilization-card')! + .groupBy!.join(', '), + ); + }); + + 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..24264cd199 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,23 +11,38 @@ 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', () => { expect(getActiveInfraCorrelations({})).toEqual([]); }); + it('treats an empty detect attribute as absent', () => { + // An empty value correlates to nothing, so admitting it would surface an + // Infrastructure tab whose groups all render as null. + expect(getActiveInfraCorrelations({ 'k8s.node.name': '' })).toEqual([]); + expect(getActiveInfraCorrelations({ 'k8s.pod.uid': '' })).toEqual([]); + }); + + it('drops only the group whose detect attribute is empty', () => { + const active = getActiveInfraCorrelations({ + 'k8s.pod.uid': 'pod-abc', + 'k8s.node.name': '', + }); + expect(active.map(c => c.title)).toEqual(['Pod']); + }); + it('returns no groups for unrelated resource attributes', () => { expect( getActiveInfraCorrelations({ @@ -40,7 +57,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 +78,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 +113,90 @@ 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('splits utilization by task rather than filtering to general', () => { + const utilizationChart = gpuCorrelation.charts.find( + c => c.cardTestId === 'gpu-utilization-card', + ); + // Filtering here would hide a node saturated on video encode, so the task + // dimension belongs in the groupBy instead. + expect(utilizationChart?.groupBy).toHaveLength(2); + expect(utilizationChart?.groupBy![1]).toContain( + "Attributes['hw.gpu.task']", + ); + }); + + it('normalizes a missing task to general rather than a separate series', () => { + const utilizationChart = gpuCorrelation.charts.find( + c => c.cardTestId === 'gpu-utilization-card', + ); + expect(utilizationChart?.groupBy![1]).toBe( + "if(Attributes['hw.gpu.task'] != '', Attributes['hw.gpu.task'], 'general')", + ); + }); + + it('does not split the memory chart by task', () => { + const memChart = gpuCorrelation.charts.find( + c => c.cardTestId === 'gpu-memory-utilization-card', + ); + // Memory is per-device, not per-engine. + expect(memChart?.groupBy).toHaveLength(1); + }); + + it('includes hw.id/hw.name/hw.model in the device groupBy expression', () => { + for (const chart of gpuCorrelation.charts) { + 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..b3c5cbda70 100644 --- a/packages/app/src/components/infraCorrelations.ts +++ b/packages/app/src/components/infraCorrelations.ts @@ -1,19 +1,26 @@ +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 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 +40,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 +68,42 @@ 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']), '')))`; + +// A GPU reports utilization per engine, so `hw.gpu.utilization` carries up to +// three series per device distinguished only by `hw.gpu.task` (general, +// encoder, decoder -- see the OTel hardware semconv). They are grouped rather +// than filtered to `general`: averaging the engines together would understate +// a busy GPU, and filtering to one would hide a node saturated on video +// encode. Producers that emit a single unlabelled utilization figure mean the +// compute engine, so a missing task normalizes to `general` instead of +// forming its own series. +const GPU_TASK_GROUP_BY_EXPR = `if(Attributes['hw.gpu.task'] != '', Attributes['hw.gpu.task'], 'general')`; + +const GPU_CHART_SPECS: readonly InfraChartSpec[] = [ + { + title: 'GPU utilization', + cardTestId: 'gpu-utilization-card', + field: 'utilization', + numberFormat: GPU_UTILIZATION_NUMBER_FORMAT, + groupBy: [GPU_GROUP_BY_EXPR, GPU_TASK_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,19 +120,33 @@ 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 -// (non-null) on the given resource attributes. This is the single source of +// Returns the built-in correlation groups whose detect attribute holds a +// usable value on the given resource attributes. This is the single source of // truth for both the Infrastructure tab gate (rowHasK8sContext) and the panel // renderer (DBInfraPanel), so the gate and the render never drift apart. +// +// The test is truthiness rather than `!= null` so that it matches the +// renderer's own guard exactly: an attribute present but empty correlates to +// nothing, so admitting it would surface an Infrastructure tab whose groups +// all render as null, and would fire the metric-availability probe with an +// empty value. export function getActiveInfraCorrelations( resourceAttributes: Record | null | undefined, ): readonly InfraCorrelation[] { if (!resourceAttributes) { return []; } - return INFRA_CORRELATIONS.filter( - correlation => resourceAttributes[correlation.detectAttribute] != null, + return INFRA_CORRELATIONS.filter(correlation => + Boolean(resourceAttributes[correlation.detectAttribute]), ); } diff --git a/packages/app/src/hooks/__tests__/useAvailableMetricNames.test.ts b/packages/app/src/hooks/__tests__/useAvailableMetricNames.test.ts new file mode 100644 index 0000000000..0b31f3a8fc --- /dev/null +++ b/packages/app/src/hooks/__tests__/useAvailableMetricNames.test.ts @@ -0,0 +1,89 @@ +import { + MetricsDataType, + TMetricSource, +} from '@hyperdx/common-utils/dist/types'; +import { renderHook } from '@testing-library/react'; + +import { useAvailableMetricNames } from '@/hooks/useAvailableMetricNames'; +import { useGetKeyValues } from '@/hooks/useMetadata'; + +jest.mock('@/hooks/useMetadata'); + +const mockUseGetKeyValues = useGetKeyValues as jest.MockedFunction< + typeof useGetKeyValues +>; + +const METRIC_SOURCE = { + id: 'metric-source-1', + kind: 'metric', + name: 'Metrics', + connection: 'conn-1', + from: { databaseName: 'default', tableName: '' }, + timestampValueExpression: 'TimeUnix', + resourceAttributesExpression: 'ResourceAttributes', + metricTables: { [MetricsDataType.Gauge]: 'otel_metrics_gauge' }, +} as unknown as TMetricSource; + +const DATE_RANGE: [Date, Date] = [ + new Date('2024-01-01T00:00:00Z'), + new Date('2024-01-03T00:00:00Z'), +]; + +const METRIC_NAMES = ['hw.gpu.utilization', 'hw.gpu.memory.utilization']; + +function mockQuery(overrides: Record) { + mockUseGetKeyValues.mockReturnValue({ + data: undefined, + isLoading: false, + isPlaceholderData: false, + ...overrides, + } as unknown as ReturnType); +} + +function render() { + return renderHook(() => + useAvailableMetricNames({ + metricSource: METRIC_SOURCE, + correlationWhere: 'ResourceAttributes.k8s.node.name:"node-1"', + metricNames: METRIC_NAMES, + dateRange: DATE_RANGE, + }), + ); +} + +describe('useAvailableMetricNames', () => { + afterEach(() => jest.resetAllMocks()); + + it('reports the metric names the query returned', () => { + mockQuery({ data: [{ key: 'MetricName', value: METRIC_NAMES }] }); + const { result } = render(); + expect([...result.current.availableMetrics].sort()).toEqual( + [...METRIC_NAMES].sort(), + ); + expect(result.current.isLoading).toBe(false); + }); + + it('reports loading while the first query is in flight', () => { + mockQuery({ isLoading: true }); + expect(render().result.current.isLoading).toBe(true); + }); + + // useGetKeyValues sets `placeholderData: keepPreviousData`, so switching to a + // different correlated resource keeps the previous one's answer readable with + // `isLoading` false. Reporting that as settled would leak one host's chart + // set onto the next. + it('reports loading while showing another resource placeholder data', () => { + mockQuery({ + data: [{ key: 'MetricName', value: METRIC_NAMES }], + isLoading: false, + isPlaceholderData: true, + }); + const { result } = render(); + expect(result.current.isLoading).toBe(true); + }); + + it('returns an empty set when the query has no data', () => { + mockQuery({ data: undefined }); + expect(render().result.current.availableMetrics.size).toBe(0); + }); +}); diff --git a/packages/app/src/hooks/useAvailableMetricNames.ts b/packages/app/src/hooks/useAvailableMetricNames.ts new file mode 100644 index 0000000000..dbcb344da3 --- /dev/null +++ b/packages/app/src/hooks/useAvailableMetricNames.ts @@ -0,0 +1,89 @@ +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, isPlaceholderData } = 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 ?? []), + // `useGetKeyValues` sets `placeholderData: keepPreviousData`, so when the + // correlated resource changes the previous resource's answer stays + // readable while the new query runs -- and `isLoading` reads false + // throughout, because the query is `success` on placeholder data rather + // than `pending`. Callers gating on availability must therefore treat + // placeholder data as "not yet known", or one host's chart set leaks + // onto the next. + isLoading: isLoading || isPlaceholderData, + }), + [data, isLoading, isPlaceholderData], + ); +}