diff --git a/docs/collector.registry.md b/docs/collector.registry.md index 4bae6e29..9633dd31 100644 --- a/docs/collector.registry.md +++ b/docs/collector.registry.md @@ -56,6 +56,7 @@ YAML: metric: windows_registry_windows_major_version # optional help: Windows major version number # optional type: gauge # optional + multiplier: 1 # optional labels: # optional product: windows - name: memory_management # required @@ -77,6 +78,7 @@ JSON: "metric": "windows_registry_windows_major_version", "help": "Windows major version number", "type": "gauge", + "multiplier": 1, "labels": { "product": "windows" } } ] @@ -157,6 +159,18 @@ in the registry. This key is optional. +##### multiplier + +A factor that the value is multiplied by before it is exported, to convert it to +the base unit the Prometheus [naming conventions](https://prometheus.io/docs/practices/naming/#base-units) +ask for. For example, `0.001` converts milliseconds to seconds, `0.01` percent to +a ratio and `3600` hours to seconds. It must be a positive number, so counters +keep counting up. If the multiplier is the reciprocal of an integer, like `0.001`, +the value is divided by that integer instead, which avoids rounding artifacts +such as `0.009000000000000001`. If not specified, the value is exported as is. + +This key is optional. + ##### labels Labels is a map of key-value pairs that will be added as constant labels to the diff --git a/internal/collector/registry/registry.go b/internal/collector/registry/registry.go index b1fd99a7..a3393d1b 100644 --- a/internal/collector/registry/registry.go +++ b/internal/collector/registry/registry.go @@ -31,6 +31,7 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/prometheus-community/windows_exporter/internal/mi" + "github.com/prometheus-community/windows_exporter/internal/multiplier" "github.com/prometheus-community/windows_exporter/internal/types" "github.com/prometheus/client_golang/prometheus" "go.yaml.in/yaml/v3" @@ -233,6 +234,15 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { continue } + m, err := multiplier.New(value.Multiplier) + if err != nil { + errs = append(errs, fmt.Errorf("value %q of key %s: %w", value.Name, label, err)) + + continue + } + + value.multiplier = m + help := value.Help if help == "" { help = "windows_exporter: custom registry metric" @@ -369,7 +379,7 @@ func (c *Collector) collectKey(ch chan<- prometheus.Metric, key Key) error { ch <- prometheus.MustNewConstMetric( value.desc, value.metricType, - float64(val), + value.multiplier.Apply(float64(val)), ) } diff --git a/internal/collector/registry/registry_output_test.go b/internal/collector/registry/registry_output_test.go index f59d9abb..6345043b 100644 --- a/internal/collector/registry/registry_output_test.go +++ b/internal/collector/registry/registry_output_test.go @@ -78,6 +78,7 @@ func TestCollectorMetrics(t *testing.T) { // 64-bit handling. Both are well under 2^53, so they convert to float64 exactly. require.NoError(t, k.SetDWordValue("TestDword", 1234)) require.NoError(t, k.SetQWordValue("TestQword", 5_000_000_000)) + require.NoError(t, k.SetDWordValue("TestMilliseconds", 9)) require.NoError(t, k.Close()) t.Cleanup(func() { @@ -105,6 +106,13 @@ func TestCollectorMetrics(t *testing.T) { Type: "counter", Labels: map[string]string{"foo": "bar"}, }, + // The multiplier converts milliseconds to seconds; 9 * 0.001 + // would be 0.009000000000000001. + { + Name: "TestMilliseconds", + Metric: "windows_registry_test_scaled_seconds", + Multiplier: new(0.001), + }, }, }, { @@ -137,6 +145,9 @@ windows_registry_key_success{key="testgroup"} 1 # HELP windows_registry_test_custom_metric custom registry help text # TYPE windows_registry_test_custom_metric counter windows_registry_test_custom_metric{foo="bar"} 5e+09 +# HELP windows_registry_test_scaled_seconds windows_exporter: custom registry metric +# TYPE windows_registry_test_scaled_seconds gauge +windows_registry_test_scaled_seconds 0.009 # HELP windows_registry_testgroup_testdword windows_exporter: custom registry metric # TYPE windows_registry_testgroup_testdword gauge windows_registry_testgroup_testdword 1234 diff --git a/internal/collector/registry/registry_test.go b/internal/collector/registry/registry_test.go index 1ee30dd3..88edb80c 100644 --- a/internal/collector/registry/registry_test.go +++ b/internal/collector/registry/registry_test.go @@ -121,6 +121,16 @@ func TestCollectorBuildErrors(t *testing.T) { }, }}, }, + { + name: "invalid multiplier", + config: registry.Config{Keys: []registry.Key{ + { + Name: "windows_nt", + Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`, + Values: []registry.Value{{Name: "CurrentMajorVersionNumber", Multiplier: new(0.0)}}, + }, + }}, + }, { name: "shared metric name with inconsistent help text", config: registry.Config{Keys: []registry.Key{ diff --git a/internal/collector/registry/types.go b/internal/collector/registry/types.go index ae4035b7..7e5d4fbb 100644 --- a/internal/collector/registry/types.go +++ b/internal/collector/registry/types.go @@ -19,6 +19,7 @@ package registry import ( "github.com/prometheus-community/windows_exporter/internal/config/yamlstring" + "github.com/prometheus-community/windows_exporter/internal/multiplier" "github.com/prometheus/client_golang/prometheus" "go.yaml.in/yaml/v3" winregistry "golang.org/x/sys/windows/registry" @@ -36,15 +37,17 @@ type Key struct { } type Value struct { - Name string `json:"name" yaml:"name"` - Metric string `json:"metric" yaml:"metric"` - Help string `json:"help" yaml:"help"` - Type string `json:"type" yaml:"type"` - Labels map[string]string `json:"labels" yaml:"labels"` + Name string `json:"name" yaml:"name"` + Metric string `json:"metric" yaml:"metric"` + Help string `json:"help" yaml:"help"` + Type string `json:"type" yaml:"type"` + Labels map[string]string `json:"labels" yaml:"labels"` + Multiplier *float64 `json:"multiplier" yaml:"multiplier"` // resolved at Build time desc *prometheus.Desc metricType prometheus.ValueType + multiplier multiplier.Multiplier } // UnmarshalYAML decodes the registry block of the configuration file, where diff --git a/internal/multiplier/multiplier.go b/internal/multiplier/multiplier.go new file mode 100644 index 00000000..038b829c --- /dev/null +++ b/internal/multiplier/multiplier.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package multiplier applies the optional multiplier of a user-defined metric +// of the wmi, performancecounter and registry collectors. It converts values +// to base units, for example milliseconds to seconds with 0.001. +package multiplier + +import ( + "fmt" + "math" +) + +// maxExactInteger is the largest integer up to which every integer has an +// exact float64 representation. +const maxExactInteger = 1 << 53 + +// Multiplier scales values. The zero value leaves them unchanged. +type Multiplier struct { + factor float64 + divisor float64 +} + +// New validates the multiplier of the configuration; nil means none. It must +// be a positive finite number, so that counters stay monotonic. +func New(multiplier *float64) (Multiplier, error) { + if multiplier == nil { + return Multiplier{}, nil + } + + m := *multiplier + + if math.IsNaN(m) || math.IsInf(m, 0) || m <= 0 { + return Multiplier{}, fmt.Errorf("multiplier must be a positive finite number, got %v", m) + } + + if m == 1 { + return Multiplier{}, nil + } + + // x * 0.001 is not always the correctly rounded x / 1000, because 0.001 + // has no exact binary representation: 9 * 0.001 is 0.009000000000000001. + // Divide by the reciprocal instead when it is an integer. + if m < 1 { + if d := math.Round(1 / m); d <= maxExactInteger && 1/d == m { + return Multiplier{divisor: d}, nil + } + } + + return Multiplier{factor: m}, nil +} + +// Apply returns v scaled by the multiplier. +func (m Multiplier) Apply(v float64) float64 { + switch { + case m.divisor != 0: + return v / m.divisor + case m.factor != 0: + return v * m.factor + default: + return v + } +} diff --git a/internal/multiplier/multiplier_test.go b/internal/multiplier/multiplier_test.go new file mode 100644 index 00000000..fe44b897 --- /dev/null +++ b/internal/multiplier/multiplier_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multiplier_test + +import ( + "math" + "testing" + + "github.com/prometheus-community/windows_exporter/internal/multiplier" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + multiplier *float64 + value float64 + want float64 + }{ + {"unset", nil, 42, 42}, + {"one", new(1.0), 42, 42}, + {"hours to seconds", new(3600.0), 2, 7200}, + {"megabytes to bytes", new(1048576.0), 3, 3145728}, + {"milliseconds to seconds", new(0.001), 9, 0.009}, + {"percent to ratio", new(0.01), 7, 0.07}, + {"ticks to seconds", new(1e-7), 115960642, 11.5960642}, + {"non-integer reciprocal", new(0.4), 5, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + m, err := multiplier.New(tc.multiplier) + require.NoError(t, err) + require.Equal(t, tc.want, m.Apply(tc.value)) //nolint:testifylint // The result must be exact. + }) + } +} + +func TestNewInvalid(t *testing.T) { + t.Parallel() + + for _, m := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1)} { + _, err := multiplier.New(&m) + require.Error(t, err, "multiplier %v", m) + } +} + +// TestApplyDividesExactly checks that a multiplier that is the reciprocal of +// an integer gives the correctly rounded quotient, which a plain +// multiplication misses for about one in seven values. +func TestApplyDividesExactly(t *testing.T) { + t.Parallel() + + for _, divisor := range []float64{10, 100, 1000, 1024, 1e6, 1e7, 1e9} { + m, err := multiplier.New(new(1 / divisor)) + require.NoError(t, err) + + for i := range 100000 { + v := float64(i) + if got := m.Apply(v); got != v/divisor { + t.Fatalf("Apply(%v) with multiplier 1/%v = %v, want %v", v, divisor, got, v/divisor) + } + } + } +}