diff --git a/pkg/metrics/bpfmetric.go b/pkg/metrics/bpfmetric.go index 3a660e63eb9..7a95aa3b513 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 +// 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..7c76aafe08d --- /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 generating documentation. +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 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..7f135646f3c --- /dev/null +++ b/pkg/metrics/custommetric.go @@ -0,0 +1,152 @@ +// 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 +} + +// MustNewGranularCustomCounter is a convenience function that wraps +// NewGranularCustomCounter and panics on error. +func MustNewGranularCustomCounter[L FilteredLabels](opts MetricOpts) GranularCustomMetric[L] { + m, err := NewGranularCustomCounter[L](opts) + if err != nil { + panic(err) + } + return m +} + +// NewCustomCounter creates a new granularCustomCounter with no configurable labels. +func NewCustomCounter(opts MetricOpts) (GranularCustomMetric[NilLabels], error) { + return NewGranularCustomCounter[NilLabels](opts) +} + +// MustNewCustomCounter is a convenience function that wraps NewCustomCounter +// and panics on error. +func MustNewCustomCounter(opts MetricOpts) GranularCustomMetric[NilLabels] { + return MustNewGranularCustomCounter[NilLabels](opts) +} + +// 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, 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 +} + +// MustNewGranularCustomGauge is a convenience function that wraps +// NewGranularCustomGauge and panics on error. +func MustNewGranularCustomGauge[L FilteredLabels](opts MetricOpts) GranularCustomMetric[L] { + m, err := NewGranularCustomGauge[L](opts) + if err != nil { + panic(err) + } + return m +} + +// NewCustomGauge creates a new granularCustomGauge with no configurable labels. +func NewCustomGauge(opts MetricOpts) (GranularCustomMetric[NilLabels], error) { + return NewGranularCustomGauge[NilLabels](opts) +} + +// MustNewCustomGauge is a convenience function that wraps NewCustomGauge +// and panics on error. +func MustNewCustomGauge(opts MetricOpts) GranularCustomMetric[NilLabels] { + return MustNewGranularCustomGauge[NilLabels](opts) +} + +// 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, 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/doc.go b/pkg/metrics/doc.go new file mode 100644 index 00000000000..4075ea702af --- /dev/null +++ b/pkg/metrics/doc.go @@ -0,0 +1,63 @@ +// 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) +// +// `MetricOpts` 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, passed to metrics +// via type parameter. The values are always unconstrained. +// +// `GranularCounter[L FilteredLabels]` (and analogous Gauge and Histogram) +// struct is a wrapper around `prometheus.CounterVec` (Gauge, Histogram) with +// additional properties: +// - cardinality can be constrained (meaning all label values are known) +// - support for configurable labels +// - metric is initialized at startup for known label values +// - metric is automatically included in generated docs +// +// `customCollector[L FilteredLabels]` 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]` (and analogous Gauge) interface +// represents a custom metric (e.g. read directly form a BPF map). Similarly +// like "regular" metrics tracked by prometheus library, it supports +// constraining cardinality and configurable labels. +package metrics 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..956125344fe 100644 --- a/pkg/metrics/granularmetric.go +++ b/pkg/metrics/granularmetric.go @@ -4,58 +4,220 @@ 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...) + } + } + // second constrained labels + current = current[len(dummy.Keys()):] + for i := range constrained { + for _, val := range constrained[i].Values { + current[i] = val + initMetric(lvs...) } } - return nil + // 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 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). +// You can also pass func() {} to disable the default initialization. +// - 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 +} + +// NewCounter creates a new GranularCounter with no configurable labels. +func NewCounter(opts MetricOpts, init func()) (*GranularCounter[NilLabels], error) { + return NewGranularCounter[NilLabels](opts, init) +} + +// MustNewCounter is a convenience function that wraps NewCounter and panics on +// error. +func MustNewCounter(opts MetricOpts, init func()) *GranularCounter[NilLabels] { + result, err := NewGranularCounter[NilLabels](opts, init) 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 +225,124 @@ 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 +} + +// NewGauge creates a new GranularGauge with no configurable labels. +func NewGauge(opts MetricOpts, init func()) (*GranularGauge[NilLabels], error) { + return NewGranularGauge[NilLabels](opts, init) +} + +// MustNewGauge is a convenience function that wraps NewGauge and panics on +// error. +func MustNewGauge(opts MetricOpts, init func()) *GranularGauge[NilLabels] { + result, err := NewGranularGauge[NilLabels](opts, init) 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 +350,134 @@ 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 } +// NewHistogram creates a new GranularHistogram with no configurable labels. +func NewHistogram(opts HistogramOpts, init func()) (*GranularHistogram[NilLabels], error) { + return NewGranularHistogram[NilLabels](opts, init) +} + +// MustNewHistogram is a convenience function that wraps NewHistogram and panics +// on error. +func MustNewHistogram(opts HistogramOpts, init func()) *GranularHistogram[NilLabels] { + result, err := NewGranularHistogram[NilLabels](opts, init) + 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..bb3735633d4 --- /dev/null +++ b/pkg/metrics/metricsgroup.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import ( + "errors" + + "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() +} + +// Group 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 Group interface { + prometheus.Collector + prometheus.Registerer + RegisterWithInit(CollectorWithInit) error + MustRegisterWithInit(CollectorWithInit) + IsConstrained() bool + Init() + InitForDocs() +} + +// metricsGroup wraps prometheus.Registry and implements Group. +type metricsGroup struct { + registry *prometheus.Registry + constrained bool // whether the group has constrained cardinality + initFunc func() + initForDocsFunc func() +} + +// NewMetricsGroup creates a new Group. +// The constrained argument indicates whether the group should accept only +// metrics with constrained cardinality. +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). +func (r *metricsGroup) Register(c prometheus.Collector) error { + return r.registry.Register(c) +} + +// MustRegister implements Group (prometheus.Registerer). +func (r *metricsGroup) MustRegister(cs ...prometheus.Collector) { + r.registry.MustRegister(cs...) +} + +// Unregister implements Group (prometheus.Registerer). +func (r *metricsGroup) Unregister(c prometheus.Collector) bool { + return r.registry.Unregister(c) +} + +// IsConstrained implements Group. +func (r *metricsGroup) IsConstrained() bool { + return r.constrained +} + +// RegisterWithInit implements Group. 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 Group. +func (r *metricsGroup) MustRegisterWithInit(c CollectorWithInit) { + err := r.RegisterWithInit(c) + if err != nil { + panic(err) + } +} + +// Init implements Group. +func (r *metricsGroup) Init() { + if r.initFunc != nil { + r.initFunc() + } +} + +// InitForDocs implements Group. +func (r *metricsGroup) InitForDocs() { + if r.initForDocsFunc != nil { + r.initForDocsFunc() + } +} diff --git a/pkg/metrics/opts.go b/pkg/metrics/opts.go new file mode 100644 index 00000000000..af36678ffa5 --- /dev/null +++ b/pkg/metrics/opts.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Tetragon + +package metrics + +import ( + "fmt" + "slices" + + "github.com/prometheus/client_golang/prometheus" +) + +// 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 +}