From 386866fbce186256a64e5c5e6629d36d1c784c30 Mon Sep 17 00:00:00 2001 From: Anna Kapuscinska Date: Mon, 17 Jun 2024 16:11:22 +0100 Subject: [PATCH] Rewrite the metrics library Fixes: #2376 Signed-off-by: Anna Kapuscinska --- pkg/metrics/bpfmetric.go | 48 +++-- pkg/metrics/customcollector.go | 67 ++++++ pkg/metrics/custommetric.go | 120 +++++++++++ pkg/metrics/filteredlabels.go | 56 +++++ pkg/metrics/granularmetric.go | 369 +++++++++++++++++++++++++++++---- pkg/metrics/labels.go | 40 ++-- pkg/metrics/metricsgroup.go | 148 +++++++++++++ pkg/metrics/metricwithinit.go | 68 ++++++ 8 files changed, 838 insertions(+), 78 deletions(-) create mode 100644 pkg/metrics/customcollector.go create mode 100644 pkg/metrics/custommetric.go create mode 100644 pkg/metrics/filteredlabels.go create mode 100644 pkg/metrics/metricsgroup.go create mode 100644 pkg/metrics/metricwithinit.go diff --git a/pkg/metrics/bpfmetric.go b/pkg/metrics/bpfmetric.go index 3a660e63eb9..3a21259e2f2 100644 --- a/pkg/metrics/bpfmetric.go +++ b/pkg/metrics/bpfmetric.go @@ -3,45 +3,59 @@ package metrics -import "github.com/prometheus/client_golang/prometheus" +import ( + "github.com/prometheus/client_golang/prometheus" +) + +// The nterface in this file provides a bridge between the new metrics library +// and the existing code defining metrics. It's considered deprecated - use the +// interface from custommetric.go instead. -// BPFMetric represents a metric read directly from a BPF map. -// It's intended to be used in custom collectors. The interface doesn't provide -// any validation, so it's up to the collector implementer to guarantee the -// metrics consistency. type BPFMetric interface { Desc() *prometheus.Desc MustMetric(value float64, labelValues ...string) prometheus.Metric } type bpfCounter struct { - desc *prometheus.Desc + metric *granularCustomCounter[NilLabels] } +// DEPRECATED: Use NewGranularCustomCounter instead. func NewBPFCounter(desc *prometheus.Desc) BPFMetric { - return &bpfCounter{desc: desc} + return &bpfCounter{ + metric: &granularCustomCounter[NilLabels]{ + desc: desc, + constrained: false, + }, + } } -func (c *bpfCounter) Desc() *prometheus.Desc { - return c.desc +func (m *bpfCounter) Desc() *prometheus.Desc { + return m.metric.Desc() } -func (c *bpfCounter) MustMetric(value float64, labelValues ...string) prometheus.Metric { - return prometheus.MustNewConstMetric(c.desc, prometheus.CounterValue, value, labelValues...) +func (m *bpfCounter) MustMetric(value float64, labelValues ...string) prometheus.Metric { + return m.metric.MustMetric(value, &NilLabels{}, labelValues...) } type bpfGauge struct { - desc *prometheus.Desc + metric *granularCustomGauge[NilLabels] } +// DEPRECATED: Use NewGranularCustomGauge instead. func NewBPFGauge(desc *prometheus.Desc) BPFMetric { - return &bpfGauge{desc: desc} + return &bpfGauge{ + metric: &granularCustomGauge[NilLabels]{ + desc: desc, + constrained: false, + }, + } } -func (g *bpfGauge) Desc() *prometheus.Desc { - return g.desc +func (m *bpfGauge) Desc() *prometheus.Desc { + return m.metric.Desc() } -func (g *bpfGauge) MustMetric(value float64, labelValues ...string) prometheus.Metric { - return prometheus.MustNewConstMetric(g.desc, prometheus.GaugeValue, value, labelValues...) +func (m *bpfGauge) MustMetric(value float64, labelValues ...string) prometheus.Metric { + return m.metric.MustMetric(value, &NilLabels{}, labelValues...) } diff --git a/pkg/metrics/customcollector.go b/pkg/metrics/customcollector.go new file mode 100644 index 00000000000..96400fa6bf7 --- /dev/null +++ b/pkg/metrics/customcollector.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +type collectFunc func(chan<- prometheus.Metric) + +type customCollector[L FilteredLabels] struct { + metrics []GranularCustomMetric[L] + collectFunc collectFunc + collectForDocsFunc collectFunc +} + +// NewCustomCollector creates a new customCollector. +// +// If collectForDocs is nil, the collector will use collect function for both +// regular metrics server and documentation generation. +func NewCustomCollector[L FilteredLabels]( + metrics []GranularCustomMetric[L], collect collectFunc, collectForDocs collectFunc, +) CollectorWithInit { + return &customCollector[L]{ + metrics: metrics, + collectFunc: collect, + collectForDocsFunc: collectForDocs, + } +} + +// Describe implements CollectorWithInit (prometheus.Collector). +func (c *customCollector[L]) Describe(ch chan<- *prometheus.Desc) { + for _, m := range c.metrics { + ch <- m.Desc() + } +} + +// Collect implements CollectorWithInit (prometheus.Collector). +func (c *customCollector[L]) Collect(ch chan<- prometheus.Metric) { + if c.collectFunc != nil { + c.collectFunc(ch) + } +} + +// IsConstrained implements CollectorWithInit. +func (c *customCollector[L]) IsConstrained() bool { + for _, m := range c.metrics { + if !m.IsConstrained() { + return false + } + } + return true +} + +// Init implements CollectorWithInit. +func (c *customCollector[L]) Init() { + // since metrics are collected independently, there's nothing to initialize +} + +// InitForDocs implements CollectorWithInit. +func (c *customCollector[L]) InitForDocs() { + // override Collect method if there's a special one for docs + if c.collectForDocsFunc != nil { + c.collectFunc = c.collectForDocsFunc + } +} diff --git a/pkg/metrics/custommetric.go b/pkg/metrics/custommetric.go new file mode 100644 index 00000000000..ca078a8d38e --- /dev/null +++ b/pkg/metrics/custommetric.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +// GranularCustomMetric represents a metric collected independently of +// prometheus package, for example in a BPF map. It's intended to be used in +// a custom collector (see customcollector.go). The interface doesn't provide +// any validation, so it's up to the collector implementer to guarantee the +// metrics consistency. +type GranularCustomMetric[L FilteredLabels] interface { + Desc() *prometheus.Desc + MustMetric(value float64, commonLvs *L, extraLvs ...string) prometheus.Metric + IsConstrained() bool +} + +// getDesc is a helper function to retrieve the descriptor for a metric and +// check if the metric is constrained. +// +// See getVariableLabels for the labels order. +func getDesc[L FilteredLabels](opts *MetricOpts) (*prometheus.Desc, bool, error) { + labels, constrained, err := getVariableLabels[L](opts) + if err != nil { + return nil, false, err + } + + desc := prometheus.NewDesc( + prometheus.BuildFQName(opts.Namespace, opts.Subsystem, opts.Name), + opts.Help, + labels, + opts.ConstLabels, + ) + return desc, constrained, nil +} + +// counter + +type granularCustomCounter[L FilteredLabels] struct { + desc *prometheus.Desc + constrained bool +} + +// NewGranularCustomCounter creates a new granularCustomCounter. +func NewGranularCustomCounter[L FilteredLabels](opts MetricOpts) (GranularCustomMetric[L], error) { + desc, constrained, err := getDesc[L](&opts) + if err != nil { + return nil, err + } + + return &granularCustomCounter[L]{ + desc: desc, + constrained: constrained, + }, nil +} + +// NewCustomCounter creates a new granularCustomCounter with no configurable labels. +func NewCustomCounter(opts MetricOpts) (GranularCustomMetric[NilLabels], error) { + return NewGranularCustomCounter[NilLabels](opts) +} + +// Desc implements GranularCustomMetric. +func (c *granularCustomCounter[L]) Desc() *prometheus.Desc { + return c.desc +} + +// MustMetric implements GranularCustomMetric. +func (m *granularCustomCounter[L]) MustMetric(value float64, commonLvs *L, extraLvs ...string) prometheus.Metric { + lvs := append((*commonLvs).Values(), extraLvs...) + return prometheus.MustNewConstMetric(m.desc, prometheus.CounterValue, value, lvs...) +} + +// IsConstrained implements GranularCustomMetric. +func (m *granularCustomCounter[L]) IsConstrained() bool { + return m.constrained +} + +// gauge + +type granularCustomGauge[L FilteredLabels] struct { + desc *prometheus.Desc + constrained bool +} + +// NewGranularCustomGauge creates a new granularCustomGauge. +func NewGranularCustomGauge[L FilteredLabels](opts MetricOpts) (GranularCustomMetric[L], error) { + desc, constrained, err := getDesc[L](&opts) + if err != nil { + return nil, err + } + + return &granularCustomGauge[L]{ + desc: desc, + constrained: constrained, + }, nil +} + +// NewCustomGauge creates a new granularCustomGauge with no configurable labels. +func NewCustomGauge(opts MetricOpts) (GranularCustomMetric[NilLabels], error) { + return NewGranularCustomGauge[NilLabels](opts) +} + +// Desc implements GranularCustomMetric. +func (c *granularCustomGauge[L]) Desc() *prometheus.Desc { + return c.desc +} + +// MustMetric implements GranularCustomMetric. +func (m *granularCustomGauge[L]) MustMetric(value float64, commonLvs *L, extraLvs ...string) prometheus.Metric { + lvs := append((*commonLvs).Values(), extraLvs...) + return prometheus.MustNewConstMetric(m.desc, prometheus.GaugeValue, value, lvs...) +} + +// IsConstrained implements GranularCustomMetric. +func (m *granularCustomGauge[L]) IsConstrained() bool { + return m.constrained +} diff --git a/pkg/metrics/filteredlabels.go b/pkg/metrics/filteredlabels.go new file mode 100644 index 00000000000..a5a38f65473 --- /dev/null +++ b/pkg/metrics/filteredlabels.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import ( + "github.com/cilium/tetragon/pkg/metrics/consts" +) + +type FilteredLabels interface { + Keys() []string + Values() []string +} + +// FilteredLabelsWithExamples extends FilteredLabels with a method returning +// example label values, intended to be used when generating documentation. +type FilteredLabelsWithExamples interface { + FilteredLabels + ExampleValues() []string +} + +type NilLabels struct{} + +func (l NilLabels) Keys() []string { return []string{} } + +func (l NilLabels) Values() []string { return []string{} } + +func (l NilLabels) ExampleValues() []string { return []string{} } + +type ProcessLabels struct { + Namespace string + Workload string + Pod string + Binary string +} + +func NewProcessLabels(namespace, workload, pod, binary string) *ProcessLabels { + return &ProcessLabels{ + Namespace: namespace, + Workload: workload, + Pod: pod, + Binary: binary, + } +} + +func (l ProcessLabels) Keys() []string { + return []string{"namespace", "workload", "pod", "binary"} +} + +func (l ProcessLabels) Values() []string { + return []string{l.Namespace, l.Workload, l.Pod, l.Binary} +} + +func (l ProcessLabels) ExampleValues() []string { + return []string{consts.ExampleNamespace, consts.ExampleWorkload, consts.ExamplePod, consts.ExampleBinary} +} diff --git a/pkg/metrics/granularmetric.go b/pkg/metrics/granularmetric.go index 1d14f8baed1..6bd7fd43047 100644 --- a/pkg/metrics/granularmetric.go +++ b/pkg/metrics/granularmetric.go @@ -4,58 +4,203 @@ package metrics import ( - "fmt" "slices" "github.com/prometheus/client_golang/prometheus" ) -func validateExtraLabels(common []string, extra []string) error { - for _, label := range extra { - if slices.Contains(common, label) { - return fmt.Errorf("extra labels can't contain any of the following: %v", common) +type initMetricFunc func(...string) + +// initAllCombinations initializes a metric with all possible combinations of +// label values. +func initAllCombinations(initMetric initMetricFunc, labels []ConstrainedLabel) { + initCombinations(initMetric, labels, make([]string, len(labels)), 0) +} + +// initCombinations is a helper function that recursively initializes a metric +// with possible combinations of label values. +// +// There are a few assumptions about the arguments: +// - initMetric is not nil +// - labels and lvs have the same length +// - cursor is in the range [0, len(labels)] +// If any of these is not met, the function will do nothing. +func initCombinations(initMetric initMetricFunc, labels []ConstrainedLabel, lvs []string, cursor int) { + if initMetric == nil || len(labels) != len(lvs) || cursor < 0 || cursor > len(labels) { + // The function was called with invalid arguments. Silently return. + return + } + if cursor == len(labels) { + initMetric(lvs...) + return + } + for _, val := range labels[cursor].Values { + lvs[cursor] = val + initCombinations(initMetric, labels, lvs, cursor+1) + } +} + +// initForDocs initializes the metric for the purpose of generating +// documentation. For each of FilteredLabels and unconstrained labels, it sets +// an example value and initializes the metric with it. For each of constrained +// labels - iterates over all values and initializes the metric with each of +// them. The metrics initialized would likely be considered invalid in a real +// metrics server - but here we care only about extracting labels for +// documentation, so we don't try to make the metrics realistic. +func initForDocs[L FilteredLabels](initMetric initMetricFunc, constrained []ConstrainedLabel, unconstrained []UnconstrainedLabel) { + var dummy L + lvs := make([]string, len(dummy.Keys())+len(constrained)+len(unconstrained)) + + // first FilteredLabels + current := lvs + if ex, ok := any(dummy).(FilteredLabelsWithExamples); ok { + for i, val := range ex.ExampleValues() { + current[i] = val + initMetric(lvs...) + } + } else { + for i := range dummy.Keys() { + current[i] = "example" + initMetric(lvs...) } } - return nil + // second constrained labels + current = current[len(dummy.Keys()):] + for i := range constrained { + for _, val := range constrained[i].Values { + current[i] = val + initMetric(lvs...) + } + } + // third unconstrained labels + current = current[len(constrained):] + for i := range unconstrained { + current[len(constrained)+i] = unconstrained[i].ExampleValue + initMetric(lvs...) + } } // counter +// GranularCounter wraps prometheus.CounterVec and implements CollectorWithInit. type GranularCounter[L FilteredLabels] struct { - metric *prometheus.CounterVec + metric *prometheus.CounterVec + constrained bool + initFunc func() + initForDocs func() } -func NewGranularCounter[L FilteredLabels](opts prometheus.CounterOpts, extraLabels []string) (*GranularCounter[L], error) { - var dummy L - commonLabels := dummy.Keys() - err := validateExtraLabels(commonLabels, extraLabels) +// NewGranularCounter creates a new GranularCounter. +// +// The init argument is a function that initializes the metric with some label +// values. Doing so allows us to keep resources usage predictable. If the +// metric is constrained (i.e. type parameter is NilLabels and there are no +// unconstrained labels) and init is nil, then the metric will be initialized +// with all possible combinations of labels. If the metric is unconstrained, it +// won't be initialized by default. +// +// Pass an init function if: +// a) metric is constrained but not all combinations of labels make sense (e.g. +// there is a hierarchy between labels or two labels represent the same thing in +// different formats, or two labels are mutually exclusive, or ...) +// b) metric is unconstrained, but some of the unconstrained label values are +// known beforehand, so can be initialized. +func NewGranularCounter[L FilteredLabels](opts MetricOpts, init func()) (*GranularCounter[L], error) { + labels, constrained, err := getVariableLabels[L](&opts) if err != nil { return nil, err } + + promOpts := prometheus.CounterOpts{ + Namespace: opts.Namespace, + Subsystem: opts.Subsystem, + Name: opts.Name, + Help: opts.Help, + ConstLabels: opts.ConstLabels, + } + var metric *prometheus.CounterVec + if slices.Contains(labels, "pod") && slices.Contains(labels, "namespace") { + // set up metric to be deleted when a pod is deleted + metric = NewCounterVecWithPod(promOpts, labels) + } else { + metric = prometheus.NewCounterVec(promOpts, labels) + } + + initMetric := func(lvs ...string) { + metric.WithLabelValues(lvs...).Add(0) + } + + // if metric is constrained, default to initializing all combinations of labels + if constrained && init == nil { + init = func() { + initAllCombinations(initMetric, opts.ConstrainedLabels) + } + } + return &GranularCounter[L]{ - // NB: Using the WithPod wrapper means an implicit assumption that the metric has "pod" and - // "namespace" labels, and will be cleaned up on pod deletion. If this is not the case, the - // metric will still work, just the unnecessary cleanup logic will add some overhead. - metric: NewCounterVecWithPod(opts, append(commonLabels, extraLabels...)), + metric: metric, + constrained: constrained, + initFunc: init, + initForDocs: func() { + initForDocs[L](initMetric, opts.ConstrainedLabels, opts.UnconstrainedLabels) + }, }, nil } -func MustNewGranularCounter[L FilteredLabels](opts prometheus.CounterOpts, extraLabels []string) *GranularCounter[L] { - result, err := NewGranularCounter[L](opts, extraLabels) +// MustNewGranularCounter is a convenience function that wraps +// NewGranularCounter and panics on error. +// +// NOTE: The function takes different arguments than NewGranularCounter, to +// provide a bridge between the new metrics library and the existing code +// defining metrics. We should change it in the future, so that both functions +// take the same arguments. +func MustNewGranularCounter[L FilteredLabels](promOpts prometheus.CounterOpts, extraLabels []string) *GranularCounter[L] { + unconstrained := labelsToUnconstrained(extraLabels) + opts := MetricOpts{ + Opts: prometheus.Opts(promOpts), + UnconstrainedLabels: unconstrained, + } + result, err := NewGranularCounter[L](opts, nil) if err != nil { panic(err) } return result } +// Describe implements CollectorWithInit (prometheus.Collector). func (m *GranularCounter[L]) Describe(ch chan<- *prometheus.Desc) { m.metric.Describe(ch) } +// Collect implements CollectorWithInit (prometheus.Collector). func (m *GranularCounter[L]) Collect(ch chan<- prometheus.Metric) { m.metric.Collect(ch) } +// IsConstrained implements CollectorWithInit. +func (m *GranularCounter[L]) IsConstrained() bool { + return m.constrained +} + +// Init implements CollectorWithInit. +func (m *GranularCounter[L]) Init() { + if m.initFunc != nil { + m.initFunc() + } +} + +// InitForDocs implements CollectorWithInit. +func (m *GranularCounter[L]) InitForDocs() { + if m.initForDocs != nil { + m.initForDocs() + } +} + +// WithLabelValues is similar to WithLabelValues method from prometheus +// package, but takes generic FilteredLabels as the first argument. +// +// The following arguments are values of first constrained labels, then +// unconstrained labels. func (m *GranularCounter[L]) WithLabelValues(commonLvs *L, extraLvs ...string) prometheus.Counter { lvs := append((*commonLvs).Values(), extraLvs...) return m.metric.WithLabelValues(lvs...) @@ -63,41 +208,109 @@ func (m *GranularCounter[L]) WithLabelValues(commonLvs *L, extraLvs ...string) p // gauge +// GranularGauge wraps prometheus.GaugeVec and implements CollectorWithInit. type GranularGauge[L FilteredLabels] struct { - metric *prometheus.GaugeVec + metric *prometheus.GaugeVec + constrained bool + initFunc func() + initForDocs func() } -func NewGranularGauge[L FilteredLabels](opts prometheus.GaugeOpts, extraLabels []string) (*GranularGauge[L], error) { - var dummy L - commonLabels := dummy.Keys() - err := validateExtraLabels(commonLabels, extraLabels) +// NewGranularGauge creates a new GranularGauge. +// +// See NewGranularCounter for usage notes. +func NewGranularGauge[L FilteredLabels](opts MetricOpts, init func()) (*GranularGauge[L], error) { + labels, constrained, err := getVariableLabels[L](&opts) if err != nil { return nil, err } + + promOpts := prometheus.GaugeOpts{ + Namespace: opts.Namespace, + Subsystem: opts.Subsystem, + Name: opts.Name, + Help: opts.Help, + ConstLabels: opts.ConstLabels, + } + var metric *prometheus.GaugeVec + if slices.Contains(labels, "pod") && slices.Contains(labels, "namespace") { + // set up metric to be deleted when a pod is deleted + metric = NewGaugeVecWithPod(promOpts, labels) + } else { + metric = prometheus.NewGaugeVec(promOpts, labels) + } + + initMetric := func(lvs ...string) { + metric.WithLabelValues(lvs...).Set(0) + } + + // if metric is constrained, default to initializing all combinations of labels + if constrained && init == nil { + init = func() { + initAllCombinations(initMetric, opts.ConstrainedLabels) + } + } + return &GranularGauge[L]{ - // NB: Using the WithPod wrapper means an implicit assumption that the metric has "pod" and - // "namespace" labels, and will be cleaned up on pod deletion. If this is not the case, the - // metric will still work, just the unnecessary cleanup logic will add some overhead. - metric: NewGaugeVecWithPod(opts, append(commonLabels, extraLabels...)), + metric: metric, + constrained: constrained, + initFunc: init, + initForDocs: func() { + initForDocs[L](initMetric, opts.ConstrainedLabels, opts.UnconstrainedLabels) + }, }, nil } -func MustNewGranularGauge[L FilteredLabels](opts prometheus.GaugeOpts, extraLabels []string) *GranularGauge[L] { - result, err := NewGranularGauge[L](opts, extraLabels) +// MustNewGranularGauge is a convenience function that wraps +// NewGranularGauge and panics on error. +// +// See MustNewGranularCounter for usage notes. +func MustNewGranularGauge[L FilteredLabels](promOpts prometheus.GaugeOpts, extraLabels []string) *GranularGauge[L] { + unconstrained := labelsToUnconstrained(extraLabels) + opts := MetricOpts{ + Opts: prometheus.Opts(promOpts), + UnconstrainedLabels: unconstrained, + } + result, err := NewGranularGauge[L](opts, nil) if err != nil { panic(err) } return result } +// Describe implements CollectorWithInit (prometheus.Collector). func (m *GranularGauge[L]) Describe(ch chan<- *prometheus.Desc) { m.metric.Describe(ch) } +// Collect implements CollectorWithInit (prometheus.Collector). func (m *GranularGauge[L]) Collect(ch chan<- prometheus.Metric) { m.metric.Collect(ch) } +// IsConstrained implements CollectorWithInit. +func (m *GranularGauge[L]) IsConstrained() bool { + return m.constrained +} + +// Init implements CollectorWithInit. +func (m *GranularGauge[L]) Init() { + if m.initFunc != nil { + m.initFunc() + } +} + +// InitForDocs implements CollectorWithInit. +func (m *GranularGauge[L]) InitForDocs() { + if m.initForDocs != nil { + m.initForDocs() + } +} + +// WithLabelValues is similar to WithLabelValues method from prometheus +// package, but takes generic FilteredLabels as the first argument. +// +// See GranularCounter.WithLabelValues for usage notes. func (m *GranularGauge[L]) WithLabelValues(commonLvs *L, extraLvs ...string) prometheus.Gauge { lvs := append((*commonLvs).Values(), extraLvs...) return m.metric.WithLabelValues(lvs...) @@ -105,41 +318,119 @@ func (m *GranularGauge[L]) WithLabelValues(commonLvs *L, extraLvs ...string) pro // histogram +// GranularHistogram wraps prometheus.HistogramVec and implements CollectorWithInit. type GranularHistogram[L FilteredLabels] struct { - metric *prometheus.HistogramVec + metric *prometheus.HistogramVec + constrained bool + initFunc func() + initForDocs func() } -func NewGranularHistogram[L FilteredLabels](opts prometheus.HistogramOpts, extraLabels []string) (*GranularHistogram[L], error) { - var dummy L - commonLabels := dummy.Keys() - err := validateExtraLabels(commonLabels, extraLabels) +// NewGranularHistogram creates a new GranularHistogram. +// +// See NewGranularCounter for usage notes. +func NewGranularHistogram[L FilteredLabels](opts HistogramOpts, init func()) (*GranularHistogram[L], error) { + labels, constrained, err := getVariableLabels[L](&opts.MetricOpts) if err != nil { return nil, err } + + promOpts := prometheus.HistogramOpts{ + Namespace: opts.Namespace, + Subsystem: opts.Subsystem, + Name: opts.Name, + Help: opts.Help, + ConstLabels: opts.ConstLabels, + Buckets: opts.Buckets, + } + var metric *prometheus.HistogramVec + if slices.Contains(labels, "pod") && slices.Contains(labels, "namespace") { + // set up metric to be deleted when a pod is deleted + metric = NewHistogramVecWithPod(promOpts, labels) + } else { + metric = prometheus.NewHistogramVec(promOpts, labels) + } + + initMetric := func(lvs ...string) { + metric.WithLabelValues(lvs...) + } + + // if metric is constrained, default to initializing all combinations of labels + if constrained && init == nil { + init = func() { + initAllCombinations(initMetric, opts.ConstrainedLabels) + } + } + return &GranularHistogram[L]{ - // NB: Using the WithPod wrapper means an implicit assumption that the metric has "pod" and - // "namespace" labels, and will be cleaned up on pod deletion. If this is not the case, the - // metric will still work, just the unnecessary cleanup logic will add some overhead. - metric: NewHistogramVecWithPod(opts, append(commonLabels, extraLabels...)), + metric: metric, + constrained: constrained, + initFunc: init, + initForDocs: func() { + initForDocs[L](initMetric, opts.ConstrainedLabels, opts.UnconstrainedLabels) + }, }, nil } -func MustNewGranularHistogram[L FilteredLabels](opts prometheus.HistogramOpts, extraLabels []string) *GranularHistogram[L] { - result, err := NewGranularHistogram[L](opts, extraLabels) +// MustNewGranularHistogram is a convenience function that wraps +// NewGranularHistogram and panics on error. +// +// See MustNewGranularCounter for usage notes. +func MustNewGranularHistogram[L FilteredLabels](promOpts prometheus.HistogramOpts, extraLabels []string) *GranularHistogram[L] { + unconstrained := labelsToUnconstrained(extraLabels) + opts := HistogramOpts{ + MetricOpts: MetricOpts{ + Opts: prometheus.Opts{ + Namespace: promOpts.Namespace, + Subsystem: promOpts.Subsystem, + Name: promOpts.Name, + Help: promOpts.Help, + ConstLabels: promOpts.ConstLabels, + }, + UnconstrainedLabels: unconstrained, + }, + Buckets: promOpts.Buckets, + } + result, err := NewGranularHistogram[L](opts, nil) if err != nil { panic(err) } return result } +// Describe implements CollectorWithInit (prometheus.Collector). func (m *GranularHistogram[L]) Describe(ch chan<- *prometheus.Desc) { m.metric.Describe(ch) } +// Collect implements CollectorWithInit (prometheus.Collector). func (m *GranularHistogram[L]) Collect(ch chan<- prometheus.Metric) { m.metric.Collect(ch) } +// IsConstrained implements CollectorWithInit. +func (m *GranularHistogram[L]) IsConstrained() bool { + return m.constrained +} + +// Init implements CollectorWithInit. +func (m *GranularHistogram[L]) Init() { + if m.initFunc != nil { + m.initFunc() + } +} + +// InitForDocs implements CollectorWithInit. +func (m *GranularHistogram[L]) InitForDocs() { + if m.initForDocs != nil { + m.initForDocs() + } +} + +// WithLabelValues is similar to WithLabelValues method from prometheus +// package, but takes generic FilteredLabels as the first argument. +// +// See GranularCounter.WithLabelValues for usage notes. func (m *GranularHistogram[L]) WithLabelValues(commonLvs *L, extraLvs ...string) prometheus.Observer { lvs := append((*commonLvs).Values(), extraLvs...) return m.metric.WithLabelValues(lvs...) diff --git a/pkg/metrics/labels.go b/pkg/metrics/labels.go index df894c27f7a..8653adfd125 100644 --- a/pkg/metrics/labels.go +++ b/pkg/metrics/labels.go @@ -3,31 +3,27 @@ package metrics -type FilteredLabels interface { - Keys() []string - Values() []string +// ConstrainedLabel represents a label with constrained cardinality. +// Values is a list of all possible values of the label. +type ConstrainedLabel struct { + Name string + Values []string } -type ProcessLabels struct { - Namespace string - Workload string - Pod string - Binary string +// UnconstrainedLabel represents a label with unconstrained cardinality. +// ExampleValue is an example value of the label used for documentation. +type UnconstrainedLabel struct { + Name string + ExampleValue string } -func NewProcessLabels(namespace, workload, pod, binary string) *ProcessLabels { - return &ProcessLabels{ - Namespace: namespace, - Workload: workload, - Pod: pod, - Binary: binary, +func labelsToUnconstrained(labels []string) []UnconstrainedLabel { + unconstrained := make([]UnconstrainedLabel, len(labels)) + for i, label := range labels { + unconstrained[i] = UnconstrainedLabel{ + Name: label, + ExampleValue: "example", + } } -} - -func (l ProcessLabels) Keys() []string { - return []string{"namespace", "workload", "pod", "binary"} -} - -func (l ProcessLabels) Values() []string { - return []string{l.Namespace, l.Workload, l.Pod, l.Binary} + return unconstrained } diff --git a/pkg/metrics/metricsgroup.go b/pkg/metrics/metricsgroup.go new file mode 100644 index 00000000000..3137b4aba6e --- /dev/null +++ b/pkg/metrics/metricsgroup.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import ( + "errors" + + "github.com/prometheus/client_golang/prometheus" +) + +// MetricsGroup extends prometheus.Registerer with methods for metrics initialization and checking constraints. It also +// includes prometheus.Collector, because MetricsGroups are intended to be registered in a root prometheus.Registry. +type MetricsGroup interface { + prometheus.Collector + prometheus.Registerer + RegisterWithInit(CollectorWithInit) error + MustRegisterWithInit(CollectorWithInit) + IsConstrained() bool + Init() + InitForDocs() +} + +// metricsGroup wraps prometheus.Registry and implements MetricsGroup. +type metricsGroup struct { + registry *prometheus.Registry + constrained bool // whether the group has constrained cardinality + initFunc func() + initForDocsFunc func() +} + +// NewMetricsGroup creates a new MetricsGroup. +// The constrained argument indicates whether the group should accept only metrics with constrained cardinality. +func NewMetricsGroup(constrained bool) MetricsGroup { + return &metricsGroup{ + registry: prometheus.NewPedanticRegistry(), + constrained: constrained, + initFunc: func() {}, + initForDocsFunc: func() {}, + } +} + +// Describe implements MetricsGroup (prometheus.Collector). +func (r *metricsGroup) Describe(ch chan<- *prometheus.Desc) { + r.registry.Describe(ch) +} + +// Collect implements MetricsGroup (prometheus.Collector). +func (r *metricsGroup) Collect(ch chan<- prometheus.Metric) { + r.registry.Collect(ch) +} + +// Register implements MetricsGroup (prometheus.Registerer). +func (r *metricsGroup) Register(c prometheus.Collector) error { + return r.registry.Register(c) +} + +// MustRegister implements MetricsGroup (prometheus.Registerer). +func (r *metricsGroup) MustRegister(cs ...prometheus.Collector) { + r.registry.MustRegister(cs...) +} + +// Unregister implements MetricsGroup (prometheus.Registerer). +func (r *metricsGroup) Unregister(c prometheus.Collector) bool { + return r.registry.Unregister(c) +} + +// IsConstrained implements MetricsGroup. +func (r *metricsGroup) IsConstrained() bool { + return r.constrained +} + +// RegisterWithInit implements MetricsGroup. It wraps Register method and additionally: +// - checks constraints - attempt to register an unconstrained collector in a constrained group results in an error +// - extends Init and InitForDocs methods with initialization of the registered collector +func (r *metricsGroup) RegisterWithInit(c CollectorWithInit) error { + // check constraints + if r.IsConstrained() && !c.IsConstrained() { + return errors.New("can't register unconstrained metrics in a constrained group") + } + + // register + err := r.Register(c) + if err != nil { + return err + } + + // extend init + oldInit := r.initFunc + if oldInit == nil { + oldInit = func() {} + } + r.initFunc = func() { + oldInit() + c.Init() + } + oldInitForDocs := r.initForDocsFunc + if oldInitForDocs == nil { + oldInitForDocs = func() {} + } + r.initForDocsFunc = func() { + oldInitForDocs() + c.InitForDocs() + } + return nil +} + +// MustRegisterWithInit implements MetricsGroup. +func (r *metricsGroup) MustRegisterWithInit(c CollectorWithInit) { + err := r.RegisterWithInit(c) + if err != nil { + panic(err) + } +} + +// Init implements MetricsGroup. +func (r *metricsGroup) Init() { + if r.initFunc != nil { + r.initFunc() + } +} + +// InitForDocs implements MetricsGroup. +func (r *metricsGroup) InitForDocs() { + if r.initForDocsFunc != nil { + r.initForDocsFunc() + } +} + +// func example() { +// rootRegistry := prometheus.NewPedanticRegistry() + +// healthRegistry := NewMetricsGroup(true) +// errorsMetric, _ := NewGranularCounter[metrics.ProcessLabels](MetricOpts{}, func() {}) +// cacheMetric := NewCustomCollector[metrics.ProcessLabels]([]GranularCustomMetric[metrics.ProcessLabels]{}, func(ch chan<- prometheus.Metric) {}, func(ch chan<- prometheus.Metric) {}) +// healthRegistry.RegisterWithInit(errorsMetric) +// healthRegistry.RegisterWithInit(cacheMetric) + +// if healthEnabled { +// rootRegistry.MustRegister(healthRegistry) +// healthRegistry.Init() +// } + +// if generatingHealthDocs { +// rootRegistry.MustRegister(healthRegistry) +// healthRegistry.InitForDocs() +// } +// } diff --git a/pkg/metrics/metricwithinit.go b/pkg/metrics/metricwithinit.go new file mode 100644 index 00000000000..9f2b15d6a8b --- /dev/null +++ b/pkg/metrics/metricwithinit.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import ( + "fmt" + "slices" + + "github.com/prometheus/client_golang/prometheus" +) + +// CollectorWithInit extends prometheus.Collector with methods for metrics +// initialization and checking constraints. +type CollectorWithInit interface { + prometheus.Collector + IsConstrained() bool + Init() + InitForDocs() +} + +// MetricOpts extends prometheus.Opts with constrained and unconstrained labels. +// +// NOTE: If using granular metric interface, labels passed via type parameter +// will be added to the final metric at the beginning of the label list and are +// assumed to be unconstrained. Labels passed via MetricOpts must not overlap +// with labels passed via type parameter. +type MetricOpts struct { + prometheus.Opts + ConstrainedLabels []ConstrainedLabel + UnconstrainedLabels []UnconstrainedLabel +} + +// HistogramOpts extends MetricsOpts with histogram-specific fields. +type HistogramOpts struct { + MetricOpts + Buckets []float64 +} + +// getVariableLabels is a helper function to retrieve the full label list for +// a metric and check if the metric is constrained. The resulting labels will +// follow the order: +// 1. FilteredLabels passed via type parameter (assumed to be unconstrained) +// 2. constrained labels passed via opts +// 3. unconstrained labels passed via opts +func getVariableLabels[L FilteredLabels](opts *MetricOpts) ([]string, bool, error) { + var dummy L + commonLabels := dummy.Keys() + + extraLabels := make([]string, len(opts.ConstrainedLabels)+len(opts.UnconstrainedLabels)) + for i, label := range opts.ConstrainedLabels { + extraLabels[i] = label.Name + } + for i, label := range opts.UnconstrainedLabels { + extraLabels[i+len(opts.ConstrainedLabels)] = label.Name + } + // check if labels passed via opts are not overlapping with FilteredLabels + for _, l := range extraLabels { + if slices.Contains(commonLabels, l) { + return nil, false, fmt.Errorf("extra labels can't contain any of the following: %v", commonLabels) + } + } + + // check if labels are constrained (FilteredLabels are assumed to be unconstrained) + constrained := len(commonLabels) == 0 && len(opts.UnconstrainedLabels) == 0 + + return append(commonLabels, extraLabels...), constrained, nil +}