feat(unas): export the SSD cache and its cache-bay SSDs (#1109)

This commit is contained in:
Phillip Cloud
2026-10-17 14:13:08 -04:00
committed by GitHub
parent 87ded9886e
commit 20ee82b3e6
8 changed files with 253 additions and 77 deletions
+1 -1
View File
@@ -310,7 +310,7 @@
# This section is entirely optional. The plugin is off unless you set enable = true and
# add at least one [[unas.device]] below; otherwise it stays silent and inert.
# Metrics land under the unifi_unas_ prefix in Prometheus, and in the unas_device,
# unas_pool, unas_disk, and unas_share measurements in InfluxDB.
# unas_pool, unas_pool_cache, unas_disk, and unas_share measurements in InfluxDB.
#
#[unas]
# enable = true
+2
View File
@@ -72,3 +72,5 @@ require (
golang.org/x/sys v0.48.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
replace github.com/unpoller/unifi/v6 => github.com/cpcloud/unifi/v6 v6.0.0-20261017135054-b7989cc3fe67
+2 -2
View File
@@ -25,6 +25,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpcloud/unifi/v6 v6.0.0-20261017135054-b7989cc3fe67 h1:rEiPcEkXizLqLkEKgaND9ozy9WJZMNjSp6on85/PHNY=
github.com/cpcloud/unifi/v6 v6.0.0-20261017135054-b7989cc3fe67/go.mod h1:fJ1lU/mpKrQ8Jv3b2yq399ATlrUuiOZAMbKWcw/1GaU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -121,8 +123,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/unpoller/unifi/v6 v6.3.1 h1:gUBOYmSl/kmTAlSVfTEaDYqGU6iao5biMDeuQcCZmLE=
github.com/unpoller/unifi/v6 v6.3.1/go.mod h1:fJ1lU/mpKrQ8Jv3b2yq399ATlrUuiOZAMbKWcw/1GaU=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+64 -23
View File
@@ -77,37 +77,78 @@ func (u *DatadogUnifi) batchUNASPools(r report, d *unifi.UNASDevice, s *unifi.UN
_ = r.reportGauge(raidName("expected_protection"), rg.ExpectedProtection.Val, raidTags)
_ = r.reportGauge(raidName("progress"), rg.Progress.Val, raidTags)
}
if c := p.Cache; c != nil {
u.batchUNASPoolCache(r, d, p.ID, c)
}
}
}
func (u *DatadogUnifi) batchUNASPoolCache(r report, d *unifi.UNASDevice, poolID string, c *unifi.UNASPoolCache) {
metricName := metricNamespace("unas_pool_cache")
tags := tagMapToTags(cleanTags(map[string]string{
"source": d.SourceName,
"name": d.Name(),
"pool_id": poolID,
"mode": c.Mode,
"status": c.Status,
}))
_ = r.reportGauge(metricName("size"), c.Size.Val, tags)
_ = r.reportGauge(metricName("used"), c.Composition.Used.Val, tags)
_ = r.reportGauge(metricName("dirty"), c.Composition.Dirty.Val, tags)
_ = r.reportGauge(metricName("read_hit_rate"), c.Composition.ReadHitRate.Val, tags)
_ = r.reportGauge(metricName("write_hit_rate"), c.Composition.WriteHitRate.Val, tags)
}
func (u *DatadogUnifi) batchUNASDisks(r report, d *unifi.UNASDevice, s *unifi.UNASStorage) {
metricName := metricNamespace("unas_disk")
for _, disk := range s.Disks {
tags := tagMapToTags(cleanTags(map[string]string{
"source": d.SourceName,
"name": d.Name(),
"slot_id": disk.SlotID,
"location": disk.Location,
"pool_id": disk.PoolID,
"disk_type": disk.Type,
"state": disk.State,
"model": disk.Model,
"serial": disk.Serial,
}))
_ = r.reportGauge(metricName("size"), disk.Size.Val, tags)
_ = r.reportGauge(metricName("temperature"), disk.Temperature.Val, tags)
_ = r.reportGauge(metricName("health_score"), disk.HealthScore.Val, tags)
_ = r.reportGauge(metricName("power_on_hours"), disk.PowerOnHours.Val, tags)
_ = r.reportGauge(metricName("rpm"), disk.RPM.Val, tags)
_ = r.reportGauge(metricName("bad_sector_count"), disk.BadSectorCount.Val, tags)
_ = r.reportGauge(metricName("uncorrectable_sector_count"), disk.UncorrectableSectorCount.Val, tags)
_ = r.reportGauge(metricName("read_error_rate"), disk.ReadErrorRate.Val, tags)
_ = r.reportGauge(metricName("smart_read_error_count"), disk.SmartReadErrorCount.Val, tags)
_ = r.reportGauge(metricName("read_kbps"), disk.ReadKBPS.Val, tags)
_ = r.reportGauge(metricName("write_kbps"), disk.WriteKBPS.Val, tags)
u.batchUNASDisk(r, d, disk)
}
// Cache bays hold SSDs with the same fields as pool disks; disk_type tells them apart.
for _, disk := range s.CacheSlots {
if disk.State == "empty" {
continue
}
tags := u.batchUNASDisk(r, d, disk)
_ = r.reportGauge(metricName("life_span"), disk.LifeSpan.Val, tags)
}
}
// batchUNASDisk reports one disk's gauges and returns its tags.
func (u *DatadogUnifi) batchUNASDisk(r report, d *unifi.UNASDevice, disk unifi.UNASDisk) []string {
metricName := metricNamespace("unas_disk")
tags := tagMapToTags(cleanTags(map[string]string{
"source": d.SourceName,
"name": d.Name(),
"slot_id": disk.SlotID,
"location": disk.Location,
"pool_id": disk.PoolID,
"disk_type": disk.Type,
"state": disk.State,
"model": disk.Model,
"serial": disk.Serial,
}))
_ = r.reportGauge(metricName("size"), disk.Size.Val, tags)
_ = r.reportGauge(metricName("temperature"), disk.Temperature.Val, tags)
_ = r.reportGauge(metricName("health_score"), disk.HealthScore.Val, tags)
_ = r.reportGauge(metricName("power_on_hours"), disk.PowerOnHours.Val, tags)
_ = r.reportGauge(metricName("rpm"), disk.RPM.Val, tags)
_ = r.reportGauge(metricName("bad_sector_count"), disk.BadSectorCount.Val, tags)
_ = r.reportGauge(metricName("uncorrectable_sector_count"), disk.UncorrectableSectorCount.Val, tags)
_ = r.reportGauge(metricName("read_error_rate"), disk.ReadErrorRate.Val, tags)
_ = r.reportGauge(metricName("smart_read_error_count"), disk.SmartReadErrorCount.Val, tags)
_ = r.reportGauge(metricName("read_kbps"), disk.ReadKBPS.Val, tags)
_ = r.reportGauge(metricName("write_kbps"), disk.WriteKBPS.Val, tags)
return tags
}
func (u *DatadogUnifi) batchUNASShares(r report, d *unifi.UNASDevice) {
+65 -30
View File
@@ -114,38 +114,73 @@ func (u *InfluxUnifi) batchUNASStorage(r report, d *unifi.UNASDevice, s *unifi.U
},
Fields: fields,
})
if c := p.Cache; c != nil {
r.send(&metric{
Table: "unas_pool_cache",
Tags: map[string]string{
"source": d.SourceName,
"name": d.Name(),
"pool_id": p.ID,
"mode": c.Mode,
"status": c.Status,
},
Fields: map[string]any{
"size": c.Size.Val,
"used": c.Composition.Used.Val,
"dirty": c.Composition.Dirty.Val,
"read_hit_rate": c.Composition.ReadHitRate.Val,
"write_hit_rate": c.Composition.WriteHitRate.Val,
},
})
}
}
for _, disk := range s.Disks {
r.send(&metric{
Table: "unas_disk",
Tags: map[string]string{
"source": d.SourceName,
"name": d.Name(),
"slot_id": disk.SlotID,
"location": disk.Location,
"pool_id": disk.PoolID,
"disk_type": disk.Type,
"state": disk.State,
"model": disk.Model,
"serial": disk.Serial,
"firmware": disk.Firmware,
},
Fields: map[string]any{
"size": disk.Size.Val,
"temperature": disk.Temperature.Val,
"health_score": disk.HealthScore.Val,
"power_on_hours": disk.PowerOnHours.Val,
"rpm": disk.RPM.Val,
"bad_sector_count": disk.BadSectorCount.Val,
"uncorrectable_sector_count": disk.UncorrectableSectorCount.Val,
"read_error_rate": disk.ReadErrorRate.Val,
"smart_read_error_count": disk.SmartReadErrorCount.Val,
"read_kbps": disk.ReadKBPS.Val,
"write_kbps": disk.WriteKBPS.Val,
"is_global_hot_spare": disk.IsGlobalHotSpare.Val,
"is_local_hot_spare": disk.IsLocalHotSpare.Val,
},
})
r.send(unasDiskMetric(d, disk))
}
// Cache bays hold SSDs with the same fields as pool disks; disk_type tells them apart.
for _, disk := range s.CacheSlots {
if disk.State == "empty" {
continue
}
m := unasDiskMetric(d, disk)
m.Fields["life_span"] = disk.LifeSpan.Val
r.send(m)
}
}
func unasDiskMetric(d *unifi.UNASDevice, disk unifi.UNASDisk) *metric {
return &metric{
Table: "unas_disk",
Tags: map[string]string{
"source": d.SourceName,
"name": d.Name(),
"slot_id": disk.SlotID,
"location": disk.Location,
"pool_id": disk.PoolID,
"disk_type": disk.Type,
"state": disk.State,
"model": disk.Model,
"serial": disk.Serial,
"firmware": disk.Firmware,
},
Fields: map[string]any{
"size": disk.Size.Val,
"temperature": disk.Temperature.Val,
"health_score": disk.HealthScore.Val,
"power_on_hours": disk.PowerOnHours.Val,
"rpm": disk.RPM.Val,
"bad_sector_count": disk.BadSectorCount.Val,
"uncorrectable_sector_count": disk.UncorrectableSectorCount.Val,
"read_error_rate": disk.ReadErrorRate.Val,
"smart_read_error_count": disk.SmartReadErrorCount.Val,
"read_kbps": disk.ReadKBPS.Val,
"write_kbps": disk.WriteKBPS.Val,
"is_global_hot_spare": disk.IsGlobalHotSpare.Val,
"is_local_hot_spare": disk.IsLocalHotSpare.Val,
},
}
}
+11 -5
View File
@@ -74,7 +74,7 @@ Data comes from the UniFi Drive API. Four endpoints are polled per cycle:
| Endpoint | Yields |
|---|---|
| `/proxy/drive/api/v2/systems/device-info` | name, model, version, firmware, status, CPU load and temperature, memory |
| `/proxy/drive/api/v2/storage` | pools (capacity, usage, status, RAID groups) and disks (health score, temperature, power-on hours, RPM, size, bad and uncorrectable sectors, read/write KB/s) |
| `/proxy/drive/api/v2/storage` | pools (capacity, usage, status, RAID groups, SSD cache size, fill, dirty data, and hit rates), disks (health score, temperature, power-on hours, RPM, size, bad and uncorrectable sectors, read/write KB/s), and the SSDs in the cache bays (the same disk fields plus remaining endurance) |
| `/proxy/users/drive/api/v2/drives` | per-share id, name, type, status, quota, usage, member count |
| `/proxy/drive/api/v2/systems/network-io` | receive and transmit KB/s |
@@ -91,12 +91,18 @@ bare `unas_` prefix — the Grafana dashboard in
[#785](https://github.com/unpoller/unpoller/issues/785) needs its queries adjusted
accordingly. Consistency with every other unpoller metric wins here.
The SSD cache in front of a pool reports as `unifi_unas_pool_cache_*`. Cache-bay SSDs report
as `unifi_unas_disk_*` with `disk_type` set to the SSD's type, plus
`unifi_unas_disk_life_span_percent`. A cache bay and a drive bay can share a `slot_id`, so
filter on `disk_type` to tell them apart.
### InfluxDB and DataDog
Four measurements / metric prefixes: `unas_device`, `unas_pool`, `unas_disk`, and
`unas_share` (DataDog also emits `unas_raid_group`). They are kept separate because each has
its own tag set; folding them together would give every point the union of those tags with
most of them empty.
Five measurements / metric prefixes: `unas_device`, `unas_pool`, `unas_pool_cache`,
`unas_disk`, and `unas_share` (DataDog also emits `unas_raid_group`). Cache-bay SSDs report
under `unas_disk` with `disk_type` set to the SSD's type and an extra `life_span` field. The
measurements are kept separate because each has its own tag set; folding them together would
give every point the union of those tags with most of them empty.
Loki and OpenTelemetry are not wired up: Loki carries events only, and a UNAS console exposes
no event endpoints in this version.
+67 -16
View File
@@ -27,6 +27,12 @@ type unasDevice struct {
RaidCurrentProtection *prometheus.Desc
RaidExpectedProtection *prometheus.Desc
RaidProgress *prometheus.Desc
// SSD caches in front of storage pools.
PoolCacheSize *prometheus.Desc
PoolCacheUsed *prometheus.Desc
PoolCacheDirty *prometheus.Desc
PoolCacheReadHitRate *prometheus.Desc
PoolCacheWriteHitRate *prometheus.Desc
// Physical disks.
DiskSize *prometheus.Desc
DiskTemperature *prometheus.Desc
@@ -39,6 +45,7 @@ type unasDevice struct {
DiskSmartReadErrorCount *prometheus.Desc
DiskReadKBPS *prometheus.Desc
DiskWriteKBPS *prometheus.Desc
DiskLifeSpan *prometheus.Desc
// Shares ("drives").
ShareQuota *prometheus.Desc
ShareUsage *prometheus.Desc
@@ -51,6 +58,7 @@ func descUNASDevice(ns string) *unasDevice { // nolint: funlen
console := []string{"source", "name"}
pool := []string{"source", "name", "pool_id", "pool_type", "status"}
raid := []string{"source", "name", "pool_id", "raid_group_id", "current_level", "config_level"}
cache := []string{"source", "name", "pool_id", "mode", "status"}
disk := []string{"source", "name", "slot_id", "pool_id", "disk_type", "state", "model", "serial"}
share := []string{"source", "name", "share_id", "share_name", "share_type", "status"}
@@ -82,6 +90,16 @@ func descUNASDevice(ns string) *unasDevice { // nolint: funlen
"UNAS RAID group expected redundancy", raid, nil),
RaidProgress: prometheus.NewDesc(ns+"raid_group_progress_percent",
"UNAS RAID group rebuild or expansion progress", raid, nil),
PoolCacheSize: prometheus.NewDesc(ns+"pool_cache_size_bytes",
"UNAS storage pool SSD cache size", cache, nil),
PoolCacheUsed: prometheus.NewDesc(ns+"pool_cache_used_bytes",
"UNAS storage pool SSD cache bytes holding cached data", cache, nil),
PoolCacheDirty: prometheus.NewDesc(ns+"pool_cache_dirty_bytes",
"UNAS storage pool SSD cache bytes not yet written to the pool's disks", cache, nil),
PoolCacheReadHitRate: prometheus.NewDesc(ns+"pool_cache_read_hit_percent",
"UNAS storage pool SSD cache read hit rate as reported by the console", cache, nil),
PoolCacheWriteHitRate: prometheus.NewDesc(ns+"pool_cache_write_hit_percent",
"UNAS storage pool SSD cache write hit rate as reported by the console", cache, nil),
DiskSize: prometheus.NewDesc(ns+"disk_size_bytes",
"UNAS disk size", disk, nil),
DiskTemperature: prometheus.NewDesc(ns+"disk_temperature_celsius",
@@ -104,6 +122,8 @@ func descUNASDevice(ns string) *unasDevice { // nolint: funlen
"UNAS disk instantaneous read throughput in KB/s", disk, nil),
DiskWriteKBPS: prometheus.NewDesc(ns+"disk_write_kbps",
"UNAS disk instantaneous write throughput in KB/s", disk, nil),
DiskLifeSpan: prometheus.NewDesc(ns+"disk_life_span_percent",
"UNAS SSD rated endurance left", disk, nil),
ShareQuota: prometheus.NewDesc(ns+"share_quota_bytes",
"UNAS share quota (0 when unlimited)", share, nil),
ShareUsage: prometheus.NewDesc(ns+"share_usage_bytes",
@@ -184,26 +204,57 @@ func (u *promUnifi) exportUNASStorage(r report, d *unifi.UNASDevice, s *unifi.UN
{u.UNASDevice.RaidProgress, gauge, rg.Progress, raid},
})
}
if c := p.Cache; c != nil {
cache := []string{d.SourceName, name, p.ID, c.Mode, c.Status}
r.send([]*metric{
{u.UNASDevice.PoolCacheSize, gauge, c.Size, cache},
{u.UNASDevice.PoolCacheUsed, gauge, c.Composition.Used, cache},
{u.UNASDevice.PoolCacheDirty, gauge, c.Composition.Dirty, cache},
{u.UNASDevice.PoolCacheReadHitRate, gauge, c.Composition.ReadHitRate, cache},
{u.UNASDevice.PoolCacheWriteHitRate, gauge, c.Composition.WriteHitRate, cache},
})
}
}
for _, disk := range s.Disks {
labels := []string{
d.SourceName, name, disk.SlotID, disk.PoolID,
disk.Type, disk.State, disk.Model, disk.Serial,
u.exportUNASDisk(r, d, disk)
}
// Cache slots hold SSDs with the same fields as the pool's disks; the
// disk_type label tells them apart.
for _, disk := range s.CacheSlots {
if disk.State == "empty" {
continue
}
r.send([]*metric{
{u.UNASDevice.DiskSize, gauge, disk.Size, labels},
{u.UNASDevice.DiskTemperature, gauge, disk.Temperature, labels},
{u.UNASDevice.DiskHealthScore, gauge, disk.HealthScore, labels},
{u.UNASDevice.DiskPowerOnHours, gauge, disk.PowerOnHours, labels},
{u.UNASDevice.DiskRPM, gauge, disk.RPM, labels},
{u.UNASDevice.DiskBadSectorCount, gauge, disk.BadSectorCount, labels},
{u.UNASDevice.DiskUncorrectableSectorCount, gauge, disk.UncorrectableSectorCount, labels},
{u.UNASDevice.DiskReadErrorRate, gauge, disk.ReadErrorRate, labels},
{u.UNASDevice.DiskSmartReadErrorCount, gauge, disk.SmartReadErrorCount, labels},
{u.UNASDevice.DiskReadKBPS, gauge, disk.ReadKBPS, labels},
{u.UNASDevice.DiskWriteKBPS, gauge, disk.WriteKBPS, labels},
})
u.exportUNASDisk(r, d, disk)
r.send([]*metric{{u.UNASDevice.DiskLifeSpan, gauge, disk.LifeSpan, unasDiskLabels(d, disk)}})
}
}
func unasDiskLabels(d *unifi.UNASDevice, disk unifi.UNASDisk) []string {
return []string{
d.SourceName, d.Name(), disk.SlotID, disk.PoolID,
disk.Type, disk.State, disk.Model, disk.Serial,
}
}
func (u *promUnifi) exportUNASDisk(r report, d *unifi.UNASDevice, disk unifi.UNASDisk) {
labels := unasDiskLabels(d, disk)
r.send([]*metric{
{u.UNASDevice.DiskSize, gauge, disk.Size, labels},
{u.UNASDevice.DiskTemperature, gauge, disk.Temperature, labels},
{u.UNASDevice.DiskHealthScore, gauge, disk.HealthScore, labels},
{u.UNASDevice.DiskPowerOnHours, gauge, disk.PowerOnHours, labels},
{u.UNASDevice.DiskRPM, gauge, disk.RPM, labels},
{u.UNASDevice.DiskBadSectorCount, gauge, disk.BadSectorCount, labels},
{u.UNASDevice.DiskUncorrectableSectorCount, gauge, disk.UncorrectableSectorCount, labels},
{u.UNASDevice.DiskReadErrorRate, gauge, disk.ReadErrorRate, labels},
{u.UNASDevice.DiskSmartReadErrorCount, gauge, disk.SmartReadErrorCount, labels},
{u.UNASDevice.DiskReadKBPS, gauge, disk.ReadKBPS, labels},
{u.UNASDevice.DiskWriteKBPS, gauge, disk.WriteKBPS, labels},
})
}
+41
View File
@@ -101,3 +101,44 @@ func TestExportUNASDeviceTolerantOfNilSections(t *testing.T) {
u.exportUNASDevice(empty, nil)
assert.Empty(t, empty.sent)
}
// The SSD cache reports on its own label set, and an occupied cache bay reports as a disk
// with disk_type SSD plus its remaining endurance. An empty bay reports nothing.
func TestExportUNASSSDCache(t *testing.T) {
t.Parallel()
d := testUNASDevice()
d.Storage.Pools[0].Cache = &unifi.UNASPoolCache{
Status: "fullyOperational",
Mode: "readOnly",
Size: unifi.FlexInt{Val: 1000},
}
d.Storage.Pools[0].Cache.Composition.ReadHitRate = unifi.FlexInt{Val: 87}
d.Storage.CacheSlots = []unifi.UNASDisk{
{SlotID: "1", Type: "SSD", State: "healthy", Serial: "SSD-1", LifeSpan: unifi.FlexInt{Val: 97}},
{SlotID: "2", State: "empty"},
}
r := &fakeReport{}
u := &promUnifi{UNASDevice: descUNASDevice("unifi_")}
u.exportUNASDevice(r, d)
byDesc := map[*prometheus.Desc][]*metric{}
for _, m := range r.sent {
byDesc[m.Desc] = append(byDesc[m.Desc], m)
}
hit := byDesc[u.UNASDevice.PoolCacheReadHitRate]
require.Len(t, hit, 1)
assert.Equal(t, unifi.FlexInt{Val: 87}, hit[0].Value)
assert.Equal(t, []string{"https://unas.example", "UNAS-Pro", "pool-1", "readOnly", "fullyOperational"},
hit[0].Labels)
life := byDesc[u.UNASDevice.DiskLifeSpan]
require.Len(t, life, 1, "only the occupied bay reports endurance")
assert.Equal(t, unifi.FlexInt{Val: 97}, life[0].Value)
assert.Equal(t, "SSD", life[0].Labels[4])
// The pool's hard drive and the occupied bay each report temperature; the empty bay does not.
assert.Len(t, byDesc[u.UNASDevice.DiskTemperature], 2)
}