diff --git a/prometheus/promsafe/safe.go b/prometheus/promsafe/safe.go new file mode 100644 index 0000000..c1fee8a --- /dev/null +++ b/prometheus/promsafe/safe.go @@ -0,0 +1,387 @@ +// Copyright 2024 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 promsafe provides safe labeling - strongly typed labels in prometheus metrics. +// Enjoy promsafe as you wish! +package promsafe + +import ( + "fmt" + "reflect" + "strings" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// +// promsafe configuration: promauto-compatibility, etc +// + +// factory stands for a global promauto.Factory to be used (if any) +var factory *promauto.Factory + +// SetupGlobalPromauto sets a global promauto.Factory to be used for all promsafe metrics. +// This means that each promsafe.New* call will use this promauto.Factory. +func SetupGlobalPromauto(factoryArg ...promauto.Factory) { + if len(factoryArg) == 0 { + f := promauto.With(prometheus.DefaultRegisterer) + factory = &f + } else { + f := factoryArg[0] + factory = &f + } +} + +// CustomLabelsProvider is an interface that allows to convert anything to a prometheus.Labels +// It allows to provide your own FAST implementation of Struct->prometheus.Labels conversion +// without using reflection. +type CustomLabelsProvider interface { + ToPrometheusLabels() prometheus.Labels +} + +// CustomLabelNamesProvider is an interface that allows to convert anything to an order list of label names +// Result of it is considered to be when registering new metric. As we need proper ORDERED list of label names +// It allows to provide your own FAST implementation of Struct->[]string conversion without any reflection +type CustomLabelNamesProvider interface { + ToLabelNames() []string +} + +// promsafeTag is the tag name used for promsafe labels inside structs. +// The tag is optional, as if not present, field is used with snake_cased FieldName. +// It's useful to use a tag when you want to override the default naming or exclude a field from the metric. +var promsafeTag = "promsafe" + +// SetPromsafeTag sets the tag name used for promsafe labels inside structs. +func SetPromsafeTag(tag string) { + promsafeTag = tag +} + +// labelsProviderMarker is a marker interface for enforcing type-safety of StructLabelProvider. +type labelsProviderMarker interface { + labelsProviderMarker() +} + +// StructLabelProvider should be embedded in any struct that serves as a label provider. +type StructLabelProvider struct{} + +var _ labelsProviderMarker = (*StructLabelProvider)(nil) + +func (s StructLabelProvider) labelsProviderMarker() { + panic("labelsProviderMarker interface method should never be called") +} + +// newEmptyLabels creates a new empty labels instance of type T +// It's a bit tricky as we want to support both structs and pointers to structs +// e.g. &MyLabels{StructLabelProvider} or MyLabels{StructLabelProvider} +func newEmptyLabels[T labelsProviderMarker]() T { + var emptyLabels T + + // Let's Support both Structs or Pointer to Structs given as T + val := reflect.ValueOf(&emptyLabels).Elem() + if val.Kind() == reflect.Ptr { + val.Set(reflect.New(val.Type().Elem())) + } + + return emptyLabels +} + +// NewCounterVecT creates a new CounterVecT with type-safe labels. +func NewCounterVecT[T labelsProviderMarker](opts prometheus.CounterOpts) *CounterVecT[T] { + emptyLabels := newEmptyLabels[T]() + + var inner *prometheus.CounterVec + if factory != nil { + inner = factory.NewCounterVec(opts, extractLabelNames(emptyLabels)) + } else { + inner = prometheus.NewCounterVec(opts, extractLabelNames(emptyLabels)) + } + + return &CounterVecT[T]{inner: inner} +} + +// CounterVecT is a wrapper around prometheus.CounterVec that allows type-safe labels. +type CounterVecT[T labelsProviderMarker] struct { + inner *prometheus.CounterVec +} + +// GetMetricWithLabelValues covers prometheus.CounterVec.GetMetricWithLabelValues +// Deprecated: Use GetMetricWith() instead. We can't provide a []string safe implementation in promsafe +func (c *CounterVecT[T]) GetMetricWithLabelValues(lvs ...string) (prometheus.Counter, error) { + panic("There can't be a SAFE GetMetricWithLabelValues(). Use GetMetricWith() instead") +} + +// GetMetricWith behaves like prometheus.CounterVec.GetMetricWith but with type-safe labels. +func (c *CounterVecT[T]) GetMetricWith(labels T) (prometheus.Counter, error) { + return c.inner.GetMetricWith(extractLabelsWithValues(labels)) +} + +// WithLabelValues covers like prometheus.CounterVec.WithLabelValues. +// Deprecated: Use With() instead. We can't provide a []string safe implementation in promsafe +func (c *CounterVecT[T]) WithLabelValues(lvs ...string) prometheus.Counter { + panic("There can't be a SAFE WithLabelValues(). Use With() instead") +} + +// With behaves like prometheus.CounterVec.With but with type-safe labels. +func (c *CounterVecT[T]) With(labels T) prometheus.Counter { + return c.inner.With(extractLabelsWithValues(labels)) +} + +// CurryWith behaves like prometheus.CounterVec.CurryWith but with type-safe labels. +// It still returns a CounterVecT, but it's inner prometheus.CounterVec is curried. +func (c *CounterVecT[T]) CurryWith(labels T) (*CounterVecT[T], error) { + curriedInner, err := c.inner.CurryWith(extractLabelsWithValues(labels)) + if err != nil { + return nil, err + } + c.inner = curriedInner + return c, nil +} + +// MustCurryWith behaves like prometheus.CounterVec.MustCurryWith but with type-safe labels. +// It still returns a CounterVecT, but it's inner prometheus.CounterVec is curried. +func (c *CounterVecT[T]) MustCurryWith(labels T) *CounterVecT[T] { + c.inner = c.inner.MustCurryWith(extractLabelsWithValues(labels)) + return c +} + +// Unsafe returns the underlying prometheus.CounterVec +// it's used to call any other method of prometheus.CounterVec that doesn't require type-safe labels +func (c *CounterVecT[T]) Unsafe() *prometheus.CounterVec { + return c.inner +} + +// NewCounterT simply creates a new prometheus.Counter. +// As it doesn't have any labels, it's already type-safe. +// We keep this method just for consistency and interface fulfillment. +func NewCounterT(opts prometheus.CounterOpts) prometheus.Counter { + return prometheus.NewCounter(opts) +} + +// NewCounterFuncT simply creates a new prometheus.CounterFunc. +// As it doesn't have any labels, it's already type-safe. +// We keep this method just for consistency and interface fulfillment. +func NewCounterFuncT(opts prometheus.CounterOpts, function func() float64) prometheus.CounterFunc { + return prometheus.NewCounterFunc(opts, function) +} + +// +// Shorthand for Metrics with a single label +// + +// NewCounterVecT1 creates a new CounterVecT with the only single label +func NewCounterVecT1(opts prometheus.CounterOpts, labelName string) *CounterVecT1 { + var inner *prometheus.CounterVec + if factory != nil { + inner = factory.NewCounterVec(opts, []string{labelName}) + } else { + inner = prometheus.NewCounterVec(opts, []string{labelName}) + } + + return &CounterVecT1{inner: inner, labelName: labelName} +} + +// CounterVecT1 is a wrapper around prometheus.CounterVec that allows a single type-safe label. +type CounterVecT1 struct { + labelName string + inner *prometheus.CounterVec +} + +// GetMetricWithLabelValues behaves like prometheus.CounterVec.GetMetricWithLabelValues but with type-safe labels. +func (c *CounterVecT1) GetMetricWithLabelValues(labelValue string) (prometheus.Counter, error) { + return c.inner.GetMetricWithLabelValues(labelValue) +} + +// GetMetricWith behaves like prometheus.CounterVec.GetMetricWith but with type-safe labels. +// We keep this function for consistency. Actually is useless and it's the same as GetMetricWithLabelValues +func (c *CounterVecT1) GetMetricWith(labelValue string) (prometheus.Counter, error) { + return c.inner.GetMetricWith(prometheus.Labels{c.labelName: labelValue}) +} + +// WithLabelValues behaves like prometheus.CounterVec.WithLabelValues but with type-safe labels. +func (c *CounterVecT1) WithLabelValues(labelValue string) prometheus.Counter { + return c.inner.WithLabelValues(labelValue) +} + +// With behaves like prometheus.CounterVec.With but with type-safe labels. +// We keep this function for consistency. Actually is useless and it's the same as WithLabelValues +func (c *CounterVecT1) With(labelValue string) prometheus.Counter { + return c.inner.With(prometheus.Labels{c.labelName: labelValue}) +} + +// CurryWith behaves like prometheus.CounterVec.CurryWith but with type-safe labels. +// It still returns a CounterVecT, but it's inner prometheus.CounterVec is curried. +func (c *CounterVecT1) CurryWith(labelValue string) (*CounterVecT1, error) { + curriedInner, err := c.inner.CurryWith(prometheus.Labels{c.labelName: labelValue}) + if err != nil { + return nil, err + } + c.inner = curriedInner + return c, nil +} + +// MustCurryWith behaves like prometheus.CounterVec.MustCurryWith but with type-safe labels. +// It still returns a CounterVecT, but it's inner prometheus.CounterVec is curried. +func (c *CounterVecT1) MustCurryWith(labelValue string) *CounterVecT1 { + c.inner = c.inner.MustCurryWith(prometheus.Labels{c.labelName: labelValue}) + return c +} + +// Unsafe returns the underlying prometheus.CounterVec +// it's used to call any other method of prometheus.CounterVec that doesn't require type-safe labels +func (c *CounterVecT1) Unsafe() *prometheus.CounterVec { + return c.inner +} + +// +// Promauto compatibility +// + +// Factory is a promauto-like factory that allows type-safe labels. +// We have to duplicate promauto.Factory logic here, because promauto.Factory's registry is private. +type Factory[T labelsProviderMarker] struct { + r prometheus.Registerer +} + +// WithAuto is a helper function that allows to use promauto.With with promsafe.With +func WithAuto[T labelsProviderMarker](r prometheus.Registerer) Factory[T] { + return Factory[T]{r: r} +} + +// NewCounterVecT works like promauto.NewCounterVec but with type-safe labels +func (f Factory[T]) NewCounterVecT(opts prometheus.CounterOpts) *CounterVecT[T] { + c := NewCounterVecT[T](opts) + if f.r != nil { + f.r.MustRegister(c.inner) + } + return c +} + +// NewCounterT wraps promauto.NewCounter. +// As it doesn't require any labels, it's already type-safe, and we keep it for consistency. +func (f Factory[T]) NewCounterT(opts prometheus.CounterOpts) prometheus.Counter { + return promauto.With(f.r).NewCounter(opts) +} + +// NewCounterFuncT wraps promauto.NewCounterFunc. +// As it doesn't require any labels, it's already type-safe, and we keep it for consistency. +func (f Factory[T]) NewCounterFuncT(opts prometheus.CounterOpts, function func() float64) prometheus.CounterFunc { + return promauto.With(f.r).NewCounterFunc(opts, function) +} + +// TODO: we can't use Factory with NewCounterT1. If we need, then we need a new type-less Factory + +// +// Helpers +// + +// extractLabelsWithValues extracts labels names+values from a given labelsProviderMarker (parent instance of a StructLabelProvider) +func extractLabelsWithValues(labelProvider labelsProviderMarker) prometheus.Labels { + if any(labelProvider) == nil { + return nil + } + + if clp, ok := labelProvider.(CustomLabelsProvider); ok { + return clp.ToPrometheusLabels() + } + + // Here, then, it can be only a struct, that is a parent of StructLabelProvider + return extractLabelFromStruct(labelProvider) +} + +// extractLabelNames extracts labels names from a given labelsProviderMarker (parent instance of aStructLabelProvider) +func extractLabelNames(labelProvider labelsProviderMarker) []string { + if any(labelProvider) == nil { + return nil + } + + // If custom implementation is done, just do it + if clp, ok := labelProvider.(CustomLabelNamesProvider); ok { + return clp.ToLabelNames() + } + + // Fallback to slow implementation via reflect + // We'll return label values in order of fields in the struct + val := reflect.Indirect(reflect.ValueOf(labelProvider)) + typ := val.Type() + + labelNames := make([]string, 0, typ.NumField()) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.Anonymous { + continue + } + + var fieldName string + if ourTag := field.Tag.Get(promsafeTag); ourTag == "-" { + continue + } else if ourTag != "" { + fieldName = ourTag + } else { + fieldName = toSnakeCase(field.Name) + } + + labelNames = append(labelNames, fieldName) + } + + return labelNames +} + +// extractLabelFromStruct extracts labels names+values from a given StructLabelProvider +func extractLabelFromStruct(structWithLabels any) prometheus.Labels { + labels := prometheus.Labels{} + + val := reflect.Indirect(reflect.ValueOf(structWithLabels)) + typ := val.Type() + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.Anonymous { + continue + } + + var labelName string + if ourTag := field.Tag.Get(promsafeTag); ourTag != "" { + if ourTag == "-" { // tag="-" means "skip this field" + continue + } + labelName = ourTag + } else { + labelName = toSnakeCase(field.Name) + } + + labels[labelName] = stringifyLabelValue(val.Field(i)) + } + return labels +} + +// stringifyLabelValue makes up a valid string value from a given field's value +// It's used ONLY in fallback reflect mode +// Field value might be a pointer, that's why we do reflect.Indirect() +// Note: in future we can handle default values here as well +func stringifyLabelValue(v reflect.Value) string { + return fmt.Sprintf("%v", reflect.Indirect(v).Interface()) +} + +// Convert struct field names to snake_case for Prometheus label compliance. +func toSnakeCase(s string) string { + s = strings.TrimSpace(s) + var result []rune + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + result = append(result, '_') + } + result = append(result, r) + } + return strings.ToLower(string(result)) +} diff --git a/prometheus/promsafe/safe_test.go b/prometheus/promsafe/safe_test.go new file mode 100644 index 0000000..977a031 --- /dev/null +++ b/prometheus/promsafe/safe_test.go @@ -0,0 +1,197 @@ +// Copyright 2024 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 promsafe_test + +import ( + "log" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promsafe" +) + +// Important: This is not a test file. These are only examples! +// These can be considered smoke tests, but nothing more. +// TODO: Write real tests + +func ExampleNewCounterVecT_multiple_labels_manual() { + // Manually registering with multiple labels + + type MyCounterLabels struct { + promsafe.StructLabelProvider + EventType string + Success bool + Position uint8 // yes, it's a number, but be careful with high-cardinality labels + + ShouldNotBeUsed string `promsafe:"-"` + } + + c := promsafe.NewCounterVecT[MyCounterLabels](prometheus.CounterOpts{ + Name: "items_counted_detailed", + }) + + // Manually register the counter + if err := prometheus.Register(c.Unsafe()); err != nil { + log.Fatal("could not register: ", err.Error()) + } + + // and now, because of generics we can call Inc() with filled struct of labels: + counter := c.With(MyCounterLabels{ + EventType: "reservation", Success: true, Position: 1, + }) + counter.Inc() + + // Output: +} + +func ExampleNewCounterVecT_promauto_migrated() { + // Examples on how to migrate from promauto to promsafe + // When promauto was using a custom factory with custom registry + + myReg := prometheus.NewRegistry() + + counterOpts := prometheus.CounterOpts{ + Name: "items_counted_detailed_auto", + } + + // Old unsafe code + // promauto.With(myReg).NewCounterVec(counterOpts, []string{"event_type", "source"}) + // becomes: + + type MyLabels struct { + promsafe.StructLabelProvider + EventType string + Source string + } + c := promsafe.WithAuto[MyLabels](myReg).NewCounterVecT(counterOpts) + + c.With(MyLabels{ + EventType: "reservation", Source: "source1", + }).Inc() + + // Output: +} + +func ExampleNewCounterVecT_promauto_global_migrated() { + // Examples on how to migrate from promauto to promsafe + // when promauto public API was used (with default registry) + + // Setup so every NewCounter* call will use default registry + // like promauto does + // Note: it actually accepts other registry to become a default one + promsafe.SetupGlobalPromauto() + defer func() { + // cleanup for other examples + promsafe.SetupGlobalPromauto(promauto.With(nil)) + }() + + counterOpts := prometheus.CounterOpts{ + Name: "items_counted_detailed_auto_global", + } + + // Old code: + //c := promauto.NewCounterVec(counterOpts, []string{"status", "source"}) + //c.With(prometheus.Labels{ + // "status": "active", + // "source": "source1", + //}).Inc() + // becomes: + + type MyLabels struct { + promsafe.StructLabelProvider + Status string + Source string + } + c := promsafe.NewCounterVecT[*MyLabels](counterOpts) + + c.With(&MyLabels{ + Status: "active", Source: "source1", + }).Inc() + + // Output: +} + +func ExampleNewCounterVecT_pointer_to_labels_promauto() { + // It's possible to use pointer to labels struct + myReg := prometheus.NewRegistry() + + counterOpts := prometheus.CounterOpts{ + Name: "items_counted_detailed_ptr", + } + + type MyLabels struct { + promsafe.StructLabelProvider + EventType string + Source string + } + c := promsafe.WithAuto[*MyLabels](myReg).NewCounterVecT(counterOpts) + + c.With(&MyLabels{ + EventType: "reservation", Source: "source1", + }).Inc() + + // Output: +} + +// FastMyLabels is a struct that will have a custom method that converts to prometheus.Labels +type FastMyLabels struct { + promsafe.StructLabelProvider + EventType string + Source string +} + +// ToPrometheusLabels does a superfast conversion to labels. So no reflection is involved. +func (f FastMyLabels) ToPrometheusLabels() prometheus.Labels { + return prometheus.Labels{"event_type": f.EventType, "source": f.Source} +} + +// ToLabelNames does a superfast label names list. So no reflection is involved. +func (f FastMyLabels) ToLabelNames() []string { + return []string{"event_type", "source"} +} + +func ExampleNewCounterVecT_fast_safe_labels_provider() { + // It's possible to use pointer to labels struct + myReg := prometheus.NewRegistry() + + counterOpts := prometheus.CounterOpts{ + Name: "items_counted_fast", + } + + c := promsafe.WithAuto[FastMyLabels](myReg).NewCounterVecT(counterOpts) + + c.With(FastMyLabels{ + EventType: "reservation", Source: "source1", + }).Inc() + + // Output: +} + +func ExampleNewCounterVecT_single_label_manual() { + // Manually registering with a single label + // Example of usage of shorthand: no structs no generics, but one string only + + c := promsafe.NewCounterVecT1(prometheus.CounterOpts{ + Name: "items_counted_by_status", + }, "status") + + // Manually register the counter + if err := prometheus.Register(c.Unsafe()); err != nil { + log.Fatal("could not register: ", err.Error()) + } + + c.With("active").Inc() + + // Output: +}