gocollector: Added options to Go Collector for changing the (#1031)
* Renamed files. Signed-off-by: Bartlomiej Plotka <bwplotka@gmail.com> * gocollector: Added options to Go Collector for diffetent collections. Fixes https://github.com/prometheus/client_golang/issues/983 Also: * fixed TestMemStatsEquivalence, it was noop before (: * Removed gc_cpu_fraction metric completely, since it's not working completely for Go1.17+ Signed-off-by: Bartlomiej Plotka <bwplotka@gmail.com>
This commit is contained in:
parent
cc7991d977
commit
24172847e3
|
@ -14,3 +14,27 @@
|
||||||
// Package collectors provides implementations of prometheus.Collector to
|
// Package collectors provides implementations of prometheus.Collector to
|
||||||
// conveniently collect process and Go-related metrics.
|
// conveniently collect process and Go-related metrics.
|
||||||
package collectors
|
package collectors
|
||||||
|
|
||||||
|
import "github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
// NewBuildInfoCollector returns a collector collecting a single metric
|
||||||
|
// "go_build_info" with the constant value 1 and three labels "path", "version",
|
||||||
|
// and "checksum". Their label values contain the main module path, version, and
|
||||||
|
// checksum, respectively. The labels will only have meaningful values if the
|
||||||
|
// binary is built with Go module support and from source code retrieved from
|
||||||
|
// the source repository (rather than the local file system). This is usually
|
||||||
|
// accomplished by building from outside of GOPATH, specifying the full address
|
||||||
|
// of the main package, e.g. "GO111MODULE=on go run
|
||||||
|
// github.com/prometheus/client_golang/examples/random". If built without Go
|
||||||
|
// module support, all label values will be "unknown". If built with Go module
|
||||||
|
// support but using the source code from the local file system, the "path" will
|
||||||
|
// be set appropriately, but "checksum" will be empty and "version" will be
|
||||||
|
// "(devel)".
|
||||||
|
//
|
||||||
|
// This collector uses only the build information for the main module. See
|
||||||
|
// https://github.com/povilasv/prommod for an example of a collector for the
|
||||||
|
// module dependencies.
|
||||||
|
func NewBuildInfoCollector() prometheus.Collector {
|
||||||
|
//nolint:staticcheck // Ignore SA1019 until v2.
|
||||||
|
return prometheus.NewBuildInfoCollector()
|
||||||
|
}
|
||||||
|
|
|
@ -11,6 +11,9 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
//go:build !go1.17
|
||||||
|
// +build !go1.17
|
||||||
|
|
||||||
package collectors
|
package collectors
|
||||||
|
|
||||||
import "github.com/prometheus/client_golang/prometheus"
|
import "github.com/prometheus/client_golang/prometheus"
|
||||||
|
@ -42,28 +45,5 @@ import "github.com/prometheus/client_golang/prometheus"
|
||||||
// NOTE: The problem is solved in Go 1.15, see
|
// NOTE: The problem is solved in Go 1.15, see
|
||||||
// https://github.com/golang/go/issues/19812 for the related Go issue.
|
// https://github.com/golang/go/issues/19812 for the related Go issue.
|
||||||
func NewGoCollector() prometheus.Collector {
|
func NewGoCollector() prometheus.Collector {
|
||||||
//nolint:staticcheck // Ignore SA1019 until v2.
|
|
||||||
return prometheus.NewGoCollector()
|
return prometheus.NewGoCollector()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBuildInfoCollector returns a collector collecting a single metric
|
|
||||||
// "go_build_info" with the constant value 1 and three labels "path", "version",
|
|
||||||
// and "checksum". Their label values contain the main module path, version, and
|
|
||||||
// checksum, respectively. The labels will only have meaningful values if the
|
|
||||||
// binary is built with Go module support and from source code retrieved from
|
|
||||||
// the source repository (rather than the local file system). This is usually
|
|
||||||
// accomplished by building from outside of GOPATH, specifying the full address
|
|
||||||
// of the main package, e.g. "GO111MODULE=on go run
|
|
||||||
// github.com/prometheus/client_golang/examples/random". If built without Go
|
|
||||||
// module support, all label values will be "unknown". If built with Go module
|
|
||||||
// support but using the source code from the local file system, the "path" will
|
|
||||||
// be set appropriately, but "checksum" will be empty and "version" will be
|
|
||||||
// "(devel)".
|
|
||||||
//
|
|
||||||
// This collector uses only the build information for the main module. See
|
|
||||||
// https://github.com/povilasv/prommod for an example of a collector for the
|
|
||||||
// module dependencies.
|
|
||||||
func NewBuildInfoCollector() prometheus.Collector {
|
|
||||||
//nolint:staticcheck // Ignore SA1019 until v2.
|
|
||||||
return prometheus.NewBuildInfoCollector()
|
|
||||||
}
|
|
|
@ -0,0 +1,91 @@
|
||||||
|
// Copyright 2021 The Prometheus 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.
|
||||||
|
|
||||||
|
//go:build go1.17
|
||||||
|
// +build go1.17
|
||||||
|
|
||||||
|
package collectors
|
||||||
|
|
||||||
|
import "github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
//nolint:staticcheck // Ignore SA1019 until v2.
|
||||||
|
type goOptions = prometheus.GoCollectorOptions
|
||||||
|
type goOption func(o *goOptions)
|
||||||
|
|
||||||
|
type GoCollectionOption uint32
|
||||||
|
|
||||||
|
const (
|
||||||
|
// GoRuntimeMemStatsCollection represents the metrics represented by runtime.MemStats structure such as
|
||||||
|
// go_memstats_alloc_bytes
|
||||||
|
// go_memstats_alloc_bytes_total
|
||||||
|
// go_memstats_sys_bytes
|
||||||
|
// go_memstats_lookups_total
|
||||||
|
// go_memstats_mallocs_total
|
||||||
|
// go_memstats_frees_total
|
||||||
|
// go_memstats_heap_alloc_bytes
|
||||||
|
// go_memstats_heap_sys_bytes
|
||||||
|
// go_memstats_heap_idle_bytes
|
||||||
|
// go_memstats_heap_inuse_bytes
|
||||||
|
// go_memstats_heap_released_bytes
|
||||||
|
// go_memstats_heap_objects
|
||||||
|
// go_memstats_stack_inuse_bytes
|
||||||
|
// go_memstats_stack_sys_bytes
|
||||||
|
// go_memstats_mspan_inuse_bytes
|
||||||
|
// go_memstats_mspan_sys_bytes
|
||||||
|
// go_memstats_mcache_inuse_bytes
|
||||||
|
// go_memstats_mcache_sys_bytes
|
||||||
|
// go_memstats_buck_hash_sys_bytes
|
||||||
|
// go_memstats_gc_sys_bytes
|
||||||
|
// go_memstats_other_sys_bytes
|
||||||
|
// go_memstats_next_gc_bytes
|
||||||
|
// so the metrics known from pre client_golang v1.12.0, except skipped go_memstats_gc_cpu_fraction (see
|
||||||
|
// https://github.com/prometheus/client_golang/issues/842#issuecomment-861812034 for explanation.
|
||||||
|
//
|
||||||
|
// NOTE that this mode represents runtime.MemStats statistics, but they are
|
||||||
|
// actually implemented using new runtime/metrics package.
|
||||||
|
// Deprecated: Use GoRuntimeMetricsCollection instead going forward.
|
||||||
|
GoRuntimeMemStatsCollection GoCollectionOption = 1 << iota
|
||||||
|
// GoRuntimeMetricsCollection is the new set of metrics represented by runtime/metrics package and follows
|
||||||
|
// consistent naming. The exposed metric set depends on Go version, but it is controlled against
|
||||||
|
// unexpected cardinality. This set has overlapping information with GoRuntimeMemStatsCollection, just with
|
||||||
|
// new names. GoRuntimeMetricsCollection is what is recommended for using going forward.
|
||||||
|
GoRuntimeMetricsCollection
|
||||||
|
)
|
||||||
|
|
||||||
|
// WithGoCollections allows enabling different collections for Go collector on top of base metrics
|
||||||
|
// like go_goroutines, go_threads, go_gc_duration_seconds, go_memstats_last_gc_time_seconds, go_info.
|
||||||
|
//
|
||||||
|
// Check GoRuntimeMemStatsCollection and GoRuntimeMetricsCollection for more details. You can use none,
|
||||||
|
// one or more collections at once. For example:
|
||||||
|
// WithGoCollections(GoRuntimeMemStatsCollection | GoRuntimeMetricsCollection) means both GoRuntimeMemStatsCollection
|
||||||
|
// metrics and GoRuntimeMetricsCollection will be exposed.
|
||||||
|
//
|
||||||
|
// Use WithGoCollections(GoRuntimeMemStatsCollection) to have Go collector working in
|
||||||
|
// the compatibility mode with client_golang pre v1.12 (move to runtime/metrics).
|
||||||
|
func WithGoCollections(flags uint32) goOption {
|
||||||
|
return func(o *goOptions) {
|
||||||
|
o.EnabledCollections = flags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGoCollector returns a collector that exports metrics about the current Go
|
||||||
|
// process using debug.GCStats using runtime/metrics.
|
||||||
|
func NewGoCollector(opts ...goOption) prometheus.Collector {
|
||||||
|
//nolint:staticcheck // Ignore SA1019 until v2.
|
||||||
|
promPkgOpts := make([]func(o *prometheus.GoCollectorOptions), len(opts))
|
||||||
|
for i, opt := range opts {
|
||||||
|
promPkgOpts[i] = opt
|
||||||
|
}
|
||||||
|
//nolint:staticcheck // Ignore SA1019 until v2.
|
||||||
|
return prometheus.NewGoCollector(promPkgOpts...)
|
||||||
|
}
|
|
@ -197,14 +197,6 @@ func goRuntimeMemStats() memStatsMetrics {
|
||||||
),
|
),
|
||||||
eval: func(ms *runtime.MemStats) float64 { return float64(ms.NextGC) },
|
eval: func(ms *runtime.MemStats) float64 { return float64(ms.NextGC) },
|
||||||
valType: GaugeValue,
|
valType: GaugeValue,
|
||||||
}, {
|
|
||||||
desc: NewDesc(
|
|
||||||
memstatNamespace("gc_cpu_fraction"),
|
|
||||||
"The fraction of this program's available CPU time used by the GC since the program started.",
|
|
||||||
nil, nil,
|
|
||||||
),
|
|
||||||
eval: func(ms *runtime.MemStats) float64 { return ms.GCCPUFraction },
|
|
||||||
valType: GaugeValue,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -268,7 +260,6 @@ func (c *baseGoCollector) Collect(ch chan<- Metric) {
|
||||||
quantiles[0.0] = stats.PauseQuantiles[0].Seconds()
|
quantiles[0.0] = stats.PauseQuantiles[0].Seconds()
|
||||||
ch <- MustNewConstSummary(c.gcDesc, uint64(stats.NumGC), stats.PauseTotal.Seconds(), quantiles)
|
ch <- MustNewConstSummary(c.gcDesc, uint64(stats.NumGC), stats.PauseTotal.Seconds(), quantiles)
|
||||||
ch <- MustNewConstMetric(c.gcLastTimeDesc, GaugeValue, float64(stats.LastGC.UnixNano())/1e9)
|
ch <- MustNewConstMetric(c.gcLastTimeDesc, GaugeValue, float64(stats.LastGC.UnixNano())/1e9)
|
||||||
|
|
||||||
ch <- MustNewConstMetric(c.goInfoDesc, GaugeValue, 1)
|
ch <- MustNewConstMetric(c.goInfoDesc, GaugeValue, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -278,6 +269,7 @@ func memstatNamespace(s string) string {
|
||||||
|
|
||||||
// memStatsMetrics provide description, evaluator, runtime/metrics name, and
|
// memStatsMetrics provide description, evaluator, runtime/metrics name, and
|
||||||
// value type for memstat metrics.
|
// value type for memstat metrics.
|
||||||
|
// TODO(bwplotka): Remove with end Go 1.16 EOL and replace with runtime/metrics.Description
|
||||||
type memStatsMetrics []struct {
|
type memStatsMetrics []struct {
|
||||||
desc *Desc
|
desc *Desc
|
||||||
eval func(*runtime.MemStats) float64
|
eval func(*runtime.MemStats) float64
|
||||||
|
|
|
@ -40,13 +40,28 @@ type goCollector struct {
|
||||||
//
|
//
|
||||||
// Deprecated: Use collectors.NewGoCollector instead.
|
// Deprecated: Use collectors.NewGoCollector instead.
|
||||||
func NewGoCollector() Collector {
|
func NewGoCollector() Collector {
|
||||||
|
msMetrics := goRuntimeMemStats()
|
||||||
|
msMetrics = append(msMetrics, struct {
|
||||||
|
desc *Desc
|
||||||
|
eval func(*runtime.MemStats) float64
|
||||||
|
valType ValueType
|
||||||
|
}{
|
||||||
|
// This metric is omitted in Go1.17+, see https://github.com/prometheus/client_golang/issues/842#issuecomment-861812034
|
||||||
|
desc: NewDesc(
|
||||||
|
memstatNamespace("gc_cpu_fraction"),
|
||||||
|
"The fraction of this program's available CPU time used by the GC since the program started.",
|
||||||
|
nil, nil,
|
||||||
|
),
|
||||||
|
eval: func(ms *runtime.MemStats) float64 { return ms.GCCPUFraction },
|
||||||
|
valType: GaugeValue,
|
||||||
|
})
|
||||||
return &goCollector{
|
return &goCollector{
|
||||||
base: newBaseGoCollector(),
|
base: newBaseGoCollector(),
|
||||||
msLast: &runtime.MemStats{},
|
msLast: &runtime.MemStats{},
|
||||||
msRead: runtime.ReadMemStats,
|
msRead: runtime.ReadMemStats,
|
||||||
msMaxWait: time.Second,
|
msMaxWait: time.Second,
|
||||||
msMaxAge: 5 * time.Minute,
|
msMaxAge: 5 * time.Minute,
|
||||||
msMetrics: goRuntimeMemStats(),
|
msMetrics: msMetrics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,66 @@ import (
|
||||||
dto "github.com/prometheus/client_model/go"
|
dto "github.com/prometheus/client_model/go"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
goGCHeapTinyAllocsObjects = "/gc/heap/tiny/allocs:objects"
|
||||||
|
goGCHeapAllocsObjects = "/gc/heap/allocs:objects"
|
||||||
|
goGCHeapFreesObjects = "/gc/heap/frees:objects"
|
||||||
|
goGCHeapAllocsBytes = "/gc/heap/allocs:bytes"
|
||||||
|
goGCHeapObjects = "/gc/heap/objects:objects"
|
||||||
|
goGCHeapGoalBytes = "/gc/heap/goal:bytes"
|
||||||
|
goMemoryClassesTotalBytes = "/memory/classes/total:bytes"
|
||||||
|
goMemoryClassesHeapObjectsBytes = "/memory/classes/heap/objects:bytes"
|
||||||
|
goMemoryClassesHeapUnusedBytes = "/memory/classes/heap/unused:bytes"
|
||||||
|
goMemoryClassesHeapReleasedBytes = "/memory/classes/heap/released:bytes"
|
||||||
|
goMemoryClassesHeapFreeBytes = "/memory/classes/heap/free:bytes"
|
||||||
|
goMemoryClassesHeapStacksBytes = "/memory/classes/heap/stacks:bytes"
|
||||||
|
goMemoryClassesOSStacksBytes = "/memory/classes/os-stacks:bytes"
|
||||||
|
goMemoryClassesMetadataMSpanInuseBytes = "/memory/classes/metadata/mspan/inuse:bytes"
|
||||||
|
goMemoryClassesMetadataMSPanFreeBytes = "/memory/classes/metadata/mspan/free:bytes"
|
||||||
|
goMemoryClassesMetadataMCacheInuseBytes = "/memory/classes/metadata/mcache/inuse:bytes"
|
||||||
|
goMemoryClassesMetadataMCacheFreeBytes = "/memory/classes/metadata/mcache/free:bytes"
|
||||||
|
goMemoryClassesProfilingBucketsBytes = "/memory/classes/profiling/buckets:bytes"
|
||||||
|
goMemoryClassesMetadataOtherBytes = "/memory/classes/metadata/other:bytes"
|
||||||
|
goMemoryClassesOtherBytes = "/memory/classes/other:bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runtime/metrics names required for runtimeMemStats like logic.
|
||||||
|
var rmForMemStats = []string{goGCHeapTinyAllocsObjects,
|
||||||
|
goGCHeapAllocsObjects,
|
||||||
|
goGCHeapFreesObjects,
|
||||||
|
goGCHeapAllocsBytes,
|
||||||
|
goGCHeapObjects,
|
||||||
|
goGCHeapGoalBytes,
|
||||||
|
goMemoryClassesTotalBytes,
|
||||||
|
goMemoryClassesHeapObjectsBytes,
|
||||||
|
goMemoryClassesHeapUnusedBytes,
|
||||||
|
goMemoryClassesHeapReleasedBytes,
|
||||||
|
goMemoryClassesHeapFreeBytes,
|
||||||
|
goMemoryClassesHeapStacksBytes,
|
||||||
|
goMemoryClassesOSStacksBytes,
|
||||||
|
goMemoryClassesMetadataMSpanInuseBytes,
|
||||||
|
goMemoryClassesMetadataMSPanFreeBytes,
|
||||||
|
goMemoryClassesMetadataMCacheInuseBytes,
|
||||||
|
goMemoryClassesMetadataMCacheFreeBytes,
|
||||||
|
goMemoryClassesProfilingBucketsBytes,
|
||||||
|
goMemoryClassesMetadataOtherBytes,
|
||||||
|
goMemoryClassesOtherBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
func bestEffortLookupRM(lookup []string) []metrics.Description {
|
||||||
|
ret := make([]metrics.Description, 0, len(lookup))
|
||||||
|
for _, rm := range metrics.All() {
|
||||||
|
for _, m := range lookup {
|
||||||
|
if m == rm.Name {
|
||||||
|
ret = append(ret, rm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
type goCollector struct {
|
type goCollector struct {
|
||||||
|
opt GoCollectorOptions
|
||||||
base baseGoCollector
|
base baseGoCollector
|
||||||
|
|
||||||
// mu protects updates to all fields ensuring a consistent
|
// mu protects updates to all fields ensuring a consistent
|
||||||
|
@ -51,12 +110,46 @@ type goCollector struct {
|
||||||
msMetrics memStatsMetrics
|
msMetrics memStatsMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Those are not exposed due to need to move Go collector to another package in v2.
|
||||||
|
// See issue https://github.com/prometheus/client_golang/issues/1030.
|
||||||
|
goRuntimeMemStatsCollection uint32 = 1 << iota
|
||||||
|
goRuntimeMetricsCollection
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoCollectorOptions should not be used be directly by anything, except `collectors` package.
|
||||||
|
// Use it via collectors package instead. See issue
|
||||||
|
// https://github.com/prometheus/client_golang/issues/1030.
|
||||||
|
//
|
||||||
|
// Deprecated: Use collectors.WithGoCollections
|
||||||
|
type GoCollectorOptions struct {
|
||||||
|
// EnabledCollection sets what type of collections collector should expose on top of base collection.
|
||||||
|
// By default it's goMemStatsCollection | goRuntimeMetricsCollection.
|
||||||
|
EnabledCollections uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c GoCollectorOptions) isEnabled(flag uint32) bool {
|
||||||
|
return c.EnabledCollections&flag != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultGoCollections = goRuntimeMemStatsCollection | goRuntimeMetricsCollection
|
||||||
|
|
||||||
// NewGoCollector is the obsolete version of collectors.NewGoCollector.
|
// NewGoCollector is the obsolete version of collectors.NewGoCollector.
|
||||||
// See there for documentation.
|
// See there for documentation.
|
||||||
//
|
//
|
||||||
// Deprecated: Use collectors.NewGoCollector instead.
|
// Deprecated: Use collectors.NewGoCollector instead.
|
||||||
func NewGoCollector() Collector {
|
func NewGoCollector(opts ...func(o *GoCollectorOptions)) Collector {
|
||||||
descriptions := metrics.All()
|
opt := GoCollectorOptions{EnabledCollections: defaultGoCollections}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(&opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var descriptions []metrics.Description
|
||||||
|
if opt.isEnabled(goRuntimeMetricsCollection) {
|
||||||
|
descriptions = metrics.All()
|
||||||
|
} else if opt.isEnabled(goRuntimeMemStatsCollection) {
|
||||||
|
descriptions = bestEffortLookupRM(rmForMemStats)
|
||||||
|
}
|
||||||
|
|
||||||
// Collect all histogram samples so that we can get their buckets.
|
// Collect all histogram samples so that we can get their buckets.
|
||||||
// The API guarantees that the buckets are always fixed for the lifetime
|
// The API guarantees that the buckets are always fixed for the lifetime
|
||||||
|
@ -67,7 +160,11 @@ func NewGoCollector() Collector {
|
||||||
histograms = append(histograms, metrics.Sample{Name: d.Name})
|
histograms = append(histograms, metrics.Sample{Name: d.Name})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metrics.Read(histograms)
|
|
||||||
|
if len(histograms) > 0 {
|
||||||
|
metrics.Read(histograms)
|
||||||
|
}
|
||||||
|
|
||||||
bucketsMap := make(map[string][]float64)
|
bucketsMap := make(map[string][]float64)
|
||||||
for i := range histograms {
|
for i := range histograms {
|
||||||
bucketsMap[histograms[i].Name] = histograms[i].Value.Float64Histogram().Buckets
|
bucketsMap[histograms[i].Name] = histograms[i].Value.Float64Histogram().Buckets
|
||||||
|
@ -83,7 +180,7 @@ func NewGoCollector() Collector {
|
||||||
if !ok {
|
if !ok {
|
||||||
// Just ignore this metric; we can't do anything with it here.
|
// Just ignore this metric; we can't do anything with it here.
|
||||||
// If a user decides to use the latest version of Go, we don't want
|
// If a user decides to use the latest version of Go, we don't want
|
||||||
// to fail here. This condition is tested elsewhere.
|
// to fail here. This condition is tested in TestExpectedRuntimeMetrics.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -123,12 +220,18 @@ func NewGoCollector() Collector {
|
||||||
}
|
}
|
||||||
metricSet = append(metricSet, m)
|
metricSet = append(metricSet, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var msMetrics memStatsMetrics
|
||||||
|
if opt.isEnabled(goRuntimeMemStatsCollection) {
|
||||||
|
msMetrics = goRuntimeMemStats()
|
||||||
|
}
|
||||||
return &goCollector{
|
return &goCollector{
|
||||||
|
opt: opt,
|
||||||
base: newBaseGoCollector(),
|
base: newBaseGoCollector(),
|
||||||
rmSampleBuf: sampleBuf,
|
rmSampleBuf: sampleBuf,
|
||||||
rmSampleMap: sampleMap,
|
rmSampleMap: sampleMap,
|
||||||
rmMetrics: metricSet,
|
rmMetrics: metricSet,
|
||||||
msMetrics: goRuntimeMemStats(),
|
msMetrics: msMetrics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,40 +266,47 @@ func (c *goCollector) Collect(ch chan<- Metric) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
// Populate runtime/metrics sample buffer.
|
if len(c.rmSampleBuf) > 0 {
|
||||||
metrics.Read(c.rmSampleBuf)
|
// Populate runtime/metrics sample buffer.
|
||||||
|
metrics.Read(c.rmSampleBuf)
|
||||||
|
}
|
||||||
|
|
||||||
// Update all our metrics from rmSampleBuf.
|
if c.opt.isEnabled(goRuntimeMetricsCollection) {
|
||||||
for i, sample := range c.rmSampleBuf {
|
// Collect all our metrics from rmSampleBuf.
|
||||||
// N.B. switch on concrete type because it's significantly more efficient
|
for i, sample := range c.rmSampleBuf {
|
||||||
// than checking for the Counter and Gauge interface implementations. In
|
// N.B. switch on concrete type because it's significantly more efficient
|
||||||
// this case, we control all the types here.
|
// than checking for the Counter and Gauge interface implementations. In
|
||||||
switch m := c.rmMetrics[i].(type) {
|
// this case, we control all the types here.
|
||||||
case *counter:
|
switch m := c.rmMetrics[i].(type) {
|
||||||
// Guard against decreases. This should never happen, but a failure
|
case *counter:
|
||||||
// to do so will result in a panic, which is a harsh consequence for
|
// Guard against decreases. This should never happen, but a failure
|
||||||
// a metrics collection bug.
|
// to do so will result in a panic, which is a harsh consequence for
|
||||||
v0, v1 := m.get(), unwrapScalarRMValue(sample.Value)
|
// a metrics collection bug.
|
||||||
if v1 > v0 {
|
v0, v1 := m.get(), unwrapScalarRMValue(sample.Value)
|
||||||
m.Add(unwrapScalarRMValue(sample.Value) - m.get())
|
if v1 > v0 {
|
||||||
|
m.Add(unwrapScalarRMValue(sample.Value) - m.get())
|
||||||
|
}
|
||||||
|
m.Collect(ch)
|
||||||
|
case *gauge:
|
||||||
|
m.Set(unwrapScalarRMValue(sample.Value))
|
||||||
|
m.Collect(ch)
|
||||||
|
case *batchHistogram:
|
||||||
|
m.update(sample.Value.Float64Histogram(), c.exactSumFor(sample.Name))
|
||||||
|
m.Collect(ch)
|
||||||
|
default:
|
||||||
|
panic("unexpected metric type")
|
||||||
}
|
}
|
||||||
m.Collect(ch)
|
|
||||||
case *gauge:
|
|
||||||
m.Set(unwrapScalarRMValue(sample.Value))
|
|
||||||
m.Collect(ch)
|
|
||||||
case *batchHistogram:
|
|
||||||
m.update(sample.Value.Float64Histogram(), c.exactSumFor(sample.Name))
|
|
||||||
m.Collect(ch)
|
|
||||||
default:
|
|
||||||
panic("unexpected metric type")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ms is a dummy MemStats that we populate ourselves so that we can
|
// ms is a dummy MemStats that we populate ourselves so that we can
|
||||||
// populate the old metrics from it.
|
// populate the old metrics from it if goMemStatsCollection is enabled.
|
||||||
var ms runtime.MemStats
|
if c.opt.isEnabled(goRuntimeMemStatsCollection) {
|
||||||
memStatsFromRM(&ms, c.rmSampleMap)
|
var ms runtime.MemStats
|
||||||
for _, i := range c.msMetrics {
|
memStatsFromRM(&ms, c.rmSampleMap)
|
||||||
ch <- MustNewConstMetric(i.desc, i.valType, i.eval(&ms))
|
for _, i := range c.msMetrics {
|
||||||
|
ch <- MustNewConstMetric(i.desc, i.valType, i.eval(&ms))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -261,35 +371,30 @@ func memStatsFromRM(ms *runtime.MemStats, rm map[string]*metrics.Sample) {
|
||||||
// while having Mallocs - Frees still represent a live object count.
|
// while having Mallocs - Frees still represent a live object count.
|
||||||
// Unfortunately, MemStats doesn't actually export a large allocation count,
|
// Unfortunately, MemStats doesn't actually export a large allocation count,
|
||||||
// so it's impossible to pull this number out directly.
|
// so it's impossible to pull this number out directly.
|
||||||
tinyAllocs := lookupOrZero("/gc/heap/tiny/allocs:objects")
|
tinyAllocs := lookupOrZero(goGCHeapTinyAllocsObjects)
|
||||||
ms.Mallocs = lookupOrZero("/gc/heap/allocs:objects") + tinyAllocs
|
ms.Mallocs = lookupOrZero(goGCHeapAllocsObjects) + tinyAllocs
|
||||||
ms.Frees = lookupOrZero("/gc/heap/frees:objects") + tinyAllocs
|
ms.Frees = lookupOrZero(goGCHeapFreesObjects) + tinyAllocs
|
||||||
|
|
||||||
ms.TotalAlloc = lookupOrZero("/gc/heap/allocs:bytes")
|
ms.TotalAlloc = lookupOrZero(goGCHeapAllocsBytes)
|
||||||
ms.Sys = lookupOrZero("/memory/classes/total:bytes")
|
ms.Sys = lookupOrZero(goMemoryClassesTotalBytes)
|
||||||
ms.Lookups = 0 // Already always zero.
|
ms.Lookups = 0 // Already always zero.
|
||||||
ms.HeapAlloc = lookupOrZero("/memory/classes/heap/objects:bytes")
|
ms.HeapAlloc = lookupOrZero(goMemoryClassesHeapObjectsBytes)
|
||||||
ms.Alloc = ms.HeapAlloc
|
ms.Alloc = ms.HeapAlloc
|
||||||
ms.HeapInuse = ms.HeapAlloc + lookupOrZero("/memory/classes/heap/unused:bytes")
|
ms.HeapInuse = ms.HeapAlloc + lookupOrZero(goMemoryClassesHeapUnusedBytes)
|
||||||
ms.HeapReleased = lookupOrZero("/memory/classes/heap/released:bytes")
|
ms.HeapReleased = lookupOrZero(goMemoryClassesHeapReleasedBytes)
|
||||||
ms.HeapIdle = ms.HeapReleased + lookupOrZero("/memory/classes/heap/free:bytes")
|
ms.HeapIdle = ms.HeapReleased + lookupOrZero(goMemoryClassesHeapFreeBytes)
|
||||||
ms.HeapSys = ms.HeapInuse + ms.HeapIdle
|
ms.HeapSys = ms.HeapInuse + ms.HeapIdle
|
||||||
ms.HeapObjects = lookupOrZero("/gc/heap/objects:objects")
|
ms.HeapObjects = lookupOrZero(goGCHeapObjects)
|
||||||
ms.StackInuse = lookupOrZero("/memory/classes/heap/stacks:bytes")
|
ms.StackInuse = lookupOrZero(goMemoryClassesHeapStacksBytes)
|
||||||
ms.StackSys = ms.StackInuse + lookupOrZero("/memory/classes/os-stacks:bytes")
|
ms.StackSys = ms.StackInuse + lookupOrZero(goMemoryClassesOSStacksBytes)
|
||||||
ms.MSpanInuse = lookupOrZero("/memory/classes/metadata/mspan/inuse:bytes")
|
ms.MSpanInuse = lookupOrZero(goMemoryClassesMetadataMSpanInuseBytes)
|
||||||
ms.MSpanSys = ms.MSpanInuse + lookupOrZero("/memory/classes/metadata/mspan/free:bytes")
|
ms.MSpanSys = ms.MSpanInuse + lookupOrZero(goMemoryClassesMetadataMSPanFreeBytes)
|
||||||
ms.MCacheInuse = lookupOrZero("/memory/classes/metadata/mcache/inuse:bytes")
|
ms.MCacheInuse = lookupOrZero(goMemoryClassesMetadataMCacheInuseBytes)
|
||||||
ms.MCacheSys = ms.MCacheInuse + lookupOrZero("/memory/classes/metadata/mcache/free:bytes")
|
ms.MCacheSys = ms.MCacheInuse + lookupOrZero(goMemoryClassesMetadataMCacheFreeBytes)
|
||||||
ms.BuckHashSys = lookupOrZero("/memory/classes/profiling/buckets:bytes")
|
ms.BuckHashSys = lookupOrZero(goMemoryClassesProfilingBucketsBytes)
|
||||||
ms.GCSys = lookupOrZero("/memory/classes/metadata/other:bytes")
|
ms.GCSys = lookupOrZero(goMemoryClassesMetadataOtherBytes)
|
||||||
ms.OtherSys = lookupOrZero("/memory/classes/other:bytes")
|
ms.OtherSys = lookupOrZero(goMemoryClassesOtherBytes)
|
||||||
ms.NextGC = lookupOrZero("/gc/heap/goal:bytes")
|
ms.NextGC = lookupOrZero(goGCHeapGoalBytes)
|
||||||
|
|
||||||
// N.B. LastGC is omitted because runtime.GCStats already has this.
|
|
||||||
// See https://github.com/prometheus/client_golang/issues/842#issuecomment-861812034
|
|
||||||
// for more details.
|
|
||||||
ms.LastGC = 0
|
|
||||||
|
|
||||||
// N.B. GCCPUFraction is intentionally omitted. This metric is not useful,
|
// N.B. GCCPUFraction is intentionally omitted. This metric is not useful,
|
||||||
// and often misleading due to the fact that it's an average over the lifetime
|
// and often misleading due to the fact that it's an average over the lifetime
|
|
@ -28,78 +28,96 @@ import (
|
||||||
dto "github.com/prometheus/client_model/go"
|
dto "github.com/prometheus/client_model/go"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGoCollectorRuntimeMetrics(t *testing.T) {
|
func TestRmForMemStats(t *testing.T) {
|
||||||
metrics := collectGoMetrics(t)
|
if got, want := len(bestEffortLookupRM(rmForMemStats)), len(rmForMemStats); got != want {
|
||||||
|
t.Errorf("got %d, want %d metrics", got, want)
|
||||||
msChecklist := make(map[string]bool)
|
|
||||||
for _, m := range goRuntimeMemStats() {
|
|
||||||
msChecklist[m.desc.fqName] = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(metrics) == 0 {
|
|
||||||
t.Fatal("no metrics created by Collect")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check a few specific metrics.
|
|
||||||
//
|
|
||||||
// Checking them all is somewhat pointless because the runtime/metrics
|
|
||||||
// metrics are going to shift underneath us. Also if we try to check
|
|
||||||
// against the runtime/metrics package in an automated fashion we're kind
|
|
||||||
// of missing the point, because we have to do all the same work the code
|
|
||||||
// has to do to perform the translation. Same for supporting old metric
|
|
||||||
// names (the best we can do here is make sure they're all accounted for).
|
|
||||||
var sysBytes, allocs float64
|
|
||||||
for _, m := range metrics {
|
|
||||||
name := m.Desc().fqName
|
|
||||||
switch name {
|
|
||||||
case "go_memory_classes_total_bytes":
|
|
||||||
checkMemoryMetric(t, m, &sysBytes)
|
|
||||||
case "go_sys_bytes":
|
|
||||||
checkMemoryMetric(t, m, &sysBytes)
|
|
||||||
case "go_gc_heap_allocs_bytes_total":
|
|
||||||
checkMemoryMetric(t, m, &allocs)
|
|
||||||
case "go_alloc_bytes_total":
|
|
||||||
checkMemoryMetric(t, m, &allocs)
|
|
||||||
}
|
|
||||||
if present, ok := msChecklist[name]; ok {
|
|
||||||
if present {
|
|
||||||
t.Errorf("memstats metric %s found more than once", name)
|
|
||||||
}
|
|
||||||
msChecklist[name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name := range msChecklist {
|
|
||||||
if present := msChecklist[name]; !present {
|
|
||||||
t.Errorf("memstats metric %s not collected", name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkMemoryMetric(t *testing.T, m Metric, expValue *float64) {
|
func expectedBaseMetrics() map[string]struct{} {
|
||||||
t.Helper()
|
metrics := map[string]struct{}{}
|
||||||
|
b := newBaseGoCollector()
|
||||||
|
for _, m := range []string{
|
||||||
|
b.gcDesc.fqName,
|
||||||
|
b.goInfoDesc.fqName,
|
||||||
|
b.goroutinesDesc.fqName,
|
||||||
|
b.gcLastTimeDesc.fqName,
|
||||||
|
b.threadsDesc.fqName,
|
||||||
|
} {
|
||||||
|
metrics[m] = struct{}{}
|
||||||
|
}
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
pb := &dto.Metric{}
|
func addExpectedRuntimeMemStats(metrics map[string]struct{}) map[string]struct{} {
|
||||||
m.Write(pb)
|
for _, m := range goRuntimeMemStats() {
|
||||||
var value float64
|
metrics[m.desc.fqName] = struct{}{}
|
||||||
if g := pb.GetGauge(); g != nil {
|
|
||||||
value = g.GetValue()
|
|
||||||
} else {
|
|
||||||
value = pb.GetCounter().GetValue()
|
|
||||||
}
|
}
|
||||||
if value <= 0 {
|
return metrics
|
||||||
t.Error("bad value for total memory")
|
}
|
||||||
|
|
||||||
|
func addExpectedRuntimeMetrics(metrics map[string]struct{}) map[string]struct{} {
|
||||||
|
for _, m := range expectedRuntimeMetrics {
|
||||||
|
metrics[m] = struct{}{}
|
||||||
}
|
}
|
||||||
if *expValue == 0 {
|
return metrics
|
||||||
*expValue = value
|
}
|
||||||
} else if value != *expValue {
|
|
||||||
t.Errorf("legacy metric and runtime/metrics metric do not match: want %d, got %d", int64(*expValue), int64(value))
|
func TestGoCollector(t *testing.T) {
|
||||||
|
for _, tcase := range []struct {
|
||||||
|
collections uint32
|
||||||
|
expectedFQNameSet map[string]struct{}
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
collections: 0,
|
||||||
|
expectedFQNameSet: expectedBaseMetrics(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collections: goRuntimeMemStatsCollection,
|
||||||
|
expectedFQNameSet: addExpectedRuntimeMemStats(expectedBaseMetrics()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collections: goRuntimeMetricsCollection,
|
||||||
|
expectedFQNameSet: addExpectedRuntimeMetrics(expectedBaseMetrics()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collections: goRuntimeMemStatsCollection | goRuntimeMetricsCollection,
|
||||||
|
expectedFQNameSet: addExpectedRuntimeMemStats(addExpectedRuntimeMetrics(expectedBaseMetrics())),
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
if ok := t.Run("", func(t *testing.T) {
|
||||||
|
goMetrics := collectGoMetrics(t, tcase.collections)
|
||||||
|
goMetricSet := make(map[string]Metric)
|
||||||
|
for _, m := range goMetrics {
|
||||||
|
goMetricSet[m.Desc().fqName] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range goMetrics {
|
||||||
|
name := goMetrics[i].Desc().fqName
|
||||||
|
|
||||||
|
if _, ok := tcase.expectedFQNameSet[name]; !ok {
|
||||||
|
t.Errorf("found unpexpected metric %s", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now iterate over the expected metrics and look for removals.
|
||||||
|
for expectedName := range tcase.expectedFQNameSet {
|
||||||
|
if _, ok := goMetricSet[expectedName]; !ok {
|
||||||
|
t.Errorf("missing expected metric %s in collection", expectedName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var sink interface{}
|
var sink interface{}
|
||||||
|
|
||||||
func TestBatchHistogram(t *testing.T) {
|
func TestBatchHistogram(t *testing.T) {
|
||||||
goMetrics := collectGoMetrics(t)
|
goMetrics := collectGoMetrics(t, defaultGoCollections)
|
||||||
|
|
||||||
var mhist Metric
|
var mhist Metric
|
||||||
for _, m := range goMetrics {
|
for _, m := range goMetrics {
|
||||||
|
@ -126,7 +144,7 @@ func TestBatchHistogram(t *testing.T) {
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
sink = make([]byte, 128)
|
sink = make([]byte, 128)
|
||||||
}
|
}
|
||||||
collectGoMetrics(t)
|
collectGoMetrics(t, defaultGoCollections)
|
||||||
for i, v := range hist.counts {
|
for i, v := range hist.counts {
|
||||||
if v != countsCopy[i] {
|
if v != countsCopy[i] {
|
||||||
t.Error("counts changed during new collection")
|
t.Error("counts changed during new collection")
|
||||||
|
@ -175,10 +193,12 @@ func TestBatchHistogram(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectGoMetrics(t *testing.T) []Metric {
|
func collectGoMetrics(t *testing.T, enabledCollections uint32) []Metric {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
c := NewGoCollector().(*goCollector)
|
c := NewGoCollector(func(o *GoCollectorOptions) {
|
||||||
|
o.EnabledCollections = enabledCollections
|
||||||
|
}).(*goCollector)
|
||||||
|
|
||||||
// Collect all metrics.
|
// Collect all metrics.
|
||||||
ch := make(chan Metric)
|
ch := make(chan Metric)
|
||||||
|
@ -201,7 +221,8 @@ func collectGoMetrics(t *testing.T) []Metric {
|
||||||
|
|
||||||
func TestMemStatsEquivalence(t *testing.T) {
|
func TestMemStatsEquivalence(t *testing.T) {
|
||||||
var msReal, msFake runtime.MemStats
|
var msReal, msFake runtime.MemStats
|
||||||
descs := metrics.All()
|
descs := bestEffortLookupRM(rmForMemStats)
|
||||||
|
|
||||||
samples := make([]metrics.Sample, len(descs))
|
samples := make([]metrics.Sample, len(descs))
|
||||||
samplesMap := make(map[string]*metrics.Sample)
|
samplesMap := make(map[string]*metrics.Sample)
|
||||||
for i := range descs {
|
for i := range descs {
|
||||||
|
@ -214,9 +235,9 @@ func TestMemStatsEquivalence(t *testing.T) {
|
||||||
|
|
||||||
// Populate msReal.
|
// Populate msReal.
|
||||||
runtime.ReadMemStats(&msReal)
|
runtime.ReadMemStats(&msReal)
|
||||||
|
// Populate msFake and hope that no GC happened in between (:
|
||||||
// Populate msFake.
|
|
||||||
metrics.Read(samples)
|
metrics.Read(samples)
|
||||||
|
|
||||||
memStatsFromRM(&msFake, samplesMap)
|
memStatsFromRM(&msFake, samplesMap)
|
||||||
|
|
||||||
// Iterate over them and make sure they're somewhat close.
|
// Iterate over them and make sure they're somewhat close.
|
||||||
|
@ -227,9 +248,16 @@ func TestMemStatsEquivalence(t *testing.T) {
|
||||||
for i := 0; i < msRealValue.NumField(); i++ {
|
for i := 0; i < msRealValue.NumField(); i++ {
|
||||||
fr := msRealValue.Field(i)
|
fr := msRealValue.Field(i)
|
||||||
ff := msFakeValue.Field(i)
|
ff := msFakeValue.Field(i)
|
||||||
switch typ.Kind() {
|
|
||||||
|
if typ.Field(i).Name == "PauseTotalNs" || typ.Field(i).Name == "LastGC" {
|
||||||
|
// We don't use those fields for metrics,
|
||||||
|
// thus we are not interested in having this filled.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch fr.Kind() {
|
||||||
|
// Fields which we are interested in are all uint64s.
|
||||||
|
// The only float64 field GCCPUFraction is by design omitted.
|
||||||
case reflect.Uint64:
|
case reflect.Uint64:
|
||||||
// N.B. Almost all fields of MemStats are uint64s.
|
|
||||||
vr := fr.Interface().(uint64)
|
vr := fr.Interface().(uint64)
|
||||||
vf := ff.Interface().(uint64)
|
vf := ff.Interface().(uint64)
|
||||||
if float64(vr-vf)/float64(vf) > 0.05 {
|
if float64(vr-vf)/float64(vf) > 0.05 {
|
||||||
|
@ -240,7 +268,7 @@ func TestMemStatsEquivalence(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpectedRuntimeMetrics(t *testing.T) {
|
func TestExpectedRuntimeMetrics(t *testing.T) {
|
||||||
goMetrics := collectGoMetrics(t)
|
goMetrics := collectGoMetrics(t, goRuntimeMetricsCollection)
|
||||||
goMetricSet := make(map[string]Metric)
|
goMetricSet := make(map[string]Metric)
|
||||||
for _, m := range goMetrics {
|
for _, m := range goMetrics {
|
||||||
goMetricSet[m.Desc().fqName] = m
|
goMetricSet[m.Desc().fqName] = m
|
||||||
|
@ -253,6 +281,7 @@ func TestExpectedRuntimeMetrics(t *testing.T) {
|
||||||
rmName := descs[i].Name
|
rmName := descs[i].Name
|
||||||
rmSet[rmName] = struct{}{}
|
rmSet[rmName] = struct{}{}
|
||||||
|
|
||||||
|
// expectedRuntimeMetrics depends on Go version.
|
||||||
expFQName, ok := expectedRuntimeMetrics[rmName]
|
expFQName, ok := expectedRuntimeMetrics[rmName]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Errorf("found new runtime/metrics metric %s", rmName)
|
t.Errorf("found new runtime/metrics metric %s", rmName)
|
||||||
|
@ -268,6 +297,7 @@ func TestExpectedRuntimeMetrics(t *testing.T) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now iterate over the expected metrics and look for removals.
|
// Now iterate over the expected metrics and look for removals.
|
||||||
cardinality := 0
|
cardinality := 0
|
||||||
for rmName, fqName := range expectedRuntimeMetrics {
|
for rmName, fqName := range expectedRuntimeMetrics {
|
Loading…
Reference in New Issue