diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index cce9fbd71a..0a97c34954 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -16,6 +16,7 @@ - [Rolling Pool Update/Reboot] New `shutdownPinnedVms` option: VMs that cannot be migrated because they use a host-bound device (PCI passthrough, vGPU, SR-IOV VIF) are cleanly shut down before their host reboots and started again on it afterwards, instead of aborting the whole run. When such VMs block the run, XO now lists them and asks for confirmation instead of failing with a raw `CANNOT_EVACUATE_HOST` error (PR [#10125](https://github.com/vatesfr/xen-orchestra/pull/10125)) - [i18n] Update Czech, Dutch, German, Korean, Portuguese and Slovak translations (PR [#10033](https://github.com/vatesfr/xen-orchestra/pull/10033)) - [XO6/copyAll button] Add copy all button for IP list and bond device (PR [#10081](https://github.com/vatesfr/xen-orchestra/pull/10081)) +- [OpenMetrics] Add `content_type` to SR capacity metrics and the full `sr_uuid` to host disk metrics for easier Grafana correlation (PR [#10149](https://github.com/vatesfr/xen-orchestra/pull/10149)) ### Bug fixes @@ -54,6 +55,7 @@ - @xen-orchestra/web minor - @xen-orchestra/web-core minor - xo-server minor +- xo-server-openmetrics minor - xo-web minor diff --git a/docs/docs/xo5/advanced.md b/docs/docs/xo5/advanced.md index 63e5c11868..6f06b19955 100644 --- a/docs/docs/xo5/advanced.md +++ b/docs/docs/xo5/advanced.md @@ -510,6 +510,8 @@ Infrastructure metrics are prefixed with `xcp_` and XO management plane metrics | `xcp_sr_physical_size_bytes` | gauge | SR physical size in bytes | | `xcp_sr_physical_usage_bytes` | gauge | SR physical space used in bytes | +SR capacity metrics include the XAPI `content_type` (for example `user`, `disk`, or `iso`) alongside `sr_uuid`, `sr_name`, and `sr_type` labels. + #### VDI Disk Size Metrics | Metric | Type | Description | @@ -589,9 +591,10 @@ All metrics include these labels for filtering: | `pool_name` | Pool name | | `uuid` | Object UUID (host or VM) | | `type` | Object type (`host` or `vm`) | +| `content_type` | XAPI SR content type (e.g. `user`, `disk`, `iso`) — on SR capacity metrics and `xo_sr_total` | | `host_name` | Host name (for host metrics) | | `vm_name` | VM name (for VM metrics) | -| `sr_uuid` | Storage Repository UUID (for SR metrics) | +| `sr_uuid` | Storage Repository UUID (for SR, VDI, XOSTOR, and resolved host disk metrics) | | `sr_name` | Storage Repository name (for disk metrics) | | `vdi_uuid` | Virtual Disk UUID (for VDI metrics) | | `vdi_name` | Virtual Disk name (for VM disk and VDI metrics) | diff --git a/packages/xo-server-openmetrics/src/index.mts b/packages/xo-server-openmetrics/src/index.mts index 2c772959bd..5689cc529c 100644 --- a/packages/xo-server-openmetrics/src/index.mts +++ b/packages/xo-server-openmetrics/src/index.mts @@ -101,7 +101,8 @@ export interface LabelLookupData { vms: Record hosts: Record srs: Record - srTruncatedToUuid: Record // maps any UUID truncation (prefix or suffix) to the full SR UUID + // Maps UUID truncations to full SR UUIDs; null marks a collision. + srTruncatedToUuid: Record vdiUuidToSrUuid: Record // maps VDI UUID to parent SR UUID } @@ -113,6 +114,7 @@ interface XapiCredentialsPayload { export type SrDataItem = Pick & { pool_id: string pool_name: string + content_type: XoSr['content_type'] /** * Verbatim `XoSr.SR_type` (e.g. `'linstor'`, `'lvm'`, `'nfs'`). Emitted as * the `sr_type` OpenMetrics label so Grafana queries can filter / split @@ -513,20 +515,24 @@ export const SR_UUID_TRUNCATIONS: ReadonlyArray = [8, 12, 16, 20] * each truncation to the full UUID. XCP-ng RRD legends encode the SR as the * first 8 chars of the UUID, but older / variant builds may use the suffix; * indexing both keeps the SR-name and `sr_type` resolution stable across - * versions. First match wins, so existing entries are never overwritten. + * versions. Ambiguous truncations are marked with null to prevent incorrect + * cross-pool enrichment. * * Exported for testability. */ -export function indexSrUuidTruncations(uuid: string, index: Record): void { +export function indexSrUuidTruncations(uuid: string, index: Record): void { for (const truncLen of SR_UUID_TRUNCATIONS) { if (uuid.length < truncLen) continue const prefix = uuid.slice(0, truncLen) const suffix = uuid.slice(-truncLen) - if (index[prefix] === undefined) { - index[prefix] = uuid - } - if (index[suffix] === undefined) { - index[suffix] = uuid + + for (const truncation of [prefix, suffix]) { + const indexedUuid = index[truncation] + if (indexedUuid === undefined) { + index[truncation] = uuid + } else if (indexedUuid !== uuid) { + index[truncation] = null + } } } } @@ -1203,6 +1209,7 @@ class OpenMetricsPlugin { name_label: sr.name_label, pool_id: sr.$poolId, pool_name: poolLabelMap.get(sr.$poolId) ?? '', + content_type: sr.content_type, sr_type: sr.SR_type ?? '', size: sr.size, physical_usage: sr.physical_usage, diff --git a/packages/xo-server-openmetrics/src/open-metric-server.mts b/packages/xo-server-openmetrics/src/open-metric-server.mts index a6ac3ec5d0..81cd7179fb 100644 --- a/packages/xo-server-openmetrics/src/open-metric-server.mts +++ b/packages/xo-server-openmetrics/src/open-metric-server.mts @@ -105,7 +105,7 @@ interface LabelLookupData { vms: Record hosts: Record srs: Record - srTruncatedToUuid: Record + srTruncatedToUuid: Record vdiUuidToSrUuid: Record } diff --git a/packages/xo-server-openmetrics/src/openmetric-formatter.mts b/packages/xo-server-openmetrics/src/openmetric-formatter.mts index 58fb30cc2c..4691b645dd 100644 --- a/packages/xo-server-openmetrics/src/openmetric-formatter.mts +++ b/packages/xo-server-openmetrics/src/openmetric-formatter.mts @@ -1007,8 +1007,10 @@ export function transformMetric( if (extractedLabels.sr !== undefined) { const srSuffix = extractedLabels.sr // Try to find the full SR UUID from the suffix - const srUuid = labelContext.labels.srTruncatedToUuid[srSuffix] - if (srUuid !== undefined) { + const srUuidIndex = labelContext.labels.srTruncatedToUuid + const srUuid = Object.hasOwn(srUuidIndex, srSuffix) ? srUuidIndex[srSuffix] : undefined + if (srUuid != null) { + labels.sr_uuid = srUuid const srInfo = labelContext.labels.srs[srUuid] if (srInfo !== undefined) { if (srInfo.name_label !== '') { @@ -1228,6 +1230,7 @@ export function formatSrMetrics(srDataList: SrDataItem[]): FormattedMetric[] { if (sr.pool_name !== '') { baseLabels.pool_name = sr.pool_name } + baseLabels.content_type = sr.content_type if (sr.sr_type !== '') { baseLabels.sr_type = sr.sr_type } diff --git a/packages/xo-server-openmetrics/src/openmetric-formatter.test.mts b/packages/xo-server-openmetrics/src/openmetric-formatter.test.mts index f0b27bf477..6fe4ca4709 100644 --- a/packages/xo-server-openmetrics/src/openmetric-formatter.test.mts +++ b/packages/xo-server-openmetrics/src/openmetric-formatter.test.mts @@ -1004,7 +1004,7 @@ describe('transformMetric with labelContext', () => { assert.equal(result.labels.sr_type, undefined) }) - it('should resolve sr_name and sr_type from a UUID prefix (XCP-ng RRD encoding)', () => { + it('should resolve SR labels from a UUID prefix (XCP-ng RRD encoding)', () => { // XCP-ng encodes the SR in RRD legends as the first 8 chars of the UUID. // The label context must therefore index that prefix. const fullUuid = 'c787b75c-3e0d-70fa-d0c3-cbfd382d7e33' @@ -1029,10 +1029,57 @@ describe('transformMetric with labelContext', () => { assert.ok(result) assert.equal(result.labels.sr, prefix) + assert.equal(result.labels.sr_uuid, fullUuid) assert.equal(result.labels.sr_name, 'XOSTOR NVME') assert.equal(result.labels.sr_type, 'linstor') }) + it('should omit SR enrichment when a UUID truncation is ambiguous', () => { + const prefix = 'c787b75c' + const ctx = createLabelContext() + ctx.labels.srTruncatedToUuid[prefix] = null + const metric: ParsedMetric = { + legend: { + cf: 'AVERAGE', + objectType: 'host', + uuid: 'host-uuid-123', + metricName: `iops_read_${prefix}`, + rawLegend: `AVERAGE:host:host-uuid-123:iops_read_${prefix}`, + }, + value: 42, + timestamp: 1700000000, + } + + const result = transformMetric(metric, 'pool-456', ctx) + + assert.ok(result) + assert.equal(result.labels.sr, prefix) + assert.equal(result.labels.sr_uuid, undefined) + assert.equal(result.labels.sr_name, undefined) + assert.equal(result.labels.sr_type, undefined) + }) + + it('should ignore inherited SR truncation keys', () => { + const metric: ParsedMetric = { + legend: { + cf: 'AVERAGE', + objectType: 'host', + uuid: 'host-uuid-123', + metricName: 'iops_read_constructor', + rawLegend: 'AVERAGE:host:host-uuid-123:iops_read_constructor', + }, + value: 42, + timestamp: 1700000000, + } + + const result = transformMetric(metric, 'pool-456', createLabelContext()) + + assert.ok(result) + assert.equal(result.labels.sr, 'constructor') + assert.equal(result.labels.sr_uuid, undefined) + assert.doesNotThrow(() => formatToOpenMetrics([result])) + }) + it('should handle missing label context gracefully', () => { const metric: ParsedMetric = { legend: { @@ -2121,6 +2168,7 @@ describe('formatSrMetrics', () => { usage: 500_000_000_000, pool_id: 'pool-789', pool_name: 'Production Pool', + content_type: 'user', sr_type: 'lvm', ...overrides, }) @@ -2143,6 +2191,16 @@ describe('formatSrMetrics', () => { } }) + it('should emit content_type label when present', () => { + const metrics = formatSrMetrics([createSrDataItem({ content_type: 'iso' })]) + + assert.equal(metrics.length, 3) + for (const m of metrics) { + assert.equal(m.labels.content_type, 'iso') + } + assert.match(formatToOpenMetrics(metrics), /content_type="iso"/) + }) + it('should distinguish multiple SRs by sr_type label', () => { const metrics = formatSrMetrics([ createSrDataItem({ uuid: 'sr-a', sr_type: 'linstor' }), @@ -3350,7 +3408,7 @@ describe('formatXostorUpdatesMetrics', () => { describe('indexSrUuidTruncations', () => { it('should index both UUID prefix and suffix at all configured lengths', () => { const uuid = 'c787b75c-3e0d-70fa-d0c3-cbfd382d7e33' - const index: Record = {} + const index: Record = {} indexSrUuidTruncations(uuid, index) @@ -3365,19 +3423,23 @@ describe('indexSrUuidTruncations', () => { } }) - it('should not overwrite existing entries (first match wins)', () => { - const uuid = 'c787b75c-3e0d-70fa-d0c3-cbfd382d7e33' - const prefix = uuid.slice(0, 8) - const index: Record = { [prefix]: 'first-sr-uuid' } + it('should keep colliding truncations marked as ambiguous', () => { + const firstUuid = 'c787b75c-3e0d-70fa-d0c3-cbfd382d7e33' + const secondUuid = 'c787b75c-1111-2222-3333-444444444444' + const thirdUuid = 'c787b75c-aaaa-bbbb-cccc-dddddddddddd' + const prefix = firstUuid.slice(0, 8) + const index: Record = {} - indexSrUuidTruncations(uuid, index) + indexSrUuidTruncations(firstUuid, index) + indexSrUuidTruncations(secondUuid, index) + indexSrUuidTruncations(thirdUuid, index) - assert.equal(index[prefix], 'first-sr-uuid') + assert.equal(index[prefix], null) }) it('should skip truncations longer than the UUID itself', () => { const shortUuid = 'abc1234' - const index: Record = {} + const index: Record = {} indexSrUuidTruncations(shortUuid, index) @@ -3580,6 +3642,7 @@ describe('tags label on status, uptime and SR metrics', () => { usage: 250, pool_id: 'pool-1', pool_name: 'Pool', + content_type: 'user', sr_type: 'lvm', tags: ['fast', 'ssd'], },