Add device tag support to Prometheus metrics

- Add 'tag' label to all device metric descriptors
- Update exportWithTags helper to create separate metric series per tag
- Update all device export functions (UAP, USW, UDM, USG, UXG, PDU, UBB, UCI) to include tags
- Update all label arrays (VAP, Radio, Port, etc.) to include tag label
- Devices with multiple tags create multiple metric series (one per tag)
- Devices without tags export with tag=""

Requires unpoller/unifi#92
This commit is contained in:
brngates98
2026-01-28 20:48:10 -05:00
parent 2a44b2f0be
commit 6d85ea76ab
14 changed files with 462 additions and 245 deletions

2
go.mod
View File

@@ -47,4 +47,4 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
)
// replace github.com/unpoller/unifi/v5 => ../unifi
replace github.com/unpoller/unifi/v5 => ../unifi

2
go.sum
View File

@@ -77,8 +77,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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/unpoller/unifi/v5 v5.7.0 h1:mGdHLOvmeQqnyB8mgI/ZzMMd/7kaGm+zd9p6iIF4W6g=
github.com/unpoller/unifi/v5 v5.7.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=

View File

@@ -4,7 +4,6 @@ package datadogunifi
import (
"fmt"
"reflect"
"time"
"github.com/DataDog/datadog-go/v5/statsd"
@@ -355,7 +354,9 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
case *unifi.SpeedTestResult:
u.batchSpeedTest(r, v)
default:
u.LogErrorf("invalid export, type=%+v", reflect.TypeOf(v))
if u.Collector != nil && u.Collector.Poller().LogUnknownTypes {
u.LogDebugf("unknown export type: %T", v)
}
}
}

View File

