Files
unpoller-unpoller-4/pkg/otelunifi/report.go
Cody Lee 18c6e66a8e feat: add Site Magic site-to-site VPN metrics (closes #926) (#983)
* feat: add Site Magic site-to-site VPN metrics (closes #926)

Bump github.com/unpoller/unifi/v5 to v5.25.0 which adds:
- GetMagicSiteToSiteVPN / GetMagicSiteToSiteVPNSite API methods
- MagicSiteToSiteVPN types with mesh, connection, device, and status structs
- Missing VPN health fields on Site.Health (SiteToSiteNumActive/Inactive,
  SiteToSiteRxBytes/TxBytes/RxPackets/TxPackets)

Implement VPN metrics collection across all output plugins:
- Collect Site Magic VPN mesh data per-site in inputunifi pollController
- Propagate VPNMeshes through poller.Metrics / AppendMetrics
- Apply DefaultSiteNameOverride for VPN meshes in augmentMetrics /
  applySiteNameOverride
- influxunifi: vpn_mesh, vpn_mesh_connection, vpn_mesh_status tables
- promunifi: vpn_mesh_*, vpn_tunnel_*, vpn_mesh_status_* gauges
- datadogunifi: unifi.vpn_mesh.*, unifi.vpn_tunnel.*, unifi.vpn_mesh_status.*

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* feat(otelunifi): add Site Magic VPN metrics to OpenTelemetry output

Adds exportVPNMeshes to the otel output plugin, emitting the same
unifi_vpn_mesh_*, unifi_vpn_tunnel_*, and unifi_vpn_mesh_status_*
gauges as the other output plugins.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:08:09 -05:00

240 lines
7.6 KiB
Go

package otelunifi
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"github.com/unpoller/unifi/v5"
"github.com/unpoller/unpoller/pkg/poller"
)
// Report accumulates counters that are printed to a log line.
type Report struct {
Total int // Total count of metrics recorded.
Errors int // Total count of errors recording metrics.
Sites int // Total count of sites exported.
Clients int // Total count of clients exported.
UAP int // Total count of UAP devices exported.
USW int // Total count of USW devices exported.
USG int // Total count of USG devices exported.
UDM int // Total count of UDM devices exported.
UXG int // Total count of UXG devices exported.
Elapsed time.Duration // Duration elapsed collecting and exporting.
}
func (r *Report) String() string {
return fmt.Sprintf(
"Sites: %d, Clients: %d, UAP: %d, USW: %d, USG/UDM/UXG: %d/%d/%d, Metrics: %d, Errs: %d, Elapsed: %v",
r.Sites, r.Clients, r.UAP, r.USW, r.USG, r.UDM, r.UXG,
r.Total, r.Errors, r.Elapsed.Round(time.Millisecond),
)
}
// reportMetrics converts poller.Metrics to OTel measurements.
func (u *OtelOutput) reportMetrics(m *poller.Metrics, _ *poller.Events) (*Report, error) {
r := &Report{}
start := time.Now()
meter := otel.GetMeterProvider().Meter(PluginName)
ctx := context.Background()
u.exportSites(ctx, meter, m, r)
u.exportClients(ctx, meter, m, r)
u.exportDevices(ctx, meter, m, r)
u.exportFirewallPolicies(ctx, meter, m, r)
u.exportTopology(ctx, meter, m, r)
u.exportPortAnomalies(ctx, meter, m, r)
u.exportVPNMeshes(ctx, meter, m, r)
r.Elapsed = time.Since(start)
return r, nil
}
// exportSites emits site-level gauge metrics.
func (u *OtelOutput) exportSites(ctx context.Context, meter metric.Meter, m *poller.Metrics, r *Report) {
for _, item := range m.Sites {
s, ok := item.(*unifi.Site)
if !ok {
continue
}
r.Sites++
for _, h := range s.Health {
attrs := attribute.NewSet(
attribute.String("site_name", s.SiteName),
attribute.String("source", s.SourceName),
attribute.String("subsystem", h.Subsystem),
attribute.String("status", h.Status),
)
u.recordGauge(ctx, meter, r, "unifi_site_users",
"Number of users on the site subsystem", h.NumUser.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_guests",
"Number of guests on the site subsystem", h.NumGuest.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_iot",
"Number of IoT devices on the site subsystem", h.NumIot.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_aps",
"Number of access points", h.NumAp.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_gateways",
"Number of gateways", h.NumGw.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_switches",
"Number of switches", h.NumSw.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_adopted",
"Number of adopted devices", h.NumAdopted.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_disconnected",
"Number of disconnected devices", h.NumDisconnected.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_pending",
"Number of pending devices", h.NumPending.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_disabled",
"Number of disabled devices", h.NumDisabled.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_latency_seconds",
"Site WAN latency in seconds", h.Latency.Val/1000, attrs) //nolint:mnd
u.recordGauge(ctx, meter, r, "unifi_site_uptime_seconds",
"Site uptime in seconds", h.Uptime.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_tx_bytes_rate",
"Site transmit bytes rate", h.TxBytesR.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_site_rx_bytes_rate",
"Site receive bytes rate", h.RxBytesR.Val, attrs)
}
}
}
// exportClients emits per-client gauge metrics.
func (u *OtelOutput) exportClients(ctx context.Context, meter metric.Meter, m *poller.Metrics, r *Report) {
for _, item := range m.Clients {
c, ok := item.(*unifi.Client)
if !ok {
continue
}
r.Clients++
attrs := attribute.NewSet(
attribute.String("mac", c.Mac),
attribute.String("site_name", c.SiteName),
attribute.String("source", c.SourceName),
attribute.String("name", c.Name),
attribute.String("ip", c.IP),
attribute.String("oui", c.Oui),
attribute.String("network", c.Network),
attribute.String("ap_name", c.ApName),
attribute.String("sw_name", c.SwName),
attribute.Bool("wired", c.IsWired.Val),
)
u.recordGauge(ctx, meter, r, "unifi_client_uptime_seconds",
"Client uptime in seconds", c.Uptime.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_client_rx_bytes",
"Client total bytes received", c.RxBytes.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_client_tx_bytes",
"Client total bytes transmitted", c.TxBytes.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_client_rx_bytes_rate",
"Client receive bytes rate", c.RxBytesR.Val, attrs)
u.recordGauge(ctx, meter, r, "unifi_client_tx_bytes_rate",
"Client transmit bytes rate", c.TxBytesR.Val, attrs)
if !c.IsWired.Val {
wifiAttrs := attribute.NewSet(
attribute.String("mac", c.Mac),
attribute.String("site_name", c.SiteName),
attribute.String("source", c.SourceName),
attribute.String("name", c.Name),
attribute.String("ip", c.IP),
attribute.String("oui", c.Oui),
attribute.String("network", c.Network),
attribute.String("ap_name", c.ApName),
attribute.String("sw_name", c.SwName),
attribute.Bool("wired", false),
attribute.String("essid", c.Essid),
attribute.String("radio", c.Radio),
attribute.String("radio_proto", c.RadioProto),
)
u.recordGauge(ctx, meter, r, "unifi_client_signal_db",
"Client signal strength in dBm", c.Signal.Val, wifiAttrs)
u.recordGauge(ctx, meter, r, "unifi_client_noise_db",
"Client AP noise floor in dBm", c.Noise.Val, wifiAttrs)
u.recordGauge(ctx, meter, r, "unifi_client_rssi_db",
"Client RSSI in dBm", c.Rssi.Val, wifiAttrs)
u.recordGauge(ctx, meter, r, "unifi_client_tx_rate_bps",
"Client transmit rate in bps", c.TxRate.Val, wifiAttrs)
u.recordGauge(ctx, meter, r, "unifi_client_rx_rate_bps",
"Client receive rate in bps", c.RxRate.Val, wifiAttrs)
}
}
}
// exportDevices routes each device to its type-specific exporter.
func (u *OtelOutput) exportDevices(ctx context.Context, meter metric.Meter, m *poller.Metrics, r *Report) {
for _, item := range m.Devices {
switch d := item.(type) {
case *unifi.UAP:
r.UAP++
u.exportUAP(ctx, meter, r, d)
case *unifi.USW:
r.USW++
u.exportUSW(ctx, meter, r, d)
case *unifi.USG:
r.USG++
u.exportUSG(ctx, meter, r, d)
case *unifi.UDM:
r.UDM++
u.exportUDM(ctx, meter, r, d)
case *unifi.UXG:
r.UXG++
u.exportUXG(ctx, meter, r, d)
default:
if u.Collector.Poller().LogUnknownTypes {
u.LogDebugf("otel: unknown device type: %T", item)
}
}
}
}
// recordGauge is a helper that records a single float64 gauge observation.
func (u *OtelOutput) recordGauge(
_ context.Context,
meter metric.Meter,
r *Report,
name, description string,
value float64,
attrs attribute.Set,
) {
g, err := meter.Float64ObservableGauge(name, metric.WithDescription(description))
if err != nil {
r.Errors++
u.LogDebugf("otel: creating gauge %s: %v", name, err)
return
}
_, err = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
o.ObserveFloat64(g, value, metric.WithAttributeSet(attrs))
return nil
}, g)
if err != nil {
r.Errors++
u.LogDebugf("otel: registering callback for %s: %v", name, err)
return
}
r.Total++
}