minio/cmd/metrics-v3-types.go
Eng Zer Jun c07e5b49d4
refactor: replace experimental maps and slices with stdlib (#20679)
The experimental functions are now available in the standard library in
Go 1.23 [1].

[1]: https://go.dev/doc/go1.23#new-unique-package

Signed-off-by: Eng Zer Jun <engzerjun@gmail.com>
2024-11-25 09:10:22 -08:00

516 lines
16 KiB
Go

// Copyright (c) 2015-2024 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"github.com/minio/minio-go/v7/pkg/set"
"github.com/minio/minio/internal/logger"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
)
type collectorPath string
// metricPrefix converts a collector path to a metric name prefix. The path is
// converted to snake-case (by replaced '/' and '-' with '_') and prefixed with
// `minio_`.
func (cp collectorPath) metricPrefix() string {
s := strings.TrimPrefix(string(cp), SlashSeparator)
s = strings.ReplaceAll(s, SlashSeparator, "_")
s = strings.ReplaceAll(s, "-", "_")
return "minio_" + s
}
// isDescendantOf returns true if it is a descendant of (or the same as)
// `ancestor`.
//
// For example:
//
// /a, /a/b, /a/b/c are all descendants of /a.
// /abc or /abd/a are not descendants of /ab.
func (cp collectorPath) isDescendantOf(arg string) bool {
descendant := string(cp)
if descendant == arg {
return true
}
if len(arg) >= len(descendant) {
return false
}
if !strings.HasSuffix(arg, SlashSeparator) {
arg += SlashSeparator
}
return strings.HasPrefix(descendant, arg)
}
// MetricType - represents the type of a metric.
type MetricType int
const (
// CounterMT - represents a counter metric.
CounterMT MetricType = iota
// GaugeMT - represents a gauge metric.
GaugeMT
// HistogramMT - represents a histogram metric.
HistogramMT
)
// rangeL - represents a range label.
const rangeL = "range"
func (mt MetricType) String() string {
switch mt {
case CounterMT:
return "counter"
case GaugeMT:
return "gauge"
case HistogramMT:
return "histogram"
default:
return "*unknown*"
}
}
func (mt MetricType) toProm() prometheus.ValueType {
switch mt {
case CounterMT:
return prometheus.CounterValue
case GaugeMT:
return prometheus.GaugeValue
case HistogramMT:
return prometheus.CounterValue
default:
panic(fmt.Sprintf("unknown metric type: %d", mt))
}
}
// MetricDescriptor - represents a metric descriptor.
type MetricDescriptor struct {
Name MetricName
Type MetricType
Help string
VariableLabels []string
// managed values follow:
labelSet map[string]struct{}
}
func (md *MetricDescriptor) getLabelSet() map[string]struct{} {
if md.labelSet != nil {
return md.labelSet
}
md.labelSet = make(map[string]struct{}, len(md.VariableLabels))
for _, label := range md.VariableLabels {
md.labelSet[label] = struct{}{}
}
return md.labelSet
}
func (md *MetricDescriptor) toPromName(namePrefix string) string {
return prometheus.BuildFQName(namePrefix, "", string(md.Name))
}
func (md *MetricDescriptor) toPromDesc(namePrefix string, extraLabels map[string]string) *prometheus.Desc {
return prometheus.NewDesc(
md.toPromName(namePrefix),
md.Help,
md.VariableLabels, extraLabels,
)
}
// NewCounterMD - creates a new counter metric descriptor.
func NewCounterMD(name MetricName, help string, labels ...string) MetricDescriptor {
return MetricDescriptor{
Name: name,
Type: CounterMT,
Help: help,
VariableLabels: labels,
}
}
// NewGaugeMD - creates a new gauge metric descriptor.
func NewGaugeMD(name MetricName, help string, labels ...string) MetricDescriptor {
return MetricDescriptor{
Name: name,
Type: GaugeMT,
Help: help,
VariableLabels: labels,
}
}
type metricValue struct {
Labels map[string]string
Value float64
}
// MetricValues - type to set metric values retrieved while loading metrics. A
// value of this type is passed to the `MetricsLoaderFn`.
type MetricValues struct {
values map[MetricName][]metricValue
descriptors map[MetricName]MetricDescriptor
}
func newMetricValues(d map[MetricName]MetricDescriptor) MetricValues {
return MetricValues{
values: make(map[MetricName][]metricValue, len(d)),
descriptors: d,
}
}
// ToPromMetrics - converts the internal metric values to Prometheus
// adding the given name prefix. The extraLabels are added to each metric as
// constant labels.
func (m *MetricValues) ToPromMetrics(namePrefix string, extraLabels map[string]string,
) []prometheus.Metric {
metrics := make([]prometheus.Metric, 0, len(m.values))
for metricName, mv := range m.values {
desc := m.descriptors[metricName]
promDesc := desc.toPromDesc(namePrefix, extraLabels)
for _, v := range mv {
// labelValues is in the same order as the variable labels in the
// descriptor.
labelValues := make([]string, 0, len(v.Labels))
for _, k := range desc.VariableLabels {
labelValues = append(labelValues, v.Labels[k])
}
metrics = append(metrics,
prometheus.MustNewConstMetric(promDesc, desc.Type.toProm(), v.Value,
labelValues...))
}
}
return metrics
}
// Set - sets a metric value along with any provided labels. It is used only
// with Gauge and Counter metrics.
//
// If the MetricName given here is not present in the `MetricsGroup`'s
// descriptors, this function panics.
//
// Panics if `labels` is not a list of ordered label name and label value pairs
// or if all labels for the metric are not provided.
func (m *MetricValues) Set(name MetricName, value float64, labels ...string) {
desc, ok := m.descriptors[name]
if !ok {
panic(fmt.Sprintf("metric has no description: %s", name))
}
if len(labels)%2 != 0 {
panic("labels must be a list of ordered key-value pairs")
}
validLabels := desc.getLabelSet()
labelMap := make(map[string]string, len(labels)/2)
for i := 0; i < len(labels); i += 2 {
if _, ok := validLabels[labels[i]]; !ok {
panic(fmt.Sprintf("invalid label: %s (metric: %s)", labels[i], name))
}
labelMap[labels[i]] = labels[i+1]
}
if len(labels)/2 != len(validLabels) {
panic("not all labels were given values")
}
v, ok := m.values[name]
if !ok {
v = make([]metricValue, 0, 1)
}
// If valid non zero value set the metrics
if value > 0 {
m.values[name] = append(v, metricValue{
Labels: labelMap,
Value: value,
})
}
}
// SetHistogram - sets values for the given MetricName using the provided
// histogram.
//
// `filterByLabels` is a map of label names to list of allowed label values to
// filter by. Note that this filtering happens before any renaming of labels.
//
// `renameLabels` is a map of label names to rename. The keys are the original
// label names and the values are the new label names.
//
// `bucketFilter` is a list of bucket values to filter. If this is non-empty,
// only metrics for the given buckets are added.
//
// `extraLabels` are additional labels to add to each metric. They are ordered
// label name and value pairs.
func (m *MetricValues) SetHistogram(name MetricName, hist *prometheus.HistogramVec,
filterByLabels map[string]set.StringSet, renameLabels map[string]string, bucketFilter []string,
extraLabels ...string,
) {
if _, ok := m.descriptors[name]; !ok {
panic(fmt.Sprintf("metric has no description: %s", name))
}
dummyDesc := MetricDescription{}
metricsV2 := getHistogramMetrics(hist, dummyDesc, false)
mainLoop:
for _, metric := range metricsV2 {
for label, allowedValues := range filterByLabels {
if !allowedValues.Contains(metric.VariableLabels[label]) {
continue mainLoop
}
}
// If a bucket filter is provided, only add metrics for the given
// buckets.
if len(bucketFilter) > 0 && !slices.Contains(bucketFilter, metric.VariableLabels["bucket"]) {
continue
}
labels := make([]string, 0, len(metric.VariableLabels)*2)
for k, v := range metric.VariableLabels {
if newLabel, ok := renameLabels[k]; ok {
labels = append(labels, newLabel, v)
} else {
labels = append(labels, k, v)
}
}
labels = append(labels, extraLabels...)
// If valid non zero value set the metrics
if metric.Value > 0 {
m.Set(name, metric.Value, labels...)
}
}
}
// SetHistogramValues - sets values for the given MetricName using the provided map of
// range to value.
func SetHistogramValues[V uint64 | int64 | float64](m MetricValues, name MetricName, values map[string]V, labels ...string) {
for rng, val := range values {
m.Set(name, float64(val), append(labels, rangeL, rng)...)
}
}
// MetricsLoaderFn - represents a function to load metrics from the
// metricsCache.
//
// Note that returning an error here will cause the Metrics handler to return a
// 500 Internal Server Error.
type MetricsLoaderFn func(context.Context, MetricValues, *metricsCache) error
// JoinLoaders - joins multiple loaders into a single loader. The returned
// loader will call each of the given loaders in order. If any of the loaders
// return an error, the returned loader will return that error.
func JoinLoaders(loaders ...MetricsLoaderFn) MetricsLoaderFn {
return func(ctx context.Context, m MetricValues, c *metricsCache) error {
for _, loader := range loaders {
if err := loader(ctx, m, c); err != nil {
return err
}
}
return nil
}
}
// BucketMetricsLoaderFn - represents a function to load metrics from the
// metricsCache and the system for a given list of buckets.
//
// Note that returning an error here will cause the Metrics handler to return a
// 500 Internal Server Error.
type BucketMetricsLoaderFn func(context.Context, MetricValues, *metricsCache, []string) error
// JoinBucketLoaders - joins multiple bucket loaders into a single loader,
// similar to `JoinLoaders`.
func JoinBucketLoaders(loaders ...BucketMetricsLoaderFn) BucketMetricsLoaderFn {
return func(ctx context.Context, m MetricValues, c *metricsCache, b []string) error {
for _, loader := range loaders {
if err := loader(ctx, m, c, b); err != nil {
return err
}
}
return nil
}
}
// MetricsGroup - represents a group of metrics. It includes a `MetricsLoaderFn`
// function that provides a way to load the metrics from the system. The metrics
// are cached and refreshed after a given timeout.
//
// For metrics with a `bucket` dimension, a list of buckets argument is required
// to collect the metrics.
//
// It implements the prometheus.Collector interface for metric groups without a
// bucket dimension. For metric groups with a bucket dimension, use the
// `GetBucketCollector` method to get a `BucketCollector` that implements the
// prometheus.Collector interface.
type MetricsGroup struct {
// Path (relative to the Metrics v3 base endpoint) at which this group of
// metrics is served. This value is converted into a metric name prefix
// using `.metricPrefix()` and is added to each metric returned.
CollectorPath collectorPath
// List of all metric descriptors that could be returned by the loader.
Descriptors []MetricDescriptor
// (Optional) Extra (constant) label KV pairs to be added to each metric in
// the group.
ExtraLabels map[string]string
// Loader functions to load metrics. Only one of these will be set. Metrics
// returned by these functions must be present in the `Descriptors` list.
loader MetricsLoaderFn
bucketLoader BucketMetricsLoaderFn
// Cache for all metrics groups. Set via `.SetCache` method.
cache *metricsCache
// managed values follow:
// map of metric descriptors by metric name.
descriptorMap map[MetricName]MetricDescriptor
// For bucket metrics, the list of buckets is stored here. It is used in the
// Collect() call. This is protected by the `bucketsLock`.
bucketsLock sync.Mutex
buckets []string
}
// NewMetricsGroup creates a new MetricsGroup. To create a metrics group for
// metrics with a `bucket` dimension (label), use `NewBucketMetricsGroup`.
//
// The `loader` function loads metrics from the cache and the system.
func NewMetricsGroup(path collectorPath, descriptors []MetricDescriptor,
loader MetricsLoaderFn,
) *MetricsGroup {
mg := &MetricsGroup{
CollectorPath: path,
Descriptors: descriptors,
loader: loader,
}
mg.validate()
return mg
}
// NewBucketMetricsGroup creates a new MetricsGroup for metrics with a `bucket`
// dimension (label).
//
// The `loader` function loads metrics from the cache and the system for a given
// list of buckets.
func NewBucketMetricsGroup(path collectorPath, descriptors []MetricDescriptor,
loader BucketMetricsLoaderFn,
) *MetricsGroup {
mg := &MetricsGroup{
CollectorPath: path,
Descriptors: descriptors,
bucketLoader: loader,
}
mg.validate()
return mg
}
// AddExtraLabels - adds extra (constant) label KV pairs to the metrics group.
// This is a helper to initialize the `ExtraLabels` field. The argument is a
// list of ordered label name and value pairs.
func (mg *MetricsGroup) AddExtraLabels(labels ...string) {
if len(labels)%2 != 0 {
panic("Labels must be an ordered list of name value pairs")
}
if mg.ExtraLabels == nil {
mg.ExtraLabels = make(map[string]string, len(labels))
}
for i := 0; i < len(labels); i += 2 {
mg.ExtraLabels[labels[i]] = labels[i+1]
}
}
// IsBucketMetricsGroup - returns true if the given MetricsGroup is a bucket
// metrics group.
func (mg *MetricsGroup) IsBucketMetricsGroup() bool {
return mg.bucketLoader != nil
}
// Describe - implements prometheus.Collector interface.
func (mg *MetricsGroup) Describe(ch chan<- *prometheus.Desc) {
for _, desc := range mg.Descriptors {
ch <- desc.toPromDesc(mg.CollectorPath.metricPrefix(), mg.ExtraLabels)
}
}
// Collect - implements prometheus.Collector interface.
func (mg *MetricsGroup) Collect(ch chan<- prometheus.Metric) {
metricValues := newMetricValues(mg.descriptorMap)
var err error
if mg.IsBucketMetricsGroup() {
err = mg.bucketLoader(GlobalContext, metricValues, mg.cache, mg.buckets)
} else {
err = mg.loader(GlobalContext, metricValues, mg.cache)
}
// There is no way to handle errors here, so we panic the current goroutine
// and the Metrics API handler returns a 500 HTTP status code. This should
// normally not happen, and usually indicates a bug.
logger.CriticalIf(GlobalContext, errors.Wrap(err, "failed to get metrics"))
promMetrics := metricValues.ToPromMetrics(mg.CollectorPath.metricPrefix(),
mg.ExtraLabels)
for _, metric := range promMetrics {
ch <- metric
}
}
// LockAndSetBuckets - locks the buckets and sets the given buckets. It returns
// a function to unlock the buckets.
func (mg *MetricsGroup) LockAndSetBuckets(buckets []string) func() {
mg.bucketsLock.Lock()
mg.buckets = buckets
return func() {
mg.bucketsLock.Unlock()
}
}
// MetricFQN - returns the fully qualified name for the given metric name.
func (mg *MetricsGroup) MetricFQN(name MetricName) string {
v, ok := mg.descriptorMap[name]
if !ok {
// This should never happen.
return ""
}
return v.toPromName(mg.CollectorPath.metricPrefix())
}
func (mg *MetricsGroup) validate() {
if len(mg.Descriptors) == 0 {
panic("Descriptors must be set")
}
// For bools A and B, A XOR B <=> A != B.
isExactlyOneSet := (mg.loader == nil) != (mg.bucketLoader == nil)
if !isExactlyOneSet {
panic("Exactly one Loader function must be set")
}
mg.descriptorMap = make(map[MetricName]MetricDescriptor, len(mg.Descriptors))
for _, desc := range mg.Descriptors {
mg.descriptorMap[desc.Name] = desc
}
}
// SetCache is a helper to initialize MetricsGroup. It sets the cache object.
func (mg *MetricsGroup) SetCache(c *metricsCache) {
mg.cache = c
}