@@ -25,6 +25,7 @@ type Logs struct {
type Report struct {
Start time.Time
Oldest time.Time
Collect poller.Collect
poller.Logger
Counts map[string]int
}
@@ -34,6 +35,7 @@ func (l *Loki) NewReport(start time.Time) *Report {
return &Report{
Start: start,
Oldest: l.last,
Collect: l.Collect,
Logger: l,
Counts: make(map[string]int),
}
@@ -60,7 +62,9 @@ func (r *Report) ProcessEventLogs(events *poller.Events) *Logs {
case *unifi.ProtectLogEntry:
r.ProtectLogEvent(event, logs)
default: // unlikely.
r.LogErrorf("unknown event type: %T", e)
if r.Collect != nil && r.Collect.Poller().LogUnknownTypes {
r.LogDebugf("unknown event type: %T", e)
}
}
}

View File

@@ -64,14 +64,14 @@ func descPDU(ns string) *pdu {
outlet := ns + "outlet_"
pns := ns + "port_"
sfp := pns + "sfp_"
labelS := []string{"site_name", "name", "source"}
labelP := []string{"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source"}
labelS := []string{"site_name", "name", "source", "tag"}
labelP := []string{"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source", "tag"}
labelF := []string{
"sfp_part", "sfp_vendor", "sfp_serial", "sfp_compliance",
"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source",
"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source", "tag",
}
labelO := []string{
"outlet_description", "outlet_index", "outlet_name", "site_name", "name", "source",
"outlet_description", "outlet_index", "outlet_name", "site_name", "name", "source", "tag",
}
nd := prometheus.NewDesc
@@ -136,8 +136,13 @@ func (u *promUnifi) exportPDU(r report, d *unifi.PDU) {
return
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
u.exportPDUstats(r, labels, d.Stat.Sw)
u.exportPDUPrtTable(r, labels, d.PortTable)
@@ -146,7 +151,7 @@ func (u *promUnifi) exportPDU(r report, d *unifi.PDU) {
u.exportSYSstats(r, labels, d.SysStats, d.SystemStats)
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta)
r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)},
{u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradeable.Val, labels},
})
@@ -163,6 +168,7 @@ func (u *promUnifi) exportPDU(r report, d *unifi.PDU) {
if d.TotalMaxPower.Txt != "" {
r.send([]*metric{{u.Device.TotalMaxPower, gauge, d.TotalMaxPower, labels}})
}
})
}
// Switch Stats.
@@ -204,7 +210,7 @@ func (u *promUnifi) exportPDUPrtTable(r report, labels []string, pt []unifi.Port
// Copy labels, and add four new ones.
labelP := []string{
labels[2] + " Port " + p.PortIdx.Txt, p.PortIdx.Txt,
p.Name, p.Mac, p.IP, labels[1], labels[2], labels[3],
p.Name, p.Mac, p.IP, labels[1], labels[2], labels[3], labels[4],
}
if p.PoeEnable.Val && p.PortPoe.Val {
@@ -218,7 +224,7 @@ func (u *promUnifi) exportPDUPrtTable(r report, labels []string, pt []unifi.Port
if p.SFPFound.Val {
labelF := []string{
p.SFPPart, p.SFPVendor, p.SFPSerial, p.SFPCompliance,
labelP[0], labelP[1], labelP[2], labelP[3], labelP[4], labelP[5], labelP[6], labelP[7],
labelP[0], labelP[1], labelP[2], labelP[3], labelP[4], labelP[5], labelP[6], labelP[7], labelP[8],
}
r.send([]*metric{
@@ -258,7 +264,7 @@ func (u *promUnifi) exportPDUOutletTable(r report, labels []string, ot []unifi.O
// Copy labels, and add four new ones.
labelOutlet := []string{
labels[2] + " Outlet " + o.Index.Txt, o.Index.Txt,
o.Name, labels[1], labels[2], labels[3],
o.Name, labels[1], labels[2], labels[3], labels[4],
}
r.send([]*metric{
@@ -277,7 +283,7 @@ func (u *promUnifi) exportPDUOutletTable(r report, labels []string, ot []unifi.O
// Copy labels, and add four new ones.
labelOutlet := []string{
labels[2] + " Outlet Override " + o.Index.Txt, o.Index.Txt,
o.Name, labels[1], labels[2], labels[3],
o.Name, labels[1], labels[2], labels[3], labels[4],
}
r.send([]*metric{

View File

@@ -111,9 +111,9 @@ func descRogueAP(ns string) *rogueap {
}
func descUAP(ns string) *uap { // nolint: funlen
labelA := []string{"stat", "site_name", "name", "source"} // stat + labels[1:]
labelV := []string{"vap_name", "bssid", "radio", "radio_name", "essid", "usage", "site_name", "name", "source"}
labelR := []string{"radio_name", "radio", "site_name", "name", "source"}
labelA := []string{"stat", "site_name", "name", "source", "tag"} // stat + labels[1:]
labelV := []string{"vap_name", "bssid", "radio", "radio_name", "essid", "usage", "site_name", "name", "source", "tag"}
labelR := []string{"radio_name", "radio", "site_name", "name", "source", "tag"}
nd := prometheus.NewDesc
return &uap{
@@ -219,8 +219,14 @@ func (u *promUnifi) exportUAP(r report, d *unifi.UAP) {
return
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
u.exportUAPstats(r, labels, d.Stat.Ap, d.BytesD, d.TxBytesD, d.RxBytesD, d.BytesR)
u.exportVAPtable(r, labels, d.VapTable)
u.exportPRTtable(r, labels, d.PortTable)
@@ -229,10 +235,11 @@ func (u *promUnifi) exportUAP(r report, d *unifi.UAP) {
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta)
u.exportRADtable(r, labels, d.RadioTable, d.RadioTableStats)
r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)},
{u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradable.Val, labels},
})
})
}
// udm doesn't have these stats exposed yet, so pass 2 or 6 metrics.
@@ -241,8 +248,8 @@ func (u *promUnifi) exportUAPstats(r report, labels []string, ap *unifi.Ap, byte
return
}
labelU := []string{"user", labels[1], labels[2], labels[3]}
labelG := []string{"guest", labels[1], labels[2], labels[3]}
labelU := []string{"user", labels[1], labels[2], labels[3], labels[4]}
labelG := []string{"guest", labels[1], labels[2], labels[3], labels[4]}
r.send([]*metric{
// ap only stuff.
{u.Device.BytesD, counter, bytes[0], labels}, // not sure if these 3 Ds are counters or gauges.
@@ -290,7 +297,7 @@ func (u *promUnifi) exportVAPtable(r report, labels []string, vt unifi.VapTable)
continue
}
labelV := []string{v.Name, v.Bssid, v.Radio, v.RadioName, v.Essid, v.Usage, labels[1], labels[2], labels[3]}
labelV := []string{v.Name, v.Bssid, v.Radio, v.RadioName, v.Essid, v.Usage, labels[1], labels[2], labels[3], labels[4]}
r.send([]*metric{
{u.UAP.VAPCcq, gauge, float64(v.Ccq) / 1000.0, labelV},
{u.UAP.VAPMacFilterRejections, counter, v.MacFilterRejections, labelV},
@@ -337,7 +344,7 @@ func (u *promUnifi) exportVAPtable(r report, labels []string, vt unifi.VapTable)
func (u *promUnifi) exportRADtable(r report, labels []string, rt unifi.RadioTable, rts unifi.RadioTableStats) {
// radio table
for _, p := range rt {
labelR := []string{p.Name, p.Radio, labels[1], labels[2], labels[3]}
labelR := []string{p.Name, p.Radio, labels[1], labels[2], labels[3], labels[4]}
labelRUser := append(labelR, "user")
labelRGuest := append(labelR, "guest")

View File

@@ -13,8 +13,13 @@ func (u *promUnifi) exportUBB(r report, d *unifi.UBB) {
return
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
// Export UBB-specific stats if available
u.exportUBBstats(r, labels, d.Stat)
@@ -34,7 +39,7 @@ func (u *promUnifi) exportUBB(r report, d *unifi.UBB) {
// Device info, uptime, and temperature
r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)},
{u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Temperature, gauge, d.GeneralTemperature.Val, append(labels, d.Name, "general")},
})
@@ -50,6 +55,7 @@ func (u *promUnifi) exportUBB(r report, d *unifi.UBB) {
{u.Device.Counter, gauge, d.LinkQualityCurrent.Val, append(labels, "link_quality_current")},
{u.Device.Counter, gauge, d.LinkCapacity.Val, append(labels, "link_capacity")},
})
})
}
// exportUBBstats exports UBB-specific stats from the Bb structure.
@@ -62,6 +68,7 @@ func (u *promUnifi) exportUBBstats(r report, labels []string, stat *unifi.UBBSta
bb := stat.Bb
// Export aggregated stats (total across both radios)
// labels is [type, site_name, name, source, tag], so labels[1:] = [site_name, name, source, tag]
labelTotal := append([]string{"total"}, labels[1:]...)
r.send([]*metric{
{u.UAP.ApRxPackets, counter, bb.RxPackets, labelTotal},

View File

@@ -15,8 +15,14 @@ func (u *promUnifi) exportUCI(r report, d *unifi.UCI) {
sw = d.Stat.Sw
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
// Shared data (all devices do this).
u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes)
@@ -28,7 +34,8 @@ func (u *promUnifi) exportUCI(r report, d *unifi.UCI) {
u.exportUSWstats(r, labels, sw)
// Dream Machine System Data.
r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)},
{u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels},
})
})
}

View File

@@ -36,36 +36,36 @@ type unifiDevice struct {
func descDevice(ns string) *unifiDevice {
labels := []string{"type", "site_name", "name", "source"}
infoLabels := []string{"version", "model", "serial", "mac", "ip", "id"}
infoLabels := []string{"version", "model", "serial", "mac", "ip", "id", "tag"}
return &unifiDevice{
Info: prometheus.NewDesc(ns+"info", "Device Information", append(labels, infoLabels...), nil),
Uptime: prometheus.NewDesc(ns+"uptime_seconds", "Device Uptime", labels, nil),
Uptime: prometheus.NewDesc(ns+"uptime_seconds", "Device Uptime", append(labels, "tag"), nil),
Temperature: prometheus.NewDesc(ns+"temperature_celsius", "Temperature",
append(labels, "temp_area", "temp_type"), nil),
append(labels, "temp_area", "temp_type", "tag"), nil),
Storage: prometheus.NewDesc(ns+"storage", "Storage",
append(labels, "mountpoint", "storage_name", "storage_reading"), nil),
TotalMaxPower: prometheus.NewDesc(ns+"max_power_total", "Total Max Power", labels, nil),
OutletACPowerConsumption: prometheus.NewDesc(ns+"outlet_ac_power_consumption", "Outlet AC Power Consumption", labels, nil),
PowerSource: prometheus.NewDesc(ns+"power_source", "Power Source", labels, nil),
FanLevel: prometheus.NewDesc(ns+"fan_level", "Fan Level", labels, nil),
TotalTxBytes: prometheus.NewDesc(ns+"transmit_bytes_total", "Total Transmitted Bytes", labels, nil),
TotalRxBytes: prometheus.NewDesc(ns+"receive_bytes_total", "Total Received Bytes", labels, nil),
TotalBytes: prometheus.NewDesc(ns+"bytes_total", "Total Bytes Transferred", labels, nil),
BytesR: prometheus.NewDesc(ns+"rate_bytes", "Transfer Rate", labels, nil),
BytesD: prometheus.NewDesc(ns+"d_bytes", "Total Bytes D???", labels, nil),
TxBytesD: prometheus.NewDesc(ns+"d_tranmsit_bytes", "Transmit Bytes D???", labels, nil),
RxBytesD: prometheus.NewDesc(ns+"d_receive_bytes", "Receive Bytes D???", labels, nil),
Counter: prometheus.NewDesc(ns+"stations", "Number of Stations", append(labels, "station_type"), nil),
Loadavg1: prometheus.NewDesc(ns+"load_average_1", "System Load Average 1 Minute", labels, nil),
Loadavg5: prometheus.NewDesc(ns+"load_average_5", "System Load Average 5 Minutes", labels, nil),
Loadavg15: prometheus.NewDesc(ns+"load_average_15", "System Load Average 15 Minutes", labels, nil),
MemUsed: prometheus.NewDesc(ns+"memory_used_bytes", "System Memory Used", labels, nil),
MemTotal: prometheus.NewDesc(ns+"memory_installed_bytes", "System Installed Memory", labels, nil),
MemBuffer: prometheus.NewDesc(ns+"memory_buffer_bytes", "System Memory Buffer", labels, nil),
CPU: prometheus.NewDesc(ns+"cpu_utilization_ratio", "System CPU % Utilized", labels, nil),
Mem: prometheus.NewDesc(ns+"memory_utilization_ratio", "System Memory % Utilized", labels, nil),
Upgradeable: prometheus.NewDesc(ns+"upgradable", "Upgrade-able", labels, nil),
append(labels, "mountpoint", "storage_name", "storage_reading", "tag"), nil),
TotalMaxPower: prometheus.NewDesc(ns+"max_power_total", "Total Max Power", append(labels, "tag"), nil),
OutletACPowerConsumption: prometheus.NewDesc(ns+"outlet_ac_power_consumption", "Outlet AC Power Consumption", append(labels, "tag"), nil),
PowerSource: prometheus.NewDesc(ns+"power_source", "Power Source", append(labels, "tag"), nil),
FanLevel: prometheus.NewDesc(ns+"fan_level", "Fan Level", append(labels, "tag"), nil),
TotalTxBytes: prometheus.NewDesc(ns+"transmit_bytes_total", "Total Transmitted Bytes", append(labels, "tag"), nil),
TotalRxBytes: prometheus.NewDesc(ns+"receive_bytes_total", "Total Received Bytes", append(labels, "tag"), nil),
TotalBytes: prometheus.NewDesc(ns+"bytes_total", "Total Bytes Transferred", append(labels, "tag"), nil),
BytesR: prometheus.NewDesc(ns+"rate_bytes", "Transfer Rate", append(labels, "tag"), nil),
BytesD: prometheus.NewDesc(ns+"d_bytes", "Total Bytes D???", append(labels, "tag"), nil),
TxBytesD: prometheus.NewDesc(ns+"d_tranmsit_bytes", "Transmit Bytes D???", append(labels, "tag"), nil),
RxBytesD: prometheus.NewDesc(ns+"d_receive_bytes", "Receive Bytes D???", append(labels, "tag"), nil),
Counter: prometheus.NewDesc(ns+"stations", "Number of Stations", append(labels, "station_type", "tag"), nil),
Loadavg1: prometheus.NewDesc(ns+"load_average_1", "System Load Average 1 Minute", append(labels, "tag"), nil),
Loadavg5: prometheus.NewDesc(ns+"load_average_5", "System Load Average 5 Minutes", append(labels, "tag"), nil),
Loadavg15: prometheus.NewDesc(ns+"load_average_15", "System Load Average 15 Minutes", append(labels, "tag"), nil),
MemUsed: prometheus.NewDesc(ns+"memory_used_bytes", "System Memory Used", append(labels, "tag"), nil),
MemTotal: prometheus.NewDesc(ns+"memory_installed_bytes", "System Installed Memory", append(labels, "tag"), nil),
MemBuffer: prometheus.NewDesc(ns+"memory_buffer_bytes", "System Memory Buffer", append(labels, "tag"), nil),
CPU: prometheus.NewDesc(ns+"cpu_utilization_ratio", "System CPU % Utilized", append(labels, "tag"), nil),
Mem: prometheus.NewDesc(ns+"memory_utilization_ratio", "System Memory % Utilized", append(labels, "tag"), nil),
Upgradeable: prometheus.NewDesc(ns+"upgradable", "Upgrade-able", append(labels, "tag"), nil),
}
}
@@ -75,43 +75,69 @@ func (u *promUnifi) exportUDM(r report, d *unifi.UDM) {
return
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
// Export metrics with tags - create separate series for each tag
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := baseLabels
infoLabels := append(baseInfoLabels, tag)
// Shared data (all devices do this).
u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes)
u.exportSYSstats(r, labels, d.SysStats, d.SystemStats)
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta, d.NumDesktop, d.NumMobile, d.NumHandheld)
u.exportBYTstats(r, append(labels, tag), d.TxBytes, d.RxBytes)
u.exportSYSstats(r, append(labels, tag), d.SysStats, d.SystemStats)
u.exportSTAcount(r, append(labels, tag), d.UserNumSta, d.GuestNumSta, d.NumDesktop, d.NumMobile, d.NumHandheld)
// Switch Data
u.exportUSWstats(r, labels, d.Stat.Sw)
u.exportPRTtable(r, labels, d.PortTable)
u.exportUSWstats(r, append(labels, tag), d.Stat.Sw)
u.exportPRTtable(r, append(labels, tag), d.PortTable)
// Gateway Data
u.exportWANPorts(r, labels, d.Wan1, d.Wan2)
u.exportUSGstats(r, labels, d.Stat.Gw, d.SpeedtestStatus, d.Uplink)
u.exportWANPorts(r, append(labels, tag), d.Wan1, d.Wan2)
u.exportUSGstats(r, append(labels, tag), d.Stat.Gw, d.SpeedtestStatus, d.Uplink)
// Dream Machine System Data.
r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradeable.Val, labels},
{u.Device.Uptime, gauge, d.Uptime, append(labels, tag)},
{u.Device.Upgradeable, gauge, d.Upgradeable.Val, append(labels, tag)},
})
// UDM pro has special temp sensors. UDM non-pro may not have temp; not sure.
for _, t := range d.Temperatures {
r.send([]*metric{{u.Device.Temperature, gauge, t.Value, append(labels, t.Name, t.Type)}})
r.send([]*metric{{u.Device.Temperature, gauge, t.Value, append(labels, t.Name, t.Type, tag)}})
}
// UDM pro and UXG have hard drives.
for _, t := range d.Storage {
r.send([]*metric{
{u.Device.Storage, gauge, t.Size.Val, append(labels, t.MountPoint, t.Name, "size")},
{u.Device.Storage, gauge, t.Used.Val, append(labels, t.MountPoint, t.Name, "used")},
{u.Device.Storage, gauge, t.Size.Val, append(labels, t.MountPoint, t.Name, "size", tag)},
{u.Device.Storage, gauge, t.Used.Val, append(labels, t.MountPoint, t.Name, "used", tag)},
})
}
})
// Wireless Data - UDM (non-pro) only
if d.Stat.Ap != nil && d.VapTable != nil {
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
u.exportUAPstats(r, labels, d.Stat.Ap, d.BytesD, d.TxBytesD, d.RxBytesD, d.BytesR)
u.exportVAPtable(r, labels, *d.VapTable)
u.exportRADtable(r, labels, *d.RadioTable, *d.RadioTableStats)
})
}
}
// exportWithTags exports metrics with tag support. If device has multiple tags,
// each tag creates a separate metric series. If no tags, exports with tag="".
func (u *promUnifi) exportWithTags(r report, tags []string, fn func([]string)) {
if len(tags) == 0 {
// No tags - export once with empty tag
fn([]string{""})
return
}
// Multiple tags - export once per tag
for _, tag := range tags {
fn([]string{tag})
}
}

