diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a47c66e..61705437f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +* [FEATURE] prometheus: Add Desc.Info to inspect the meta-data a Desc declares without parsing Desc.String. #2094 +* [FEATURE] prometheus: Add Registry.DescribeAll to collect the Desc of every registered Collector into a slice. #2094 +* [FEATURE] testutil: Add CollectAndDescribe to inspect the metrics a Collector declares, including those that have not produced a sample. #2094 * [FEATURE] testutil: Add GatherAndFormat to encode a subset of metrics from a Gatherer. #2091 ## 1.24.1 / 2026-07-23 diff --git a/prometheus/counter.go b/prometheus/counter.go index 7d963d3af..58cebadab 100644 --- a/prometheus/counter.go +++ b/prometheus/counter.go @@ -90,7 +90,7 @@ func NewCounter(opts CounterOpts) Counter { opts.Help, UnconstrainedLabels(nil), opts.ConstLabels, - WithUnit(opts.Unit), + WithUnit(opts.Unit), withType(dto.MetricType_COUNTER), ) if opts.now == nil { opts.now = time.Now @@ -206,7 +206,7 @@ func (v2) NewCounterVec(opts CounterVecOpts) *CounterVec { opts.Help, opts.VariableLabels, opts.ConstLabels, - WithUnit(opts.Unit), + WithUnit(opts.Unit), withType(dto.MetricType_COUNTER), ) if opts.now == nil { opts.now = time.Now @@ -356,6 +356,6 @@ func NewCounterFunc(opts CounterOpts, function func() float64) CounterFunc { opts.Help, UnconstrainedLabels(nil), opts.ConstLabels, - WithUnit(opts.Unit), + WithUnit(opts.Unit), withType(dto.MetricType_COUNTER), ), CounterValue, function) } diff --git a/prometheus/desc.go b/prometheus/desc.go index a3c92e7a4..68cee1db9 100644 --- a/prometheus/desc.go +++ b/prometheus/desc.go @@ -49,6 +49,11 @@ type Desc struct { help string // unit provides the unit of this metric. unit string + // metricType is the type of the metric this Desc belongs to. It is + // MetricType_UNTYPED unless a typed constructor of this package set it, + // as a Desc created via NewDesc does not carry a type: the type of a + // const metric is provided per sample, e.g. via MustNewConstMetric. + metricType dto.MetricType // constLabelPairs contains precalculated DTO label pairs based on // the constant labels. constLabelPairs []*dto.LabelPair @@ -78,6 +83,15 @@ func WithUnit(unit string) DescOpt { } } +// withType sets the metric type for a Desc. It is unexported on purpose: only +// the typed constructors of this package know the type at Desc construction +// time. +func withType(t dto.MetricType) DescOpt { + return func(d *Desc) { + d.metricType = t + } +} + // NewDesc allocates and initializes a new Desc. Errors are recorded in the Desc // and will be reported on registration time. variableLabels and constLabels can // be nil if no such labels should be set. fqName must not be empty. @@ -106,6 +120,7 @@ func (v2) NewDesc(fqName, help string, variableLabels ConstrainableLabels, const fqName: fqName, help: help, variableLabels: variableLabels.compile(), + metricType: dto.MetricType_UNTYPED, } for _, opt := range opts { @@ -195,8 +210,59 @@ func (v2) NewDesc(fqName, help string, variableLabels ConstrainableLabels, const // a Collector to signal inability to describe itself. func NewInvalidDesc(err error) *Desc { return &Desc{ - err: err, + err: err, + metricType: dto.MetricType_UNTYPED, + } +} + +// DescInfo is a read-only view of the meta-data a Desc declares. It is meant +// for introspection, e.g. to check that a Collector describes the metrics a +// schema says it should. Info allocates on every call and is not meant for +// hot paths. +type DescInfo struct { + // FQName is the fully-qualified name of the metric. + FQName string + // Help is the help string of the metric. + Help string + // Unit is the unit of the metric, empty if none was set. + Unit string + // Type is the type of the metric. It is MetricType_UNTYPED for a Desc + // created via NewDesc, which does not carry a type. + Type dto.MetricType + // VariableLabels are the names of the labels whose values vary per + // metric, in the order they were provided at construction. It is nil if + // the metric has no variable labels. + VariableLabels []string + // ConstLabels are the labels fixed at construction time. It is nil if + // the metric has no const labels. + ConstLabels Labels + // Err is the error that occurred during construction, if any. The + // remaining fields may be zero if Err is non-nil. + Err error +} + +// Info returns a read-only view of the meta-data of the Desc. In contrast to +// String, the returned value is structured and therefore suitable for +// programmatic inspection. +func (d *Desc) Info() DescInfo { + info := DescInfo{ + FQName: d.fqName, + Help: d.help, + Unit: d.unit, + Type: d.metricType, + Err: d.err, + } + if d.variableLabels != nil && len(d.variableLabels.names) > 0 { + info.VariableLabels = make([]string, len(d.variableLabels.names)) + copy(info.VariableLabels, d.variableLabels.names) + } + if len(d.constLabelPairs) > 0 { + info.ConstLabels = make(Labels, len(d.constLabelPairs)) + for _, lp := range d.constLabelPairs { + info.ConstLabels[lp.GetName()] = lp.GetValue() + } } + return info } // Err returns an error that occurred during construction, if any. diff --git a/prometheus/desc_test.go b/prometheus/desc_test.go index d8affc5aa..a4ea9e2e3 100644 --- a/prometheus/desc_test.go +++ b/prometheus/desc_test.go @@ -14,7 +14,11 @@ package prometheus import ( + "errors" + "reflect" "testing" + + dto "github.com/prometheus/client_model/go" ) func TestNewDescInvalidConstLabelValues(t *testing.T) { @@ -88,3 +92,109 @@ func TestNewDescWithUnit_String(t *testing.T) { t.Errorf("String: unexpected output:\ngot: %s\nwant: %s", desc.String(), desc.String()) } } + +func TestDescInfo(t *testing.T) { + for _, tc := range []struct { + name string + desc *Desc + want DescInfo + }{ + { + name: "NewDesc carries no type", + desc: NewDesc("no_type", "help", []string{"var"}, Labels{"const": "value"}), + want: DescInfo{ + FQName: "no_type", + Help: "help", + Type: dto.MetricType_UNTYPED, + VariableLabels: []string{"var"}, + ConstLabels: Labels{"const": "value"}, + }, + }, + { + name: "counter", + desc: NewCounter(CounterOpts{Name: "counted_total", Help: "help", Unit: "s"}).Desc(), + want: DescInfo{ + FQName: "counted_total", + Help: "help", + Unit: "s", + Type: dto.MetricType_COUNTER, + }, + }, + { + name: "gauge vec", + desc: NewGaugeVec(GaugeOpts{Name: "gauged", Help: "help"}, []string{"a", "b"}). + WithLabelValues("1", "2").Desc(), + want: DescInfo{ + FQName: "gauged", + Help: "help", + Type: dto.MetricType_GAUGE, + VariableLabels: []string{"a", "b"}, + }, + }, + { + name: "histogram", + desc: NewHistogram(HistogramOpts{Name: "observed", Help: "help"}).Desc(), + want: DescInfo{FQName: "observed", Help: "help", Type: dto.MetricType_HISTOGRAM}, + }, + { + name: "summary", + desc: NewSummary(SummaryOpts{Name: "summarized", Help: "help"}).Desc(), + want: DescInfo{FQName: "summarized", Help: "help", Type: dto.MetricType_SUMMARY}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := tc.desc.Info() + if got.Err != nil { + t.Fatalf("unexpected error: %s", got.Err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestDescInfoOfInvalidDesc(t *testing.T) { + // NewInvalidDesc leaves variableLabels nil, so Info must not panic on it. + info := NewInvalidDesc(errors.New("boom")).Info() + if info.Err == nil { + t.Fatal("expected Info to report the construction error") + } + if info.Type != dto.MetricType_UNTYPED { + t.Errorf("got type %s, want UNTYPED", info.Type) + } +} + +func TestDescInfoDoesNotAliasDesc(t *testing.T) { + desc := NewDesc("aliased", "help", []string{"var"}, Labels{"const": "value"}) + + info := desc.Info() + info.VariableLabels[0] = "mutated" + info.ConstLabels["const"] = "mutated" + + fresh := desc.Info() + if fresh.VariableLabels[0] != "var" { + t.Errorf("mutating the returned VariableLabels changed the Desc: %v", fresh.VariableLabels) + } + if fresh.ConstLabels["const"] != "value" { + t.Errorf("mutating the returned ConstLabels changed the Desc: %v", fresh.ConstLabels) + } +} + +func TestWrappedDescKeepsType(t *testing.T) { + reg := NewPedanticRegistry() + WrapRegistererWithPrefix("wrapped_", reg). + MustRegister(NewCounter(CounterOpts{Name: "counted_total", Help: "help"})) + + descs := reg.DescribeAll() + if len(descs) != 1 { + t.Fatalf("got %d descs, want 1", len(descs)) + } + info := descs[0].Info() + if info.FQName != "wrapped_counted_total" { + t.Errorf("got name %q, want %q", info.FQName, "wrapped_counted_total") + } + if info.Type != dto.MetricType_COUNTER { + t.Errorf("got type %s, want COUNTER", info.Type) + } +} diff --git a/prometheus/gauge.go b/prometheus/gauge.go index 41e54bf27..105c44ac3 100644 --- a/prometheus/gauge.go +++ b/prometheus/gauge.go @@ -81,7 +81,7 @@ func NewGauge(opts GaugeOpts) Gauge { opts.Help, UnconstrainedLabels(nil), opts.ConstLabels, - WithUnit(opts.Unit), + WithUnit(opts.Unit), withType(dto.MetricType_GAUGE), ) result := &gauge{desc: desc, labelPairs: desc.constLabelPairs} result.init(result) // Init self-collection. @@ -164,7 +164,7 @@ func (v2) NewGaugeVec(opts GaugeVecOpts) *GaugeVec { opts.Help, opts.VariableLabels, opts.ConstLabels, - WithUnit(opts.Unit), + WithUnit(opts.Unit), withType(dto.MetricType_GAUGE), ) return &GaugeVec{ MetricVec: NewMetricVec(desc, func(lvs ...string) Metric { @@ -309,6 +309,6 @@ func NewGaugeFunc(opts GaugeOpts, function func() float64) GaugeFunc { opts.Help, UnconstrainedLabels(nil), opts.ConstLabels, - WithUnit(opts.Unit), + WithUnit(opts.Unit), withType(dto.MetricType_GAUGE), ), GaugeValue, function) } diff --git a/prometheus/histogram.go b/prometheus/histogram.go index 88bae3b32..60c0c548e 100644 --- a/prometheus/histogram.go +++ b/prometheus/histogram.go @@ -530,7 +530,7 @@ func NewHistogram(opts HistogramOpts) Histogram { opts.Help, UnconstrainedLabels(nil), opts.ConstLabels, - WithUnit(opts.Unit), + WithUnit(opts.Unit), withType(dto.MetricType_HISTOGRAM), ), opts, ) @@ -1194,7 +1194,7 @@ func (v2) NewHistogramVec(opts HistogramVecOpts) *HistogramVec { opts.Help, opts.VariableLabels, opts.ConstLabels, - WithUnit(opts.Unit), + WithUnit(opts.Unit), withType(dto.MetricType_HISTOGRAM), ) return &HistogramVec{ MetricVec: NewMetricVec(desc, func(lvs ...string) Metric { diff --git a/prometheus/registry.go b/prometheus/registry.go index ed0681c8b..54f19d7ce 100644 --- a/prometheus/registry.go +++ b/prometheus/registry.go @@ -595,6 +595,26 @@ func (r *Registry) Describe(ch chan<- *Desc) { } } +// DescribeAll returns the Desc of every checked Collector registered with r. +// It is a convenience wrapper around Describe for callers that want a slice +// rather than draining a channel, e.g. tests that assert on the metrics an +// application declares. Unchecked Collectors are not included, as they do not +// report any Desc. +func (r *Registry) DescribeAll() []*Desc { + ch := make(chan *Desc) + go func() { + defer close(ch) + r.Describe(ch) + }() + + var descs []*Desc + for d := range ch { + descs = append(descs, d) + } + + return descs +} + // Helper wrapper around Collector.Collect. // It tries to collect from the channel, recovers on panic and // if it has recovered from a panic, then it sends an InvalidMetric into diff --git a/prometheus/registry_test.go b/prometheus/registry_test.go index 379984fef..23359fc5d 100644 --- a/prometheus/registry_test.go +++ b/prometheus/registry_test.go @@ -27,9 +27,11 @@ import ( "net/http" "net/http/httptest" "os" + "runtime" "strconv" "strings" "sync" + "sync/atomic" "testing" "time" @@ -53,6 +55,24 @@ func (u uncheckedCollector) Collect(c chan<- prometheus.Metric) { u.c.Collect(c) } +// goexitCollector describes a single Desc on its first Describe call and +// abandons the calling goroutine with runtime.Goexit on every call after that. +// The first call lets it register; the later ones stand in for a Describe that +// ends its goroutine without returning, as a call to testing.T.Fatal does. +type goexitCollector struct { + desc *prometheus.Desc + described atomic.Bool +} + +func (c *goexitCollector) Describe(ch chan<- *prometheus.Desc) { + if c.described.Swap(true) { + runtime.Goexit() + } + ch <- c.desc +} + +func (c *goexitCollector) Collect(_ chan<- prometheus.Metric) {} + func testHandler(t testing.TB) { // TODO(beorn7): This test is a bit too "end-to-end". It tests quite a // few moving parts that are not strongly coupled. They could/should be @@ -1420,3 +1440,22 @@ func TestGatherDoesNotLeakGoroutines(t *testing.T) { } } } + +func TestDescribeAllReturnsIfDescribeAbandonsItsGoroutine(t *testing.T) { + reg := prometheus.NewPedanticRegistry() + reg.MustRegister(&goexitCollector{ + desc: prometheus.NewDesc("goexit_total", "help", nil, nil), + }) + + done := make(chan []*prometheus.Desc, 1) + go func() { done <- reg.DescribeAll() }() + + select { + case descs := <-done: + if len(descs) != 0 { + t.Errorf("got %d Descs, want 0", len(descs)) + } + case <-time.After(5 * time.Second): + t.Fatal("DescribeAll did not return: its Desc channel stays open when Describe ends its goroutine") + } +} diff --git a/prometheus/summary.go b/prometheus/summary.go index c12b8d13d..2d1b05c8a 100644 --- a/prometheus/summary.go +++ b/prometheus/summary.go @@ -189,7 +189,7 @@ func NewSummary(opts SummaryOpts) Summary { opts.Help, UnconstrainedLabels(nil), opts.ConstLabels, - WithUnit(opts.Unit), + WithUnit(opts.Unit), withType(dto.MetricType_SUMMARY), ), opts, ) @@ -582,7 +582,7 @@ func (v2) NewSummaryVec(opts SummaryVecOpts) *SummaryVec { opts.Help, opts.VariableLabels, opts.ConstLabels, - WithUnit(opts.Unit), + WithUnit(opts.Unit), withType(dto.MetricType_SUMMARY), ) return &SummaryVec{ MetricVec: NewMetricVec(desc, func(lvs ...string) Metric { diff --git a/prometheus/testutil/testutil.go b/prometheus/testutil/testutil.go index ff62fe066..2c76f7efe 100644 --- a/prometheus/testutil/testutil.go +++ b/prometheus/testutil/testutil.go @@ -45,6 +45,7 @@ import ( "fmt" "io" "net/http" + "slices" "github.com/kylelemons/godebug/diff" dto "github.com/prometheus/client_model/go" @@ -120,6 +121,40 @@ func ToFloat64(c prometheus.Collector) float64 { panic(fmt.Errorf("collected a non-gauge/counter/untyped metric: %s", pb)) } +// CollectAndDescribe returns the DescInfo of every Desc the provided Collector +// describes, restricted to the provided metricNames if any are given. +// +// In contrast to the GatherAnd… functions, it also reports metrics that have +// not produced a sample yet and would therefore be absent from an exposition. +// That makes it suitable for asserting that a Collector still declares the +// metrics it is expected to, e.g. to catch a renamed metric or a changed label +// set before it reaches users. +// +// There is no GatherAndDescribe counterpart because the prometheus.Gatherer +// interface does not expose descriptors. Use prometheus.Registry.DescribeAll +// to inspect a whole Registry. +func CollectAndDescribe(c prometheus.Collector, metricNames ...string) []prometheus.DescInfo { + ch := make(chan *prometheus.Desc) + go func() { + defer close(ch) + c.Describe(ch) + }() + + var infos []prometheus.DescInfo + for desc := range ch { + if desc == nil { + continue + } + info := desc.Info() + if len(metricNames) > 0 && !slices.Contains(metricNames, info.FQName) { + continue + } + infos = append(infos, info) + } + + return infos +} + // CollectAndCount registers the provided Collector with a newly created // pedantic Registry. It then calls GatherAndCount with that Registry and with // the provided metricNames. In the unlikely case that the registration or the diff --git a/prometheus/testutil/testutil_test.go b/prometheus/testutil/testutil_test.go index bf07d486e..b719aa7b1 100644 --- a/prometheus/testutil/testutil_test.go +++ b/prometheus/testutil/testutil_test.go @@ -17,9 +17,13 @@ import ( "fmt" "net/http" "net/http/httptest" + "reflect" + "runtime" "strings" "testing" + "time" + dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" "github.com/prometheus/client_golang/prometheus" @@ -501,3 +505,70 @@ foo_bar{fizz="bang"} 1 t.Errorf("filtered gather included unexpected metric: %q", gotS) } } + +func TestCollectAndDescribe(t *testing.T) { + c := prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "described_total", Help: "help", Unit: "s"}, + []string{"label"}, + ) + + // The Vec has no children, so it reports nothing to Gather but still + // describes its Desc. + if got := CollectAndCount(c); got != 0 { + t.Fatalf("got %d gathered metrics, want 0", got) + } + + got := CollectAndDescribe(c) + want := []prometheus.DescInfo{{ + FQName: "described_total", + Help: "help", + Unit: "s", + Type: dto.MetricType_COUNTER, + VariableLabels: []string{"label"}, + }} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %+v, want %+v", got, want) + } + + if got := CollectAndDescribe(c, "other_total"); got != nil { + t.Errorf("got %+v for a non-matching metricNames filter, want nil", got) + } +} + +// describerFunc is a Collector that only describes, using the provided func. +type describerFunc func(chan<- *prometheus.Desc) + +func (f describerFunc) Describe(ch chan<- *prometheus.Desc) { f(ch) } +func (describerFunc) Collect(chan<- prometheus.Metric) {} + +func TestCollectAndDescribeReturnsIfDescribeAbandonsItsGoroutine(t *testing.T) { + // runtime.Goexit stands in for a Describe that ends its goroutine without + // returning, as a call to testing.T.Fatal does. + c := describerFunc(func(chan<- *prometheus.Desc) { runtime.Goexit() }) + + done := make(chan []prometheus.DescInfo, 1) + go func() { done <- CollectAndDescribe(c) }() + + select { + case infos := <-done: + if infos != nil { + t.Errorf("got %+v, want nil", infos) + } + case <-time.After(5 * time.Second): + t.Fatal("CollectAndDescribe did not return: its Desc channel stays open when Describe ends its goroutine") + } +} + +func TestCollectAndDescribeSkipsNilDesc(t *testing.T) { + desc := prometheus.NewDesc("described_total", "help", nil, nil) + c := describerFunc(func(ch chan<- *prometheus.Desc) { + ch <- nil + ch <- desc + }) + + got := CollectAndDescribe(c) + want := []prometheus.DescInfo{desc.Info()} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %+v, want %+v", got, want) + } +} diff --git a/prometheus/wrap.go b/prometheus/wrap.go index 697f55558..5fe57adaa 100644 --- a/prometheus/wrap.go +++ b/prometheus/wrap.go @@ -233,13 +233,14 @@ func wrapDesc(desc *Desc, prefix string, labels Labels) *Desc { unit: desc.unit, variableLabels: desc.variableLabels, constLabelPairs: desc.constLabelPairs, + metricType: desc.metricType, err: fmt.Errorf("attempted wrapping with already existing label name %q", ln), } } constLabels[ln] = lv } // NewDesc will do remaining validations. - newDesc := V2.NewDesc(prefix+desc.fqName, desc.help, desc.variableLabels, constLabels, WithUnit(desc.unit)) + newDesc := V2.NewDesc(prefix+desc.fqName, desc.help, desc.variableLabels, constLabels, WithUnit(desc.unit), withType(desc.metricType)) // Propagate errors if there was any. This will override any error // created by NewDesc above, i.e. earlier errors get precedence. if desc.err != nil {