feat: add network topology metrics (closes #931) (#981)

Bumps github.com/unpoller/unifi/v5 to v5.23.0 which adds
GetTopology() fetching vertices (devices/clients) and edges
(wired/wireless connections) from /proxy/network/v2/api/site/{site}/topology.

Changes across the stack:
- poller.Metrics: add Topologies []any field + AppendMetrics support
- inputunifi: collect topology per-site (non-fatal on older controllers),
  pass through augmentMetrics with site name override support
- promunifi: new topology.go with summary, connection-type, link-quality,
  and band-distribution gauges
- influxunifi: new topology.go with topology_summary and topology_edge
  measurements
- datadogunifi: new topology.go with equivalent Datadog gauges
- otelunifi: new topology.go with OpenTelemetry gauge observations

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cody Lee
2026-03-23 18:44:51 -05:00
committed by GitHub
parent f3d4e21e0e
commit 643c108674
14 changed files with 482 additions and 2 deletions

View File

@@ -325,6 +325,10 @@ func (u *DatadogUnifi) loopPoints(r report) {
for _, p := range m.FirewallPolicies {
u.switchExport(r, p)
}
for _, t := range m.Topologies {
u.switchExport(r, t)
}
}
func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
@@ -367,6 +371,8 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
u.batchWAN(r, v)
case *unifi.FirewallPolicy:
u.batchFirewallPolicy(r, v)
case *unifi.Topology:
u.batchTopology(r, v)
default:
if u.Collector != nil && u.Collector.Poller().LogUnknownTypes {
u.LogDebugf("unknown export type: %T", v)

View File

@@ -0,0 +1,101 @@
package datadogunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchTopology generates topology datapoints for Datadog.
func (u *DatadogUnifi) batchTopology(r report, t *unifi.Topology) {
if t == nil {
return
}
metricName := metricNamespace("topology")
siteTags := []string{
tag("site_name", t.SiteName),
tag("source", t.SourceName),
}
var (
devices int
clients int
wired int
wireless int
fullDuplex int
)
unknownSwitch := 0.0
if t.HasUnknownSwitch {
unknownSwitch = 1.0
}
for i := range t.Vertices {
switch t.Vertices[i].Type {
case "DEVICE":
devices++
case "CLIENT":
clients++
}
}
bandCounts := make(map[string]int)
for i := range t.Edges {
e := &t.Edges[i]
edgeTags := []string{
tag("uplink_mac", e.UplinkMac),
tag("downlink_mac", e.DownlinkMac),
tag("link_type", e.Type),
tag("site_name", t.SiteName),
tag("source", t.SourceName),
}
switch e.Type {
case "WIRED":
wired++
if e.Duplex == "FULL_DUPLEX" {
fullDuplex++
}
_ = r.reportGauge(metricName("link_rate_mbps"), e.RateMbps.Val, edgeTags)
case "WIRELESS":
wireless++
if e.RadioBand != "" {
bandCounts[e.RadioBand]++
}
if e.ExperienceScore.Val > 0 {
_ = r.reportGauge(metricName("link_experience_score"), e.ExperienceScore.Val, edgeTags)
}
}
}
summary := map[string]float64{
"vertices_total": float64(len(t.Vertices)),
"edges_total": float64(len(t.Edges)),
"devices_total": float64(devices),
"clients_total": float64(clients),
"connections_wired": float64(wired),
"connections_wireless": float64(wireless),
"wired_full_duplex": float64(fullDuplex),
"has_unknown_switch": unknownSwitch,
}
for name, value := range summary {
_ = r.reportGauge(metricName(name), value, siteTags)
}
for band, count := range bandCounts {
bandTags := []string{
tag("band", band),
tag("site_name", t.SiteName),
tag("source", t.SourceName),
}
_ = r.reportGauge(metricName("connections_by_band"), float64(count), bandTags)
}
}