View File

@@ -41,7 +41,7 @@ type usg struct {
}
func descUSG(ns string) *usg {
labels := []string{"port", "site_name", "name", "source"}
labels := []string{"port", "site_name", "name", "source", "tag"}
return &usg{
WanRxPackets: prometheus.NewDesc(ns+"wan_receive_packets_total", "WAN Receive Packets Total", labels, nil),
@@ -82,8 +82,13 @@ func (u *promUnifi) exportUSG(r report, d *unifi.USG) {
return
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
for _, t := range d.Temperatures {
r.send([]*metric{{u.Device.Temperature, gauge, t.Value, append(labels, t.Name, t.Type)}})
@@ -105,10 +110,11 @@ func (u *promUnifi) exportUSG(r report, d *unifi.USG) {
u.exportUSGstats(r, labels, d.Stat.Gw, d.SpeedtestStatus, d.Uplink)
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta, d.NumDesktop, d.UserNumSta, d.GuestNumSta)
r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)},
{u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradable.Val, labels},
})
})
}
// Gateway Stats.
@@ -125,8 +131,8 @@ func (u *promUnifi) exportUSGstats(r report, labels []string, gw *unifi.Gw, st u
return
}
labelLan := []string{"lan", labels[1], labels[2], labels[3]}
labelWan := []string{sourceInterface, labels[1], labels[2], labels[3]}
labelLan := []string{"lan", labels[1], labels[2], labels[3], labels[4]}
labelWan := []string{sourceInterface, labels[1], labels[2], labels[3], labels[4]}
r.send([]*metric{
{u.USG.LanRxPackets, counter, gw.LanRxPackets, labelLan},
@@ -154,7 +160,7 @@ func (u *promUnifi) exportWANPorts(r report, labels []string, wans ...unifi.Wan)
continue // only record UP interfaces.
}
labelWan := []string{wan.Name, labels[1], labels[2], labels[3]}
labelWan := []string{wan.Name, labels[1], labels[2], labels[3], labels[4]}
r.send([]*metric{
{u.USG.WanRxPackets, counter, wan.RxPackets, labelWan},

View File

@@ -55,11 +55,11 @@ type usw struct {
func descUSW(ns string) *usw {
pns := ns + "port_"
sfp := pns + "sfp_"
labelS := []string{"site_name", "name", "source"}
labelP := []string{"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source"}
labelS := []string{"site_name", "name", "source", "tag"}
labelP := []string{"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source", "tag"}
labelF := []string{
"sfp_part", "sfp_vendor", "sfp_serial", "sfp_compliance",
"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source",
"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source", "tag",
}
nd := prometheus.NewDesc
@@ -116,8 +116,13 @@ func (u *promUnifi) exportUSW(r report, d *unifi.USW) {
return
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
u.exportUSWstats(r, labels, d.Stat.Sw)
u.exportPRTtable(r, labels, d.PortTable)
@@ -125,7 +130,7 @@ func (u *promUnifi) exportUSW(r report, d *unifi.USW) {
u.exportSYSstats(r, labels, d.SysStats, d.SystemStats)
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta)
r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)},
{u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradable.Val, labels},
})
@@ -142,6 +147,7 @@ func (u *promUnifi) exportUSW(r report, d *unifi.USW) {
if d.TotalMaxPower.Txt != "" {
r.send([]*metric{{u.Device.TotalMaxPower, gauge, d.TotalMaxPower, labels}})
}
})
}
// Switch Stats.
@@ -183,7 +189,7 @@ func (u *promUnifi) exportPRTtable(r report, labels []string, pt []unifi.Port) {
// Copy labels, and add four new ones.
labelP := []string{
labels[2] + " Port " + p.PortIdx.Txt, p.PortIdx.Txt,
p.Name, p.Mac, p.IP, labels[1], labels[2], labels[3],
p.Name, p.Mac, p.IP, labels[1], labels[2], labels[3], labels[4],
}
if p.PoeEnable.Val && p.PortPoe.Val {
@@ -197,7 +203,7 @@ func (u *promUnifi) exportPRTtable(r report, labels []string, pt []unifi.Port) {
if p.SFPFound.Val {
labelF := []string{
p.SFPPart, p.SFPVendor, p.SFPSerial, p.SFPCompliance,
labelP[0], labelP[1], labelP[2], labelP[3], labelP[4], labelP[5], labelP[6], labelP[7],
labelP[0], labelP[1], labelP[2], labelP[3], labelP[4], labelP[5], labelP[6], labelP[7], labelP[8],
}
r.send([]*metric{

View File

@@ -20,8 +20,14 @@ func (u *promUnifi) exportUXG(r report, d *unifi.UXG) {
sw = d.Stat.Sw
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
// Shared data (all devices do this).
u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes)
u.exportSYSstats(r, labels, d.SysStats, d.SystemStats)
@@ -34,7 +40,7 @@ func (u *promUnifi) exportUXG(r report, d *unifi.UXG) {
u.exportUSGstats(r, labels, gw, d.SpeedtestStatus, d.Uplink)
// Dream Machine System Data.
r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)},
{u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels},
})
@@ -49,4 +55,5 @@ func (u *promUnifi) exportUXG(r report, d *unifi.UXG) {
{u.Device.Storage, gauge, t.Used.Val, append(labels, t.MountPoint, t.Name, "used")},
})
}
})
}

