Skip to content

Commit 5d421be

Browse files
committed
Stongly typed labels: promsafe feature introduced
Signed-off-by: Eugene <[email protected]>
1 parent dbf72fc commit 5d421be

File tree

2 files changed

+438
-0
lines changed

2 files changed

+438
-0
lines changed

prometheus/promsafe/safe.go

+307
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// Copyright 2024 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
// Package promsafe provides safe labeling - strongly typed labels in prometheus metrics.
15+
// Enjoy promsafe as you wish!
16+
package promsafe
17+
18+
import (
19+
"fmt"
20+
"reflect"
21+
"strings"
22+
23+
"github.com/prometheus/client_golang/prometheus"
24+
"github.com/prometheus/client_golang/prometheus/promauto"
25+
)
26+
27+
//
28+
// promsafe configuration: promauto-compatibility, etc
29+
//
30+
31+
// factory stands for a global promauto.Factory to be used (if any)
32+
var factory *promauto.Factory
33+
34+
// SetupGlobalPromauto sets a global promauto.Factory to be used for all promsafe metrics.
35+
// This means that each promsafe.New* call will use this promauto.Factory.
36+
func SetupGlobalPromauto(factoryArg ...promauto.Factory) {
37+
if len(factoryArg) == 0 {
38+
f := promauto.With(prometheus.DefaultRegisterer)
39+
factory = &f
40+
} else {
41+
f := factoryArg[0]
42+
factory = &f
43+
}
44+
}
45+
46+
// promsafeTag is the tag name used for promsafe labels inside structs.
47+
// The tag is optional, as if not present, field is used with snake_cased FieldName.
48+
// It's useful to use a tag when you want to override the default naming or exclude a field from the metric.
49+
var promsafeTag = "promsafe"
50+
51+
// SetPromsafeTag sets the tag name used for promsafe labels inside structs.
52+
func SetPromsafeTag(tag string) {
53+
promsafeTag = tag
54+
}
55+
56+
// labelProviderMarker is a marker interface for enforcing type-safety.
57+
// With its help we can force our label-related functions to only accept SingleLabelProvider or StructLabelProvider.
58+
type labelProviderMarker interface {
59+
marker()
60+
}
61+
62+
// SingleLabelProvider is a type used for declaring a single label.
63+
// When used as labelProviderMarker it provides just a label name.
64+
// It's meant to be used with single-label metrics only!
65+
// Use StructLabelProvider for multi-label metrics.
66+
type SingleLabelProvider string
67+
68+
var _ labelProviderMarker = SingleLabelProvider("")
69+
70+
func (s SingleLabelProvider) marker() {
71+
panic("marker interface method should never be called")
72+
}
73+
74+
// StructLabelProvider should be embedded in any struct that serves as a label provider.
75+
type StructLabelProvider struct{}
76+
77+
var _ labelProviderMarker = (*StructLabelProvider)(nil)
78+
79+
func (s StructLabelProvider) marker() {
80+
panic("marker interface method should never be called")
81+
}
82+
83+
// handler is a helper struct that helps us to handle type-safe labels
84+
// It holds a label name in case if it's the only label (when SingleLabelProvider is used).
85+
type handler[T labelProviderMarker] struct {
86+
theOnlyLabelName string
87+
}
88+
89+
func newHandler[T labelProviderMarker](labelProvider T) handler[T] {
90+
var h handler[T]
91+
if s, ok := any(labelProvider).(SingleLabelProvider); ok {
92+
h.theOnlyLabelName = string(s)
93+
}
94+
return h
95+
}
96+
97+
// extractLabelsWithValues extracts labels names+values from a given labelProviderMarker (SingleLabelProvider or StructLabelProvider)
98+
func (h handler[T]) extractLabels(labelProvider T) []string {
99+
if any(labelProvider) == nil {
100+
return nil
101+
}
102+
if s, ok := any(labelProvider).(SingleLabelProvider); ok {
103+
return []string{string(s)}
104+
}
105+
106+
// Here, then, it can be only a struct, that is a parent of StructLabelProvider
107+
labels := extractLabelFromStruct(labelProvider)
108+
labelNames := make([]string, 0, len(labels))
109+
for k := range labels {
110+
labelNames = append(labelNames, k)
111+
}
112+
return labelNames
113+
}
114+
115+
// extractLabelsWithValues extracts labels names+values from a given labelProviderMarker (SingleLabelProvider or StructLabelProvider)
116+
func (h handler[T]) extractLabelsWithValues(labelProvider T) prometheus.Labels {
117+
if any(labelProvider) == nil {
118+
return nil
119+
}
120+
121+
// TODO: let's handle defaults as well, why not?
122+
123+
if s, ok := any(labelProvider).(SingleLabelProvider); ok {
124+
return prometheus.Labels{h.theOnlyLabelName: string(s)}
125+
}
126+
127+
// Here, then, it can be only a struct, that is a parent of StructLabelProvider
128+
return extractLabelFromStruct(labelProvider)
129+
}
130+
131+
// extractLabelValues extracts label string values from a given labelProviderMarker (SingleLabelProvider or StructLabelProvider)
132+
func (h handler[T]) extractLabelValues(labelProvider T) []string {
133+
m := h.extractLabelsWithValues(labelProvider)
134+
135+
labelValues := make([]string, 0, len(m))
136+
for _, v := range m {
137+
labelValues = append(labelValues, v)
138+
}
139+
return labelValues
140+
}
141+
142+
// NewCounterVecT creates a new CounterVecT with type-safe labels.
143+
func NewCounterVecT[T labelProviderMarker](opts prometheus.CounterOpts, labels T) *CounterVecT[T] {
144+
h := newHandler(labels)
145+
146+
var inner *prometheus.CounterVec
147+
148+
if factory != nil {
149+
inner = factory.NewCounterVec(opts, h.extractLabels(labels))
150+
} else {
151+
inner = prometheus.NewCounterVec(opts, h.extractLabels(labels))
152+
}
153+
154+
return &CounterVecT[T]{
155+
handler: h,
156+
inner: inner,
157+
}
158+
}
159+
160+
// CounterVecT is a wrapper around prometheus.CounterVecT that allows type-safe labels.
161+
type CounterVecT[T labelProviderMarker] struct {
162+
handler[T]
163+
inner *prometheus.CounterVec
164+
}
165+
166+
// GetMetricWithLabelValues behaves like prometheus.CounterVec.GetMetricWithLabelValues but with type-safe labels.
167+
func (c *CounterVecT[T]) GetMetricWithLabelValues(labels T) (prometheus.Counter, error) {
168+
return c.inner.GetMetricWithLabelValues(c.handler.extractLabelValues(labels)...)
169+
}
170+
171+
// GetMetricWith behaves like prometheus.CounterVec.GetMetricWith but with type-safe labels.
172+
func (c *CounterVecT[T]) GetMetricWith(labels T) (prometheus.Counter, error) {
173+
return c.inner.GetMetricWith(c.handler.extractLabelsWithValues(labels))
174+
}
175+
176+
// WithLabelValues behaves like prometheus.CounterVec.WithLabelValues but with type-safe labels.
177+
func (c *CounterVecT[T]) WithLabelValues(labels T) prometheus.Counter {
178+
return c.inner.WithLabelValues(c.handler.extractLabelValues(labels)...)
179+
}
180+
181+
// With behaves like prometheus.CounterVec.With but with type-safe labels.
182+
func (c *CounterVecT[T]) With(labels T) prometheus.Counter {
183+
return c.inner.With(c.handler.extractLabelsWithValues(labels))
184+
}
185+
186+
// CurryWith behaves like prometheus.CounterVec.CurryWith but with type-safe labels.
187+
// It still returns a CounterVecT, but it's inner prometheus.CounterVec is curried.
188+
func (c *CounterVecT[T]) CurryWith(labels T) (*CounterVecT[T], error) {
189+
curriedInner, err := c.inner.CurryWith(c.handler.extractLabelsWithValues(labels))
190+
if err != nil {
191+
return nil, err
192+
}
193+
c.inner = curriedInner
194+
return c, nil
195+
}
196+
197+
// MustCurryWith behaves like prometheus.CounterVec.MustCurryWith but with type-safe labels.
198+
// It still returns a CounterVecT, but it's inner prometheus.CounterVec is curried.
199+
func (c *CounterVecT[T]) MustCurryWith(labels T) *CounterVecT[T] {
200+
c.inner = c.inner.MustCurryWith(c.handler.extractLabelsWithValues(labels))
201+
return c
202+
}
203+
204+
// Unsafe returns the underlying prometheus.CounterVec
205+
// it's used to call any other method of prometheus.CounterVec that doesn't require type-safe labels
206+
func (c *CounterVecT[T]) Unsafe() *prometheus.CounterVec {
207+
return c.inner
208+
}
209+
210+
// NewCounterT simply creates a new prometheus.Counter.
211+
// As it doesn't have any labels, it's already type-safe.
212+
// We keep this method just for consistency and interface fulfillment.
213+
func NewCounterT(opts prometheus.CounterOpts) prometheus.Counter {
214+
return prometheus.NewCounter(opts)
215+
}
216+
217+
// NewCounterFuncT simply creates a new prometheus.CounterFunc.
218+
// As it doesn't have any labels, it's already type-safe.
219+
// We keep this method just for consistency and interface fulfillment.
220+
func NewCounterFuncT(opts prometheus.CounterOpts, function func() float64) prometheus.CounterFunc {
221+
return prometheus.NewCounterFunc(opts, function)
222+
}
223+
224+
//
225+
// Promauto compatibility
226+
//
227+
228+
// Factory is a promauto-like factory that allows type-safe labels.
229+
// We have to duplicate promauto.Factory logic here, because promauto.Factory's registry is private.
230+
type Factory[T labelProviderMarker] struct {
231+
r prometheus.Registerer
232+
}
233+
234+
// WithAuto is a helper function that allows to use promauto.With with promsafe.With
235+
func WithAuto(r prometheus.Registerer) Factory[labelProviderMarker] {
236+
return Factory[labelProviderMarker]{r: r}
237+
}
238+
239+
// NewCounterVecT works like promauto.NewCounterVec but with type-safe labels
240+
func (f Factory[T]) NewCounterVecT(opts prometheus.CounterOpts, labels T) *CounterVecT[T] {
241+
c := NewCounterVecT(opts, labels)
242+
if f.r != nil {
243+
f.r.MustRegister(c.inner)
244+
}
245+
return c
246+
}
247+
248+
// NewCounterT wraps promauto.NewCounter.
249+
// As it doesn't require any labels, it's already type-safe, and we keep it for consistency.
250+
func (f Factory[T]) NewCounterT(opts prometheus.CounterOpts) prometheus.Counter {
251+
return promauto.With(f.r).NewCounter(opts)
252+
}
253+
254+
// NewCounterFuncT wraps promauto.NewCounterFunc.
255+
// As it doesn't require any labels, it's already type-safe, and we keep it for consistency.
256+
func (f Factory[T]) NewCounterFuncT(opts prometheus.CounterOpts, function func() float64) prometheus.CounterFunc {
257+
return promauto.With(f.r).NewCounterFunc(opts, function)
258+
}
259+
260+
//
261+
// Helpers
262+
//
263+
264+
// extractLabelFromStruct extracts labels names+values from a given StructLabelProvider
265+
func extractLabelFromStruct(structWithLabels any) prometheus.Labels {
266+
labels := prometheus.Labels{}
267+
268+
val := reflect.Indirect(reflect.ValueOf(structWithLabels))
269+
typ := val.Type()
270+
271+
for i := 0; i < typ.NumField(); i++ {
272+
field := typ.Field(i)
273+
if field.Anonymous {
274+
continue
275+
}
276+
277+
var labelName string
278+
if ourTag := field.Tag.Get(promsafeTag); ourTag != "" {
279+
if ourTag == "-" { // tag="-" means "skip this field"
280+
continue
281+
}
282+
labelName = ourTag
283+
} else {
284+
labelName = toSnakeCase(field.Name)
285+
}
286+
287+
// Note: we don't handle defaults values for now
288+
// so it can have "nil" values, if you had *string fields, etc
289+
fieldVal := fmt.Sprintf("%v", val.Field(i).Interface())
290+
291+
labels[labelName] = fieldVal
292+
}
293+
return labels
294+
}
295+
296+
// Convert struct field names to snake_case for Prometheus label compliance.
297+
func toSnakeCase(s string) string {
298+
s = strings.TrimSpace(s)
299+
var result []rune
300+
for i, r := range s {
301+
if i > 0 && r >= 'A' && r <= 'Z' {
302+
result = append(result, '_')
303+
}
304+
result = append(result, r)
305+
}
306+
return strings.ToLower(string(result))
307+
}

0 commit comments

Comments
 (0)