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) <noreply@anthropic.com>

* fix(otelunifi): rename unused ctx parameter to _ in recordGauge

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

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cody Lee
2026-03-23 18:19:18 -05:00
committed by GitHub
parent 4c34180047
commit 521c2f88bc
10 changed files with 1111 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import (
_ "github.com/unpoller/unpoller/pkg/datadogunifi" _ "github.com/unpoller/unpoller/pkg/datadogunifi"
_ "github.com/unpoller/unpoller/pkg/influxunifi" _ "github.com/unpoller/unpoller/pkg/influxunifi"
_ "github.com/unpoller/unpoller/pkg/lokiunifi" _ "github.com/unpoller/unpoller/pkg/lokiunifi"
_ "github.com/unpoller/unpoller/pkg/otelunifi"
_ "github.com/unpoller/unpoller/pkg/promunifi" _ "github.com/unpoller/unpoller/pkg/promunifi"
) )

150
pkg/otelunifi/README.md Normal file
View File

@@ -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 `<url>/v1/metrics`. Default port `4318`.
- **gRPC** (`protocol = "grpc"`): Sends to `<host>:<port>`. 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
```

6
pkg/otelunifi/events.go Normal file
View File

@@ -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.

47
pkg/otelunifi/logger.go Normal file
View File

@@ -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...)
}
}

298
pkg/otelunifi/otelunifi.go Normal file
View File

@@ -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 <key>" 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}
}
}

233
pkg/otelunifi/report.go Normal file
View File

@@ -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++
}

87
pkg/otelunifi/uap.go Normal file
View File

@@ -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)
}
}

82
pkg/otelunifi/udm.go Normal file
View File

@@ -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)
}

87
pkg/otelunifi/usg.go Normal file
View File

@@ -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)
}

120
pkg/otelunifi/usw.go Normal file
View File

@@ -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)
}