Skip to content

Commit 71f7c73

Browse files
committed
Add generic exporter for object state
1 parent 1a13c9b commit 71f7c73

File tree

1 file changed

+189
-0
lines changed

1 file changed

+189
-0
lines changed

pkg/metrics/exporter/exporter.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/*
2+
Copyright 2025 Tim Ebert.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package exporter
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"reflect"
23+
"time"
24+
25+
"github.com/prometheus/client_golang/prometheus"
26+
"k8s.io/apimachinery/pkg/api/meta"
27+
"k8s.io/apimachinery/pkg/runtime"
28+
"sigs.k8s.io/controller-runtime/pkg/client"
29+
"sigs.k8s.io/controller-runtime/pkg/manager"
30+
"sigs.k8s.io/controller-runtime/pkg/metrics"
31+
)
32+
33+
// Exporter is a generic state metrics exporter for a given Kubernetes object kind that can be added to a
34+
// controller-runtime manager.
35+
type Exporter[O client.Object, L client.ObjectList] struct {
36+
client.Reader
37+
38+
Namespace, Subsystem string
39+
40+
// StaticLabelKeys are added to all Metrics of this Exporter.
41+
StaticLabelKeys []string
42+
// GenerateStaticLabelValues returns the values for StaticLabelKeys.
43+
GenerateStaticLabelValues func(O) []string
44+
45+
Metrics []Metric[O]
46+
47+
// optional
48+
ListOptions []client.ListOption
49+
}
50+
51+
// Metric describes a single metric and how to generate it per object.
52+
type Metric[O client.Object] struct {
53+
Name, Help string
54+
LabelKeys []string
55+
56+
// Generate returns all metric values for the given object.
57+
// staticLabelValues is the result of Exporter.GenerateStaticLabelValues and should be added as the first label values
58+
// to all metrics.
59+
Generate GenerateFunc[O]
60+
61+
// desc is completed in Exporter.AddToManager.
62+
desc *prometheus.Desc
63+
}
64+
65+
type GenerateFunc[O client.Object] func(desc *prometheus.Desc, obj O, staticLabelValues []string, ch chan<- prometheus.Metric)
66+
67+
// AddToManager adds this exporter to the given manager and completes the Metrics descriptors.
68+
func (e *Exporter[O, L]) AddToManager(mgr manager.Manager) error {
69+
if e.Reader == nil {
70+
e.Reader = mgr.GetCache()
71+
}
72+
73+
for i, m := range e.Metrics {
74+
m.desc = prometheus.NewDesc(
75+
prometheus.BuildFQName(e.Namespace, e.Subsystem, m.Name),
76+
m.Help,
77+
append(e.StaticLabelKeys, m.LabelKeys...),
78+
nil,
79+
)
80+
e.Metrics[i] = m
81+
}
82+
83+
return mgr.Add(e)
84+
}
85+
86+
// NeedLeaderElection tells the manager to run the exporter in all instances.
87+
func (e *Exporter[O, L]) NeedLeaderElection() bool {
88+
return false
89+
}
90+
91+
// Start registers this collector in the controller-runtime metrics registry.
92+
// When Start runs, caches have already been started, so we are ready to export metrics.
93+
func (e *Exporter[O, L]) Start(_ context.Context) error {
94+
if err := metrics.Registry.Register(e); err != nil {
95+
return fmt.Errorf("failed to register %s exporter: %w", e.Subsystem, err)
96+
}
97+
98+
return nil
99+
}
100+
101+
func (e *Exporter[O, L]) Describe(ch chan<- *prometheus.Desc) {
102+
for _, m := range e.Metrics {
103+
ch <- m.desc
104+
}
105+
}
106+
107+
func (e *Exporter[O, L]) Collect(ch chan<- prometheus.Metric) {
108+
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
109+
defer cancel()
110+
111+
list := reflect.New(reflect.TypeOf(*new(L)).Elem()).Interface().(client.ObjectList)
112+
if err := e.List(ctx, list, e.ListOptions...); err != nil {
113+
e.handleError(ch, fmt.Errorf("error listing %T: %w", list, err))
114+
return
115+
}
116+
117+
if err := meta.EachListItem(list, func(obj runtime.Object) error {
118+
o := obj.(O)
119+
staticLabelValues := e.GenerateStaticLabelValues(o)
120+
121+
for _, m := range e.Metrics {
122+
m.Generate(m.desc, o, staticLabelValues, ch)
123+
}
124+
125+
return nil
126+
}); err != nil {
127+
e.handleError(ch, fmt.Errorf("error iterarting %T: %w", list, err))
128+
return
129+
}
130+
}
131+
132+
func (e *Exporter[O, L]) handleError(ch chan<- prometheus.Metric, err error) {
133+
for _, m := range e.Metrics {
134+
ch <- prometheus.NewInvalidMetric(m.desc, err)
135+
}
136+
}
137+
138+
// GenerateStateSet returns a GenerateFunc that emits stateset metrics:
139+
// - it generates one metric per known state
140+
// - the value is 1 if getState returns the state, 0 otherwise
141+
// - if unknownState is given, a metric with this state label is generated with value 1 if no other state has matched
142+
func GenerateStateSet[O client.Object](knownStates []string, unknownState *string, getState func(O) string) GenerateFunc[O] {
143+
return func(desc *prometheus.Desc, obj O, staticLabelValues []string, ch chan<- prometheus.Metric) {
144+
actual := getState(obj)
145+
146+
// generate metrics for all known states
147+
known := false
148+
for _, state := range knownStates {
149+
hasState := actual == state
150+
if hasState {
151+
known = true
152+
}
153+
154+
ch <- stateSetMetric(desc, state, hasState, staticLabelValues)
155+
}
156+
157+
if unknownState != nil {
158+
// generate a metric for unknown states
159+
ch <- stateSetMetric(desc, *unknownState, !known, staticLabelValues)
160+
}
161+
}
162+
}
163+
164+
func stateSetMetric(desc *prometheus.Desc, state string, value bool, staticLabelValues []string) prometheus.Metric {
165+
v := 0
166+
if value {
167+
v = 1
168+
}
169+
170+
return prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, float64(v), append(staticLabelValues, state)...)
171+
}
172+
173+
// KnownStates converts the given slice of ~string to a slice of strings.
174+
func KnownStates[T ~string](s []T) []string {
175+
out := make([]string, len(s))
176+
for i := range s {
177+
out[i] = string(s[i])
178+
}
179+
return out
180+
}
181+
182+
// KnownStatesStringer converts the given slice of fmt.Stringer to a slice of strings.
183+
func KnownStatesStringer[T fmt.Stringer](s []T) []string {
184+
out := make([]string, len(s))
185+
for i := range s {
186+
out[i] = s[i].String()
187+
}
188+
return out
189+
}

0 commit comments

Comments
 (0)