From 521c2f88bcbb80b3e65ce7fac0e5bf8ca56e46fb Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Mon, 23 Mar 2026 18:19:18 -0500 Subject: [PATCH] feat(otelunifi): add OpenTelemetry output plugin (#978) * feat(otelunifi): add OpenTelemetry output plugin Adds a new push-based output plugin that exports UniFi metrics to any OTLP-compatible backend (Grafana Alloy/Mimir, Honeycomb, Datadog via OTel, New Relic, etc.) using the Go OpenTelemetry SDK v1.42. Config (default disabled): [otel] url = "http://localhost:4318" protocol = "http" # or "grpc" interval = "30s" timeout = "10s" disable = false api_key = "" # optional Bearer auth Env var prefix: UP_OTEL_* Exported metrics: - Sites: user/guest/IoT counts, AP/GW/SW counts, latency, uptime, tx/rx rates per subsystem - Clients: uptime, rx/tx bytes & rates; signal/noise/RSSI for wireless - UAP: up, uptime, CPU/mem, load, per-radio channel/power, per-VAP station count/satisfaction/bytes - USW: up, uptime, CPU/mem, load, aggregate rx/tx bytes, per-port up/speed/bytes/packets/errors/dropped/PoE - USG: up, uptime, CPU/mem, load, per-WAN rx/tx bytes/packets/errors - UDM/UXG: up, uptime, CPU/mem, load averages Closes #933 Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(otelunifi): rename unused ctx parameter to _ in recordGauge Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(otelunifi): replace Disable with Enable (default false) Plugin is opt-in: set enable=true / UP_OTEL_ENABLE=true to activate. Closes part of #933. Co-Authored-By: Claude Sonnet 4.6 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- main.go | 1 + pkg/otelunifi/README.md | 150 +++++++++++++++++++ pkg/otelunifi/events.go | 6 + pkg/otelunifi/logger.go | 47 ++++++ pkg/otelunifi/otelunifi.go | 298 +++++++++++++++++++++++++++++++++++++ pkg/otelunifi/report.go | 233 +++++++++++++++++++++++++++++ pkg/otelunifi/uap.go | 87 +++++++++++ pkg/otelunifi/udm.go | 82 ++++++++++ pkg/otelunifi/usg.go | 87 +++++++++++ pkg/otelunifi/usw.go | 120 +++++++++++++++ 10 files changed, 1111 insertions(+) create mode 100644 pkg/otelunifi/README.md create mode 100644 pkg/otelunifi/events.go create mode 100644 pkg/otelunifi/logger.go create mode 100644 pkg/otelunifi/otelunifi.go create mode 100644 pkg/otelunifi/report.go create mode 100644 pkg/otelunifi/uap.go create mode 100644 pkg/otelunifi/udm.go create mode 100644 pkg/otelunifi/usg.go create mode 100644 pkg/otelunifi/usw.go diff --git a/main.go b/main.go index 25916ad6..07b97e86 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( _ "github.com/unpoller/unpoller/pkg/datadogunifi" _ "github.com/unpoller/unpoller/pkg/influxunifi" _ "github.com/unpoller/unpoller/pkg/lokiunifi" + _ "github.com/unpoller/unpoller/pkg/otelunifi" _ "github.com/unpoller/unpoller/pkg/promunifi" ) diff --git a/pkg/otelunifi/README.md b/pkg/otelunifi/README.md new file mode 100644 index 00000000..52925cbd --- /dev/null +++ b/pkg/otelunifi/README.md @@ -0,0 +1,150 @@ +# otelunifi — OpenTelemetry Output Plugin + +Exports UniFi metrics to any [OpenTelemetry Protocol (OTLP)](https://opentelemetry.io/docs/specs/otel/protocol/) compatible backend via push, using the Go OTel SDK. + +Compatible backends include Grafana Alloy/Mimir, Honeycomb, Datadog (via OTel collector), Grafana Tempo, New Relic, Lightstep, and any vendor that accepts OTLP. + +## Configuration + +The plugin is **disabled by default**. Set `enable = true` (or `UP_OTEL_ENABLE=true`) to enable it. + +### TOML + +```toml +[otel] + url = "http://localhost:4318" # OTLP HTTP endpoint (default) + protocol = "http" # "http" (default) or "grpc" + interval = "30s" + timeout = "10s" + enable = true + dead_ports = false + + # Optional bearer token for authenticated collectors (e.g. Grafana Cloud) + api_key = "" +``` + +### YAML + +```yaml +otel: + url: "http://localhost:4318" + protocol: http + interval: 30s + timeout: 10s + enable: true + dead_ports: false + api_key: "" +``` + +### Environment Variables + +All config keys use the `UP_OTEL_` prefix: + +| Variable | Default | Description | +|---|---|---| +| `UP_OTEL_URL` | `http://localhost:4318` | OTLP endpoint URL | +| `UP_OTEL_PROTOCOL` | `http` | Transport: `http` or `grpc` | +| `UP_OTEL_INTERVAL` | `30s` | Push interval | +| `UP_OTEL_TIMEOUT` | `10s` | Per-export timeout | +| `UP_OTEL_ENABLE` | `false` | Set to `true` to enable | +| `UP_OTEL_API_KEY` | `` | Bearer token for auth | +| `UP_OTEL_DEAD_PORTS` | `false` | Include down/disabled switch ports | + +## Protocol Notes + +- **HTTP** (`protocol = "http"`): Sends to `/v1/metrics`. Default port `4318`. +- **gRPC** (`protocol = "grpc"`): Sends to `:`. Default `localhost:4317`. The URL for gRPC should be `host:port` (no scheme). + +## Exported Metrics + +All metrics use the `unifi_` prefix and carry identifying attributes (labels). + +### Site metrics (`unifi_site_*`) + +Attributes: `site_name`, `source`, `subsystem`, `status` + +| Metric | Description | +|---|---| +| `unifi_site_users` | Connected user count | +| `unifi_site_guests` | Connected guest count | +| `unifi_site_iot` | IoT device count | +| `unifi_site_aps` | Access point count | +| `unifi_site_gateways` | Gateway count | +| `unifi_site_switches` | Switch count | +| `unifi_site_adopted` | Adopted device count | +| `unifi_site_disconnected` | Disconnected device count | +| `unifi_site_latency_seconds` | WAN latency | +| `unifi_site_uptime_seconds` | Site uptime | +| `unifi_site_tx_bytes_rate` | Transmit bytes rate | +| `unifi_site_rx_bytes_rate` | Receive bytes rate | + +### Client metrics (`unifi_client_*`) + +Attributes: `mac`, `name`, `ip`, `site_name`, `source`, `oui`, `network`, `ap_name`, `sw_name`, `wired` + +Wireless-only additional attributes: `essid`, `radio`, `radio_proto` + +| Metric | Description | +|---|---| +| `unifi_client_uptime_seconds` | Client uptime | +| `unifi_client_rx_bytes` | Total bytes received | +| `unifi_client_tx_bytes` | Total bytes transmitted | +| `unifi_client_rx_bytes_rate` | Receive rate | +| `unifi_client_tx_bytes_rate` | Transmit rate | +| `unifi_client_signal_db` | Signal strength (wireless) | +| `unifi_client_noise_db` | Noise floor (wireless) | +| `unifi_client_rssi_db` | RSSI (wireless) | +| `unifi_client_tx_rate_bps` | TX rate (wireless) | +| `unifi_client_rx_rate_bps` | RX rate (wireless) | + +### Device metrics + +#### UAP (`unifi_device_uap_*`) + +Attributes: `mac`, `name`, `model`, `version`, `type`, `ip`, `site_name`, `source` + +Includes: `up`, `uptime_seconds`, `cpu_utilization`, `mem_utilization`, `load_avg_{1,5,15}`, per-radio `channel`/`tx_power_dbm`, per-VAP `num_stations`/`satisfaction`/`rx_bytes`/`tx_bytes`. + +#### USW (`unifi_device_usw_*`) + +Attributes: `mac`, `name`, `model`, `version`, `type`, `ip`, `site_name`, `source` + +Includes: `up`, `uptime_seconds`, `cpu_utilization`, `mem_utilization`, `load_avg_1`, `rx_bytes`, `tx_bytes`, and per-port metrics (`port_up`, `port_speed_mbps`, `port_rx_bytes`, `port_tx_bytes`, `port_poe_*`, etc.). + +#### USG (`unifi_device_usg_*`) + +Includes: `up`, `uptime_seconds`, `cpu_utilization`, `mem_utilization`, and per-WAN interface (`wan_rx_bytes`, `wan_tx_bytes`, `wan_rx_packets`, `wan_tx_packets`, `wan_rx_errors`, `wan_tx_errors`, `wan_speed_mbps`). + +#### UDM (`unifi_device_udm_*`) + +Includes: `up`, `uptime_seconds`, `cpu_utilization`, `mem_utilization`, `load_avg_{1,5,15}`. + +#### UXG (`unifi_device_uxg_*`) + +Includes: `up`, `uptime_seconds`, `cpu_utilization`, `mem_utilization`, `load_avg_1`. + +## Example: Grafana Alloy + +```alloy +otelcol.receiver.otlp "default" { + grpc { endpoint = "0.0.0.0:4317" } + http { endpoint = "0.0.0.0:4318" } + + output { + metrics = [otelcol.exporter.prometheus.default.input] + } +} +``` + +Set `UP_OTEL_URL=http://alloy-host:4318` and `UP_OTEL_ENABLE=true` in unpoller's environment. + +## Example: Grafana Cloud (OTLP with auth) + +```toml +[otel] + url = "https://otlp-gateway-prod-us-central-0.grafana.net/otlp" + protocol = "http" + api_key = "instanceID:grafana_cloud_api_token" + interval = "60s" + enable = true +``` diff --git a/pkg/otelunifi/events.go b/pkg/otelunifi/events.go new file mode 100644 index 00000000..a4569df0 --- /dev/null +++ b/pkg/otelunifi/events.go @@ -0,0 +1,6 @@ +package otelunifi + +// Events handling is intentionally a no-op for now. +// UniFi events (alarms, IDS, anomalies) are log-like data that would be +// better suited for an OTel Logs signal once the Go OTel SDK stabilises +// the log bridge API. This file exists as a placeholder. diff --git a/pkg/otelunifi/logger.go b/pkg/otelunifi/logger.go new file mode 100644 index 00000000..dde35e90 --- /dev/null +++ b/pkg/otelunifi/logger.go @@ -0,0 +1,47 @@ +package otelunifi + +import ( + "fmt" + "time" + + "github.com/unpoller/unpoller/pkg/webserver" +) + +// Logf logs an informational message. +func (u *OtelOutput) Logf(msg string, v ...any) { + webserver.NewOutputEvent(PluginName, PluginName, &webserver.Event{ + Ts: time.Now(), + Msg: fmt.Sprintf(msg, v...), + Tags: map[string]string{"type": "info"}, + }) + + if u.Collector != nil { + u.Collector.Logf(msg, v...) + } +} + +// LogErrorf logs an error message. +func (u *OtelOutput) LogErrorf(msg string, v ...any) { + webserver.NewOutputEvent(PluginName, PluginName, &webserver.Event{ + Ts: time.Now(), + Msg: fmt.Sprintf(msg, v...), + Tags: map[string]string{"type": "error"}, + }) + + if u.Collector != nil { + u.Collector.LogErrorf(msg, v...) + } +} + +// LogDebugf logs a debug message. +func (u *OtelOutput) LogDebugf(msg string, v ...any) { + webserver.NewOutputEvent(PluginName, PluginName, &webserver.Event{ + Ts: time.Now(), + Msg: fmt.Sprintf(msg, v...), + Tags: map[string]string{"type": "debug"}, + }) + + if u.Collector != nil { + u.Collector.LogDebugf(msg, v...) + } +} diff --git a/pkg/otelunifi/otelunifi.go b/pkg/otelunifi/otelunifi.go new file mode 100644 index 00000000..4d7d0465 --- /dev/null +++ b/pkg/otelunifi/otelunifi.go @@ -0,0 +1,298 @@ +// Package otelunifi provides the methods to turn UniFi measurements into +// OpenTelemetry metrics and export them via OTLP. +package otelunifi + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "golift.io/cnfg" + + "github.com/unpoller/unpoller/pkg/poller" + "github.com/unpoller/unpoller/pkg/webserver" +) + +// PluginName is the name of this plugin. +const PluginName = "otel" + +const ( + defaultInterval = 30 * time.Second + minimumInterval = 10 * time.Second + defaultOTLPHTTPURL = "http://localhost:4318" + defaultOTLPGRPCURL = "localhost:4317" + protoHTTP = "http" + protoGRPC = "grpc" +) + +// Config defines the data needed to export metrics via OpenTelemetry. +type Config struct { + // URL is the OTLP endpoint to send metrics to. + // For HTTP: http://localhost:4318 + // For gRPC: localhost:4317 + URL string `json:"url,omitempty" toml:"url,omitempty" xml:"url" yaml:"url"` + + // APIKey is an optional bearer token / API key for authentication. + // Sent as the "Authorization: Bearer " header. + APIKey string `json:"api_key,omitempty" toml:"api_key,omitempty" xml:"api_key" yaml:"api_key"` + + // Interval controls the push interval for sending metrics to the OTLP endpoint. + Interval cnfg.Duration `json:"interval,omitempty" toml:"interval,omitempty" xml:"interval" yaml:"interval"` + + // Timeout is the per-export deadline. + Timeout cnfg.Duration `json:"timeout,omitempty" toml:"timeout,omitempty" xml:"timeout" yaml:"timeout"` + + // Protocol selects the OTLP transport protocol: "http" (default) or "grpc". + Protocol string `json:"protocol,omitempty" toml:"protocol,omitempty" xml:"protocol" yaml:"protocol"` + + // Enable when true enables this output plugin. + Enable bool `json:"enable" toml:"enable" xml:"enable,attr" yaml:"enable"` + + // DeadPorts when true will save data for dead ports, for example ports that are down or disabled. + DeadPorts bool `json:"dead_ports" toml:"dead_ports" xml:"dead_ports" yaml:"dead_ports"` +} + +// OtelUnifi wraps the config for nested TOML/JSON/YAML config file support. +type OtelUnifi struct { + *Config `json:"otel" toml:"otel" xml:"otel" yaml:"otel"` +} + +// OtelOutput is the working struct for this plugin. +type OtelOutput struct { + Collector poller.Collect + LastCheck time.Time + provider *sdkmetric.MeterProvider + *OtelUnifi +} + +var _ poller.OutputPlugin = &OtelOutput{} + +func init() { //nolint:gochecknoinits + u := &OtelOutput{OtelUnifi: &OtelUnifi{Config: &Config{}}, LastCheck: time.Now()} + + poller.NewOutput(&poller.Output{ + Name: PluginName, + Config: u.OtelUnifi, + OutputPlugin: u, + }) +} + +// Enabled returns true when the plugin is configured and enabled. +func (u *OtelOutput) Enabled() bool { + if u == nil { + return false + } + + if u.Config == nil { + return false + } + + return u.Enable +} + +// DebugOutput validates the plugin configuration without starting the run loop. +func (u *OtelOutput) DebugOutput() (bool, error) { + if u == nil { + return true, nil + } + + if !u.Enabled() { + return true, nil + } + + u.setConfigDefaults() + + if u.URL == "" { + return false, fmt.Errorf("otel: URL must be set") + } + + proto := u.Protocol + if proto != protoHTTP && proto != protoGRPC { + return false, fmt.Errorf("otel: protocol must be %q or %q, got %q", protoHTTP, protoGRPC, proto) + } + + return true, nil +} + +// Run is the main loop called by the poller core. +func (u *OtelOutput) Run(c poller.Collect) error { + u.Collector = c + + if !u.Enabled() { + u.LogDebugf("OTel output not enabled, skipping.") + + return nil + } + + u.Logf("OpenTelemetry (OTel) output plugin enabled") + u.setConfigDefaults() + + if err := u.setupProvider(); err != nil { + return fmt.Errorf("otel: setup provider: %w", err) + } + + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := u.provider.Shutdown(ctx); err != nil { + u.LogErrorf("otel: shutdown provider: %v", err) + } + }() + + webserver.UpdateOutput(&webserver.Output{Name: PluginName, Config: u.Config}) + u.pollController() + + return nil +} + +// pollController runs the ticker loop, pushing metrics on each tick. +func (u *OtelOutput) pollController() { + interval := u.Interval.Duration.Round(time.Second) + ticker := time.NewTicker(interval) + + defer ticker.Stop() + + u.Logf("OTel->OTLP started, protocol: %s, interval: %v, url: %s", + u.Protocol, interval, u.URL) + + for u.LastCheck = range ticker.C { + u.poll(interval) + } +} + +// poll fetches metrics once and sends them to the OTLP endpoint. +func (u *OtelOutput) poll(interval time.Duration) { + metrics, err := u.Collector.Metrics(&poller.Filter{Name: "unifi"}) + if err != nil { + u.LogErrorf("metric fetch for OTel failed: %v", err) + + return + } + + events, err := u.Collector.Events(&poller.Filter{Name: "unifi", Dur: interval}) + if err != nil { + u.LogErrorf("event fetch for OTel failed: %v", err) + + return + } + + report, err := u.reportMetrics(metrics, events) + if err != nil { + u.LogErrorf("otel report: %v", err) + + return + } + + u.Logf("OTel Metrics Exported. %v", report) +} + +// setupProvider creates and registers the OTel MeterProvider with an OTLP exporter. +func (u *OtelOutput) setupProvider() error { + ctx := context.Background() + + exp, err := u.buildExporter(ctx) + if err != nil { + return fmt.Errorf("building exporter: %w", err) + } + + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(poller.AppName), + ), + ) + if err != nil { + return fmt.Errorf("building resource: %w", err) + } + + u.provider = sdkmetric.NewMeterProvider( + sdkmetric.WithReader( + sdkmetric.NewPeriodicReader(exp, + sdkmetric.WithInterval(u.Interval.Duration), + sdkmetric.WithTimeout(u.Timeout.Duration), + ), + ), + sdkmetric.WithResource(res), + ) + + otel.SetMeterProvider(u.provider) + + return nil +} + +// buildExporter creates either an HTTP or gRPC OTLP exporter. +func (u *OtelOutput) buildExporter(ctx context.Context) (sdkmetric.Exporter, error) { + switch u.Protocol { + case protoGRPC: + opts := []otlpmetricgrpc.Option{ + otlpmetricgrpc.WithEndpoint(u.URL), + otlpmetricgrpc.WithInsecure(), + } + + if u.APIKey != "" { + opts = append(opts, otlpmetricgrpc.WithHeaders(map[string]string{ + "Authorization": "Bearer " + u.APIKey, + })) + } + + exp, err := otlpmetricgrpc.New(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("grpc exporter: %w", err) + } + + return exp, nil + + default: // http + opts := []otlpmetrichttp.Option{ + otlpmetrichttp.WithEndpoint(u.URL), + otlpmetrichttp.WithInsecure(), + } + + if u.APIKey != "" { + opts = append(opts, otlpmetrichttp.WithHeaders(map[string]string{ + "Authorization": "Bearer " + u.APIKey, + })) + } + + exp, err := otlpmetrichttp.New(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("http exporter: %w", err) + } + + return exp, nil + } +} + +// setConfigDefaults fills in zero-value fields with sensible defaults. +func (u *OtelOutput) setConfigDefaults() { + if u.Protocol == "" { + u.Protocol = protoHTTP + } + + if u.URL == "" { + switch u.Protocol { + case protoGRPC: + u.URL = defaultOTLPGRPCURL + default: + u.URL = defaultOTLPHTTPURL + } + } + + if u.Interval.Duration == 0 { + u.Interval = cnfg.Duration{Duration: defaultInterval} + } else if u.Interval.Duration < minimumInterval { + u.Interval = cnfg.Duration{Duration: minimumInterval} + } + + u.Interval = cnfg.Duration{Duration: u.Interval.Duration.Round(time.Second)} + + if u.Timeout.Duration == 0 { + u.Timeout = cnfg.Duration{Duration: 10 * time.Second} + } +} diff --git a/pkg/otelunifi/report.go b/pkg/otelunifi/report.go new file mode 100644 index 00000000..09a8315a --- /dev/null +++ b/pkg/otelunifi/report.go @@ -0,0 +1,233 @@ +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) + + 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++ +} diff --git a/pkg/otelunifi/uap.go b/pkg/otelunifi/uap.go new file mode 100644 index 00000000..65a4c3d7 --- /dev/null +++ b/pkg/otelunifi/uap.go @@ -0,0 +1,87 @@ +package otelunifi + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/unpoller/unifi/v5" +) + +// exportUAP emits metrics for a wireless access point. +func (u *OtelOutput) exportUAP(ctx context.Context, meter metric.Meter, r *Report, s *unifi.UAP) { + if !s.Adopted.Val || s.Locating.Val { + return + } + + attrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("model", s.Model), + attribute.String("version", s.Version), + attribute.String("type", s.Type), + attribute.String("ip", s.IP), + ) + + u.recordGauge(ctx, meter, r, "unifi_device_uap_uptime_seconds", + "UAP uptime in seconds", s.Uptime.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_cpu_utilization", + "UAP CPU utilization percentage", s.SystemStats.CPU.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_mem_utilization", + "UAP memory utilization percentage", s.SystemStats.Mem.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_load_avg_1", + "UAP load average 1-minute", s.SysStats.Loadavg1.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_load_avg_5", + "UAP load average 5-minute", s.SysStats.Loadavg5.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_load_avg_15", + "UAP load average 15-minute", s.SysStats.Loadavg15.Val, attrs) + + up := 0.0 + if s.State.Val == 1 { + up = 1.0 + } + + u.recordGauge(ctx, meter, r, "unifi_device_uap_up", + "Whether UAP is up (1) or down (0)", up, attrs) + + for _, radio := range s.RadioTable { + radioAttrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("radio", radio.Radio), + attribute.String("radio_name", radio.Name), + ) + + u.recordGauge(ctx, meter, r, "unifi_device_uap_radio_channel", + "UAP radio channel", float64(radio.Channel.Val), radioAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_radio_tx_power_dbm", + "UAP radio transmit power in dBm", radio.TxPower.Val, radioAttrs) + } + + for _, vap := range s.VapTable { + vapAttrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("essid", vap.Essid), + attribute.String("bssid", vap.Bssid), + attribute.String("radio", vap.Radio), + ) + + // NumSta is a plain int in the unifi library + u.recordGauge(ctx, meter, r, "unifi_device_uap_vap_num_stations", + "UAP VAP connected station count", float64(vap.NumSta), vapAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_vap_satisfaction", + "UAP VAP client satisfaction score", vap.Satisfaction.Val, vapAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_vap_rx_bytes", + "UAP VAP receive bytes total", vap.RxBytes.Val, vapAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_uap_vap_tx_bytes", + "UAP VAP transmit bytes total", vap.TxBytes.Val, vapAttrs) + } +} diff --git a/pkg/otelunifi/udm.go b/pkg/otelunifi/udm.go new file mode 100644 index 00000000..c95895f6 --- /dev/null +++ b/pkg/otelunifi/udm.go @@ -0,0 +1,82 @@ +package otelunifi + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/unpoller/unifi/v5" +) + +// exportUDM emits metrics for a UniFi Dream Machine (all variants). +func (u *OtelOutput) exportUDM(ctx context.Context, meter metric.Meter, r *Report, s *unifi.UDM) { + if !s.Adopted.Val || s.Locating.Val { + return + } + + attrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("model", s.Model), + attribute.String("version", s.Version), + attribute.String("type", s.Type), + attribute.String("ip", s.IP), + ) + + up := 0.0 + if s.State.Val == 1 { + up = 1.0 + } + + u.recordGauge(ctx, meter, r, "unifi_device_udm_up", + "Whether UDM is up (1) or down (0)", up, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_udm_uptime_seconds", + "UDM uptime in seconds", s.Uptime.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_udm_cpu_utilization", + "UDM CPU utilization percentage", s.SystemStats.CPU.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_udm_mem_utilization", + "UDM memory utilization percentage", s.SystemStats.Mem.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_udm_load_avg_1", + "UDM load average 1-minute", s.SysStats.Loadavg1.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_udm_load_avg_5", + "UDM load average 5-minute", s.SysStats.Loadavg5.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_udm_load_avg_15", + "UDM load average 15-minute", s.SysStats.Loadavg15.Val, attrs) +} + +// exportUXG emits metrics for a UniFi Next-Gen Gateway. +func (u *OtelOutput) exportUXG(ctx context.Context, meter metric.Meter, r *Report, s *unifi.UXG) { + if !s.Adopted.Val || s.Locating.Val { + return + } + + attrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("model", s.Model), + attribute.String("version", s.Version), + attribute.String("type", s.Type), + attribute.String("ip", s.IP), + ) + + up := 0.0 + if s.State.Val == 1 { + up = 1.0 + } + + u.recordGauge(ctx, meter, r, "unifi_device_uxg_up", + "Whether UXG is up (1) or down (0)", up, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uxg_uptime_seconds", + "UXG uptime in seconds", s.Uptime.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uxg_cpu_utilization", + "UXG CPU utilization percentage", s.SystemStats.CPU.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uxg_mem_utilization", + "UXG memory utilization percentage", s.SystemStats.Mem.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_uxg_load_avg_1", + "UXG load average 1-minute", s.SysStats.Loadavg1.Val, attrs) +} diff --git a/pkg/otelunifi/usg.go b/pkg/otelunifi/usg.go new file mode 100644 index 00000000..8d513cd0 --- /dev/null +++ b/pkg/otelunifi/usg.go @@ -0,0 +1,87 @@ +package otelunifi + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/unpoller/unifi/v5" +) + +// exportUSG emits metrics for a UniFi Security Gateway. +func (u *OtelOutput) exportUSG(ctx context.Context, meter metric.Meter, r *Report, s *unifi.USG) { + if !s.Adopted.Val || s.Locating.Val { + return + } + + attrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("model", s.Model), + attribute.String("version", s.Version), + attribute.String("type", s.Type), + attribute.String("ip", s.IP), + ) + + up := 0.0 + if s.State.Val == 1 { + up = 1.0 + } + + u.recordGauge(ctx, meter, r, "unifi_device_usg_up", + "Whether USG is up (1) or down (0)", up, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_usg_uptime_seconds", + "USG uptime in seconds", s.Uptime.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_usg_cpu_utilization", + "USG CPU utilization percentage", s.SystemStats.CPU.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_usg_mem_utilization", + "USG memory utilization percentage", s.SystemStats.Mem.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_usg_load_avg_1", + "USG load average 1-minute", s.SysStats.Loadavg1.Val, attrs) + + // Export WAN1 metrics + u.exportUSGWan(ctx, meter, r, s, s.Wan1, "wan1") + // Export WAN2 metrics if present + u.exportUSGWan(ctx, meter, r, s, s.Wan2, "wan2") +} + +// exportUSGWan emits metrics for a single WAN interface on a USG. +func (u *OtelOutput) exportUSGWan( + ctx context.Context, + meter metric.Meter, + r *Report, + s *unifi.USG, + wan unifi.Wan, + ifaceName string, +) { + if wan.IP == "" { + return + } + + wanAttrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("iface", ifaceName), + attribute.String("ip", wan.IP), + ) + + u.recordGauge(ctx, meter, r, "unifi_device_usg_wan_rx_bytes", + "USG WAN interface receive bytes total", wan.RxBytes.Val, wanAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usg_wan_tx_bytes", + "USG WAN interface transmit bytes total", wan.TxBytes.Val, wanAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usg_wan_rx_packets", + "USG WAN interface receive packets total", wan.RxPackets.Val, wanAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usg_wan_tx_packets", + "USG WAN interface transmit packets total", wan.TxPackets.Val, wanAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usg_wan_rx_errors", + "USG WAN interface receive errors total", wan.RxErrors.Val, wanAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usg_wan_tx_errors", + "USG WAN interface transmit errors total", wan.TxErrors.Val, wanAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usg_wan_speed_mbps", + "USG WAN interface link speed in Mbps", wan.Speed.Val, wanAttrs) +} diff --git a/pkg/otelunifi/usw.go b/pkg/otelunifi/usw.go new file mode 100644 index 00000000..17854973 --- /dev/null +++ b/pkg/otelunifi/usw.go @@ -0,0 +1,120 @@ +package otelunifi + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/unpoller/unifi/v5" +) + +// exportUSW emits metrics for a UniFi switch. +func (u *OtelOutput) exportUSW(ctx context.Context, meter metric.Meter, r *Report, s *unifi.USW) { + if !s.Adopted.Val || s.Locating.Val { + return + } + + attrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("model", s.Model), + attribute.String("version", s.Version), + attribute.String("type", s.Type), + attribute.String("ip", s.IP), + ) + + up := 0.0 + if s.State.Val == 1 { + up = 1.0 + } + + u.recordGauge(ctx, meter, r, "unifi_device_usw_up", + "Whether USW is up (1) or down (0)", up, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_uptime_seconds", + "USW uptime in seconds", s.Uptime.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_cpu_utilization", + "USW CPU utilization percentage", s.SystemStats.CPU.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_mem_utilization", + "USW memory utilization percentage", s.SystemStats.Mem.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_load_avg_1", + "USW load average 1-minute", s.SysStats.Loadavg1.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_rx_bytes", + "USW total receive bytes", s.Stat.Sw.RxBytes.Val, attrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_tx_bytes", + "USW total transmit bytes", s.Stat.Sw.TxBytes.Val, attrs) + + if !u.DeadPorts { + for _, p := range s.PortTable { + if !p.Up.Val || !p.Enable.Val { + continue + } + + u.exportUSWPort(ctx, meter, r, s, p) + } + } else { + for _, p := range s.PortTable { + u.exportUSWPort(ctx, meter, r, s, p) + } + } +} + +// exportUSWPort emits metrics for a single switch port. +func (u *OtelOutput) exportUSWPort( + ctx context.Context, + meter metric.Meter, + r *Report, + s *unifi.USW, + p unifi.Port, +) { + portAttrs := attribute.NewSet( + attribute.String("mac", s.Mac), + attribute.String("site_name", s.SiteName), + attribute.String("source", s.SourceName), + attribute.String("name", s.Name), + attribute.String("port_name", p.Name), + attribute.Int64("port_num", int64(p.PortIdx.Val)), + attribute.String("port_mac", p.Mac), + attribute.String("port_ip", p.IP), + ) + + portUp := 0.0 + if p.Up.Val { + portUp = 1.0 + } + + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_up", + "Whether switch port is up (1) or down (0)", portUp, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_speed_mbps", + "Switch port speed in Mbps", p.Speed.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_rx_bytes", + "Switch port receive bytes total", p.RxBytes.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_tx_bytes", + "Switch port transmit bytes total", p.TxBytes.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_rx_bytes_rate", + "Switch port receive bytes rate", p.RxBytesR.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_tx_bytes_rate", + "Switch port transmit bytes rate", p.TxBytesR.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_rx_packets", + "Switch port receive packets total", p.RxPackets.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_tx_packets", + "Switch port transmit packets total", p.TxPackets.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_rx_errors", + "Switch port receive errors total", p.RxErrors.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_tx_errors", + "Switch port transmit errors total", p.TxErrors.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_rx_dropped", + "Switch port receive dropped total", p.RxDropped.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_tx_dropped", + "Switch port transmit dropped total", p.TxDropped.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_poe_current_amps", + "Switch port PoE current in amps", p.PoeCurrent.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_poe_power_watts", + "Switch port PoE power in watts", p.PoePower.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_poe_voltage", + "Switch port PoE voltage", p.PoeVoltage.Val, portAttrs) + u.recordGauge(ctx, meter, r, "unifi_device_usw_port_satisfaction", + "Switch port satisfaction score", p.Satisfaction.Val, portAttrs) +}