View File

@@ -0,0 +1,42 @@
# Saving UniFi API Output
Ways to save API responses and explorer output for discovery or debugging.
## Single endpoint → file
Redirect `unpoller -j "other <path>"` to a file:
```bash
unpoller -c up.conf -j "other /api/s/default/stat/device" > device.json
unpoller -c up.conf -j "other /api/s/default/stat/sta" > clients.json
```
Use `jq` to inspect: `jq . device.json`
## Bulk dump → directory
Use the dump script to request many known endpoints and save each to a JSON file:
```bash
./scripts/dump_unifi_api.sh -c up.conf -s default -o ./api_dump
```
Output goes to `./api_dump` by default. See `./scripts/dump_unifi_api.sh -h` for options.
Note: some endpoints (e.g. `sitedpi`, `stadpi`) require POST with a body; the script only issues GETs, so those may fail or return errors. You can still inspect the saved responses.
## Saving the API explorer UI
If you're using the developer UI (e.g. [developer.ui.com](https://developer.ui.com) or another API explorer) and want to save the **list of endpoints and their details**:
1. **OpenAPI / Swagger spec**
Open DevTools → **Network**, (re)load the explorer, and look for requests to `openapi.json`, `swagger.json`, or similar. Rightclick the response → **Copy****Save as**, or use **Save all as HAR** and extract the spec from the HAR.
2. **Save page**
Use **File → Save As** (HTML) or **Print → Save as PDF** to capture the visible explorer structure. This wont persist dynamically loaded data unless the page embeds it.
3. **Export**
If the explorer has an **Export** or **Download** button (e.g. for OpenAPI YAML/JSON), use that to save the full spec.
4. **Community specs**
Community OpenAPI specs for the UniFi API exist (e.g. [ubiquiti-community/unifi-api](https://github.com/ubiquiti-community/unifi-api), [ringods/unifi-api-spec](https://github.com/ringods/unifi-api-spec)). Clone or download those repos to get machinereadable API definitions.

100
scripts/dump_unifi_api.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
#
# Dump raw JSON from UniFi Controller API endpoints to files.
# Uses unpoller -j "other <path>" for each path and saves to OUTDIR.
#
# Prerequisites: unpoller on PATH, valid config with controller auth.
#
# Usage:
# ./scripts/dump_unifi_api.sh [-c CONFIG] [-s SITE] [-o OUTDIR]
#
# Options:
# -c CONFIG Config file (default: unpoller default locations)
# -s SITE Site name for /api/s/<site>/... paths (default: default)
# -o OUTDIR Output directory (default: ./api_dump)
#
# Examples:
# ./scripts/dump_unifi_api.sh -c up.conf -o ./my_dump
# SITE=my-site ./scripts/dump_unifi_api.sh -o ./api_dump
#
set -euo pipefail
CONFIG=""
SITE="${SITE:-default}"
OUTDIR="${OUTDIR:-./api_dump}"
while getopts "c:s:o:h" opt; do
case "$opt" in
c) CONFIG="$OPTARG" ;;
s) SITE="$OPTARG" ;;
o) OUTDIR="$OPTARG" ;;
h) grep -E '^# (Usage|Options|Examples)' "$0" | sed 's/^# //'; exit 0 ;;
*) exit 1 ;;
esac
done
# Paths that need site substitution use %s
PATHS=(
"/api/stat/sites"
"/api/s/%s/stat/device"
"/api/s/%s/stat/sta"
"/api/s/%s/stat/event"
"/api/s/%s/stat/rogueap"
"/api/s/%s/stat/sitedpi"
"/api/s/%s/stat/stadpi"
"/api/s/%s/stat/alluser"
"/api/s/%s/rest/networkconf"
"/api/s/%s/list/alarm"
"/api/s/%s/stat/ips/event"
"/api/s/%s/stat/anomalies"
"/api/s/%s/stat/admins"
"/api/s/%s/stat/session"
"/api/s/%s/stat/dashboard"
"/api/s/%s/stat/health"
"/v2/api/site/%s/aggregated-dashboard?historySeconds=3600"
)
# Optional: add traffic endpoints with fixed time window (last hour)
NOW_MS=$(($(date +%s) * 1000))
START_MS=$((NOW_MS - 3600000))
PATHS+=(
"/v2/api/site/%s/traffic?start=${START_MS}&end=${NOW_MS}&includeUnidentified=false"
"/v2/api/site/%s/country-traffic?start=${START_MS}&end=${NOW_MS}"
)
UNPOLLER="${UNPOLLER:-unpoller}"
if ! command -v "$UNPOLLER" &>/dev/null; then
echo "error: $UNPOLLER not found (set UNPOLLER to path of unpoller binary)" >&2
exit 1
fi
mkdir -p "$OUTDIR"
CONF_ARGS=()
if [[ -n "$CONFIG" ]]; then
CONF_ARGS=(-c "$CONFIG")
fi
dump_one() {
local path="$1"
local sub
sub=$(echo "$path" | sed "s|%s|$SITE|g")
local fname
fname=$(echo "$sub" | sed 's|^/||; s|[/?=&]|_|g')
[[ -z "$fname" ]] && fname="root"
fname="${fname}.json"
local out="$OUTDIR/$fname"
if out_err=$("$UNPOLLER" "${CONF_ARGS[@]}" -j "other $sub" 2>&1); then
echo "$out_err" > "$out"
echo "ok $sub -> $out"
else
echo "fail $sub ($out_err)" >&2
fi
}
echo "Dumping UniFi API responses to $OUTDIR (site=$SITE)"
for p in "${PATHS[@]}"; do
dump_one "$p"
done
echo "Done. Output in $OUTDIR"