diff --git a/go.mod b/go.mod index fe48d562..059b95e9 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/prometheus/common v0.67.4 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/unpoller/unifi/v5 v5.2.1 + github.com/unpoller/unifi/v5 v5.3.0 golang.org/x/crypto v0.46.0 golang.org/x/term v0.38.0 golift.io/cnfg v0.2.3 diff --git a/go.sum b/go.sum index 6f859e98..f582a13b 100644 --- a/go.sum +++ b/go.sum @@ -75,8 +75,8 @@ 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.2.1 h1:clcF0/UKYQm4ycWlM0Pe6f+NbmGGpky3KkfuGBBmsR0= -github.com/unpoller/unifi/v5 v5.2.1/go.mod h1:a9Hl1hBnDuaJDIvHswpW8/QUQgk3gQ5U9c5EnpZXMUg= +github.com/unpoller/unifi/v5 v5.3.0 h1:6ykCP4wL5nk/icMu8Qc24ApWD0A5JdCSYxQJIg2FQyg= +github.com/unpoller/unifi/v5 v5.3.0/go.mod h1:pa6zv4Oyb1nFEm4qu/8CUv8Q25hQof04Wh2D0RXcTYc= 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= diff --git a/pkg/datadogunifi/datadog.go b/pkg/datadogunifi/datadog.go index a046c05d..f8cf4b32 100644 --- a/pkg/datadogunifi/datadog.go +++ b/pkg/datadogunifi/datadog.go @@ -302,6 +302,10 @@ func (u *DatadogUnifi) loopPoints(r report) { u.switchExport(r, s) } + for _, st := range m.SpeedTests { + u.switchExport(r, st) + } + for _, s := range r.events().Logs { u.switchExport(r, s) } @@ -348,6 +352,8 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop u.batchAlarms(r, v) case *unifi.Anomaly: u.batchAnomaly(r, v) + case *unifi.SpeedTestResult: + u.batchSpeedTest(r, v) default: u.LogErrorf("invalid export, type=%+v", reflect.TypeOf(v)) } diff --git a/pkg/datadogunifi/speedtest.go b/pkg/datadogunifi/speedtest.go new file mode 100644 index 00000000..b44328ab --- /dev/null +++ b/pkg/datadogunifi/speedtest.go @@ -0,0 +1,39 @@ +package datadogunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchSpeedTest generates Unifi Speed Test datapoints for Datadog. +// These points can be passed directly to Datadog. +func (u *DatadogUnifi) batchSpeedTest(r report, st *unifi.SpeedTestResult) { + if st == nil { + return + } + + metricName := metricNamespace("speedtest") + + tags := []string{ + tag("site_name", st.SiteName), + tag("source", st.SourceName), + tag("wan_interface", st.InterfaceName), + tag("wan_group", st.WANNetworkGroup), + tag("network_conf_id", st.NetworkConfID), + } + + data := map[string]float64{ + "download_mbps": st.DownloadMbps.Val, + "upload_mbps": st.UploadMbps.Val, + "latency_ms": st.LatencyMs.Val, + "timestamp": st.Time.Val, + } + + if st.WANProviderCapabilities != nil { + data["provider_download_kbps"] = st.WANProviderCapabilities.DownloadKbps.Val + data["provider_upload_kbps"] = st.WANProviderCapabilities.UploadKbps.Val + } + + for name, value := range data { + _ = r.reportGauge(metricName(name), value, tags) + } +} diff --git a/pkg/influxunifi/influxdb.go b/pkg/influxunifi/influxdb.go index 99c319a7..82153818 100644 --- a/pkg/influxunifi/influxdb.go +++ b/pkg/influxunifi/influxdb.go @@ -414,6 +414,10 @@ func (u *InfluxUnifi) loopPoints(r report) { u.switchExport(r, s) } + for _, st := range m.SpeedTests { + u.switchExport(r, st) + } + for _, s := range r.events().Logs { u.switchExport(r, s) } @@ -460,6 +464,8 @@ func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop u.batchAlarms(r, v) case *unifi.Anomaly: u.batchAnomaly(r, v) + case *unifi.SpeedTestResult: + u.batchSpeedTest(r, v) default: u.LogErrorf("invalid export type: %T", v) } diff --git a/pkg/influxunifi/speedtest.go b/pkg/influxunifi/speedtest.go new file mode 100644 index 00000000..adb9de16 --- /dev/null +++ b/pkg/influxunifi/speedtest.go @@ -0,0 +1,35 @@ +package influxunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchSpeedTest generates Unifi Speed Test datapoints for InfluxDB. +// These points can be passed directly to influx. +func (u *InfluxUnifi) batchSpeedTest(r report, st *unifi.SpeedTestResult) { + if st == nil { + return + } + + tags := map[string]string{ + "site_name": st.SiteName, + "source": st.SourceName, + "wan_interface": st.InterfaceName, + "wan_group": st.WANNetworkGroup, + "network_conf_id": st.NetworkConfID, + } + + fields := map[string]any{ + "download_mbps": st.DownloadMbps.Val, + "upload_mbps": st.UploadMbps.Val, + "latency_ms": st.LatencyMs.Val, + "timestamp": st.Time.Val, + } + + if st.WANProviderCapabilities != nil { + fields["provider_download_kbps"] = st.WANProviderCapabilities.DownloadKbps.Val + fields["provider_upload_kbps"] = st.WANProviderCapabilities.UploadKbps.Val + } + + r.send(&metric{Table: "speedtest", Tags: tags, Fields: fields}) +} diff --git a/pkg/inputunifi/collector.go b/pkg/inputunifi/collector.go index f5861744..f057a881 100644 --- a/pkg/inputunifi/collector.go +++ b/pkg/inputunifi/collector.go @@ -124,6 +124,12 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) { return nil, fmt.Errorf("unifi.GetDevices(%s): %w", c.URL, err) } + // Get speed test results for all WANs + if m.SpeedTests, err = c.Unifi.GetSpeedTests(sites, 86400); err != nil { + // Don't fail collection if speed tests fail - older controllers may not have this endpoint + u.LogDebugf("unifi.GetSpeedTests(%s): %v (continuing)", c.URL, err) + } + return u.augmentMetrics(c, m), nil } @@ -181,6 +187,10 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met } } + for _, speedTest := range metrics.SpeedTests { + m.SpeedTests = append(m.SpeedTests, speedTest) + } + return m } diff --git a/pkg/inputunifi/input.go b/pkg/inputunifi/input.go index 66096fbf..0b248762 100644 --- a/pkg/inputunifi/input.go +++ b/pkg/inputunifi/input.go @@ -72,6 +72,7 @@ type Metrics struct { SitesDPI []*unifi.DPITable ClientsDPI []*unifi.DPITable RogueAPs []*unifi.RogueAP + SpeedTests []*unifi.SpeedTestResult Devices *unifi.Devices } diff --git a/pkg/poller/config.go b/pkg/poller/config.go index 7388b420..55806273 100644 --- a/pkg/poller/config.go +++ b/pkg/poller/config.go @@ -86,6 +86,7 @@ type Metrics struct { ClientsDPI []any Devices []any RogueAPs []any + SpeedTests []any } // Events defines the type for log entries. diff --git a/pkg/promunifi/collector.go b/pkg/promunifi/collector.go index 6fe3b5a4..7db0ff67 100644 --- a/pkg/promunifi/collector.go +++ b/pkg/promunifi/collector.go @@ -36,14 +36,15 @@ var ErrMetricFetchFailed = fmt.Errorf("metric fetch failed") type promUnifi struct { *Config `json:"prometheus" toml:"prometheus" xml:"prometheus" yaml:"prometheus"` - Client *uclient - Device *unifiDevice - UAP *uap - USG *usg - USW *usw - PDU *pdu - Site *site - RogueAP *rogueap + Client *uclient + Device *unifiDevice + UAP *uap + USG *usg + USW *usw + PDU *pdu + Site *site + RogueAP *rogueap + SpeedTest *speedtest // This interface is passed to the Collect() method. The Collect method uses // this interface to retrieve the latest UniFi measurements and export them. Collector poller.Collect @@ -200,6 +201,7 @@ func (u *promUnifi) Run(c poller.Collect) error { u.PDU = descPDU(u.Namespace + "_device_") u.Site = descSite(u.Namespace + "_site_") u.RogueAP = descRogueAP(u.Namespace + "_rogueap_") + u.SpeedTest = descSpeedTest(u.Namespace + "_speedtest_") mux := http.NewServeMux() promver.Version = version.Version @@ -283,7 +285,7 @@ func (t *target) Describe(ch chan<- *prometheus.Desc) { // Describe satisfies the prometheus Collector. This returns all of the // metric descriptions that this packages produces. func (u *promUnifi) Describe(ch chan<- *prometheus.Desc) { - for _, f := range []any{u.Client, u.Device, u.UAP, u.USG, u.USW, u.PDU, u.Site} { + for _, f := range []any{u.Client, u.Device, u.UAP, u.USG, u.USW, u.PDU, u.Site, u.SpeedTest} { v := reflect.Indirect(reflect.ValueOf(f)) // Loop each struct member and send it to the provided channel. @@ -391,6 +393,10 @@ func (u *promUnifi) loopExports(r report) { u.switchExport(r, d) } + for _, st := range m.SpeedTests { + u.switchExport(r, st) + } + appTotal := make(totalsDPImap) catTotal := make(totalsDPImap) @@ -434,6 +440,8 @@ func (u *promUnifi) switchExport(r report, v any) { u.exportSite(r, v) case *unifi.Client: u.exportClient(r, v) + case *unifi.SpeedTestResult: + u.exportSpeedTest(r, v) default: u.LogErrorf("invalid type: %T", v) } diff --git a/pkg/promunifi/speedtest.go b/pkg/promunifi/speedtest.go new file mode 100644 index 00000000..efe6c707 --- /dev/null +++ b/pkg/promunifi/speedtest.go @@ -0,0 +1,39 @@ +package promunifi + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/unpoller/unifi/v5" +) + +type speedtest struct { + DownloadMbps *prometheus.Desc + UploadMbps *prometheus.Desc + LatencyMs *prometheus.Desc + Timestamp *prometheus.Desc +} + +func descSpeedTest(ns string) *speedtest { + labels := []string{"wan_interface", "wan_group", "site_name", "source"} + + return &speedtest{ + DownloadMbps: prometheus.NewDesc(ns+"download_mbps", "Speed Test Download in Mbps", labels, nil), + UploadMbps: prometheus.NewDesc(ns+"upload_mbps", "Speed Test Upload in Mbps", labels, nil), + LatencyMs: prometheus.NewDesc(ns+"latency_ms", "Speed Test Latency in milliseconds", labels, nil), + Timestamp: prometheus.NewDesc(ns+"timestamp_seconds", "Speed Test Timestamp (Unix epoch)", labels, nil), + } +} + +func (u *promUnifi) exportSpeedTest(r report, st *unifi.SpeedTestResult) { + if st == nil { + return + } + + labels := []string{st.InterfaceName, st.WANNetworkGroup, st.SiteName, st.SourceName} + + r.send([]*metric{ + {u.SpeedTest.DownloadMbps, gauge, st.DownloadMbps, labels}, + {u.SpeedTest.UploadMbps, gauge, st.UploadMbps, labels}, + {u.SpeedTest.LatencyMs, gauge, st.LatencyMs, labels}, + {u.SpeedTest.Timestamp, gauge, st.Time, labels}, + }) +}