feat(xo-server-openmetrics): include XO tags as tags label (#9971)

Fixes #9628
Plane : XO-2244
This commit is contained in:
Mathieu
2026-06-23 10:07:15 +02:00
committed by GitHub
parent 64401c4040
commit a5d410e0aa
8 changed files with 441 additions and 0 deletions

View File

@@ -72,6 +72,7 @@
- [i18n] Update Czech, Danish, Norwegian, Slovak, Spanish and Swedish translations (PR [#9914](https://github.com/vatesfr/xen-orchestra/pull/9914))
- [REST API] `PATCH /rest/v0/vifs/{id}` to update VIF properties (allowed IPs, locking mode, rate limit, TX checksumming) (PR [#9935](https://github.com/vatesfr/xen-orchestra/pull/9935))
- [XO 6] Add an Administration tab in the sidebar with a user management menu (PR [#9947](https://github.com/vatesfr/xen-orchestra/pull/9947))
- [OpenMetrics] Include XO tags as a `tags` label on host, VM and SR metrics [#9628](https://github.com/vatesfr/xen-orchestra/issues/9628) (PR [#9971](https://github.com/vatesfr/xen-orchestra/pull/9971))
### Bug fixes
@@ -129,6 +130,7 @@
- xo-server-ipmi-sensors patch
- xo-server-load-balancer minor
- xo-server-netbox minor
- xo-server-openmetrics minor
- xo-server-sdn-controller patch
- xo-web patch

View File

@@ -613,6 +613,28 @@ All metrics include these labels for filtering:
| `target_type` | Object targeted by an alarm: `sr` or `host` |
| `package` | XOSTOR-related RPM name for pending updates |
| `status` | SMART overall-health string: `PASSED`, `FAILED`, `UNKNOWN` |
| `tags` | XO tags of the object, comma-separated and sorted (e.g. `prod,web`) — on host, VM and SR metrics; omitted when the object has no tags |
##### Filtering by XO tags
The `tags` label carries every XO tag of the object as a single sorted, comma-separated string. Use a regex matcher to select objects carrying a given tag:
```
# CPU usage of hosts tagged "production"
xcp_host_cpu_average{tags=~"(^|.*,)production(,.*|$)"}
# Exclude VMs tagged "lab" from an alert
xcp_vm_cpu_usage{tags!~"(^|.*,)lab(,.*|$)"}
```
This is useful for MSP/multi-tenant scoping (one tag per customer), or to restrict dashboards and alerting rules to tagged subsets of the infrastructure.
:::warning
Two limitations to keep in mind:
- **Tags containing commas**: tags are joined with commas, so a single tag named `a,b` is indistinguishable from the two tags `a` and `b` in the label value. Avoid commas in tags used for monitoring.
- **Series continuity**: adding or removing a tag changes the `tags` label value, which starts a new Prometheus series for all the object's metrics (the previous series goes stale). Alerting rules with a `for:` clause will reset for that object when its tags change. This also applies once when upgrading to the first plugin version exposing this label: objects that already carry tags start new series at that point, while untagged objects are unaffected since the label is omitted for them.
:::
### PromQL Query Examples

View File

@@ -15,6 +15,7 @@ This plugin creates an HTTP server that exposes a `/metrics` endpoint compatible
- VM uptime metric (`xcp_vm_uptime_seconds`) for running VMs
- VM metrics: CPU, memory, network, disk, runstate
- `is_control_domain` label on all VM metrics to distinguish dom0 from user VMs
- `tags` label on host, VM and SR metrics carrying the XO tags of the object (comma-separated, sorted), enabling selective monitoring with regex matchers such as `{tags=~"(^|.*,)production(,.*|$)"}`
- VDI disk size metrics: virtual size and physical usage per VDI (`xcp_vdi_virtual_size_bytes`, `xcp_vdi_physical_usage_bytes`)
- Bearer token authentication
- OpenMetrics format with EOF marker

View File

@@ -23,6 +23,7 @@ This plugin creates an HTTP server that exposes a `/metrics` endpoint compatible
- VM uptime metric (`xcp_vm_uptime_seconds`) for running VMs
- VM metrics: CPU, memory, network, disk, runstate
- `is_control_domain` label on all VM metrics to distinguish dom0 from user VMs
- `tags` label on host, VM and SR metrics carrying the XO tags of the object (comma-separated, sorted), enabling selective monitoring with regex matchers such as `{tags=~"(^|.*,)production(,.*|$)"}`
- VDI disk size metrics: virtual size and physical usage per VDI (`xcp_vdi_virtual_size_bytes`, `xcp_vdi_physical_usage_bytes`)
- Bearer token authentication
- OpenMetrics format with EOF marker

View File

@@ -76,12 +76,14 @@ export interface VmLabelInfo {
power_state: string // VM power state (Running, Paused, Halted, Suspended)
pool_id: string
pool_name: string
tags?: string[]
}
export interface HostLabelInfo {
name_label: string
pifDeviceToNetworkName: Record<string, string> // { "eth0": "Management" }
startTime: number | null // Unix timestamp of host boot (from host.startTime)
tags?: string[]
}
export interface SrLabelInfo {
@@ -117,6 +119,7 @@ export type SrDataItem = Pick<XoSr, 'uuid' | 'name_label' | 'size' | 'physical_u
sr_type: string
host_id?: string
host_name?: string
tags?: string[]
}
export interface XoMetricsData {
@@ -177,6 +180,7 @@ interface VdiDataPayload {
export type HostStatusItem = Pick<XoHost, 'uuid' | 'name_label' | 'power_state' | 'enabled'> & {
pool_id: string
pool_name: string
tags?: string[]
}
interface HostStatusPayload {
@@ -186,6 +190,7 @@ interface HostStatusPayload {
export type VmStatusItem = Pick<XoVm, 'uuid' | 'name_label' | 'power_state'> & {
pool_id: string
pool_name: string
tags?: string[]
}
interface VmStatusPayload {
@@ -1171,6 +1176,7 @@ class OpenMetricsPlugin {
size: sr.size,
physical_usage: sr.physical_usage,
usage: sr.usage,
tags: sr.tags,
}
// For local (non-shared) SRs, add host information
@@ -1279,6 +1285,7 @@ class OpenMetricsPlugin {
enabled: host.enabled,
pool_id: host.$poolId,
pool_name: poolLabelMap.get(host.$poolId) ?? '',
tags: host.tags,
})
}
@@ -1308,6 +1315,7 @@ class OpenMetricsPlugin {
power_state: vm.power_state,
pool_id: vm.$poolId,
pool_name: poolLabelMap.get(vm.$poolId) ?? '',
tags: vm.tags,
})
}
@@ -1906,6 +1914,7 @@ class OpenMetricsPlugin {
power_state: vm.power_state,
pool_id: vm.$poolId,
pool_name: poolLabelMap.get(vm.$poolId) ?? '',
tags: vm.tags,
}
}
@@ -1923,6 +1932,7 @@ class OpenMetricsPlugin {
name_label: host.name_label,
pifDeviceToNetworkName,
startTime: host.startTime,
tags: host.tags,
}
}

View File

@@ -85,12 +85,14 @@ interface VmLabelInfo {
power_state: string
pool_id: string
pool_name: string
tags?: string[]
}
interface HostLabelInfo {
name_label: string
pifDeviceToNetworkName: Record<string, string>
startTime: number | null
tags?: string[]
}
interface SrLabelInfo {

View File

@@ -745,6 +745,29 @@ const METRIC_PREFIX = 'xcp'
/** OpenMetrics prefix for XO management plane metrics */
const XO_METRIC_PREFIX = 'xo'
/**
* Serialize XO object tags into the `tags` label value
*
* @param tags - Raw tag list from the XO object
* @returns Comma-separated, sorted tag list (e.g. `"prod,web"`)
*/
export function serializeTags(tags: readonly string[] | undefined): string {
if (tags === undefined || tags.length === 0) {
return ''
}
return [...new Set(tags)]
.filter(tag => tag.trim() !== '')
.sort()
.join(',')
}
function addTagsLabel(labels: Record<string, string>, tags: readonly string[] | undefined): void {
const serialized = serializeTags(tags)
if (serialized !== '') {
labels.tags = serialized
}
}
/**
* Escape special characters in label values.
*
@@ -816,6 +839,8 @@ export function transformMetric(
type: legend.objectType,
}
let objectTags: readonly string[] | undefined
// Add pool_name from host credentials
if (labelContext !== undefined) {
const hostCred = labelContext.hosts.find(h => h.poolId === poolId)
@@ -830,6 +855,7 @@ export function transformMetric(
if (hostInfo.name_label !== '') {
labels.host_name = hostInfo.name_label
}
objectTags = hostInfo.tags
// For PIF metrics, add network_name
if (extractedLabels.interface !== undefined) {
@@ -866,6 +892,7 @@ export function transformMetric(
labels.vm_name = vmInfo.name_label
}
labels.is_control_domain = vmInfo.is_control_domain ? 'true' : 'false'
objectTags = vmInfo.tags
// For VBD metrics, add vdi_name and sr_name
if (extractedLabels.device !== undefined) {
@@ -905,6 +932,7 @@ export function transformMetric(
// Add extracted labels (device, interface, vif, sr, core)
Object.assign(labels, extractedLabels)
addTagsLabel(labels, objectTags)
return {
name: `${METRIC_PREFIX}_${definition.openMetricName}`,
@@ -1058,6 +1086,8 @@ export function formatSrMetrics(srDataList: SrDataItem[]): FormattedMetric[] {
baseLabels.host_name = sr.host_name
}
addTagsLabel(baseLabels, sr.tags)
// Virtual size (virtual_allocation)
metrics.push({
name: `${METRIC_PREFIX}_sr_virtual_size_bytes`,
@@ -1181,6 +1211,8 @@ export function formatHostStatusMetrics(hostStatusList: HostStatusItem[]): Forma
labels.pool_name = host.pool_name
}
addTagsLabel(labels, host.tags)
metrics.push({
name: `${METRIC_PREFIX}_host_status`,
help: 'Host status (1 = current state)',
@@ -1221,6 +1253,8 @@ export function formatVmStatusMetrics(vmStatusList: VmStatusItem[]): FormattedMe
labels.vm_name = vm.name_label
}
addTagsLabel(labels, vm.tags)
metrics.push({
name: `${METRIC_PREFIX}_vm_status`,
help: 'VM power state indicator (always 1; current state is carried by the power_state label)',
@@ -1699,6 +1733,8 @@ export function formatHostUptimeMetrics(labelContext: LabelContext): FormattedMe
labels.host_name = hostInfo.name_label
}
addTagsLabel(labels, hostInfo.tags)
metrics.push({
name: `${METRIC_PREFIX}_host_uptime_seconds`,
help: 'Host uptime in seconds since boot',
@@ -1755,6 +1791,8 @@ export function formatVmUptimeMetrics(labelContext: LabelContext): FormattedMetr
labels.vm_name = vmInfo.name_label
}
addTagsLabel(labels, vmInfo.tags)
metrics.push({
name: `${METRIC_PREFIX}_vm_uptime_seconds`,
help: 'VM uptime in seconds since boot',

View File

@@ -22,6 +22,7 @@ import {
formatXostorClusterMetrics,
formatXostorSmartMetrics,
formatXostorUpdatesMetrics,
serializeTags,
type FormattedMetric,
type LabelContext,
} from './openmetric-formatter.mjs'
@@ -3165,3 +3166,367 @@ describe('indexSrUuidTruncations', () => {
assert.equal(Object.keys(index).length, 0)
})
})
// ============================================================================
// Tags Label Tests
// ============================================================================
describe('serializeTags', () => {
it('should return empty string for undefined or empty input', () => {
assert.equal(serializeTags(undefined), '')
assert.equal(serializeTags([]), '')
})
it('should join tags with commas', () => {
assert.equal(serializeTags(['production', 'web']), 'production,web')
})
it('should sort tags for stable series identity', () => {
assert.equal(serializeTags(['web', 'production', 'customer-a']), 'customer-a,production,web')
})
it('should deduplicate tags', () => {
assert.equal(serializeTags(['prod', 'prod', 'web']), 'prod,web')
})
it('should filter out empty tags', () => {
assert.equal(serializeTags(['', 'prod', '']), 'prod')
})
it('should filter out whitespace-only tags', () => {
assert.equal(serializeTags([' ', 'prod', '\t']), 'prod')
assert.equal(serializeTags([' ']), '')
})
it('should collapse a list of identical tags to a single entry', () => {
assert.equal(serializeTags(['prod', 'prod', 'prod']), 'prod')
})
})
describe('tags label on RRD metrics (transformMetric)', () => {
const createTaggedLabelContext = (hostTags?: string[], vmTags?: string[]): LabelContext => ({
hosts: [
{
hostId: 'host-uuid-123',
hostAddress: '192.168.1.1',
hostLabel: 'Host 1',
poolId: 'pool-456',
poolLabel: 'Production Pool',
sessionId: 'session-123',
protocol: 'https:',
},
],
labels: {
vms: {
'vm-uuid-789': {
name_label: 'Web Server',
is_control_domain: false,
vbdDeviceToVdiName: {},
vbdDeviceToVdiUuid: {},
vifIndexToNetworkName: {},
startTime: null,
power_state: 'Running',
pool_id: 'pool-456',
pool_name: 'Production Pool',
tags: vmTags,
},
},
hosts: {
'host-uuid-123': {
name_label: 'Host 1',
pifDeviceToNetworkName: {},
startTime: null,
tags: hostTags,
},
},
srs: {},
srTruncatedToUuid: {},
vdiUuidToSrUuid: {},
},
})
const hostMetric: ParsedMetric = {
legend: {
cf: 'AVERAGE',
objectType: 'host',
uuid: 'host-uuid-123',
metricName: 'cpu_avg',
rawLegend: 'AVERAGE:host:host-uuid-123:cpu_avg',
},
value: 0.75,
timestamp: 1700000000,
}
const vmMetric: ParsedMetric = {
legend: {
cf: 'AVERAGE',
objectType: 'vm',
uuid: 'vm-uuid-789',
metricName: 'cpu_usage',
rawLegend: 'AVERAGE:vm:vm-uuid-789:cpu_usage',
},
value: 0.5,
timestamp: 1700000000,
}
it('should add tags label to host metrics', () => {
const result = transformMetric(hostMetric, 'pool-456', createTaggedLabelContext(['prod', 'dc1']))
assert.ok(result)
assert.equal(result.labels.tags, 'dc1,prod')
})
it('should add tags label to VM metrics', () => {
const result = transformMetric(vmMetric, 'pool-456', createTaggedLabelContext(undefined, ['customer-a', 'web']))
assert.ok(result)
assert.equal(result.labels.tags, 'customer-a,web')
})
it('should omit tags label when host has no tags', () => {
const result = transformMetric(hostMetric, 'pool-456', createTaggedLabelContext([]))
assert.ok(result)
assert.equal(result.labels.tags, undefined)
})
it('should omit tags label when VM tags are undefined', () => {
const result = transformMetric(vmMetric, 'pool-456', createTaggedLabelContext())
assert.ok(result)
assert.equal(result.labels.tags, undefined)
})
})
describe('tags label on status, uptime and SR metrics', () => {
it('should add tags label to host status metrics', () => {
const hosts: HostStatusItem[] = [
{
uuid: 'host-1',
name_label: 'Host 1',
power_state: 'Running',
enabled: true,
pool_id: 'pool-1',
pool_name: 'Pool',
tags: ['prod', 'dc1'],
},
]
const result = formatHostStatusMetrics(hosts)
assert.equal(result[0]!.labels.tags, 'dc1,prod')
})
it('should omit tags label on host status metrics without tags', () => {
const hosts: HostStatusItem[] = [
{
uuid: 'host-1',
name_label: 'Host 1',
power_state: 'Running',
enabled: true,
pool_id: 'pool-1',
pool_name: 'Pool',
},
]
const result = formatHostStatusMetrics(hosts)
assert.equal(result[0]!.labels.tags, undefined)
})
it('should add tags label to VM status metrics', () => {
const vms: VmStatusItem[] = [
{
uuid: 'vm-1',
name_label: 'VM 1',
power_state: 'Running',
pool_id: 'pool-1',
pool_name: 'Pool',
tags: ['web', 'customer-a'],
},
]
const result = formatVmStatusMetrics(vms)
assert.equal(result[0]!.labels.tags, 'customer-a,web')
})
it('should add tags label to all SR capacity metrics', () => {
const srs: SrDataItem[] = [
{
uuid: 'sr-1',
name_label: 'Local Storage',
size: 1000,
physical_usage: 500,
usage: 250,
pool_id: 'pool-1',
pool_name: 'Pool',
sr_type: 'lvm',
tags: ['fast', 'ssd'],
},
]
const metrics = formatSrMetrics(srs)
assert.equal(metrics.length, 3)
for (const m of metrics) {
assert.equal(m.labels.tags, 'fast,ssd')
}
})
it('should add tags label to host uptime metrics', () => {
const labelContext: LabelContext = {
hosts: [
{
hostId: 'host-1',
hostAddress: '192.168.1.1',
hostLabel: 'Host 1',
poolId: 'pool-456',
poolLabel: 'Production Pool',
sessionId: 'session-123',
protocol: 'https:',
},
],
labels: {
vms: {},
hosts: {
'host-1': {
name_label: 'Host 1',
pifDeviceToNetworkName: {},
startTime: Math.floor(Date.now() / 1000) - 7200,
tags: ['prod'],
},
},
srs: {},
srTruncatedToUuid: {},
vdiUuidToSrUuid: {},
},
}
const metrics = formatHostUptimeMetrics(labelContext)
assert.equal(metrics.length, 1)
assert.equal(metrics[0]!.labels.tags, 'prod')
})
it('should add tags label to VM uptime metrics', () => {
const labelContext: LabelContext = {
hosts: [],
labels: {
vms: {
'vm-1': {
name_label: 'VM 1',
is_control_domain: false,
vbdDeviceToVdiName: {},
vbdDeviceToVdiUuid: {},
vifIndexToNetworkName: {},
startTime: Math.floor(Date.now() / 1000) - 3600,
power_state: 'Running',
pool_id: 'pool-456',
pool_name: 'Production Pool',
tags: ['web', 'customer-a'],
},
},
hosts: {},
srs: {},
srTruncatedToUuid: {},
vdiUuidToSrUuid: {},
},
}
const metrics = formatVmUptimeMetrics(labelContext)
assert.equal(metrics.length, 1)
assert.equal(metrics[0]!.labels.tags, 'customer-a,web')
})
it('should escape special characters in tag values through formatToOpenMetrics', () => {
const vms: VmStatusItem[] = [
{
uuid: 'vm-1',
name_label: 'VM 1',
power_state: 'Running',
pool_id: 'pool-1',
pool_name: 'Pool',
tags: ['with"quote', 'with\\backslash'],
},
]
const output = formatToOpenMetrics(formatVmStatusMetrics(vms))
assert.match(output, /tags="with\\"quote,with\\\\backslash"/)
})
it('should escape newlines in tag values through formatToOpenMetrics', () => {
const vms: VmStatusItem[] = [
{
uuid: 'vm-1',
name_label: 'VM 1',
power_state: 'Running',
pool_id: 'pool-1',
pool_name: 'Pool',
tags: ['line1\nline2'],
},
]
const output = formatToOpenMetrics(formatVmStatusMetrics(vms))
// The newline must be escaped as the two characters `\` + `n`
assert.match(output, /tags="line1\\nline2"/)
assert.ok(!output.includes('tags="line1\nline2"'))
})
it('should keep tags label on VBD metrics where extracted labels are added afterwards', () => {
const labelContext: LabelContext = {
hosts: [
{
hostId: 'host-uuid-123',
hostAddress: '192.168.1.1',
hostLabel: 'Host 1',
poolId: 'pool-456',
poolLabel: 'Production Pool',
sessionId: 'session-123',
protocol: 'https:',
},
],
labels: {
vms: {
'vm-uuid-789': {
name_label: 'Web Server',
is_control_domain: false,
vbdDeviceToVdiName: { xvda: 'System Disk' },
vbdDeviceToVdiUuid: { xvda: 'vdi-uuid-system' },
vifIndexToNetworkName: {},
startTime: null,
power_state: 'Running',
pool_id: 'pool-456',
pool_name: 'Production Pool',
tags: ['prod'],
},
},
hosts: {},
srs: {},
srTruncatedToUuid: {},
vdiUuidToSrUuid: {},
},
}
const metric: ParsedMetric = {
legend: {
cf: 'AVERAGE',
objectType: 'vm',
uuid: 'vm-uuid-789',
metricName: 'vbd_xvda_read',
rawLegend: 'AVERAGE:vm:vm-uuid-789:vbd_xvda_read',
},
value: 1000000,
timestamp: 1700000000,
}
const result = transformMetric(metric, 'pool-456', labelContext)
assert.ok(result)
assert.equal(result.labels.device, 'xvda')
assert.equal(result.labels.tags, 'prod')
})
})