From 0961d54e5af4a79b9c4e500d70704b05c57da689 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Tue, 25 Aug 2026 16:39:28 +0100 Subject: [PATCH 1/6] prometheus: expose descriptor metadata via Desc.Info A Desc keeps its metadata unexported and offers only Err and String, so code that wants to know what a Collector declares has to parse the output of String. That format is not an API and has changed between releases. Add DescInfo and Desc.Info returning a structured, read-only view of the name, help, unit, variable label names and const labels. Also record the metric type on the Desc. The type is otherwise only observable through Gather, which skips any metric that has not produced a sample, so the type of a vector without children cannot be checked at all today. The typed constructors set it; a Desc built with NewDesc reports UNTYPED, which is accurate because a const metric carries its type per sample rather than on the descriptor. The metric type deliberately stays out of the id and dimHash calculations so that registration consistency is unchanged. Signed-off-by: Nicolas Takashi --- prometheus/counter.go | 6 +-- prometheus/desc.go | 68 ++++++++++++++++++++++++- prometheus/desc_test.go | 110 ++++++++++++++++++++++++++++++++++++++++ prometheus/gauge.go | 6 +-- prometheus/histogram.go | 4 +- prometheus/summary.go | 4 +- prometheus/wrap.go | 3 +- 7 files changed, 189 insertions(+), 12 deletions(-) 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/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/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 { From d8129a876dd5e90a97068083124bf5df7e9b7bc3 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Tue, 25 Aug 2026 16:39:28 +0100 Subject: [PATCH 2/6] prometheus,testutil: add helpers to list declared descriptors Registry already implements Describe, but draining a channel is awkward in a test. Add Registry.DescribeAll for a slice of the descriptors of every registered checked Collector, and testutil.CollectAndDescribe for the same over a single Collector. There is no GatherAndDescribe counterpart because the Gatherer interface does not expose descriptors. Signed-off-by: Nicolas Takashi --- CHANGELOG.md | 3 +++ prometheus/registry.go | 20 +++++++++++++++++ prometheus/testutil/testutil.go | 32 ++++++++++++++++++++++++++++ prometheus/testutil/testutil_test.go | 31 +++++++++++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a47c66e..4fd55d2a1 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. #TBD +* [FEATURE] prometheus: Add Registry.DescribeAll to collect the Desc of every registered Collector into a slice. #TBD +* [FEATURE] testutil: Add CollectAndDescribe to inspect the metrics a Collector declares, including those that have not produced a sample. #TBD * [FEATURE] testutil: Add GatherAndFormat to encode a subset of metrics from a Gatherer. #2091 ## 1.24.1 / 2026-07-23 diff --git a/prometheus/registry.go b/prometheus/registry.go index ed0681c8b..aa1965755 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() { + r.Describe(ch) + close(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/testutil/testutil.go b/prometheus/testutil/testutil.go index ff62fe066..ac688fa37 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,37 @@ 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() { + c.Describe(ch) + close(ch) + }() + + var infos []prometheus.DescInfo + for desc := range ch { + 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..c140e41f4 100644 --- a/prometheus/testutil/testutil_test.go +++ b/prometheus/testutil/testutil_test.go @@ -17,9 +17,11 @@ import ( "fmt" "net/http" "net/http/httptest" + "reflect" "strings" "testing" + dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" "github.com/prometheus/client_golang/prometheus" @@ -501,3 +503,32 @@ 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) + } +} From d3ef0733165924ececf92b2617e268d7adaca2c9 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Tue, 25 Aug 2026 16:40:07 +0100 Subject: [PATCH 3/6] CHANGELOG: point the new entries at the PR Signed-off-by: Nicolas Takashi --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd55d2a1..61705437f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ ## Unreleased -* [FEATURE] prometheus: Add Desc.Info to inspect the meta-data a Desc declares without parsing Desc.String. #TBD -* [FEATURE] prometheus: Add Registry.DescribeAll to collect the Desc of every registered Collector into a slice. #TBD -* [FEATURE] testutil: Add CollectAndDescribe to inspect the metrics a Collector declares, including those that have not produced a sample. #TBD +* [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 From cfb14778d8ab58acbecf410469ddedcfb0a57800 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Fri, 11 Sep 2026 20:43:56 +0100 Subject: [PATCH 4/6] prometheus: close the DescribeAll channel on a Describe panic DescribeAll closed the channel after r.Describe returned. A Collector that panics in Describe therefore left the channel open and the ranging receiver blocked forever. Defer the close so it runs on the panic path too. Signed-off-by: Nicolas Takashi --- prometheus/registry.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prometheus/registry.go b/prometheus/registry.go index aa1965755..54f19d7ce 100644 --- a/prometheus/registry.go +++ b/prometheus/registry.go @@ -603,8 +603,8 @@ func (r *Registry) Describe(ch chan<- *Desc) { func (r *Registry) DescribeAll() []*Desc { ch := make(chan *Desc) go func() { + defer close(ch) r.Describe(ch) - close(ch) }() var descs []*Desc From 10a5f817bfdc008bc57a680e4c377b517f6c56a6 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Fri, 11 Sep 2026 20:44:03 +0100 Subject: [PATCH 5/6] testutil: close the CollectAndDescribe channel on a Describe panic Same fix as DescribeAll: the close ran only after c.Describe returned, so a Collector that panics in Describe left the receiver blocked on an open channel. Signed-off-by: Nicolas Takashi --- prometheus/testutil/testutil.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prometheus/testutil/testutil.go b/prometheus/testutil/testutil.go index ac688fa37..0abc11ae0 100644 --- a/prometheus/testutil/testutil.go +++ b/prometheus/testutil/testutil.go @@ -136,8 +136,8 @@ func ToFloat64(c prometheus.Collector) float64 { func CollectAndDescribe(c prometheus.Collector, metricNames ...string) []prometheus.DescInfo { ch := make(chan *prometheus.Desc) go func() { + defer close(ch) c.Describe(ch) - close(ch) }() var infos []prometheus.DescInfo From d89c8484c1961ce647cf5df952e4ceef87f9616e Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Fri, 11 Sep 2026 20:44:36 +0100 Subject: [PATCH 6/6] testutil: skip nil Descs in CollectAndDescribe A Collector is free to send a nil *Desc. Calling Info() on it panicked, which also left the describing goroutine blocked on the unbuffered channel. Skip nil entries instead. Signed-off-by: Nicolas Takashi --- prometheus/testutil/testutil.go | 3 +++ prometheus/testutil/testutil_test.go | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/prometheus/testutil/testutil.go b/prometheus/testutil/testutil.go index 0abc11ae0..2c76f7efe 100644 --- a/prometheus/testutil/testutil.go +++ b/prometheus/testutil/testutil.go @@ -142,6 +142,9 @@ func CollectAndDescribe(c prometheus.Collector, metricNames ...string) []prometh 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 diff --git a/prometheus/testutil/testutil_test.go b/prometheus/testutil/testutil_test.go index c140e41f4..24dcd65ff 100644 --- a/prometheus/testutil/testutil_test.go +++ b/prometheus/testutil/testutil_test.go @@ -532,3 +532,23 @@ func TestCollectAndDescribe(t *testing.T) { 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 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) + } +}