mirror of
https://github.com/unpoller/unpoller.git
synced 2026-03-31 06:24:19 -04:00
Collect port anomalies from the UniFi v2 API endpoint
/proxy/network/v2/api/site/{site}/ports/port-anomalies and export
them to all output plugins (Prometheus, InfluxDB, DataDog, OpenTelemetry).
Metrics exported per port:
- port_anomaly_count – number of anomaly events
- port_anomaly_last_seen – unix timestamp of last event
Labels: site_name, source, device_mac, port_idx, anomaly_type
Bumps github.com/unpoller/unifi/v5 to v5.24.0 which adds GetPortAnomalies.
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
go.mod
2
go.mod
@@ -12,7 +12,7 @@ require (
|
||||
github.com/prometheus/common v0.67.5
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/unpoller/unifi/v5 v5.23.0
|
||||
github.com/unpoller/unifi/v5 v5.24.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -91,6 +91,8 @@ github.com/unpoller/unifi/v5 v5.22.0 h1:ftLZcdXCtSfmd1a9nytajVCPuUoDxB1JyOPqoxPt
|
||||
github.com/unpoller/unifi/v5 v5.22.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So=
|
||||
github.com/unpoller/unifi/v5 v5.23.0 h1:aJ7qM/UNtNNa9+iCfd6Quom8F7riFPQOe5g9rMsX8os=
|
||||
github.com/unpoller/unifi/v5 v5.23.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So=
|
||||
github.com/unpoller/unifi/v5 v5.24.0 h1:+NBem1gff4n3XCbDm6FijxdwO9BYoJDAz0TCeNojxxI=
|
||||
github.com/unpoller/unifi/v5 v5.24.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
|
||||
@@ -329,6 +329,10 @@ func (u *DatadogUnifi) loopPoints(r report) {
|
||||
for _, t := range m.Topologies {
|
||||
u.switchExport(r, t)
|
||||
}
|
||||
|
||||
for _, a := range m.PortAnomalies {
|
||||
u.switchExport(r, a)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
|
||||
@@ -373,6 +377,8 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
|
||||
u.batchFirewallPolicy(r, v)
|
||||
case *unifi.Topology:
|
||||
u.batchTopology(r, v)
|
||||
case *unifi.PortAnomaly:
|
||||
u.batchPortAnomaly(r, v)
|
||||
default:
|
||||
if u.Collector != nil && u.Collector.Poller().LogUnknownTypes {
|
||||
u.LogDebugf("unknown export type: %T", v)
|
||||
|
||||
31
pkg/datadogunifi/port_anomalies.go
Normal file
31
pkg/datadogunifi/port_anomalies.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package datadogunifi
|
||||
|
||||
import (
|
||||
"github.com/unpoller/unifi/v5"
|
||||
)
|
||||
|
||||
// batchPortAnomaly generates port anomaly datapoints for Datadog.
|
||||
func (u *DatadogUnifi) batchPortAnomaly(r report, a *unifi.PortAnomaly) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
metricName := metricNamespace("port_anomaly")
|
||||
|
||||
tags := []string{
|
||||
tag("site_name", a.SiteName),
|
||||
tag("source", a.SourceName),
|
||||
tag("device_mac", a.DeviceMAC),
|
||||
tag("port_idx", a.PortIdx.Txt),
|
||||
tag("anomaly_type", a.AnomalyType),
|
||||
}
|
||||
|
||||
data := map[string]float64{
|
||||
"count": a.Count.Val,
|
||||
"last_seen": a.LastSeen.Val,
|
||||
}
|
||||
|
||||
for name, value := range data {
|
||||
_ = r.reportGauge(metricName(name), value, tags)
|
||||
}
|
||||
}
|
||||
@@ -442,6 +442,10 @@ func (u *InfluxUnifi) loopPoints(r report) {
|
||||
for _, t := range m.Topologies {
|
||||
u.switchExport(r, t)
|
||||
}
|
||||
|
||||
for _, a := range m.PortAnomalies {
|
||||
u.switchExport(r, a)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop
|
||||
@@ -486,6 +490,8 @@ func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop
|
||||
u.batchFirewallPolicy(r, v)
|
||||
case *unifi.Topology:
|
||||
u.batchTopology(r, v)
|
||||
case *unifi.PortAnomaly:
|
||||
u.batchPortAnomaly(r, v)
|
||||
default:
|
||||
if u.Collector.Poller().LogUnknownTypes {
|
||||
u.LogDebugf("unknown export type: %T", v)
|
||||
|
||||
27
pkg/influxunifi/port_anomalies.go
Normal file
27
pkg/influxunifi/port_anomalies.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package influxunifi
|
||||
|
||||
import (
|
||||
"github.com/unpoller/unifi/v5"
|
||||
)
|
||||
|
||||
// batchPortAnomaly generates a port anomaly datapoint for InfluxDB.
|
||||
func (u *InfluxUnifi) batchPortAnomaly(r report, a *unifi.PortAnomaly) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
tags := map[string]string{
|
||||
"site_name": a.SiteName,
|
||||
"source": a.SourceName,
|
||||
"device_mac": a.DeviceMAC,
|
||||
"port_idx": a.PortIdx.Txt,
|
||||
"anomaly_type": a.AnomalyType,
|
||||
}
|
||||
|
||||
fields := map[string]any{
|
||||
"count": a.Count.Val,
|
||||
"last_seen": a.LastSeen.Val,
|
||||
}
|
||||
|
||||
r.send(&metric{Table: "port_anomaly", Tags: tags, Fields: fields})
|
||||
}
|
||||
@@ -238,6 +238,14 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) {
|
||||
u.LogDebugf("Found %d Topology entries", len(m.Topologies))
|
||||
}
|
||||
|
||||
// Get port anomalies
|
||||
if m.PortAnomalies, err = c.Unifi.GetPortAnomalies(sites); err != nil {
|
||||
// Don't fail collection if port anomalies fail - older controllers may not have this endpoint
|
||||
u.LogDebugf("unifi.GetPortAnomalies(%s): %v (continuing)", c.URL, err)
|
||||
} else {
|
||||
u.LogDebugf("Found %d PortAnomalies entries", len(m.PortAnomalies))
|
||||
}
|
||||
|
||||
// Update web UI only on success; call explicitly so we never run with nil c/c.Unifi (no defer).
|
||||
// Recover so a panic in updateWeb (e.g. old image, race) never kills the poller.
|
||||
if c != nil && c.Unifi != nil {
|
||||
@@ -471,6 +479,14 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met
|
||||
m.Topologies = append(m.Topologies, topo)
|
||||
}
|
||||
|
||||
for _, anomaly := range metrics.PortAnomalies {
|
||||
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(anomaly.SiteName) {
|
||||
anomaly.SiteName = c.DefaultSiteNameOverride
|
||||
}
|
||||
|
||||
m.PortAnomalies = append(m.PortAnomalies, anomaly)
|
||||
}
|
||||
|
||||
// Apply default_site_name_override to all metrics if configured.
|
||||
// This must be done AFTER all metrics are added to m, so everything is included.
|
||||
// This allows us to use the console name for Cloud Gateways while keeping
|
||||
@@ -613,6 +629,14 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range m.PortAnomalies {
|
||||
if anomaly, ok := m.PortAnomalies[i].(*unifi.PortAnomaly); ok {
|
||||
if isDefaultSiteName(anomaly.SiteName) {
|
||||
anomaly.SiteName = overrideName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this is a helper function for augmentMetrics.
|
||||
|
||||
@@ -92,6 +92,7 @@ type Metrics struct {
|
||||
Sysinfos []*unifi.Sysinfo
|
||||
FirewallPolicies []*unifi.FirewallPolicy
|
||||
Topologies []*unifi.Topology
|
||||
PortAnomalies []*unifi.PortAnomaly
|
||||
}
|
||||
|
||||
func init() { // nolint: gochecknoinits
|
||||
|
||||
34
pkg/otelunifi/port_anomalies.go
Normal file
34
pkg/otelunifi/port_anomalies.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package otelunifi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
|
||||
"github.com/unpoller/unifi/v5"
|
||||
"github.com/unpoller/unpoller/pkg/poller"
|
||||
)
|
||||
|
||||
// exportPortAnomalies emits per-port anomaly metrics.
|
||||
func (u *OtelOutput) exportPortAnomalies(ctx context.Context, meter metric.Meter, m *poller.Metrics, r *Report) {
|
||||
for _, item := range m.PortAnomalies {
|
||||
a, ok := item.(*unifi.PortAnomaly)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
attrs := attribute.NewSet(
|
||||
attribute.String("site_name", a.SiteName),
|
||||
attribute.String("source", a.SourceName),
|
||||
attribute.String("device_mac", a.DeviceMAC),
|
||||
attribute.String("port_idx", a.PortIdx.Txt),
|
||||
attribute.String("anomaly_type", a.AnomalyType),
|
||||
)
|
||||
|
||||
u.recordGauge(ctx, meter, r, "unifi_port_anomaly_count",
|
||||
"Number of anomaly events on this port", a.Count.Val, attrs)
|
||||
u.recordGauge(ctx, meter, r, "unifi_port_anomaly_last_seen",
|
||||
"Unix timestamp of the last anomaly event on this port", a.LastSeen.Val, attrs)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func (u *OtelOutput) reportMetrics(m *poller.Metrics, _ *poller.Events) (*Report
|
||||
u.exportDevices(ctx, meter, m, r)
|
||||
u.exportFirewallPolicies(ctx, meter, m, r)
|
||||
u.exportTopology(ctx, meter, m, r)
|
||||
u.exportPortAnomalies(ctx, meter, m, r)
|
||||
|
||||
r.Elapsed = time.Since(start)
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ type Metrics struct {
|
||||
Sysinfos []any
|
||||
FirewallPolicies []any
|
||||
Topologies []any
|
||||
PortAnomalies []any
|
||||
ControllerStatuses []ControllerStatus
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics {
|
||||
existing.Sysinfos = append(existing.Sysinfos, m.Sysinfos...)
|
||||
existing.FirewallPolicies = append(existing.FirewallPolicies, m.FirewallPolicies...)
|
||||
existing.Topologies = append(existing.Topologies, m.Topologies...)
|
||||
existing.PortAnomalies = append(existing.PortAnomalies, m.PortAnomalies...)
|
||||
existing.ControllerStatuses = append(existing.ControllerStatuses, m.ControllerStatuses...)
|
||||
|
||||
return existing
|
||||
|
||||
@@ -52,6 +52,7 @@ type promUnifi struct {
|
||||
Controller *controller
|
||||
FirewallPolicy *firewallpolicy
|
||||
Topology *topology
|
||||
PortAnomaly *portanomaly
|
||||
// controllerUp tracks per-controller poll success (1) or failure (0).
|
||||
controllerUp *prometheus.GaugeVec
|
||||
// This interface is passed to the Collect() method. The Collect method uses
|
||||
@@ -219,6 +220,7 @@ func (u *promUnifi) Run(c poller.Collect) error {
|
||||
u.Controller = descController(u.Namespace + "_")
|
||||
u.FirewallPolicy = descFirewallPolicy(u.Namespace + "_")
|
||||
u.Topology = descTopology(u.Namespace + "_")
|
||||
u.PortAnomaly = descPortAnomaly(u.Namespace + "_")
|
||||
u.controllerUp = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: u.Namespace + "_controller_up",
|
||||
Help: "Whether the last poll of the UniFi controller succeeded (1) or failed (0).",
|
||||
@@ -307,7 +309,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, u.SpeedTest, u.DHCPLease, u.WAN, u.FirewallPolicy, u.Topology} {
|
||||
for _, f := range []any{u.Client, u.Device, u.UAP, u.USG, u.USW, u.PDU, u.Site, u.SpeedTest, u.DHCPLease, u.WAN, u.FirewallPolicy, u.Topology, u.PortAnomaly} {
|
||||
v := reflect.Indirect(reflect.ValueOf(f))
|
||||
|
||||
// Loop each struct member and send it to the provided channel.
|
||||
@@ -490,6 +492,15 @@ func (u *promUnifi) loopExports(r report) {
|
||||
}
|
||||
}
|
||||
|
||||
portAnomalies := make([]*unifi.PortAnomaly, 0, len(m.PortAnomalies))
|
||||
for _, a := range m.PortAnomalies {
|
||||
if anomaly, ok := a.(*unifi.PortAnomaly); ok {
|
||||
portAnomalies = append(portAnomalies, anomaly)
|
||||
}
|
||||
}
|
||||
|
||||
u.exportPortAnomalies(r, portAnomalies)
|
||||
|
||||
u.exportClientDPItotals(r, appTotal, catTotal)
|
||||
}
|
||||
|
||||
|
||||
39
pkg/promunifi/port_anomalies.go
Normal file
39
pkg/promunifi/port_anomalies.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package promunifi
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/unpoller/unifi/v5"
|
||||
)
|
||||
|
||||
type portanomaly struct {
|
||||
AnomalyCount *prometheus.Desc
|
||||
AnomalyLastSeen *prometheus.Desc
|
||||
}
|
||||
|
||||
func descPortAnomaly(ns string) *portanomaly {
|
||||
labels := []string{"site_name", "source", "device_mac", "port_idx", "anomaly_type"}
|
||||
|
||||
nd := prometheus.NewDesc
|
||||
|
||||
return &portanomaly{
|
||||
AnomalyCount: nd(ns+"port_anomaly_count", "Number of anomaly events on this port", labels, nil),
|
||||
AnomalyLastSeen: nd(ns+"port_anomaly_last_seen", "Unix timestamp of the last anomaly event on this port", labels, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *promUnifi) exportPortAnomalies(r report, anomalies []*unifi.PortAnomaly) {
|
||||
for _, a := range anomalies {
|
||||
labels := []string{
|
||||
a.SiteName,
|
||||
a.SourceName,
|
||||
a.DeviceMAC,
|
||||
a.PortIdx.Txt,
|
||||
a.AnomalyType,
|
||||
}
|
||||
|
||||
r.send([]*metric{
|
||||
{u.PortAnomaly.AnomalyCount, gauge, a.Count.Val, labels},
|
||||
{u.PortAnomaly.AnomalyLastSeen, gauge, a.LastSeen.Val, labels},
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user