diff --git a/hack/verify-prometheus-imports.sh b/hack/verify-prometheus-imports.sh index d7c689fe72f..3572a3d905f 100755 --- a/hack/verify-prometheus-imports.sh +++ b/hack/verify-prometheus-imports.sh @@ -66,6 +66,7 @@ allowed_prometheus_importers=( ./staging/src/k8s.io/component-base/metrics/testutil/metrics_test.go ./staging/src/k8s.io/component-base/metrics/testutil/promlint.go ./staging/src/k8s.io/component-base/metrics/testutil/testutil.go + ./staging/src/k8s.io/component-base/metrics/timing_histogram_test.go ./staging/src/k8s.io/component-base/metrics/value.go ./staging/src/k8s.io/component-base/metrics/wrappers.go ./test/e2e/apimachinery/flowcontrol.go diff --git a/staging/src/k8s.io/component-base/metrics/counter.go b/staging/src/k8s.io/component-base/metrics/counter.go index 7342dc37d1e..78c211a0ede 100644 --- a/staging/src/k8s.io/component-base/metrics/counter.go +++ b/staging/src/k8s.io/component-base/metrics/counter.go @@ -18,6 +18,7 @@ package metrics import ( "context" + "github.com/blang/semver/v4" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" @@ -106,9 +107,14 @@ type CounterVec struct { originalLabels []string } -// NewCounterVec returns an object which satisfies the kubeCollector and CounterVecMetric interfaces. +var _ kubeCollector = &CounterVec{} + +// TODO: make this true: var _ CounterVecMetric = &CounterVec{} + +// NewCounterVec returns an object which satisfies the kubeCollector and (almost) CounterVecMetric interfaces. // However, the object returned will not measure anything unless the collector is first -// registered, since the metric is lazily instantiated. +// registered, since the metric is lazily instantiated, and only members extracted after +// registration will actually measure anything. func NewCounterVec(opts *CounterOpts, labels []string) *CounterVec { opts.StabilityLevel.setDefaults() @@ -149,13 +155,16 @@ func (v *CounterVec) initializeDeprecatedMetric() { v.initializeMetric() } -// Default Prometheus behavior actually results in the creation of a new metric -// if a metric with the unique label values is not found in the underlying stored metricMap. +// Default Prometheus Vec behavior is that member extraction results in creation of a new element +// if one with the unique label values is not found in the underlying stored metricMap. // This means that if this function is called but the underlying metric is not registered // (which means it will never be exposed externally nor consumed), the metric will exist in memory // for perpetuity (i.e. throughout application lifecycle). // // For reference: https://github.com/prometheus/client_golang/blob/v0.9.2/prometheus/counter.go#L179-L197 +// +// In contrast, the Vec behavior in this package is that member extraction before registration +// returns a permanent noop object. // WithLabelValues returns the Counter for the given slice of label // values (same order as the VariableLabels in Desc). If that combination of diff --git a/staging/src/k8s.io/component-base/metrics/gauge.go b/staging/src/k8s.io/component-base/metrics/gauge.go index 168221ecdd5..04041bab652 100644 --- a/staging/src/k8s.io/component-base/metrics/gauge.go +++ b/staging/src/k8s.io/component-base/metrics/gauge.go @@ -18,6 +18,7 @@ package metrics import ( "context" + "github.com/blang/semver/v4" "github.com/prometheus/client_golang/prometheus" @@ -33,7 +34,11 @@ type Gauge struct { selfCollector } -// NewGauge returns an object which satisfies the kubeCollector and KubeGauge interfaces. +var _ GaugeMetric = &Gauge{} +var _ Registerable = &Gauge{} +var _ kubeCollector = &Gauge{} + +// NewGauge returns an object which satisfies the kubeCollector, Registerable, and Gauge interfaces. // However, the object returned will not measure anything unless the collector is first // registered, since the metric is lazily instantiated. func NewGauge(opts *GaugeOpts) *Gauge { @@ -88,9 +93,14 @@ type GaugeVec struct { originalLabels []string } -// NewGaugeVec returns an object which satisfies the kubeCollector and KubeGaugeVec interfaces. +var _ GaugeVecMetric = &GaugeVec{} +var _ Registerable = &GaugeVec{} +var _ kubeCollector = &GaugeVec{} + +// NewGaugeVec returns an object which satisfies the kubeCollector, Registerable, and GaugeVecMetric interfaces. // However, the object returned will not measure anything unless the collector is first -// registered, since the metric is lazily instantiated. +// registered, since the metric is lazily instantiated, and only members extracted after +// registration will actually measure anything. func NewGaugeVec(opts *GaugeOpts, labels []string) *GaugeVec { opts.StabilityLevel.setDefaults() @@ -130,26 +140,55 @@ func (v *GaugeVec) initializeDeprecatedMetric() { v.initializeMetric() } -// Default Prometheus behavior actually results in the creation of a new metric -// if a metric with the unique label values is not found in the underlying stored metricMap. +func (v *GaugeVec) WithLabelValuesChecked(lvs ...string) (GaugeMetric, error) { + if !v.IsCreated() { + if v.IsHidden() { + return noop, nil + } + return noop, errNotRegistered // return no-op gauge + } + if v.LabelValueAllowLists != nil { + v.LabelValueAllowLists.ConstrainToAllowedList(v.originalLabels, lvs) + } + elt, err := v.GaugeVec.GetMetricWithLabelValues(lvs...) + return elt, err +} + +// Default Prometheus Vec behavior is that member extraction results in creation of a new element +// if one with the unique label values is not found in the underlying stored metricMap. // This means that if this function is called but the underlying metric is not registered // (which means it will never be exposed externally nor consumed), the metric will exist in memory // for perpetuity (i.e. throughout application lifecycle). // // For reference: https://github.com/prometheus/client_golang/blob/v0.9.2/prometheus/gauge.go#L190-L208 +// +// In contrast, the Vec behavior in this package is that member extraction before registration +// returns a permanent noop object. // WithLabelValues returns the GaugeMetric for the given slice of label // values (same order as the VariableLabels in Desc). If that combination of // label values is accessed for the first time, a new GaugeMetric is created IFF the gaugeVec // has been registered to a metrics registry. func (v *GaugeVec) WithLabelValues(lvs ...string) GaugeMetric { + ans, err := v.WithLabelValuesChecked(lvs...) + if err == nil || ErrIsNotRegistered(err) { + return ans + } + panic(err) +} + +func (v *GaugeVec) WithChecked(labels map[string]string) (GaugeMetric, error) { if !v.IsCreated() { - return noop // return no-op gauge + if v.IsHidden() { + return noop, nil + } + return noop, errNotRegistered // return no-op gauge } if v.LabelValueAllowLists != nil { - v.LabelValueAllowLists.ConstrainToAllowedList(v.originalLabels, lvs) + v.LabelValueAllowLists.ConstrainLabelMap(labels) } - return v.GaugeVec.WithLabelValues(lvs...) + elt, err := v.GaugeVec.GetMetricWith(labels) + return elt, err } // With returns the GaugeMetric for the given Labels map (the label names @@ -157,13 +196,11 @@ func (v *GaugeVec) WithLabelValues(lvs ...string) GaugeMetric { // accessed for the first time, a new GaugeMetric is created IFF the gaugeVec has // been registered to a metrics registry. func (v *GaugeVec) With(labels map[string]string) GaugeMetric { - if !v.IsCreated() { - return noop // return no-op gauge + ans, err := v.WithChecked(labels) + if err == nil || ErrIsNotRegistered(err) { + return ans } - if v.LabelValueAllowLists != nil { - v.LabelValueAllowLists.ConstrainLabelMap(labels) - } - return v.GaugeVec.With(labels) + panic(err) } // Delete deletes the metric where the variable labels are the same as those @@ -219,6 +256,10 @@ func (v *GaugeVec) WithContext(ctx context.Context) *GaugeVecWithContext { } } +func (v *GaugeVec) InterfaceWithContext(ctx context.Context) GaugeVecMetric { + return v.WithContext(ctx) +} + // GaugeVecWithContext is the wrapper of GaugeVec with context. type GaugeVecWithContext struct { *GaugeVec diff --git a/staging/src/k8s.io/component-base/metrics/histogram.go b/staging/src/k8s.io/component-base/metrics/histogram.go index e93c7a4b3ff..838f09e17fd 100644 --- a/staging/src/k8s.io/component-base/metrics/histogram.go +++ b/staging/src/k8s.io/component-base/metrics/histogram.go @@ -18,6 +18,7 @@ package metrics import ( "context" + "github.com/blang/semver/v4" "github.com/prometheus/client_golang/prometheus" ) @@ -100,7 +101,10 @@ type HistogramVec struct { // NewHistogramVec returns an object which satisfies kubeCollector and wraps the // prometheus.HistogramVec object. However, the object returned will not measure -// anything unless the collector is first registered, since the metric is lazily instantiated. +// anything unless the collector is first registered, since the metric is lazily instantiated, +// and only members extracted after +// registration will actually measure anything. + func NewHistogramVec(opts *HistogramOpts, labels []string) *HistogramVec { opts.StabilityLevel.setDefaults() @@ -136,13 +140,16 @@ func (v *HistogramVec) initializeDeprecatedMetric() { v.initializeMetric() } -// Default Prometheus behavior actually results in the creation of a new metric -// if a metric with the unique label values is not found in the underlying stored metricMap. +// Default Prometheus Vec behavior is that member extraction results in creation of a new element +// if one with the unique label values is not found in the underlying stored metricMap. // This means that if this function is called but the underlying metric is not registered // (which means it will never be exposed externally nor consumed), the metric will exist in memory // for perpetuity (i.e. throughout application lifecycle). // // For reference: https://github.com/prometheus/client_golang/blob/v0.9.2/prometheus/histogram.go#L460-L470 +// +// In contrast, the Vec behavior in this package is that member extraction before registration +// returns a permanent noop object. // WithLabelValues returns the ObserverMetric for the given slice of label // values (same order as the VariableLabels in Desc). If that combination of diff --git a/staging/src/k8s.io/component-base/metrics/histogram_test.go b/staging/src/k8s.io/component-base/metrics/histogram_test.go index e563a395ac8..e13cc18281b 100644 --- a/staging/src/k8s.io/component-base/metrics/histogram_test.go +++ b/staging/src/k8s.io/component-base/metrics/histogram_test.go @@ -87,6 +87,19 @@ func TestHistogram(t *testing.T) { }) c := NewHistogram(test.HistogramOpts) registry.MustRegister(c) + cm := c.ObserverMetric.(prometheus.Metric) + + metricChan := make(chan prometheus.Metric, 2) + c.Collect(metricChan) + close(metricChan) + m1 := <-metricChan + if m1 != cm { + t.Error("Unexpected metric", m1, cm) + } + m2, ok := <-metricChan + if ok { + t.Error("Unexpected second metric", m2) + } ms, err := registry.Gather() assert.Equalf(t, test.expectedMetricCount, len(ms), "Got %v metrics, Want: %v metrics", len(ms), test.expectedMetricCount) @@ -179,7 +192,24 @@ func TestHistogramVec(t *testing.T) { }) c := NewHistogramVec(test.HistogramOpts, test.labels) registry.MustRegister(c) - c.WithLabelValues("1", "2").Observe(1.0) + ov12 := c.WithLabelValues("1", "2") + cm1 := ov12.(prometheus.Metric) + ov12.Observe(1.0) + + if test.expectedMetricCount > 0 { + metricChan := make(chan prometheus.Metric, 2) + c.Collect(metricChan) + close(metricChan) + m1 := <-metricChan + if m1 != cm1 { + t.Error("Unexpected metric", m1, cm1) + } + m2, ok := <-metricChan + if ok { + t.Error("Unexpected second metric", m2) + } + } + ms, err := registry.Gather() assert.Equalf(t, test.expectedMetricCount, len(ms), "Got %v metrics, Want: %v metrics", len(ms), test.expectedMetricCount) assert.Nil(t, err, "Gather failed %v", err) @@ -218,12 +248,12 @@ func TestHistogramWithLabelValueAllowList(t *testing.T) { var tests = []struct { desc string labelValues [][]string - expectMetricValues map[string]int + expectMetricValues map[string]uint64 }{ { desc: "Test no unexpected input", labelValues: [][]string{{"allowed", "b1"}, {"allowed", "b2"}}, - expectMetricValues: map[string]int{ + expectMetricValues: map[string]uint64{ "allowed b1": 1.0, "allowed b2": 1.0, }, @@ -231,7 +261,7 @@ func TestHistogramWithLabelValueAllowList(t *testing.T) { { desc: "Test unexpected input", labelValues: [][]string{{"allowed", "b1"}, {"not_allowed", "b1"}}, - expectMetricValues: map[string]int{ + expectMetricValues: map[string]uint64{ "allowed b1": 1.0, "unexpected b1": 1.0, }, @@ -274,7 +304,7 @@ func TestHistogramWithLabelValueAllowList(t *testing.T) { labelValuePair := aValue + " " + bValue expectedValue, ok := test.expectMetricValues[labelValuePair] assert.True(t, ok, "Got unexpected label values, lable_a is %v, label_b is %v", aValue, bValue) - actualValue := int(m.GetHistogram().GetSampleCount()) + actualValue := m.GetHistogram().GetSampleCount() assert.Equalf(t, expectedValue, actualValue, "Got %v, wanted %v as the count while setting label_a to %v and label b to %v", actualValue, expectedValue, aValue, bValue) } } diff --git a/staging/src/k8s.io/component-base/metrics/metric.go b/staging/src/k8s.io/component-base/metrics/metric.go index c72aecfc6b4..e57e0b383d1 100644 --- a/staging/src/k8s.io/component-base/metrics/metric.go +++ b/staging/src/k8s.io/component-base/metrics/metric.go @@ -22,6 +22,7 @@ import ( "github.com/blang/semver/v4" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" + promext "k8s.io/component-base/metrics/prometheusextension" "k8s.io/klog/v2" ) @@ -203,6 +204,7 @@ func (c *selfCollector) Collect(ch chan<- prometheus.Metric) { // no-op vecs for convenience var noopCounterVec = &prometheus.CounterVec{} var noopHistogramVec = &prometheus.HistogramVec{} +var noopTimingHistogramVec = &promext.TimingHistogramVec{} var noopGaugeVec = &prometheus.GaugeVec{} var noopObserverVec = &noopObserverVector{} @@ -211,17 +213,18 @@ var noop = &noopMetric{} type noopMetric struct{} -func (noopMetric) Inc() {} -func (noopMetric) Add(float64) {} -func (noopMetric) Dec() {} -func (noopMetric) Set(float64) {} -func (noopMetric) Sub(float64) {} -func (noopMetric) Observe(float64) {} -func (noopMetric) SetToCurrentTime() {} -func (noopMetric) Desc() *prometheus.Desc { return nil } -func (noopMetric) Write(*dto.Metric) error { return nil } -func (noopMetric) Describe(chan<- *prometheus.Desc) {} -func (noopMetric) Collect(chan<- prometheus.Metric) {} +func (noopMetric) Inc() {} +func (noopMetric) Add(float64) {} +func (noopMetric) Dec() {} +func (noopMetric) Set(float64) {} +func (noopMetric) Sub(float64) {} +func (noopMetric) Observe(float64) {} +func (noopMetric) ObserveWithWeight(float64, uint64) {} +func (noopMetric) SetToCurrentTime() {} +func (noopMetric) Desc() *prometheus.Desc { return nil } +func (noopMetric) Write(*dto.Metric) error { return nil } +func (noopMetric) Describe(chan<- *prometheus.Desc) {} +func (noopMetric) Collect(chan<- prometheus.Metric) {} type noopObserverVector struct{} diff --git a/staging/src/k8s.io/component-base/metrics/opts.go b/staging/src/k8s.io/component-base/metrics/opts.go index 04203b74e0a..9d359d6acb6 100644 --- a/staging/src/k8s.io/component-base/metrics/opts.go +++ b/staging/src/k8s.io/component-base/metrics/opts.go @@ -24,6 +24,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "k8s.io/apimachinery/pkg/util/sets" + promext "k8s.io/component-base/metrics/prometheusextension" ) var ( @@ -189,6 +190,54 @@ func (o *HistogramOpts) toPromHistogramOpts() prometheus.HistogramOpts { } } +// TimingHistogramOpts bundles the options for creating a TimingHistogram metric. It is +// mandatory to set Name to a non-empty string. All other fields are optional +// and can safely be left at their zero value, although it is strongly +// encouraged to set a Help string. +type TimingHistogramOpts struct { + Namespace string + Subsystem string + Name string + Help string + ConstLabels map[string]string + Buckets []float64 + InitialValue float64 + DeprecatedVersion string + deprecateOnce sync.Once + annotateOnce sync.Once + StabilityLevel StabilityLevel + LabelValueAllowLists *MetricLabelAllowList +} + +// Modify help description on the metric description. +func (o *TimingHistogramOpts) markDeprecated() { + o.deprecateOnce.Do(func() { + o.Help = fmt.Sprintf("(Deprecated since %v) %v", o.DeprecatedVersion, o.Help) + }) +} + +// annotateStabilityLevel annotates help description on the metric description with the stability level +// of the metric +func (o *TimingHistogramOpts) annotateStabilityLevel() { + o.annotateOnce.Do(func() { + o.Help = fmt.Sprintf("[%v] %v", o.StabilityLevel, o.Help) + }) +} + +// convenience function to allow easy transformation to the prometheus +// counterpart. This will do more once we have a proper label abstraction +func (o *TimingHistogramOpts) toPromHistogramOpts() promext.TimingHistogramOpts { + return promext.TimingHistogramOpts{ + Namespace: o.Namespace, + Subsystem: o.Subsystem, + Name: o.Name, + Help: o.Help, + ConstLabels: o.ConstLabels, + Buckets: o.Buckets, + InitialValue: o.InitialValue, + } +} + // SummaryOpts bundles the options for creating a Summary metric. It is // mandatory to set Name to a non-empty string. While all other fields are // optional and can safely be left at their zero value, it is recommended to set diff --git a/staging/src/k8s.io/component-base/metrics/summary.go b/staging/src/k8s.io/component-base/metrics/summary.go index fb1108f9245..c7621b986a4 100644 --- a/staging/src/k8s.io/component-base/metrics/summary.go +++ b/staging/src/k8s.io/component-base/metrics/summary.go @@ -18,6 +18,7 @@ package metrics import ( "context" + "github.com/blang/semver/v4" "github.com/prometheus/client_golang/prometheus" ) @@ -93,7 +94,9 @@ type SummaryVec struct { // NewSummaryVec returns an object which satisfies kubeCollector and wraps the // prometheus.SummaryVec object. However, the object returned will not measure -// anything unless the collector is first registered, since the metric is lazily instantiated. +// anything unless the collector is first registered, since the metric is lazily instantiated, +// and only members extracted after +// registration will actually measure anything. // // DEPRECATED: as per the metrics overhaul KEP func NewSummaryVec(opts *SummaryOpts, labels []string) *SummaryVec { @@ -130,13 +133,16 @@ func (v *SummaryVec) initializeDeprecatedMetric() { v.initializeMetric() } -// Default Prometheus behavior actually results in the creation of a new metric -// if a metric with the unique label values is not found in the underlying stored metricMap. +// Default Prometheus Vec behavior is that member extraction results in creation of a new element +// if one with the unique label values is not found in the underlying stored metricMap. // This means that if this function is called but the underlying metric is not registered // (which means it will never be exposed externally nor consumed), the metric will exist in memory // for perpetuity (i.e. throughout application lifecycle). // -// For reference: https://github.com/prometheus/client_golang/blob/v0.9.2/prometheus/summary.go#L485-L495 +// For reference: https://github.com/prometheus/client_golang/blob/v0.9.2/prometheus/histogram.go#L460-L470 +// +// In contrast, the Vec behavior in this package is that member extraction before registration +// returns a permanent noop object. // WithLabelValues returns the ObserverMetric for the given slice of label // values (same order as the VariableLabels in Desc). If that combination of diff --git a/staging/src/k8s.io/component-base/metrics/timing_histogram.go b/staging/src/k8s.io/component-base/metrics/timing_histogram.go new file mode 100644 index 00000000000..c015a04ea9e --- /dev/null +++ b/staging/src/k8s.io/component-base/metrics/timing_histogram.go @@ -0,0 +1,267 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package metrics + +import ( + "context" + "time" + + "github.com/blang/semver/v4" + promext "k8s.io/component-base/metrics/prometheusextension" +) + +// PrometheusTimingHistogram is the abstraction of the underlying histogram +// that we want to promote from the wrapper. +type PrometheusTimingHistogram interface { + GaugeMetric +} + +// TimingHistogram is our internal representation for our wrapping struct around +// timing histograms. It implements both kubeCollector and GaugeMetric +type TimingHistogram struct { + PrometheusTimingHistogram + *TimingHistogramOpts + nowFunc func() time.Time + lazyMetric + selfCollector +} + +var _ GaugeMetric = &TimingHistogram{} +var _ Registerable = &TimingHistogram{} +var _ kubeCollector = &TimingHistogram{} + +// NewTimingHistogram returns an object which is TimingHistogram-like. However, nothing +// will be measured until the histogram is registered somewhere. +func NewTimingHistogram(opts *TimingHistogramOpts) *TimingHistogram { + return NewTestableTimingHistogram(time.Now, opts) +} + +// NewTestableTimingHistogram adds injection of the clock +func NewTestableTimingHistogram(nowFunc func() time.Time, opts *TimingHistogramOpts) *TimingHistogram { + opts.StabilityLevel.setDefaults() + + h := &TimingHistogram{ + TimingHistogramOpts: opts, + nowFunc: nowFunc, + lazyMetric: lazyMetric{}, + } + h.setPrometheusHistogram(noopMetric{}) + h.lazyInit(h, BuildFQName(opts.Namespace, opts.Subsystem, opts.Name)) + return h +} + +// setPrometheusHistogram sets the underlying KubeGauge object, i.e. the thing that does the measurement. +func (h *TimingHistogram) setPrometheusHistogram(histogram promext.TimingHistogram) { + h.PrometheusTimingHistogram = histogram + h.initSelfCollection(histogram) +} + +// DeprecatedVersion returns a pointer to the Version or nil +func (h *TimingHistogram) DeprecatedVersion() *semver.Version { + return parseSemver(h.TimingHistogramOpts.DeprecatedVersion) +} + +// initializeMetric invokes the actual prometheus.Histogram object instantiation +// and stores a reference to it +func (h *TimingHistogram) initializeMetric() { + h.TimingHistogramOpts.annotateStabilityLevel() + // this actually creates the underlying prometheus gauge. + histogram, err := promext.NewTestableTimingHistogram(h.nowFunc, h.TimingHistogramOpts.toPromHistogramOpts()) + if err != nil { + panic(err) // handle as for regular histograms + } + h.setPrometheusHistogram(histogram) +} + +// initializeDeprecatedMetric invokes the actual prometheus.Histogram object instantiation +// but modifies the Help description prior to object instantiation. +func (h *TimingHistogram) initializeDeprecatedMetric() { + h.TimingHistogramOpts.markDeprecated() + h.initializeMetric() +} + +// WithContext allows the normal TimingHistogram metric to pass in context. The context is no-op now. +func (h *TimingHistogram) WithContext(ctx context.Context) GaugeMetric { + return h.PrometheusTimingHistogram +} + +// TimingHistogramVec is the internal representation of our wrapping struct around prometheus +// TimingHistogramVecs. +type TimingHistogramVec struct { + *promext.TimingHistogramVec + *TimingHistogramOpts + nowFunc func() time.Time + lazyMetric + originalLabels []string +} + +var _ GaugeVecMetric = &TimingHistogramVec{} +var _ Registerable = &TimingHistogramVec{} +var _ kubeCollector = &TimingHistogramVec{} + +// NewTimingHistogramVec returns an object which satisfies the kubeCollector, Registerable, and GaugeVecMetric interfaces +// and wraps an underlying promext.TimingHistogramVec object. Note well the way that +// behavior depends on registration and whether this is hidden. +func NewTimingHistogramVec(opts *TimingHistogramOpts, labels []string) *TimingHistogramVec { + return NewTestableTimingHistogramVec(time.Now, opts, labels) +} + +// NewTestableTimingHistogramVec adds injection of the clock. +func NewTestableTimingHistogramVec(nowFunc func() time.Time, opts *TimingHistogramOpts, labels []string) *TimingHistogramVec { + opts.StabilityLevel.setDefaults() + + fqName := BuildFQName(opts.Namespace, opts.Subsystem, opts.Name) + allowListLock.RLock() + if allowList, ok := labelValueAllowLists[fqName]; ok { + opts.LabelValueAllowLists = allowList + } + allowListLock.RUnlock() + + v := &TimingHistogramVec{ + TimingHistogramVec: noopTimingHistogramVec, + TimingHistogramOpts: opts, + nowFunc: nowFunc, + originalLabels: labels, + lazyMetric: lazyMetric{}, + } + v.lazyInit(v, fqName) + return v +} + +// DeprecatedVersion returns a pointer to the Version or nil +func (v *TimingHistogramVec) DeprecatedVersion() *semver.Version { + return parseSemver(v.TimingHistogramOpts.DeprecatedVersion) +} + +func (v *TimingHistogramVec) initializeMetric() { + v.TimingHistogramOpts.annotateStabilityLevel() + v.TimingHistogramVec = promext.NewTestableTimingHistogramVec(v.nowFunc, v.TimingHistogramOpts.toPromHistogramOpts(), v.originalLabels...) +} + +func (v *TimingHistogramVec) initializeDeprecatedMetric() { + v.TimingHistogramOpts.markDeprecated() + v.initializeMetric() +} + +// WithLabelValuesChecked, if called before this vector has been registered in +// at least one registry, will return a noop gauge and +// an error that passes ErrIsNotRegistered. +// If called on a hidden vector, +// will return a noop gauge and a nil error. +// If called with a syntactic problem in the labels, will +// return a noop gauge and an error about the labels. +// If none of the above apply, this method will return +// the appropriate vector member and a nil error. +func (v *TimingHistogramVec) WithLabelValuesChecked(lvs ...string) (GaugeMetric, error) { + if !v.IsCreated() { + if v.IsHidden() { + return noop, nil + } + return noop, errNotRegistered + } + if v.LabelValueAllowLists != nil { + v.LabelValueAllowLists.ConstrainToAllowedList(v.originalLabels, lvs) + } + ops, err := v.TimingHistogramVec.GetMetricWithLabelValues(lvs...) + return ops.(GaugeMetric), err +} + +// WithLabelValues calls WithLabelValuesChecked +// and handles errors as follows. +// An error that passes ErrIsNotRegistered is ignored +// and the noop gauge is returned; +// all other errors cause a panic. +func (v *TimingHistogramVec) WithLabelValues(lvs ...string) GaugeMetric { + ans, err := v.WithLabelValuesChecked(lvs...) + if err == nil || ErrIsNotRegistered(err) { + return ans + } + panic(err) +} + +// WithChecked, if called before this vector has been registered in +// at least one registry, will return a noop gauge and +// an error that passes ErrIsNotRegistered. +// If called on a hidden vector, +// will return a noop gauge and a nil error. +// If called with a syntactic problem in the labels, will +// return a noop gauge and an error about the labels. +// If none of the above apply, this method will return +// the appropriate vector member and a nil error. +func (v *TimingHistogramVec) WithChecked(labels map[string]string) (GaugeMetric, error) { + if !v.IsCreated() { + if v.IsHidden() { + return noop, nil + } + return noop, errNotRegistered + } + if v.LabelValueAllowLists != nil { + v.LabelValueAllowLists.ConstrainLabelMap(labels) + } + ops, err := v.TimingHistogramVec.GetMetricWith(labels) + return ops.(GaugeMetric), err +} + +// With calls WithChecked and handles errors as follows. +// An error that passes ErrIsNotRegistered is ignored +// and the noop gauge is returned; +// all other errors cause a panic. +func (v *TimingHistogramVec) With(labels map[string]string) GaugeMetric { + ans, err := v.WithChecked(labels) + if err == nil || ErrIsNotRegistered(err) { + return ans + } + panic(err) +} + +// Delete deletes the metric where the variable labels are the same as those +// passed in as labels. It returns true if a metric was deleted. +// +// It is not an error if the number and names of the Labels are inconsistent +// with those of the VariableLabels in Desc. However, such inconsistent Labels +// can never match an actual metric, so the method will always return false in +// that case. +func (v *TimingHistogramVec) Delete(labels map[string]string) bool { + if !v.IsCreated() { + return false // since we haven't created the metric, we haven't deleted a metric with the passed in values + } + return v.TimingHistogramVec.Delete(labels) +} + +// Reset deletes all metrics in this vector. +func (v *TimingHistogramVec) Reset() { + if !v.IsCreated() { + return + } + + v.TimingHistogramVec.Reset() +} + +// WithContext returns wrapped TimingHistogramVec with context +func (v *TimingHistogramVec) InterfaceWithContext(ctx context.Context) GaugeVecMetric { + return &TimingHistogramVecWithContext{ + ctx: ctx, + TimingHistogramVec: v, + } +} + +// TimingHistogramVecWithContext is the wrapper of TimingHistogramVec with context. +// Currently the context is ignored. +type TimingHistogramVecWithContext struct { + *TimingHistogramVec + ctx context.Context +} diff --git a/staging/src/k8s.io/component-base/metrics/timing_histogram_test.go b/staging/src/k8s.io/component-base/metrics/timing_histogram_test.go new file mode 100644 index 00000000000..60703d9cb8b --- /dev/null +++ b/staging/src/k8s.io/component-base/metrics/timing_histogram_test.go @@ -0,0 +1,442 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package metrics + +import ( + "testing" + "time" + + "github.com/blang/semver/v4" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + + apimachineryversion "k8s.io/apimachinery/pkg/version" + testclock "k8s.io/utils/clock/testing" +) + +func TestTimingHistogram(t *testing.T) { + v115 := semver.MustParse("1.15.0") + var tests = []struct { + desc string + *TimingHistogramOpts + registryVersion *semver.Version + expectedMetricCount int + expectedHelp string + }{ + { + desc: "Test non deprecated", + TimingHistogramOpts: &TimingHistogramOpts{ + Namespace: "namespace", + Name: "metric_test_name", + Subsystem: "subsystem", + Help: "histogram help message", + Buckets: DefBuckets, + InitialValue: 13, + }, + registryVersion: &v115, + expectedMetricCount: 1, + expectedHelp: "EXPERIMENTAL: [ALPHA] histogram help message", + }, + { + desc: "Test deprecated", + TimingHistogramOpts: &TimingHistogramOpts{ + Namespace: "namespace", + Name: "metric_test_name", + Subsystem: "subsystem", + Help: "histogram help message", + DeprecatedVersion: "1.15.0", + Buckets: DefBuckets, + InitialValue: 3, + }, + registryVersion: &v115, + expectedMetricCount: 1, + expectedHelp: "EXPERIMENTAL: [ALPHA] (Deprecated since 1.15.0) histogram help message", + }, + { + desc: "Test hidden", + TimingHistogramOpts: &TimingHistogramOpts{ + Namespace: "namespace", + Name: "metric_test_name", + Subsystem: "subsystem", + Help: "histogram help message", + DeprecatedVersion: "1.14.0", + Buckets: DefBuckets, + InitialValue: 5, + }, + registryVersion: &v115, + expectedMetricCount: 0, + expectedHelp: "EXPERIMENTAL: histogram help message", + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + registry := newKubeRegistry(apimachineryversion.Info{ + Major: "1", + Minor: "15", + GitVersion: "v1.15.0-alpha-1.12345", + }) + t0 := time.Now() + clk := testclock.NewFakePassiveClock(t0) + c := NewTestableTimingHistogram(clk.Now, test.TimingHistogramOpts) + registry.MustRegister(c) + + metricChan := make(chan prometheus.Metric) + go func() { + c.Collect(metricChan) + close(metricChan) + }() + m1 := <-metricChan + gm1, ok := m1.(GaugeMetric) + if !ok || gm1 != c.PrometheusTimingHistogram { + t.Error("Unexpected metric", m1, c.PrometheusTimingHistogram) + } + m2, ok := <-metricChan + if ok { + t.Error("Unexpected second metric", m2) + } + + ms, err := registry.Gather() + assert.Equalf(t, test.expectedMetricCount, len(ms), "Got %v metrics, Want: %v metrics", len(ms), test.expectedMetricCount) + assert.Nil(t, err, "Gather failed %v", err) + + for _, metric := range ms { + assert.Equalf(t, test.expectedHelp, metric.GetHelp(), "Got %s as help message, want %s", metric.GetHelp(), test.expectedHelp) + } + + // let's exercise the metric and check that it still works + v0 := test.TimingHistogramOpts.InitialValue + dt1 := time.Nanosecond + t1 := t0.Add(dt1) + clk.SetTime(t1) + var v1 float64 = 10 + c.Set(v1) + dt2 := time.Hour + t2 := t1.Add(dt2) + clk.SetTime(t2) + var v2 float64 = 1e6 + c.Add(v2 - v1) + dt3 := time.Microsecond + t3 := t2.Add(dt3) + clk.SetTime(t3) + c.Set(0) + expectedCount := uint64(dt1 + dt2 + dt3) + expectedSum := float64(dt1)*v0 + float64(dt2)*v1 + float64(dt3)*v2 + ms, err = registry.Gather() + assert.Nil(t, err, "Gather failed %v", err) + + for _, mf := range ms { + t.Logf("Considering metric family %s", mf.GetName()) + for _, m := range mf.GetMetric() { + assert.Equalf(t, expectedCount, m.GetHistogram().GetSampleCount(), "Got %v, want %v as the sample count of metric %s", m.GetHistogram().GetSampleCount(), expectedCount, m.String()) + assert.Equalf(t, expectedSum, m.GetHistogram().GetSampleSum(), "Got %v, want %v as the sample sum of metric %s", m.GetHistogram().GetSampleSum(), expectedSum, m.String()) + } + } + }) + } +} + +func TestTimingHistogramVec(t *testing.T) { + v115 := semver.MustParse("1.15.0") + var tests = []struct { + desc string + *TimingHistogramOpts + labels []string + registryVersion *semver.Version + expectedMetricCount int + expectedHelp string + }{ + { + desc: "Test non deprecated", + TimingHistogramOpts: &TimingHistogramOpts{ + Namespace: "namespace", + Name: "metric_test_name", + Subsystem: "subsystem", + Help: "histogram help message", + Buckets: DefBuckets, + InitialValue: 5, + }, + labels: []string{"label_a", "label_b"}, + registryVersion: &v115, + expectedMetricCount: 1, + expectedHelp: "EXPERIMENTAL: [ALPHA] histogram help message", + }, + { + desc: "Test deprecated", + TimingHistogramOpts: &TimingHistogramOpts{ + Namespace: "namespace", + Name: "metric_test_name", + Subsystem: "subsystem", + Help: "histogram help message", + DeprecatedVersion: "1.15.0", + Buckets: DefBuckets, + InitialValue: 13, + }, + labels: []string{"label_a", "label_b"}, + registryVersion: &v115, + expectedMetricCount: 1, + expectedHelp: "EXPERIMENTAL: [ALPHA] (Deprecated since 1.15.0) histogram help message", + }, + { + desc: "Test hidden", + TimingHistogramOpts: &TimingHistogramOpts{ + Namespace: "namespace", + Name: "metric_test_name", + Subsystem: "subsystem", + Help: "histogram help message", + DeprecatedVersion: "1.14.0", + Buckets: DefBuckets, + InitialValue: 42, + }, + labels: []string{"label_a", "label_b"}, + registryVersion: &v115, + expectedMetricCount: 0, + expectedHelp: "EXPERIMENTAL: histogram help message", + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + registry := newKubeRegistry(apimachineryversion.Info{ + Major: "1", + Minor: "15", + GitVersion: "v1.15.0-alpha-1.12345", + }) + t0 := time.Now() + clk := testclock.NewFakePassiveClock(t0) + c := NewTestableTimingHistogramVec(clk.Now, test.TimingHistogramOpts, test.labels) + registry.MustRegister(c) + var v0 float64 = 3 + cm1, err := c.WithLabelValuesChecked("1", "2") + if err != nil { + t.Error(err) + } + cm1.Set(v0) + + if test.expectedMetricCount > 0 { + metricChan := make(chan prometheus.Metric, 2) + c.Collect(metricChan) + close(metricChan) + m1 := <-metricChan + if m1 != cm1.(prometheus.Metric) { + t.Error("Unexpected metric", m1, cm1) + } + m2, ok := <-metricChan + if ok { + t.Error("Unexpected second metric", m2) + } + } + + ms, err := registry.Gather() + assert.Equalf(t, test.expectedMetricCount, len(ms), "Got %v metrics, Want: %v metrics", len(ms), test.expectedMetricCount) + assert.Nil(t, err, "Gather failed %v", err) + for _, metric := range ms { + if metric.GetHelp() != test.expectedHelp { + assert.Equalf(t, test.expectedHelp, metric.GetHelp(), "Got %s as help message, want %s", metric.GetHelp(), test.expectedHelp) + } + } + + // let's exercise the metric and verify it still works + c.WithLabelValues("1", "3").Set(v0) + c.WithLabelValues("2", "3").Set(v0) + dt1 := time.Nanosecond + t1 := t0.Add(dt1) + clk.SetTime(t1) + c.WithLabelValues("1", "2").Add(5.0) + c.WithLabelValues("1", "3").Add(5.0) + c.WithLabelValues("2", "3").Add(5.0) + ms, err = registry.Gather() + assert.Nil(t, err, "Gather failed %v", err) + + for _, mf := range ms { + t.Logf("Considering metric family %s", mf.String()) + assert.Equalf(t, 3, len(mf.GetMetric()), "Got %v metrics, wanted 3 as the count for family %#+v", len(mf.GetMetric()), mf) + for _, m := range mf.GetMetric() { + expectedCount := uint64(dt1) + expectedSum := float64(dt1) * v0 + assert.Equalf(t, expectedCount, m.GetHistogram().GetSampleCount(), "Got %v, expected histogram sample count to equal %d for metric %s", m.GetHistogram().GetSampleCount(), expectedCount, m.String()) + assert.Equalf(t, expectedSum, m.GetHistogram().GetSampleSum(), "Got %v, expected histogram sample sum to equal %v for metric %s", m.GetHistogram().GetSampleSum(), expectedSum, m.String()) + } + } + }) + } +} + +func TestTimingHistogramWithLabelValueAllowList(t *testing.T) { + labelAllowValues := map[string]string{ + "namespace_subsystem_metric_allowlist_test,label_a": "allowed", + } + labels := []string{"label_a", "label_b"} + opts := &TimingHistogramOpts{ + Namespace: "namespace", + Name: "metric_allowlist_test", + Subsystem: "subsystem", + InitialValue: 7, + } + var tests = []struct { + desc string + labelValues [][]string + expectMetricValues map[string]uint64 + }{ + { + desc: "Test no unexpected input", + labelValues: [][]string{{"allowed", "b1"}, {"allowed", "b2"}}, + expectMetricValues: map[string]uint64{ + "allowed b1": 1.0, + "allowed b2": 1.0, + }, + }, + { + desc: "Test unexpected input", + labelValues: [][]string{{"allowed", "b1"}, {"not_allowed", "b1"}}, + expectMetricValues: map[string]uint64{ + "allowed b1": 1.0, + "unexpected b1": 1.0, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + SetLabelAllowListFromCLI(labelAllowValues) + registry := newKubeRegistry(apimachineryversion.Info{ + Major: "1", + Minor: "15", + GitVersion: "v1.15.0-alpha-1.12345", + }) + t0 := time.Now() + clk := testclock.NewFakePassiveClock(t0) + c := NewTestableTimingHistogramVec(clk.Now, opts, labels) + registry.MustRegister(c) + var v0 float64 = 13 + for _, lv := range test.labelValues { + c.WithLabelValues(lv...).Set(v0) + } + + dt1 := 3 * time.Hour + t1 := t0.Add(dt1) + clk.SetTime(t1) + + for _, lv := range test.labelValues { + c.WithLabelValues(lv...).Add(1.0) + } + mfs, err := registry.Gather() + assert.Nil(t, err, "Gather failed %v", err) + + for _, mf := range mfs { + if *mf.Name != BuildFQName(opts.Namespace, opts.Subsystem, opts.Name) { + continue + } + mfMetric := mf.GetMetric() + t.Logf("Consider metric family %s", mf.GetName()) + + for _, m := range mfMetric { + var aValue, bValue string + for _, l := range m.Label { + if *l.Name == "label_a" { + aValue = *l.Value + } + if *l.Name == "label_b" { + bValue = *l.Value + } + } + labelValuePair := aValue + " " + bValue + expectedCount, ok := test.expectMetricValues[labelValuePair] + assert.True(t, ok, "Got unexpected label values, lable_a is %v, label_b is %v", aValue, bValue) + expectedSum := float64(dt1) * v0 * float64(expectedCount) + expectedCount *= uint64(dt1) + actualCount := m.GetHistogram().GetSampleCount() + actualSum := m.GetHistogram().GetSampleSum() + assert.Equalf(t, expectedCount, actualCount, "Got %v, wanted %v as the count while setting label_a to %v and label b to %v", actualCount, expectedCount, aValue, bValue) + assert.Equalf(t, expectedSum, actualSum, "Got %v, wanted %v as the sum while setting label_a to %v and label b to %v", actualSum, expectedSum, aValue, bValue) + } + } + }) + } +} + +func BenchmarkTimingHistogram(b *testing.B) { + b.StopTimer() + now := time.Now() + th := NewTestableTimingHistogram(func() time.Time { return now }, &TimingHistogramOpts{ + Namespace: "testns", + Subsystem: "testsubsys", + Name: "testhist", + Help: "Me", + Buckets: []float64{1, 2, 4, 8, 16}, + InitialValue: 3, + }) + registry := NewKubeRegistry() + registry.MustRegister(th) + var x int + b.StartTimer() + for i := 0; i < b.N; i++ { + now = now.Add(time.Duration(31-x) * time.Microsecond) + th.Set(float64(x)) + x = (x + i) % 23 + } +} + +func BenchmarkTimingHistogramVecEltCached(b *testing.B) { + b.StopTimer() + now := time.Now() + hv := NewTestableTimingHistogramVec(func() time.Time { return now }, &TimingHistogramOpts{ + Namespace: "testns", + Subsystem: "testsubsys", + Name: "testhist", + Help: "Me", + Buckets: []float64{1, 2, 4, 8, 16}, + InitialValue: 3, + }, + []string{"label1", "label2"}) + registry := NewKubeRegistry() + registry.MustRegister(hv) + th, err := hv.WithLabelValuesChecked("v1", "v2") + if err != nil { + b.Error(err) + } + var x int + b.StartTimer() + for i := 0; i < b.N; i++ { + now = now.Add(time.Duration(31-x) * time.Microsecond) + th.Set(float64(x)) + x = (x + i) % 23 + } +} + +func BenchmarkTimingHistogramVecEltFetched(b *testing.B) { + b.StopTimer() + now := time.Now() + hv := NewTestableTimingHistogramVec(func() time.Time { return now }, &TimingHistogramOpts{ + Namespace: "testns", + Subsystem: "testsubsys", + Name: "testhist", + Help: "Me", + Buckets: []float64{1, 2, 4, 8, 16}, + InitialValue: 3, + }, + []string{"label1", "label2"}) + registry := NewKubeRegistry() + registry.MustRegister(hv) + var x int + b.StartTimer() + for i := 0; i < b.N; i++ { + now = now.Add(time.Duration(31-x) * time.Microsecond) + hv.WithLabelValues("v1", "v2").Set(float64(x)) + x = (x + i) % 60 + } +} diff --git a/staging/src/k8s.io/component-base/metrics/wrappers.go b/staging/src/k8s.io/component-base/metrics/wrappers.go index 6ae8a458acb..06a8eec50cf 100644 --- a/staging/src/k8s.io/component-base/metrics/wrappers.go +++ b/staging/src/k8s.io/component-base/metrics/wrappers.go @@ -17,6 +17,8 @@ limitations under the License. package metrics import ( + "errors" + "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" ) @@ -65,6 +67,64 @@ type GaugeMetric interface { SetToCurrentTime() } +// GaugeVecMetric is a collection of Gauges that differ only in label values. +type GaugeVecMetric interface { + // Default Prometheus Vec behavior is that member extraction results in creation of a new element + // if one with the unique label values is not found in the underlying stored metricMap. + // This means that if this function is called but the underlying metric is not registered + // (which means it will never be exposed externally nor consumed), the metric would exist in memory + // for perpetuity (i.e. throughout application lifecycle). + // + // For reference: https://github.com/prometheus/client_golang/blob/v0.9.2/prometheus/gauge.go#L190-L208 + // + // In contrast, the Vec behavior in this package is that member extraction before registration + // returns a permanent noop object. + + // WithLabelValuesChecked, if called before this vector has been registered in + // at least one registry, will return a noop gauge and + // an error that passes ErrIsNotRegistered. + // If called on a hidden vector, + // will return a noop gauge and a nil error. + // If called with a syntactic problem in the labels, will + // return a noop gauge and an error about the labels. + // If none of the above apply, this method will return + // the appropriate vector member and a nil error. + WithLabelValuesChecked(labelValues ...string) (GaugeMetric, error) + + // WithLabelValues calls WithLabelValuesChecked + // and handles errors as follows. + // An error that passes ErrIsNotRegistered is ignored + // and the noop gauge is returned; + // all other errors cause a panic. + WithLabelValues(labelValues ...string) GaugeMetric + + // WithChecked, if called before this vector has been registered in + // at least one registry, will return a noop gauge and + // an error that passes ErrIsNotRegistered. + // If called on a hidden vector, + // will return a noop gauge and a nil error. + // If called with a syntactic problem in the labels, will + // return a noop gauge and an error about the labels. + // If none of the above apply, this method will return + // the appropriate vector member and a nil error. + WithChecked(labels map[string]string) (GaugeMetric, error) + + // With calls WithChecked and handles errors as follows. + // An error that passes ErrIsNotRegistered is ignored + // and the noop gauge is returned; + // all other errors cause a panic. + With(labels map[string]string) GaugeMetric + + // Delete asserts that the vec should have no member for the given label set. + // The returned bool indicates whether there was a change. + // The return will certainly be `false` if the given label set has the wrong + // set of label names. + Delete(map[string]string) bool + + // Reset removes all the members + Reset() +} + // ObserverMetric captures individual observations. type ObserverMetric interface { Observe(float64) @@ -93,3 +153,9 @@ type GaugeFunc interface { Metric Collector } + +func ErrIsNotRegistered(err error) bool { + return err == errNotRegistered +} + +var errNotRegistered = errors.New("metric vec is not registered yet") diff --git a/vendor/modules.txt b/vendor/modules.txt index a0d6664378d..13fbafc16ed 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -2070,6 +2070,7 @@ k8s.io/component-base/metrics/prometheus/ratelimiter k8s.io/component-base/metrics/prometheus/restclient k8s.io/component-base/metrics/prometheus/version k8s.io/component-base/metrics/prometheus/workqueue +k8s.io/component-base/metrics/prometheusextension k8s.io/component-base/metrics/testutil k8s.io/component-base/term k8s.io/component-base/traces