Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add link capacity/utilization metrics #34

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ $ unms-exporter --extra-metrics="ping"

</details>

- `link`: Fetch statistical data from UNMS and extract and export
capacity and utilization for up- and downlink.

<details><summary>Exported metrics (click to open)</summary>

- `uplink_capacity_rate`: Uplink capacity in Bit/s
- `uplink_utilization_ratio`: Uplink utilization ratio
- `downlink_capacity_rate`: Downlink capacity in Bit/s
- `downlink_utilization_ratio`: Downlink utilization ratio

Utilization ratio are expressed as a number in the range 0-1, with 0.33
meaning 33% of the capacity is in use.

</details>


Further data is available, but not currently exported (see the API
documentation for the `/devices/{id}/statistics` endpoint on your UNMS
installation to get an overview). Feel free to [open a new issue][] to
Expand Down
14 changes: 13 additions & 1 deletion client/openapi-lite.json
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,13 @@
"status": { "type": "string", "example": "active" }
}
},
"interval": {
"type": "object",
"properties": {
"start": { "type": "number" },
"end": { "type": "number" }
}
},
"CoordinatesXY.": {
"type": "object",
"properties": {
Expand All @@ -595,7 +602,12 @@
"DeviceStatistics": {
"type": "object",
"properties": {
"ping": { "$ref": "#/definitions/ListOfCoordinates" }
"interval": { "$ref": "#/definitions/interval"},
"ping": { "$ref": "#/definitions/ListOfCoordinates" },
"uplinkCapacity": { "$ref": "#/definitions/ListOfCoordinates" },
"uplinkUtilization": { "$ref": "#/definitions/ListOfCoordinates" },
"downlinkCapacity": { "$ref": "#/definitions/ListOfCoordinates" },
"downlinkUtilization": { "$ref": "#/definitions/ListOfCoordinates" }
}
},
"Error": {
Expand Down
32 changes: 31 additions & 1 deletion exporter/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func (e *Exporter) fetchDeviceData(ctx context.Context) ([]Device, error) {
}
dev := Device{nil, overview}

if e.extras.Ping {
if e.extras.NeedStatistics() {
if id := derefOrEmpty(overview.Identification.ID); id != "" {
params := &devices.GetDevicesIDStatisticsParams{
ID: id,
Expand Down Expand Up @@ -73,3 +73,33 @@ func (dev *Device) PingMetrics() *PingMetrics {

return m.Compute()
}

type LinkMetrics struct {
UplinkCapacity float64
UplinkUtilization float64
DownlinkCapacity float64
DownlinkUtilization float64
}

// LinkMetricsWindow limits the data returned from the statistics
// endpoint from which we compute the average. The smallest interval
// allowed by UNMS is 1 hour, but we don't want to wait this long for
// anomalies to become visible.
const LinkMetricsWindow = 10 * time.Minute

func (dev *Device) LinkMetrics() *LinkMetrics {
s := dev.Statistics
if s == nil {
return nil
}

max := s.Interval.End
min := float64(time.UnixMilli(int64(max)).Add(-LinkMetricsWindow).UnixMilli())

return &LinkMetrics{
UplinkCapacity: weightedMean(min, max, s.UplinkCapacity),
UplinkUtilization: weightedMean(min, max, s.UplinkUtilization),
DownlinkCapacity: weightedMean(min, max, s.DownlinkCapacity),
DownlinkUtilization: weightedMean(min, max, s.DownlinkUtilization),
}
}
10 changes: 10 additions & 0 deletions exporter/exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,16 @@ func (e *Exporter) collectImpl(ctx context.Context, out chan<- prom.Metric) erro
}
out <- e.newMetric("ping_loss_ratio", prom.GaugeValue, ratio, deviceLabels...)
}

// Link metrics, if enabled
if e.extras.Link {
if link := device.LinkMetrics(); link != nil {
out <- e.newMetric("uplink_capacity_rate", prom.GaugeValue, link.UplinkCapacity, deviceLabels...)
out <- e.newMetric("downlink_capacity_rate", prom.GaugeValue, link.DownlinkCapacity, deviceLabels...)
out <- e.newMetric("uplink_utilization_ratio", prom.GaugeValue, link.UplinkUtilization, deviceLabels...)
out <- e.newMetric("downlink_utilization_ratio", prom.GaugeValue, link.DownlinkUtilization, deviceLabels...)
}
}
}

return nil
Expand Down
30 changes: 25 additions & 5 deletions exporter/extra_metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import "fmt"
// capacity, and many more values.
type ExtraMetrics struct {
Ping bool
Link bool
}

// NeedStatistics is true, if any field is true that require data from
// the same device statistics endpoint.
func (x ExtraMetrics) NeedStatistics() bool {
return x.Ping || x.Link
}

var pingMetrics = map[string]metricSpec{
Expand All @@ -24,24 +31,37 @@ var pingMetrics = map[string]metricSpec{
"ping_rtt_std_deviation_seconds": newSpec("Standard deviation for ping round trip time in seconds", nil),
}

var linkMetrics = map[string]metricSpec{
"uplink_capacity_rate": newSpec("Uplink capacity in Bit/s", nil),
"uplink_utilization_ratio": newSpec("Uplink utilization ratio", nil),
"downlink_capacity_rate": newSpec("Downlink capacity in Bit/s", nil),
"downlink_utilization_ratio": newSpec("Downlink utilization ratio", nil),
}

func (e *Exporter) SetExtras(extras []string) error {
e.extras = ExtraMetrics{} // reset all values
for _, x := range extras {
switch x {
case "ping":
e.extras.Ping = true
case "link":
e.extras.Link = true
default:
return fmt.Errorf("unknown extra metric: %q", x)
}
}

for name, spec := range pingMetrics {
if _, exists := e.metrics[name]; !exists && e.extras.Ping {
e.configureMetrics(e.extras.Ping, pingMetrics)
e.configureMetrics(e.extras.Link, linkMetrics)
return nil
}

func (e *Exporter) configureMetrics(enable bool, metrics map[string]metricSpec) {
for name, spec := range metrics {
if _, exists := e.metrics[name]; !exists && enable {
e.metrics[name] = spec.intoDesc(name)
} else if !e.extras.Ping {
} else if !enable {
delete(e.metrics, name)
}
}

return nil
}
51 changes: 51 additions & 0 deletions exporter/weighted_mean.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package exporter

import (
"github.com/ffddorf/unms-exporter/models"
)

// weightedMean condenses a list of models.CoordinatesXY down to a single
// value. It computes a weighted arith. mean of all data points between
// tmin and tmax, giving values <= tmin a weight of 0, values >= tmax a
// weight of 1, and using a linear interpolation between these points.
//
// weight ↑
// 1 -+ ··
// | ·
// | ·
// | ·
// 0 -+ ······
// |------+-------+-→ time
// tmin tmax
//
// The typical differenece between tmin and tmax represents a time span
// of 10 minutes, and tmax represents the current timestamp.
func weightedMean(tmin, tmax float64, list models.ListOfCoordinates) (avg float64) {
if tmin > tmax {
tmin, tmax = tmax, tmin
}

// f(x) = mx + b, with b = 0 and slope m = 1/Δx
slope := 1.0 / (tmax - tmin)

// value and weight accumulator: avg = Σᵢ wᵢ·vᵢ / Σᵢ wᵢ
var numerator, denominator float64

for _, xy := range list {
t, val := xy.X, xy.Y
if t < tmin {
continue
}
weight := 1.0
if t < tmax {
weight = slope * (t - tmin)
}
numerator += weight * val
denominator += weight
}

if denominator <= 0 {
return 0
}
return numerator / denominator
}
77 changes: 77 additions & 0 deletions exporter/weighted_mean_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package exporter

import (
"fmt"
"math/rand"
"sort"
"testing"
"time"

"github.com/ffddorf/unms-exporter/models"
"github.com/stretchr/testify/assert"
)

// assert that actual = expected ± ε
func assertWithin(t *testing.T, expected, ε, actual float64, msgAndArgs ...interface{}) {
t.Helper()

if actual <= expected-ε || expected+ε <= actual {
assert.Fail(t, fmt.Sprintf("%f should be within %f of %f", actual, ε, expected), msgAndArgs...)
}
}

func TestWeightedMean_trivialCases(t *testing.T) {
assert.EqualValues(t, 0, weightedMean(0, 0, nil))
assert.EqualValues(t, 0, weightedMean(10, 0, nil))
assert.EqualValues(t, 0, weightedMean(0, 10, nil))
}

func TestWeightedMean_singleItem(t *testing.T) {
assert.EqualValues(t, 0, weightedMean(0, 10, []*models.CoordinatesXY{{X: 0, Y: 10}}))
assert.EqualValues(t, 10, weightedMean(0, 10, []*models.CoordinatesXY{{X: 10, Y: 10}}))

}

func weightedMeanTestList() models.ListOfCoordinates {
list := models.ListOfCoordinates{
{X: 20, Y: 10},
{X: 30, Y: 10},
{X: 40, Y: 10},
{X: 50, Y: 10},
{X: 60, Y: 10},
{X: 70, Y: 10},
{X: 80, Y: 10},
}

// shuffle list. order shall not matter
rand.Seed(time.Now().Unix())
sort.Slice(list, func(int, int) bool { return rand.Float64() < 0.5 })

return list
}

func TestWeightedMean_largeList(t *testing.T) {
list := weightedMeanTestList()

// if all values are 10, then the weight is irrelevant
assertWithin(t, 10, 1e-6, weightedMean(25, 25, list), "equal limits")
assertWithin(t, 10, 1e-6, weightedMean(20, 80, list), "boring case")
assertWithin(t, 10, 1e-6, weightedMean(80, 20, list), "inverted limits")
assertWithin(t, 10, 1e-6, weightedMean(0, 100, list), "limits larger than data")
}

func TestWeightedMean_outlier(t *testing.T) {
// add outlier
list := append(weightedMeanTestList(), &models.CoordinatesXY{X: 10, Y: 1000})
assertWithin(t, 10, 1e-6, weightedMean(25, 25, list[0:7]), "ignoring outlier")

// m = 1/(55-5) = 0.02, tmin = 5
// i list[i] weight weighted value
// - --------- ----------------- --------------
// 5 (70, 10) 1.0 1.0*10 = 10
// 6 (80, 10) 1.0 1.0*10 = 10
// 7 (10, 1000) 0.02*(10-5) = 0.1 0.1*1000 = 100
// sum: 2.1 sum: 120
// mean = 120/2.1 = 57.1428(5)
assertWithin(t, 57.14285, 5e-5, weightedMean(5, 55, list[5:]), "including outlier")
}
Loading