From 879c4741b022181c24228188df76991fe87068ce Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 29 Nov 2024 11:57:57 +0200 Subject: [PATCH] Promsafe introduced: Type Safe Labels. (Draft, Discussion is still required) Signed-off-by: Eugene --- prometheus/promsafe/labels_provider.go | 161 +++++++++++ prometheus/promsafe/labels_provider_test.go | 263 ++++++++++++++++++ .../promauto_adapter/promauto_adapter.go | 63 +++++ .../promauto_adapter/promauto_adapter_test.go | 58 ++++ prometheus/promsafe/safe.go | 186 +++++++++++++ prometheus/promsafe/safe_test.go | 162 +++++++++++ 6 files changed, 893 insertions(+) create mode 100644 prometheus/promsafe/labels_provider.go create mode 100644 prometheus/promsafe/labels_provider_test.go create mode 100644 prometheus/promsafe/promauto_adapter/promauto_adapter.go create mode 100644 prometheus/promsafe/promauto_adapter/promauto_adapter_test.go create mode 100644 prometheus/promsafe/safe.go create mode 100644 prometheus/promsafe/safe_test.go diff --git a/prometheus/promsafe/labels_provider.go b/prometheus/promsafe/labels_provider.go new file mode 100644 index 0000000..0d73d68 --- /dev/null +++ b/prometheus/promsafe/labels_provider.go @@ -0,0 +1,161 @@ +// 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 + +import ( + "fmt" + "reflect" + "strings" + + "github.com/prometheus/client_golang/prometheus" +) + +// LabelsProvider is an interface that allows to convert anything into prometheus.Labels +// It allows to provide your own FAST implementation of Struct->prometheus.Labels conversion +// without using reflection. +type LabelsProvider interface { + ToPrometheusLabels() prometheus.Labels + ToLabelNames() []string +} + +// 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 + + val := reflect.ValueOf(&emptyLabels).Elem() + if val.Kind() == reflect.Ptr { + ptrType := val.Type().Elem() + newValue := reflect.New(ptrType).Interface().(T) + return newValue + } + + return emptyLabels +} + +// +// Helpers +// + +// 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 +} + +// iterateStructFields iterates over struct fields, calling the given function for each field. +func iterateStructFields(structValue any, fn func(labelName string, fieldValue reflect.Value)) { + val := reflect.Indirect(reflect.ValueOf(structValue)) + typ := val.Type() + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.Anonymous { + continue + } + + // Handle tag logic centrally + var labelName string + if ourTag := field.Tag.Get(promsafeTag); ourTag == "-" { + continue // Skip field + } else if ourTag != "" { + labelName = ourTag + } else { + labelName = toSnakeCase(field.Name) + } + + fn(labelName, val.Field(i)) + } +} + +// 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.(LabelsProvider); ok { + return clp.ToPrometheusLabels() + } + + // extracting labels from a struct + labels := prometheus.Labels{} + iterateStructFields(labelProvider, func(labelName string, fieldValue reflect.Value) { + labels[labelName] = stringifyLabelValue(fieldValue) + }) + return labels +} + +// 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 lp, ok := labelProvider.(LabelsProvider); ok { + return lp.ToLabelNames() + } + + // Fallback to slow implementation via reflect + // Important! We return label names in order of fields in the struct + labelNames := make([]string, 0) + iterateStructFields(labelProvider, func(labelName string, fieldValue reflect.Value) { + labelNames = append(labelNames, labelName) + }) + + return labelNames +} + +// 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 { + // TODO: we probably want to handle custom type processing here + // e.g. sometimes booleans need to be "on"/"off" instead of "true"/"false" + 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/labels_provider_test.go b/prometheus/promsafe/labels_provider_test.go new file mode 100644 index 0000000..093d131 --- /dev/null +++ b/prometheus/promsafe/labels_provider_test.go @@ -0,0 +1,263 @@ +// 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 + +import ( + "reflect" + "strconv" + "testing" + + "github.com/prometheus/client_golang/prometheus" +) + +type TestLabels struct { + StructLabelProvider + Field1 string + Field2 int + Field3 bool +} + +type TestLabelsWithTags struct { + StructLabelProvider + FieldA string `promsafe:"custom_a"` + FieldB int `promsafe:"-"` + FieldC bool // no promsafe tag, so default to snake_case of field_c +} + +type TestLabelsWithPointers struct { + StructLabelProvider + Field1 *string + Field2 *int + Field3 *bool +} + +type TestLabelsFast struct { + StructLabelProvider + Field1 string + Field2 int + Field3 bool +} + +func (t TestLabelsFast) ToPrometheusLabels() prometheus.Labels { + return prometheus.Labels{ + "f1": t.Field1, + "f2": strconv.Itoa(t.Field2), + "f3": strconv.FormatBool(t.Field3), + } +} + +func (t TestLabelsFast) ToLabelNames() []string { + return []string{"f1", "f2", "f3"} +} + +func Test_extractLabelsWithValues(t *testing.T) { + tests := []struct { + name string + input LabelsProviderMarker + expected prometheus.Labels + shouldFail bool + }{ + { + name: "Basic struct without custom tags", + input: TestLabels{ + Field1: "value1", + Field2: 123, + Field3: true, + }, + expected: prometheus.Labels{ + "field1": "value1", + "field2": "123", + "field3": "true", + }, + }, + { + name: "Struct with custom tags and exclusions", + input: TestLabelsWithTags{ + FieldA: "customValue", + FieldB: 456, + FieldC: false, + }, + expected: prometheus.Labels{ + "custom_a": "customValue", + "field_c": "false", + }, + }, + { + name: "Struct with pointers", + input: TestLabelsWithPointers{ + Field1: ptr("ptrValue"), + Field2: ptr(789), + Field3: ptr(true), + }, + expected: prometheus.Labels{ + "field1": "ptrValue", + "field2": "789", + "field3": "true", + }, + }, + { + name: "Struct fast (with declared methods)", + input: TestLabelsFast{ + Field1: "hello", + Field2: 100, + Field3: true, + }, + expected: prometheus.Labels{ + "f1": "hello", + "f2": "100", + "f3": "true", + }, + }, + { + name: "Nil will return empty result", + input: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + if !tt.shouldFail { + t.Errorf("unexpected panic: %v", r) + } + } + }() + + // Call extractLabelsFromStruct + got := extractLabelsWithValues(tt.input) + + // Compare results + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("extractLabelFromStruct(%v) = %v; want %v", tt.input, got, tt.expected) + } + }) + } +} + +func Test_extractLabelNames(t *testing.T) { + tests := []struct { + name string + input LabelsProviderMarker + expected []string + shouldFail bool + }{ + { + name: "Basic struct without custom tags", + input: TestLabels{ + Field1: "value1", + Field2: 123, + Field3: true, + }, + expected: []string{"field1", "field2", "field3"}, + }, + { + name: "Struct with custom tags and exclusions", + input: TestLabelsWithTags{ + FieldA: "customValue", + FieldB: 456, + FieldC: false, + }, + expected: []string{"custom_a", "field_c"}, + }, + { + name: "Struct with pointers", + input: TestLabelsWithPointers{ + Field1: ptr("ptrValue"), + Field2: ptr(789), + Field3: ptr(true), + }, + expected: []string{"field1", "field2", "field3"}, + }, + { + name: "Struct fast (with declared methods)", + input: TestLabelsFast{ + Field1: "hello", + Field2: 100, + Field3: true, + }, + expected: []string{"f1", "f2", "f3"}, + }, + { + name: "Nil will return empty result", + input: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + if !tt.shouldFail { + t.Errorf("unexpected panic: %v", r) + } + } + }() + + // Call extractLabelsFromStruct + got := extractLabelNames(tt.input) + + // Compare results + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("extractLabelFromStruct(%v) = %v; want %v", tt.input, got, tt.expected) + } + }) + } +} + +func Test_NewEmptyLabels(t *testing.T) { + got1 := NewEmptyLabels[TestLabels]() + if !reflect.DeepEqual(got1, TestLabels{}) { + t.Errorf("NewEmptyLabels[%T] = %v; want %v", TestLabels{}, got1, TestLabels{}) + } + got2 := NewEmptyLabels[TestLabelsWithTags]() + if !reflect.DeepEqual(got2, TestLabelsWithTags{}) { + t.Errorf("NewEmptyLabels[%T] = %v; want %v", TestLabelsWithTags{}, got1, TestLabelsWithTags{}) + } + got3 := NewEmptyLabels[TestLabelsWithPointers]() + if !reflect.DeepEqual(got3, TestLabelsWithPointers{}) { + t.Errorf("NewEmptyLabels[%T] = %v; want %v", TestLabelsWithPointers{}, got1, TestLabelsWithPointers{}) + } + got4 := NewEmptyLabels[*TestLabelsFast]() + if !reflect.DeepEqual(*got4, TestLabelsFast{}) { + t.Errorf("NewEmptyLabels[%T] = %v; want %v", TestLabelsFast{}, *got4, TestLabelsFast{}) + } +} + +func Test_SetPromsafeTag(t *testing.T) { + SetPromsafeTag("prom") + defer func() { + SetPromsafeTag("") + }() + if promsafeTag != "prom" { + t.Errorf("promsafeTag = %v; want %v", promsafeTag, "prom") + } + + type CustomTestLabels struct { + StructLabelProvider + FieldX string `prom:"x"` + } + + extractedLabelNames := extractLabelNames(CustomTestLabels{}) + if !reflect.DeepEqual(extractedLabelNames, []string{"x"}) { + t.Errorf("Using custom promsafeTag: extractLabelNames(%v) = %v; want %v", CustomTestLabels{}, extractedLabelNames, []string{"x"}) + } +} + +// Helper functions to create pointers +func ptr[T any](v T) *T { + return &v +} diff --git a/prometheus/promsafe/promauto_adapter/promauto_adapter.go b/prometheus/promsafe/promauto_adapter/promauto_adapter.go new file mode 100644 index 0000000..9dce4ee --- /dev/null +++ b/prometheus/promsafe/promauto_adapter/promauto_adapter.go @@ -0,0 +1,63 @@ +// 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 promauto_adapter provides compatibility adapter for migration of calls of promauto into promsafe +package promauto_adapter + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promsafe" +) + +// NewCounterVec behaves as adapter-promauto.NewCounterVec but with type-safe labels +func NewCounterVec[T promsafe.LabelsProviderMarker](opts prometheus.CounterOpts) *promsafe.CounterVec[T] { + //_ = promauto.NewCounterVec // keeping for reference + + c := promsafe.NewCounterVec[T](opts) + if prometheus.DefaultRegisterer != nil { + prometheus.DefaultRegisterer.MustRegister(c.Unsafe()) + } + return c +} + +// Factory is a promauto-like factory that allows type-safe labels. +type Factory[T promsafe.LabelsProviderMarker] struct { + r prometheus.Registerer +} + +// With behaves same as adapter-promauto.With but with type-safe labels +func With[T promsafe.LabelsProviderMarker](r prometheus.Registerer) Factory[T] { + return Factory[T]{r: r} +} + +// NewCounterVec behaves like adapter-promauto.NewCounterVec but with type-safe labels +func (f Factory[T]) NewCounterVec(opts prometheus.CounterOpts) *promsafe.CounterVec[T] { + c := NewCounterVec[T](opts) + if f.r != nil { + f.r.MustRegister(c.Unsafe()) + } + return c +} + +// NewCounter 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]) NewCounter(opts prometheus.CounterOpts) prometheus.Counter { + return promauto.With(f.r).NewCounter(opts) +} + +// NewCounterFunc 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]) NewCounterFunc(opts prometheus.CounterOpts, function func() float64) prometheus.CounterFunc { + return promauto.With(f.r).NewCounterFunc(opts, function) +} diff --git a/prometheus/promsafe/promauto_adapter/promauto_adapter_test.go b/prometheus/promsafe/promauto_adapter/promauto_adapter_test.go new file mode 100644 index 0000000..d36a472 --- /dev/null +++ b/prometheus/promsafe/promauto_adapter/promauto_adapter_test.go @@ -0,0 +1,58 @@ +// 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 promauto_adapter_test + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promsafe" + + promauto "github.com/prometheus/client_golang/prometheus/promsafe/promauto_adapter" +) + +func ExampleNewCounterVec_promauto_adapted() { + // Examples on how to migrate from promauto to promsafe gently + // + // Before: + // import "github.com/prometheus/client_golang/prometheus/promauto" + // func main() { + // myReg := prometheus.NewRegistry() + // counterOpts := prometheus.CounterOpts{Name:"..."} + // promauto.With(myReg).NewCounterVec(counterOpts, []string{"event_type", "source"}) + // } + // + // After: + // + // import ( + // promauto "github.com/prometheus/client_golang/prometheus/promsafe/promauto_adapter" + // ) + // ... + + myReg := prometheus.NewRegistry() + counterOpts := prometheus.CounterOpts{ + Name: "items_counted_detailed_auto", + } + + type MyLabels struct { + promsafe.StructLabelProvider + EventType string + Source string + } + c := promauto.With[MyLabels](myReg).NewCounterVec(counterOpts) + + c.With(MyLabels{ + EventType: "reservation", Source: "source1", + }).Inc() + + // Output: +} diff --git a/prometheus/promsafe/safe.go b/prometheus/promsafe/safe.go new file mode 100644 index 0000000..1fa02d5 --- /dev/null +++ b/prometheus/promsafe/safe.go @@ -0,0 +1,186 @@ +// 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 a layer of type-safety for label management in Prometheus metrics. +// +// Promsafe introduces type-safe labels, ensuring that the labels used with +// Prometheus metrics are explicitly defined and validated at compile-time. This +// eliminates common runtime errors caused by mislabeling, such as typos or +// incorrect label orders. +// +// The following example demonstrates how to create and use a CounterVec with +// type-safe labels (compared to how it's done in a regular way): +// +// package main +// +// import ( +// "strconv" +// +// "github.com/prometheus/client_golang/prometheus" +// "github.com/prometheus/client_golang/prometheus/promsafe" +// ) +// +// // Original unsafe way (no type safety) +// +// func originalUnsafeWay() { +// counterVec := prometheus.NewCounterVec( +// prometheus.CounterOpts{ +// Name: "http_requests_total", +// Help: "Total number of HTTP requests by status code and method.", +// }, +// []string{"code", "method"}, // Labels defined as raw strings +// ) +// +// // No compile-time checks; label order and types must be correct +// // You have to know which and how many labels are expected (in proper order) +// counterVec.WithLabelValues("200", "GET").Inc() +// +// // or you can use map, that is even more fragile +// counterVec.With(prometheus.Labels{"code": "200", "method": "GET"}).Inc() +// } +// +// // Safe way (Quick implementation, reflect-based under-the-hood) +// +// type Labels1 struct { +// promsafe.StructLabelProvider +// Code int +// Method string +// } +// +// func safeReflectWay() { +// counterVec := promsafe.NewCounterVec[Labels1](prometheus.CounterOpts{ +// Name: "http_requests_total_reflection", +// Help: "Total number of HTTP requests by status code and method (reflection-based).", +// }) +// +// // Compile-time safe and readable; Will be converted into properly ordered list: "200", "GET" +// counterVec.With(Labels1{Method: "GET", Code: 200}).Inc() +// } +// +// // Safe way with manual implementation (no reflection overhead, as fast as original) +// +// type Labels2 struct { +// promsafe.StructLabelProvider +// Code int +// Method string +// } +// +// func (c Labels2) ToPrometheusLabels() prometheus.Labels { +// return prometheus.Labels{ +// "code": strconv.Itoa(c.Code), // Convert int to string +// "method": c.Method, +// } +// } +// +// func (c Labels2) ToLabelNames() []string { +// return []string{"code", "method"} +// } +// +// func safeManualWay() { +// counterVec := promsafe.NewCounterVec[Labels2](prometheus.CounterOpts{ +// Name: "http_requests_total_custom", +// Help: "Total number of HTTP requests by status code and method (manual implementation).", +// }) +// counterVec.With(Labels2{Code: 404, Method: "POST"}).Inc() +// } +// +// Package promsafe also provides compatibility adapter for integration with Prometheus's +// `promauto` package, ensuring seamless adoption while preserving type-safety. +// Methods that cannot guarantee type safety, such as those using raw `[]string` +// label values, are explicitly deprecated and will raise runtime errors. +// +// A separate package allows conservative users to entirely ignore it. And +// whoever wants to use it will do so explicitly, with an opportunity to read +// this warning. +// +// Enjoy promsafe as it's safe! +package promsafe + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +// NewCounterVec creates a new CounterVec with type-safe labels. +func NewCounterVec[T LabelsProviderMarker](opts prometheus.CounterOpts) *CounterVec[T] { + emptyLabels := NewEmptyLabels[T]() + inner := prometheus.NewCounterVec(opts, extractLabelNames(emptyLabels)) + + return &CounterVec[T]{inner: inner} +} + +// CounterVec is a wrapper around prometheus.CounterVec that allows type-safe labels. +type CounterVec[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 *CounterVec[T]) GetMetricWithLabelValues(_ ...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 *CounterVec[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 *CounterVec[T]) WithLabelValues(_ ...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 *CounterVec[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 CounterVec, but it's inner prometheus.CounterVec is curried. +func (c *CounterVec[T]) CurryWith(labels T) (*CounterVec[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 CounterVec, but it's inner prometheus.CounterVec is curried. +func (c *CounterVec[T]) MustCurryWith(labels T) *CounterVec[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 *CounterVec[T]) Unsafe() *prometheus.CounterVec { + return c.inner +} + +// NewCounter 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 NewCounter(opts prometheus.CounterOpts) prometheus.Counter { + return prometheus.NewCounter(opts) +} + +// NewCounterFunc wraps 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 NewCounterFunc(opts prometheus.CounterOpts, function func() float64) prometheus.CounterFunc { + return prometheus.NewCounterFunc(opts, function) +} + +// TODO: other methods (Gauge, Histogram, Summary, etc.) diff --git a/prometheus/promsafe/safe_test.go b/prometheus/promsafe/safe_test.go new file mode 100644 index 0000000..0b2eabb --- /dev/null +++ b/prometheus/promsafe/safe_test.go @@ -0,0 +1,162 @@ +// 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 ( + "fmt" + "log" + "strconv" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promsafe" +) + +// These are Examples that can be treated as basic smoke tests + +func ExampleNewCounterVec_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.NewCounterVec[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: +} + +// 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 ExampleNewCounterVec_fast_safe_labels_provider() { + // Note: fast labels provider has a drawback: they can't be declared as inline structs + // as we need methods + + c := promsafe.NewCounterVec[FastMyLabels](prometheus.CounterOpts{ + Name: "items_counted_detailed_fast", + }) + + // Manually register the counter + if err := prometheus.Register(c.Unsafe()); err != nil { + log.Fatal("could not register: ", err.Error()) + } + + counter := c.With(FastMyLabels{ + EventType: "reservation", Source: "source1", + }) + counter.Inc() + + // Output: +} + +// ==================== +// Benchmark Tests +// ==================== + +type TestLabels struct { + promsafe.StructLabelProvider + Label1 string + Label2 int + Label3 bool +} + +type TestLabelsFast struct { + promsafe.StructLabelProvider + Label1 string + Label2 int + Label3 bool +} + +func (t TestLabelsFast) ToPrometheusLabels() prometheus.Labels { + return prometheus.Labels{ + "label1": t.Label1, + "label2": strconv.Itoa(t.Label2), + "label3": strconv.FormatBool(t.Label3), + } +} + +func (t TestLabelsFast) ToLabelNames() []string { + return []string{"label1", "label2", "label3"} +} + +func BenchmarkCompareCreatingMetric(b *testing.B) { + // Note: on stage of creation metrics, Unique metric names are not required, + // but let it be for consistency + + b.Run("Prometheus NewCounterVec", func(b *testing.B) { + for i := 0; i < b.N; i++ { + uniqueMetricName := fmt.Sprintf("test_counter_prometheus_%d_%d", b.N, i) + + _ = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: uniqueMetricName, + Help: "A test counter created just using prometheus.NewCounterVec", + }, []string{"label1", "label2", "label3"}) + } + }) + + b.Run("Promsafe (reflect) NewCounterVec", func(b *testing.B) { + for i := 0; i < b.N; i++ { + uniqueMetricName := fmt.Sprintf("test_counter_promsafe_%d_%d", b.N, i) + + _ = promsafe.NewCounterVec[TestLabels](prometheus.CounterOpts{ + Name: uniqueMetricName, + Help: "A test counter created using promauto.NewCounterVec", + }) + } + }) + + b.Run("Promsafe (fast) NewCounterVec", func(b *testing.B) { + for i := 0; i < b.N; i++ { + uniqueMetricName := fmt.Sprintf("test_counter_promsafe_fast_%d_%d", b.N, i) + + _ = promsafe.NewCounterVec[TestLabelsFast](prometheus.CounterOpts{ + Name: uniqueMetricName, + Help: "A test counter created using promauto.NewCounterVec", + }) + } + }) +}