From 5e2318298cfbd3671dd52a6972b8500e23e2e718 Mon Sep 17 00:00:00 2001 From: Anna Kapuscinska Date: Mon, 17 Jun 2024 16:11:22 +0100 Subject: [PATCH] Extend metrics library This is a general rewrite of pkg/metrics library, but it a fully backwards compatible way. Goals and key components are described in doc.go. Fixes: #2376 Signed-off-by: Anna Kapuscinska --- pkg/metrics/bpfmetric.go | 48 +++-- pkg/metrics/counter.go | 310 +++++++++++++++++++++++++++++++-- pkg/metrics/customcollector.go | 67 +++++++ pkg/metrics/custommetric.go | 66 +++++++ pkg/metrics/doc.go | 77 ++++++++ pkg/metrics/filteredlabels.go | 24 +++ pkg/metrics/gauge.go | 292 +++++++++++++++++++++++++++++-- pkg/metrics/granularmetric.go | 79 ++++++++- pkg/metrics/group.go | 164 +++++++++++++++++ pkg/metrics/histogram.go | 212 ++++++++++++++++++++-- pkg/metrics/labels.go | 40 +++++ pkg/metrics/opts.go | 136 +++++++++++++++ 12 files changed, 1438 insertions(+), 77 deletions(-) create mode 100644 pkg/metrics/customcollector.go create mode 100644 pkg/metrics/custommetric.go create mode 100644 pkg/metrics/doc.go create mode 100644 pkg/metrics/group.go create mode 100644 pkg/metrics/labels.go create mode 100644 pkg/metrics/opts.go diff --git a/pkg/metrics/bpfmetric.go b/pkg/metrics/bpfmetric.go index 3a660e63eb9..aaf9be4fd99 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 interface in this file provides a bridge between the new metrics library +// and the existing code defining metrics. It's considered deprecated - use the +// custom metric interface 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 + *granularCustomCounter[NilLabels] } +// DEPRECATED: Use NewCustomCounter instead. func NewBPFCounter(desc *prometheus.Desc) BPFMetric { - return &bpfCounter{desc: desc} + return &bpfCounter{ + &granularCustomCounter[NilLabels]{ + desc: desc, + constrained: false, + }, + } } -func (c *bpfCounter) Desc() *prometheus.Desc { - return c.desc +func (m *bpfCounter) Desc() *prometheus.Desc { + return m.granularCustomCounter.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.granularCustomCounter.MustMetric(value, &NilLabels{}, labelValues...) } type bpfGauge struct { - desc *prometheus.Desc + *granularCustomGauge[NilLabels] } +// DEPRECATED: Use NewCustomGauge instead. func NewBPFGauge(desc *prometheus.Desc) BPFMetric { - return &bpfGauge{desc: desc} + return &bpfGauge{ + &granularCustomGauge[NilLabels]{ + desc: desc, + constrained: false, + }, + } } -func (g *bpfGauge) Desc() *prometheus.Desc { - return g.desc +func (m *bpfGauge) Desc() *prometheus.Desc { + return m.granularCustomGauge.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.granularCustomGauge.MustMetric(value, &NilLabels{}, labelValues...) } diff --git a/pkg/metrics/counter.go b/pkg/metrics/counter.go index c2343205cf8..12ad40186e4 100644 --- a/pkg/metrics/counter.go +++ b/pkg/metrics/counter.go @@ -7,9 +7,14 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -// NewCounterVecWithPod is a wrapper around prometheus.NewCounterVec that also registers the metric -// to be cleaned up when a pod is deleted. It should be used only to register metrics that have -// "pod" and "namespace" labels. +type initCounterFunc func(*prometheus.CounterVec) + +// NewCounterVecWithPod is a wrapper around prometheus.NewCounterVec that also +// registers the metric to be cleaned up when a pod is deleted. +// +// It should be used only to register metrics that have "pod" and "namespace" +// labels. Using it for metrics without these labels won't break anything, but +// might add an unnecessary overhead. func NewCounterVecWithPod(opts prometheus.CounterOpts, labels []string) *prometheus.CounterVec { metric := prometheus.NewCounterVec(opts, labels) metricsWithPodMutex.Lock() @@ -18,42 +23,313 @@ func NewCounterVecWithPod(opts prometheus.CounterOpts, labels []string) *prometh return metric } +// NewCounterVecWithPodV2 is a wrapper around prometheus.V2.NewCounterVec that also +// registers the metric to be cleaned up when a pod is deleted. +// +// See NewCounterVecWithPod for usage notes. +func NewCounterVecWithPodV2(opts prometheus.CounterVecOpts) *prometheus.CounterVec { + metric := prometheus.V2.NewCounterVec(opts) + metricsWithPodMutex.Lock() + metricsWithPod = append(metricsWithPod, metric.MetricVec) + metricsWithPodMutex.Unlock() + return metric +} + +// GranularCounter wraps prometheus.CounterVec and implements CollectorWithInit. type GranularCounter[L FilteredLabels] struct { - metric *prometheus.CounterVec + metric *prometheus.CounterVec + constrained bool + initFunc initCounterFunc + 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 in the following cases: +// - 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). +// - metric is unconstrained, but some of the unconstrained label values are +// known beforehand, so can be initialized. +// - you want to disable default initialization - pass +// func(*prometheus.CounterVec) {} in such case +func NewGranularCounter[L FilteredLabels](opts Opts, init initCounterFunc) (*GranularCounter[L], error) { + labels, constrained, err := getVariableLabels[L](&opts) if err != nil { return nil, err } + + promOpts := prometheus.CounterVecOpts{ + CounterOpts: prometheus.CounterOpts{ + Namespace: opts.Namespace, + Subsystem: opts.Subsystem, + Name: opts.Name, + Help: opts.Help, + ConstLabels: opts.ConstLabels, + }, + VariableLabels: labels, + } + var metric *prometheus.CounterVec + if promContainsLabel(labels, "pod") && promContainsLabel(labels, "namespace") { + // set up metric to be deleted when a pod is deleted + metric = NewCounterVecWithPodV2(promOpts) + } else { + metric = prometheus.V2.NewCounterVec(promOpts) + } + + initMetric := func(lvs ...string) { + metric.WithLabelValues(lvs...).Add(0) + } + + // If metric is constrained, default to initializing all combinations of + // labels. Note that in such case the initialization function doesn't + // reference the wrapped metric passed as an argument because this metric + // is captured already in initMetric closure. + if constrained && init == nil { + init = func(_ *prometheus.CounterVec) { + 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. +// +// DEPRECATED: Use MustNewGranularCounterWithInit instead. +func MustNewGranularCounter[L FilteredLabels](promOpts prometheus.CounterOpts, extraLabels []string) *GranularCounter[L] { + unconstrained := stringToUnconstrained(extraLabels) + opts := Opts{ + Opts: prometheus.Opts(promOpts), + UnconstrainedLabels: unconstrained, + } + metric, err := NewGranularCounter[L](opts, nil) + if err != nil { + panic(err) + } + return metric +} + +// MustNewGranularCounterWithInit is a convenience function that wraps +// NewGranularCounter and panics on error. +func MustNewGranularCounterWithInit[L FilteredLabels](opts Opts, init initCounterFunc) *GranularCounter[L] { + metric, err := NewGranularCounter[L](opts, init) if err != nil { panic(err) } - return result + return metric } +// 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) } -func (m *GranularCounter[L]) WithLabelValues(commonLvs *L, extraLvs ...string) prometheus.Counter { - lvs := append((*commonLvs).Values(), extraLvs...) +// 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(m.metric) + } +} + +// 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, lvs ...string) prometheus.Counter { + if commonLvs != nil { + lvs = append((*commonLvs).Values(), lvs...) + } return m.metric.WithLabelValues(lvs...) } + +// Counter wraps prometheus.CounterVec and implements CollectorWithInit. +// +// The only difference between GranularCounter[FilteredLabels] and Counter is +// WithLabelValues method, which in the latter doesn't take generic +// FilteredLabels argument. We can also use GranularCounter[NilLabels] to +// define counters with no configurable labels, but then we have to pass +// an additional nil argument to WithLabelValues. A separate type is provided +// for convenience and easy migration. +type Counter struct { + *GranularCounter[NilLabels] +} + +// NewCounter creates a new Counter. +// +// See NewGranularCounter for usage notes. +func NewCounter(opts Opts, init initCounterFunc) (*Counter, error) { + metric, err := NewGranularCounter[NilLabels](opts, init) + if err != nil { + return nil, err + } + return &Counter{metric}, nil +} + +// MustNewCounter is a convenience function that wraps NewCounter and panics on +// error. +func MustNewCounter(opts Opts, init initCounterFunc) *Counter { + metric, err := NewCounter(opts, init) + if err != nil { + panic(err) + } + return metric +} + +// Describe implements CollectorWithInit (prometheus.Collector). +func (m *Counter) Describe(ch chan<- *prometheus.Desc) { + m.GranularCounter.Describe(ch) +} + +// Collect implements CollectorWithInit (prometheus.Collector). +func (m *Counter) Collect(ch chan<- prometheus.Metric) { + m.GranularCounter.Collect(ch) +} + +// IsConstrained implements CollectorWithInit. +func (m *Counter) IsConstrained() bool { + return m.GranularCounter.IsConstrained() +} + +// Init implements CollectorWithInit. +func (m *Counter) Init() { + m.GranularCounter.Init() +} + +// InitForDocs implements CollectorWithInit. +func (m *Counter) InitForDocs() { + m.GranularCounter.InitForDocs() +} + +// WithLabelValues is similar to WithLabelValues method from prometheus +// package. The arguments are values of first constrained labels, then +// unconstrained labels. +func (m *Counter) WithLabelValues(lvs ...string) prometheus.Counter { + return m.GranularCounter.WithLabelValues(nil, lvs...) +} + +// granularCustomCounter implements GranularCustomMetric. +type granularCustomCounter[L FilteredLabels] struct { + desc *prometheus.Desc + constrained bool +} + +// NewGranularCustomCounter creates a new granularCustomCounter. +func NewGranularCustomCounter[L FilteredLabels](opts Opts) (GranularCustomMetric[L], error) { + desc, constrained, err := getDesc[L](&opts) + if err != nil { + return nil, err + } + + return &granularCustomCounter[L]{ + desc: desc, + constrained: constrained, + }, nil +} + +// MustNewGranularCustomCounter is a convenience function that wraps +// NewGranularCustomCounter and panics on error. +func MustNewGranularCustomCounter[L FilteredLabels](opts Opts) GranularCustomMetric[L] { + metric, err := NewGranularCustomCounter[L](opts) + if err != nil { + panic(err) + } + return metric +} + +// Desc implements GranularCustomMetric. +func (m *granularCustomCounter[L]) Desc() *prometheus.Desc { + return m.desc +} + +// MustMetric implements GranularCustomMetric. +func (m *granularCustomCounter[L]) MustMetric(value float64, commonLvs *L, lvs ...string) prometheus.Metric { + if commonLvs != nil { + lvs = append((*commonLvs).Values(), lvs...) + } + return prometheus.MustNewConstMetric(m.desc, prometheus.CounterValue, value, lvs...) +} + +// IsConstrained implements GranularCustomMetric. +func (m *granularCustomCounter[L]) IsConstrained() bool { + return m.constrained +} + +// customCounter implements CustomMetric. +type customCounter struct { + *granularCustomCounter[NilLabels] +} + +// NewCustomCounter creates a new customCounter. +func NewCustomCounter(opts Opts) (CustomMetric, error) { + metric, err := NewGranularCustomCounter[NilLabels](opts) + if err != nil { + return nil, err + } + counter, ok := metric.(*granularCustomCounter[NilLabels]) + if !ok { + return nil, ErrInvalidMetricType + } + return &customCounter{counter}, nil +} + +// MustNewCustomCounter is a convenience function that wraps NewCustomCounter +// and panics on error. +func MustNewCustomCounter(opts Opts) CustomMetric { + metric, err := NewCustomCounter(opts) + if err != nil { + panic(err) + } + return metric +} + +// Desc implements CustomMetric. +func (m *customCounter) Desc() *prometheus.Desc { + return m.granularCustomCounter.Desc() +} + +// MustMetric implements CustomMetric. +func (m *customCounter) MustMetric(value float64, lvs ...string) prometheus.Metric { + return m.granularCustomCounter.MustMetric(value, nil, lvs...) +} + +// IsConstrained implements CustomMetric. +func (m *customCounter) IsConstrained() bool { + return m.granularCustomCounter.IsConstrained() +} diff --git a/pkg/metrics/customcollector.go b/pkg/metrics/customcollector.go new file mode 100644 index 00000000000..d1954534de3 --- /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 struct { + metrics []customMetric + 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 generating documentation. +func NewCustomCollector( + metrics []customMetric, collect collectFunc, collectForDocs collectFunc, +) CollectorWithInit { + return &customCollector{ + metrics: metrics, + collectFunc: collect, + collectForDocsFunc: collectForDocs, + } +} + +// Describe implements CollectorWithInit (prometheus.Collector). +func (c *customCollector) Describe(ch chan<- *prometheus.Desc) { + for _, m := range c.metrics { + ch <- m.Desc() + } +} + +// Collect implements CollectorWithInit (prometheus.Collector). +func (c *customCollector) Collect(ch chan<- prometheus.Metric) { + if c.collectFunc != nil { + c.collectFunc(ch) + } +} + +// IsConstrained implements CollectorWithInit. +func (c *customCollector) IsConstrained() bool { + for _, m := range c.metrics { + if !m.IsConstrained() { + return false + } + } + return true +} + +// Init implements CollectorWithInit. +func (c *customCollector) Init() { + // since metrics are collected independently, there's nothing to initialize +} + +// InitForDocs implements CollectorWithInit. +func (c *customCollector) InitForDocs() { + // override Collect method if there's a separate 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..5cd1835f59c --- /dev/null +++ b/pkg/metrics/custommetric.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import ( + "errors" + + "github.com/prometheus/client_golang/prometheus" +) + +var ErrInvalidMetricType = errors.New("invalid metric type") + +type customMetric interface { + Desc() *prometheus.Desc + IsConstrained() bool +} + +type CustomMetrics []customMetric + +// 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 entirely up to the +// collector implementer to guarantee metrics consistency, including enforcing +// labels constraints. +type GranularCustomMetric[L FilteredLabels] interface { + customMetric + MustMetric(value float64, commonLvs *L, lvs ...string) prometheus.Metric +} + +// CustomMetric represents a metric collected independently of prometheus +// package that has no configurable labels. +// +// The only difference between GranularCustomMetric[FilteredLabels] and +// CustomMetric is MustMetric method, which in the latter doesn't take generic +// FilteredLabels argument. We can also use GranularCustomMetric[NilLabels] to +// define custom metrics with no configurable labels, but then we have to pass +// an additional nil argument to MustMetric. A separate interface is provided +// for convenience and easy migration. +// +// See GranularCustomMetric for usage notes. +type CustomMetric interface { + customMetric + MustMetric(value float64, lvs ...string) prometheus.Metric +} + +// 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 *Opts) (*prometheus.Desc, bool, error) { + labels, constrained, err := getVariableLabelNames[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 +} diff --git a/pkg/metrics/doc.go b/pkg/metrics/doc.go new file mode 100644 index 00000000000..80f0e3a54b8 --- /dev/null +++ b/pkg/metrics/doc.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +// The metrics package provides a set of helpers (wrappers around +// [prometheus Go library](https://pkg.go.dev/github.com/prometheus/client_golang/prometheus)) +// for defining and managing prometheus metrics. +// +// The package is designed to support the following functionality: +// - Group metrics based on their purpose and load groups independently. +// This gives us more control over what metrics are exposed and how +// cardinality is managed. +// - Define custom collectors, e.g. reading metrics directly from BPF maps. +// This decouples metrics from events passed through ringbuffer. +// - Let users configure high-cardinality dynamic labels, for both "regular" +// metrics and custom collectors. +// - Constrain metrics cardinality for metrics with known labels. +// - Initialize metrics with known labels on startup. +// This makes resources usage more predictable, as cardinality of these +// metrics won't grow. +// - Autogenerate reference documentation from metrics help texts. +// - Delete stale metrics. This will prevent growing cardinality. +// Currently we do it when a pod is deleted, but it should be easy to +// extend this to other cases. +// - Keep common labels consistent between metrics. +// This makes it easier to write queries. +// +// Here we describe the key parts of the metrics package. See also doc comments +// in the code for more details. +// +// `Group` interface and `metricsGroup` struct implementing it are +// wrappers around `prometheus.Registry` intended to define sub-registries of +// the root registry. In addition to registering metrics, it supports: +// - initializing metrics on startup +// - initializing metrics for generating docs +// - constraining metrics cardinality (constrained group contains only +// metrics with constrained cardinality) +// +// `Opts` struct is a wrapper around `prometheus.Opts` that additionally +// supports defining constrained and unconstrained labels. +// +// `ConstrainedLabel` and `UnconstrainedLabel` structs represent metric labels. +// +// `FilteredLabels` interface represents configurable labels. It's intended to +// be used as a type parameter when defining a granular metric, to add common +// labels. The idea is that users can configure which of these (potentially +// high-cardinality) labels are actually exposed - see `CreateProcessLabels` in +// `pkg/option` for an example. The values of these labels are always +// unconstrained. `NilLabels` package variable is a special case of +// `FilteredLabels` with no labels, which is used in convenience wrappers +// around granular metrics. +// +// `GranularCounter[L FilteredLabels]` (and analogous Gauge and Histogram) +// struct is a wrapper around `prometheus.CounterVec` (Gauge, Histogram) with +// additional properties: +// - cardinality can be constrained +// - support for configurable labels +// - metric is initialized at startup for known label values +// - metric is automatically included in generated docs +// +// `Counter` (and analogous Gauge and Histogram) struct is a convenience +// wrapper around `GranularCounter[NilLabels]` (Gauge, Histogram). +// +// `customCollector` struct represents a custom collector (e.g. reading metrics +// directly from a BPF map). It contains a list of metrics, collect function +// and an optional separate collect function for generating docs. +// +// `GranularCustomMetric[L FilteredLabels]` interface and +// `granularCustomCounter` struct (and analogous Gauge) implementing it +// represent a metric that's not stored and updated using prometheus library, +// but collected independently, e.g. directly from a BPF map. Similarly like +// "regular" metrics, it supports constraining cardinality and adding +// configurable labels via type parameter. +// +// `CustomMetric` interface and `customCounter` struct (and analogous Gauge) +// implementing it are convenience wrappers around +// `GranularCustomMetric[NilLabels]`. +package metrics diff --git a/pkg/metrics/filteredlabels.go b/pkg/metrics/filteredlabels.go index df894c27f7a..8c37b1902cb 100644 --- a/pkg/metrics/filteredlabels.go +++ b/pkg/metrics/filteredlabels.go @@ -3,11 +3,27 @@ package metrics +import ( + "github.com/cilium/tetragon/pkg/metrics/consts" +) + type FilteredLabels interface { Keys() []string Values() []string } +type FilteredLabelsExample interface { + Example() FilteredLabels +} + +type NilLabels struct{} + +func (l NilLabels) Keys() []string { return []string{} } + +func (l NilLabels) Values() []string { return []string{} } + +func (l NilLabels) Example() FilteredLabels { return l } + type ProcessLabels struct { Namespace string Workload string @@ -31,3 +47,11 @@ func (l ProcessLabels) Keys() []string { func (l ProcessLabels) Values() []string { return []string{l.Namespace, l.Workload, l.Pod, l.Binary} } + +func (l ProcessLabels) Example() FilteredLabels { + l.Namespace = consts.ExampleNamespace + l.Workload = consts.ExampleWorkload + l.Pod = consts.ExamplePod + l.Binary = consts.ExampleBinary + return l +} diff --git a/pkg/metrics/gauge.go b/pkg/metrics/gauge.go index 6650148e045..69dff54e9f2 100644 --- a/pkg/metrics/gauge.go +++ b/pkg/metrics/gauge.go @@ -7,9 +7,12 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -// NewGaugeVecWithPod is a wrapper around prometheus.NewGaugeVec that also registers the metric -// to be cleaned up when a pod is deleted. It should be used only to register metrics that have -// "pod" and "namespace" labels. +type initGaugeFunc func(*prometheus.GaugeVec) + +// NewGaugeVecWithPod is a wrapper around prometheus.NewGaugeVec that also +// registers the metric to be cleaned up when a pod is deleted. +// +// See NewCounterVecWithPod for usage notes. func NewGaugeVecWithPod(opts prometheus.GaugeOpts, labels []string) *prometheus.GaugeVec { metric := prometheus.NewGaugeVec(opts, labels) metricsWithPodMutex.Lock() @@ -18,42 +21,297 @@ func NewGaugeVecWithPod(opts prometheus.GaugeOpts, labels []string) *prometheus. return metric } +// NewGaugeVecWithPodV2 is a wrapper around prometheus.V2.NewGaugeVec that also +// registers the metric to be cleaned up when a pod is deleted. +// +// See NewCounterVecWithPod for usage notes. +func NewGaugeVecWithPodV2(opts prometheus.GaugeVecOpts) *prometheus.GaugeVec { + metric := prometheus.V2.NewGaugeVec(opts) + metricsWithPodMutex.Lock() + metricsWithPod = append(metricsWithPod, metric.MetricVec) + metricsWithPodMutex.Unlock() + return metric +} + +// GranularGauge wraps prometheus.GaugeVec and implements CollectorWithInit. type GranularGauge[L FilteredLabels] struct { - metric *prometheus.GaugeVec + metric *prometheus.GaugeVec + constrained bool + initFunc initGaugeFunc + 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 Opts, init initGaugeFunc) (*GranularGauge[L], error) { + labels, constrained, err := getVariableLabels[L](&opts) if err != nil { return nil, err } + + promOpts := prometheus.GaugeVecOpts{ + GaugeOpts: prometheus.GaugeOpts{ + Namespace: opts.Namespace, + Subsystem: opts.Subsystem, + Name: opts.Name, + Help: opts.Help, + ConstLabels: opts.ConstLabels, + }, + VariableLabels: labels, + } + var metric *prometheus.GaugeVec + if promContainsLabel(labels, "pod") && promContainsLabel(labels, "namespace") { + // set up metric to be deleted when a pod is deleted + metric = NewGaugeVecWithPodV2(promOpts) + } else { + metric = prometheus.V2.NewGaugeVec(promOpts) + } + + initMetric := func(lvs ...string) { + metric.WithLabelValues(lvs...).Set(0) + } + + // If metric is constrained, default to initializing all combinations of + // labels. Note that in such case the initialization function doesn't + // reference the wrapped metric passed as an argument because this metric + // is captured already in initMetric closure. + if constrained && init == nil { + init = func(_ *prometheus.GaugeVec) { + 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. +// +// DEPRECATED: Use MustNewGranularGaugeWithInit instead. +func MustNewGranularGauge[L FilteredLabels](promOpts prometheus.GaugeOpts, extraLabels []string) *GranularGauge[L] { + unconstrained := stringToUnconstrained(extraLabels) + opts := Opts{ + Opts: prometheus.Opts(promOpts), + UnconstrainedLabels: unconstrained, + } + metric, err := NewGranularGauge[L](opts, nil) + if err != nil { + panic(err) + } + return metric +} + +// MustNewGranularGaugeWithInit is a convenience function that wraps +// NewGranularGauge and panics on error. +func MustNewGranularGaugeWithInit[L FilteredLabels](opts Opts, init initGaugeFunc) *GranularGauge[L] { + metric, err := NewGranularGauge[L](opts, init) if err != nil { panic(err) } - return result + return metric } +// 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) } -func (m *GranularGauge[L]) WithLabelValues(commonLvs *L, extraLvs ...string) prometheus.Gauge { - lvs := append((*commonLvs).Values(), extraLvs...) +// 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(m.metric) + } +} + +// 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. The +// following arguments are values of first constrained labels, then +// unconstrained labels. +func (m *GranularGauge[L]) WithLabelValues(commonLvs *L, lvs ...string) prometheus.Gauge { + if commonLvs != nil { + lvs = append((*commonLvs).Values(), lvs...) + } return m.metric.WithLabelValues(lvs...) } + +// Gauge wraps prometheus.GaugeVec and implements CollectorWithInit. +// +// The only difference between GranularGauge[FilteredLabels] and Gauge is +// WithLabelValues method, which in the latter doesn't take generic +// FilteredLabels argument. We can also use GranularGauge[NilLabels] to +// define gauges with no configurable labels, but then we have to pass +// an additional nil argument to WithLabelValues. A separate type is provided +// for convenience and easy migration. +type Gauge struct { + *GranularGauge[NilLabels] +} + +// NewGauge creates a new Gauge. +// +// See NewGranularCounter for usage notes. +func NewGauge(opts Opts, init initGaugeFunc) (*Gauge, error) { + metric, err := NewGranularGauge[NilLabels](opts, init) + if err != nil { + return nil, err + } + return &Gauge{metric}, nil +} + +// MustNewGauge is a convenience function that wraps NewGauge and panics on +// error. +func MustNewGauge(opts Opts, init initGaugeFunc) *Gauge { + metric, err := NewGauge(opts, init) + if err != nil { + panic(err) + } + return metric +} + +// Describe implements CollectorWithInit (prometheus.Collector). +func (m *Gauge) Describe(ch chan<- *prometheus.Desc) { + m.GranularGauge.Describe(ch) +} + +// Collect implements CollectorWithInit (prometheus.Collector). +func (m *Gauge) Collect(ch chan<- prometheus.Metric) { + m.GranularGauge.Collect(ch) +} + +// IsConstrained implements CollectorWithInit. +func (m *Gauge) IsConstrained() bool { + return m.GranularGauge.IsConstrained() +} + +// Init implements CollectorWithInit. +func (m *Gauge) Init() { + m.GranularGauge.Init() +} + +// InitForDocs implements CollectorWithInit. +func (m *Gauge) InitForDocs() { + m.GranularGauge.InitForDocs() +} + +// WithLabelValues is similar to WithLabelValues method from prometheus +// package. The arguments are values of first constrained labels, then +// unconstrained labels. +func (m *Gauge) WithLabelValues(lvs ...string) prometheus.Gauge { + return m.GranularGauge.WithLabelValues(nil, lvs...) +} + +// granularCustomGauge implements GranularCustomMetric. +type granularCustomGauge[L FilteredLabels] struct { + desc *prometheus.Desc + constrained bool +} + +// NewGranularCustomGauge creates a new granularCustomGauge. +func NewGranularCustomGauge[L FilteredLabels](opts Opts) (GranularCustomMetric[L], error) { + desc, constrained, err := getDesc[L](&opts) + if err != nil { + return nil, err + } + + return &granularCustomGauge[L]{ + desc: desc, + constrained: constrained, + }, nil +} + +// MustNewGranularCustomGauge is a convenience function that wraps +// NewGranularCustomGauge and panics on error. +func MustNewGranularCustomGauge[L FilteredLabels](opts Opts) GranularCustomMetric[L] { + metric, err := NewGranularCustomGauge[L](opts) + if err != nil { + panic(err) + } + return metric +} + +// Desc implements GranularCustomMetric. +func (m *granularCustomGauge[L]) Desc() *prometheus.Desc { + return m.desc +} + +// MustMetric implements GranularCustomMetric. +func (m *granularCustomGauge[L]) MustMetric(value float64, commonLvs *L, lvs ...string) prometheus.Metric { + if commonLvs != nil { + lvs = append((*commonLvs).Values(), lvs...) + } + return prometheus.MustNewConstMetric(m.desc, prometheus.GaugeValue, value, lvs...) +} + +// IsConstrained implements GranularCustomMetric. +func (m *granularCustomGauge[L]) IsConstrained() bool { + return m.constrained +} + +// customGauge implements CustomMetric. +type customGauge struct { + *granularCustomGauge[NilLabels] +} + +// NewCustomGauge creates a new customGauge. +func NewCustomGauge(opts Opts) (CustomMetric, error) { + metric, err := NewGranularCustomGauge[NilLabels](opts) + if err != nil { + return nil, err + } + gauge, ok := metric.(*granularCustomGauge[NilLabels]) + if !ok { + return nil, ErrInvalidMetricType + } + return &customGauge{gauge}, nil +} + +// MustNewCustomGauge is a convenience function that wraps NewCustomGauge +// and panics on error. +func MustNewCustomGauge(opts Opts) CustomMetric { + metric, err := NewCustomGauge(opts) + if err != nil { + panic(err) + } + return metric +} + +// Desc implements CustomMetric. +func (m *customGauge) Desc() *prometheus.Desc { + return m.granularCustomGauge.Desc() +} + +// MustMetric implements CustomMetric. +func (m *customGauge) MustMetric(value float64, lvs ...string) prometheus.Metric { + return m.granularCustomGauge.MustMetric(value, nil, lvs...) +} + +// IsConstrained implements CustomMetric. +func (m *customGauge) IsConstrained() bool { + return m.granularCustomGauge.IsConstrained() +} diff --git a/pkg/metrics/granularmetric.go b/pkg/metrics/granularmetric.go index 01a1aa4894c..2d6eb9b39b5 100644 --- a/pkg/metrics/granularmetric.go +++ b/pkg/metrics/granularmetric.go @@ -3,16 +3,77 @@ package metrics -import ( - "fmt" - "slices" -) +type initLabelValuesFunc func(...string) -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) +// initAllCombinations initializes a metric with all possible combinations of +// label values. +func initAllCombinations(initMetric initLabelValuesFunc, 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 initLabelValuesFunc, 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 initLabelValuesFunc, constrained []ConstrainedLabel, unconstrained []UnconstrainedLabel, +) { + var dummy L + commonLabels := dummy.Keys() + lvs := make([]string, len(commonLabels)+len(constrained)+len(unconstrained)) + + // first FilteredLabels + current := lvs + if ex, ok := any(dummy).(FilteredLabelsExample); ok { + for i, val := range ex.Example().Values() { + current[i] = val + initMetric(lvs...) + } + } else { + for i := range commonLabels { + current[i] = "example" + initMetric(lvs...) } } - return nil + // second constrained labels + current = current[len(commonLabels):] + 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[i] = unconstrained[i].ExampleValue + initMetric(lvs...) + } } diff --git a/pkg/metrics/group.go b/pkg/metrics/group.go new file mode 100644 index 00000000000..9d087ae597a --- /dev/null +++ b/pkg/metrics/group.go @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import ( + "errors" + + "github.com/prometheus/client_golang/prometheus" +) + +// initializer contains methods for metrics initialization and checking +// constraints. +type initializer interface { + IsConstrained() bool + Init() + InitForDocs() +} + +// CollectorWithInit extends prometheus.Collector with initializer. +type CollectorWithInit interface { + prometheus.Collector + initializer +} + +// Group extends prometheus.Registerer with CollectorWithInit. +// It represents a sub-registry of the root prometheus.Registry. +type Group interface { + prometheus.Registerer + CollectorWithInit + ExtendInit(func()) + ExtendInitForDocs(func()) +} + +// metricsGroup wraps prometheus.Registry and implements Group +type metricsGroup struct { + registry *prometheus.Registry + // If constrained is true, group will accept collectors implementing + // initializer only if they are constrained. + constrained bool + initFunc func() + initForDocsFunc func() +} + +// NewMetricsGroup creates a new Group. +func NewMetricsGroup(constrained bool) Group { + return &metricsGroup{ + registry: prometheus.NewPedanticRegistry(), + constrained: constrained, + initFunc: func() {}, + initForDocsFunc: func() {}, + } +} + +// Describe implements Group (prometheus.Collector). +func (r *metricsGroup) Describe(ch chan<- *prometheus.Desc) { + r.registry.Describe(ch) +} + +// Collect implements Group (prometheus.Collector). +func (r *metricsGroup) Collect(ch chan<- prometheus.Metric) { + r.registry.Collect(ch) +} + +// Register implements Group (prometheus.Registerer). +// +// It wraps the Register method of the underlying registry. Additionally, if +// the collector implements initializer, it: +// - 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) Register(c prometheus.Collector) error { + cc, hasInit := c.(initializer) + if hasInit { + // check constraints + if r.IsConstrained() && !cc.IsConstrained() { + return errors.New("can't register unconstrained metrics in a constrained group") + } + } + // register + err := r.registry.Register(c) + if err != nil { + return err + } + if hasInit { + // extend init + r.ExtendInit(cc.Init) + r.ExtendInitForDocs(cc.InitForDocs) + } + return nil +} + +// MustRegister implements Group (prometheus.Registerer). +func (r *metricsGroup) MustRegister(cs ...prometheus.Collector) { + for _, c := range cs { + if err := r.Register(c); err != nil { + panic(err) + } + } +} + +// Unregister implements Group (prometheus.Registerer). +func (r *metricsGroup) Unregister(c prometheus.Collector) bool { + return r.registry.Unregister(c) +} + +// IsConstrained implements Group (initializer). +func (r *metricsGroup) IsConstrained() bool { + return r.constrained +} + +// Init implements Group (initializer). +func (r *metricsGroup) Init() { + if r.initFunc != nil { + r.initFunc() + } +} + +// InitForDocs implements Group (initializer). +func (r *metricsGroup) InitForDocs() { + if r.initForDocsFunc != nil { + r.initForDocsFunc() + } +} + +// ExtendInit extends the metricsGroup Init and InitForDocs methods. +// +// For metrics implementing CollectorWithInit, initialization function should +// be passed when defining the metric, so this method shouldn't be called +// explicitly. However, when adding existing metrics (not implementing +// CollectorWithInit) into a metrics group, it's helpful to extend the group's +// initialization function separately. +func (r *metricsGroup) ExtendInit(init func()) { + if init != nil { + if r.initFunc == nil { + r.initFunc = init + } else { + oldInit := r.initFunc + r.initFunc = func() { + oldInit() + init() + } + } + } + r.ExtendInitForDocs(init) +} + +// ExtendInit extends the metricsGroup InitForDocs method. +// +// See ExtendInit for usage notes. +func (r *metricsGroup) ExtendInitForDocs(init func()) { + if init != nil { + if r.initForDocsFunc == nil { + r.initForDocsFunc = init + } else { + oldInit := r.initForDocsFunc + r.initForDocsFunc = func() { + oldInit() + init() + } + } + } +} diff --git a/pkg/metrics/histogram.go b/pkg/metrics/histogram.go index b74c93b83d7..00c0623eaed 100644 --- a/pkg/metrics/histogram.go +++ b/pkg/metrics/histogram.go @@ -7,9 +7,12 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -// NewHistogramVecWithPod is a wrapper around prometheus.NewHistogramVec that also registers the metric -// to be cleaned up when a pod is deleted. It should be used only to register metrics that have -// "pod" and "namespace" labels. +type initHistogramFunc func(*prometheus.HistogramVec) + +// NewHistogramVecWithPod is a wrapper around prometheus.NewHistogramVec that also +// registers the metric to be cleaned up when a pod is deleted. +// +// See NewCounterVecWithPod for usage notes. func NewHistogramVecWithPod(opts prometheus.HistogramOpts, labels []string) *prometheus.HistogramVec { metric := prometheus.NewHistogramVec(opts, labels) metricsWithPodMutex.Lock() @@ -18,42 +21,217 @@ func NewHistogramVecWithPod(opts prometheus.HistogramOpts, labels []string) *pro return metric } +// NewHistogramVecWithPodV2 is a wrapper around prometheus.V2.NewHistogramVec that also +// registers the metric to be cleaned up when a pod is deleted. +// +// See NewCounterVecWithPod for usage notes. +func NewHistogramVecWithPodV2(opts prometheus.HistogramVecOpts) *prometheus.HistogramVec { + metric := prometheus.V2.NewHistogramVec(opts) + metricsWithPodMutex.Lock() + metricsWithPod = append(metricsWithPod, metric.MetricVec) + metricsWithPodMutex.Unlock() + return metric +} + +// GranularHistogram wraps prometheus.HistogramVec and implements CollectorWithInit. type GranularHistogram[L FilteredLabels] struct { - metric *prometheus.HistogramVec + metric *prometheus.HistogramVec + constrained bool + initFunc initHistogramFunc + 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 initHistogramFunc) (*GranularHistogram[L], error) { + labels, constrained, err := getVariableLabels[L](&opts.Opts) if err != nil { return nil, err } + + promOpts := prometheus.HistogramVecOpts{ + HistogramOpts: prometheus.HistogramOpts{ + Namespace: opts.Namespace, + Subsystem: opts.Subsystem, + Name: opts.Name, + Help: opts.Help, + ConstLabels: opts.ConstLabels, + Buckets: opts.Buckets, + }, + VariableLabels: labels, + } + var metric *prometheus.HistogramVec + if promContainsLabel(labels, "pod") && promContainsLabel(labels, "namespace") { + // set up metric to be deleted when a pod is deleted + metric = NewHistogramVecWithPodV2(promOpts) + } else { + metric = prometheus.V2.NewHistogramVec(promOpts) + } + + initMetric := func(lvs ...string) { + metric.WithLabelValues(lvs...) + } + + // If metric is constrained, default to initializing all combinations of + // labels. Note that in such case the initialization function doesn't + // reference the wrapped metric passed as an argument because this metric + // is captured already in initMetric closure. + if constrained && init == nil { + init = func(_ *prometheus.HistogramVec) { + 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. +// +// DEPRECATED: Use MustNewGranularHistogramWithInit instead. +func MustNewGranularHistogram[L FilteredLabels](promOpts prometheus.HistogramOpts, extraLabels []string) *GranularHistogram[L] { + unconstrained := stringToUnconstrained(extraLabels) + opts := HistogramOpts{ + Opts: Opts{ + Opts: prometheus.Opts{ + Namespace: promOpts.Namespace, + Subsystem: promOpts.Subsystem, + Name: promOpts.Name, + Help: promOpts.Help, + ConstLabels: promOpts.ConstLabels, + }, + UnconstrainedLabels: unconstrained, + }, + Buckets: promOpts.Buckets, + } + metric, err := NewGranularHistogram[L](opts, nil) + if err != nil { + panic(err) + } + return metric +} + +// MustNewGranularHistogramWithInit is a convenience function that wraps +// NewGranularHistogram and panics on error. +func MustNewGranularHistogramWithInit[L FilteredLabels](opts HistogramOpts, init initHistogramFunc) *GranularHistogram[L] { + metric, err := NewGranularHistogram[L](opts, init) if err != nil { panic(err) } - return result + return metric } +// 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) } -func (m *GranularHistogram[L]) WithLabelValues(commonLvs *L, extraLvs ...string) prometheus.Observer { - lvs := append((*commonLvs).Values(), extraLvs...) +// 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(m.metric) + } +} + +// 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. The +// following arguments are values of first constrained labels, then +// unconstrained labels. +func (m *GranularHistogram[L]) WithLabelValues(commonLvs *L, lvs ...string) prometheus.Observer { + if commonLvs != nil { + lvs = append((*commonLvs).Values(), lvs...) + } return m.metric.WithLabelValues(lvs...) } + +// Histogram wraps prometheus.HistogramVec and implements CollectorWithInit. +// +// The only difference between GranularHistogram[FilteredLabels] and Histogram is +// WithLabelValues method, which in the latter doesn't take generic +// FilteredLabels argument. We can also use GranularHistogram[NilLabels] to +// define histograms with no configurable labels, but then we have to pass +// an additional nil argument to WithLabelValues. A separate type is provided +// for convenience and easy migration. +type Histogram struct { + *GranularHistogram[NilLabels] +} + +// NewHistogram creates a new Histogram. +// +// See NewGranularCounter for usage notes. +func NewHistogram(opts HistogramOpts, init initHistogramFunc) (*Histogram, error) { + metric, err := NewGranularHistogram[NilLabels](opts, init) + if err != nil { + return nil, err + } + return &Histogram{metric}, nil +} + +// MustNewHistogram is a convenience function that wraps NewHistogram and panics on +// error. +func MustNewHistogram(opts HistogramOpts, init initHistogramFunc) *Histogram { + metric, err := NewHistogram(opts, init) + if err != nil { + panic(err) + } + return metric +} + +// Describe implements CollectorWithInit (prometheus.Collector). +func (m *Histogram) Describe(ch chan<- *prometheus.Desc) { + m.GranularHistogram.Describe(ch) +} + +// Collect implements CollectorWithInit (prometheus.Collector). +func (m *Histogram) Collect(ch chan<- prometheus.Metric) { + m.GranularHistogram.Collect(ch) +} + +// IsConstrained implements CollectorWithInit. +func (m *Histogram) IsConstrained() bool { + return m.GranularHistogram.IsConstrained() +} + +// Init implements CollectorWithInit. +func (m *Histogram) Init() { + m.GranularHistogram.Init() +} + +// InitForDocs implements CollectorWithInit. +func (m *Histogram) InitForDocs() { + m.GranularHistogram.InitForDocs() +} + +// WithLabelValues is similar to WithLabelValues method from prometheus +// package. The arguments are values of first constrained labels, then +// unconstrained labels. +func (m *Histogram) WithLabelValues(lvs ...string) prometheus.Observer { + return m.GranularHistogram.WithLabelValues(nil, lvs...) +} diff --git a/pkg/metrics/labels.go b/pkg/metrics/labels.go new file mode 100644 index 00000000000..116038966be --- /dev/null +++ b/pkg/metrics/labels.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import "github.com/prometheus/client_golang/prometheus" + +// 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 +} + +// 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 stringToUnconstrained(labels []string) []UnconstrainedLabel { + unconstrained := make([]UnconstrainedLabel, len(labels)) + for i, label := range labels { + unconstrained[i] = UnconstrainedLabel{ + Name: label, + ExampleValue: "example", + } + } + return unconstrained +} + +func promContainsLabel(labels prometheus.ConstrainedLabels, label string) bool { + for _, l := range labels { + if l.Name == label { + return true + } + } + return false +} diff --git a/pkg/metrics/opts.go b/pkg/metrics/opts.go new file mode 100644 index 00000000000..ce10f2e8df9 --- /dev/null +++ b/pkg/metrics/opts.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import ( + "fmt" + "slices" + + "github.com/prometheus/client_golang/prometheus" +) + +// Opts extends prometheus.Opts with constrained and unconstrained labels. +// +// If using granular wrappers for prometheus metrics, then constrained labels +// will be replaced with an empty string if a values outside of the list is +// passed. +// +// If using granular metric interface (either wrappers or custom metrics), then +// labels passed via type parameter: +// - are assumed to be unconstrained +// - will be added at the beginning of the final labels list +// - must not overlap with labels passed via Opts +type Opts struct { + prometheus.Opts + ConstrainedLabels []ConstrainedLabel + UnconstrainedLabels []UnconstrainedLabel +} + +// HistogramOpts extends Opts with histogram-specific fields. +type HistogramOpts struct { + Opts + Buckets []float64 +} + +func NewOpts( + namespace, subsystem, name, help string, + constLabels prometheus.Labels, constrainedLabels []ConstrainedLabel, unconstrainedLabels []UnconstrainedLabel, +) Opts { + return Opts{ + Opts: prometheus.Opts{ + Namespace: namespace, + Subsystem: subsystem, + Name: name, + Help: help, + ConstLabels: constLabels, + }, + ConstrainedLabels: constrainedLabels, + UnconstrainedLabels: unconstrainedLabels, + } +} + +// getVariableLabels is a helper function to retrieve the full label list for +// a metric and check if the metric is constrained. +// +// The return type is prometheus.ConstrainedLabels, which can be passed (as +// prometheus.ConstrainableLabels) to functions from prometheus library that +// define metrics with variable labels. Note that despite the same name, +// prometheus.ConstrainedLabels type shouldn't be confused with +// ConstrainedLabels field in Opts struct. The returned list contains all +// labels, not only those defined as constrained. +// +// The returned label list follows the order: +// 1. FilteredLabels passed via type parameter (assumed to be unconstrained) +// 2. opts.ConstrainedLabels +// 3. opts.UnconstrainedLabels +// +// Labels passed via opts.ConstrainedLabels will be constrained by prometheus +// library. If a value outside of the list is passed, it will be replaced with +// an empty string. +func getVariableLabels[L FilteredLabels](opts *Opts) (prometheus.ConstrainedLabels, bool, error) { + var dummy L + commonLabels := dummy.Keys() + labelsErr := fmt.Errorf("extra labels can't contain any of the following: %v", commonLabels) + + promLabels := make( + []prometheus.ConstrainedLabel, + len(commonLabels)+len(opts.ConstrainedLabels)+len(opts.UnconstrainedLabels), + ) + + // first FilteredLabels + current := promLabels + for i, label := range commonLabels { + current[i] = prometheus.ConstrainedLabel{ + Name: label, + } + } + // second constrained labels + current = current[len(commonLabels):] + for i, label := range opts.ConstrainedLabels { + if slices.Contains(commonLabels, label.Name) { + return nil, false, labelsErr + } + current[i] = prometheus.ConstrainedLabel{ + Name: label.Name, + Constraint: func(value string) string { + for _, v := range label.Values { + if value == v { + return value + } + } + // If the value is not in the list of possible values, + // replace it with an empty string. + return "" + }, + } + } + // third unconstrained labels + current = current[len(opts.ConstrainedLabels):] + for i, label := range opts.UnconstrainedLabels { + if slices.Contains(commonLabels, label.Name) { + return nil, false, labelsErr + } + current[i] = prometheus.ConstrainedLabel{ + Name: label.Name, + } + } + + // check if labels are constrained + constrained := len(commonLabels) == 0 && len(opts.UnconstrainedLabels) == 0 + + return promLabels, constrained, nil +} + +// getVariableLabelNames is similar to getVariableLabels, but returns a list of +// label names (as []string) instead of prometheus.ConstrainedLabels. +// +// See getVariableLabels for the labels order. +func getVariableLabelNames[L FilteredLabels](opts *Opts) ([]string, bool, error) { + labels, constrained, err := getVariableLabels[L](opts) + labelNames := make([]string, len(labels)) + for i, label := range labels { + labelNames[i] = label.Name + } + return labelNames, constrained, err +}