Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions prometheus/counter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
68 changes: 67 additions & 1 deletion prometheus/desc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly this was always the plan in 2.0 but adding it without other changes is a bit of a hack.

It's also has a bit of performance penalty, but probably manageable if there is a good motivation

// constLabelPairs contains precalculated DTO label pairs based on
// the constant labels.
constLabelPairs []*dto.LabelPair
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because withType is unexported and NewDesc defaults to UNTYPED, any collector built with NewDesc and NewConstMetric (such as GoCollector, ProcessCollector, or custom exporters) will always report UNTYPED here. This means Desc.Info().Type cannot catch type changes for const metrics, which was one of the motivating examples from issue #2004.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One way to solve this is to add withType to NewConstMetric based on value type. It would break immutability though, but would work for majority of users 🤔

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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs more thoughts. We never exposed those info publicly. Apparently the reason was the need for changes in this structure. I wonder if this is still true 🤔

Some fact digged by AI:

Key Reasons Why Desc Fields Were Kept Private

1. Desc was viewed as an internal implementation detail, not a public user-facing abstraction

In PR #1309 (when a user asked to expose variable label names on Desc), @beorn7 summarized the history and design philosophy directly:

"The longer history here is that I was never really happy with Desc. It felt like an implementation detail that the user should never see. Therefore, I've tried to keep it as much out of the way of the users as possible, and thought a lot about how to change the use cases where you still have to touch it in the future v2 version of this library.

Adding exported methods to Desc would go into the exact opposite direction.

Another fundamental concern is that this library already has a lot of knobs for the users to turn. Adding more knobs requires strong justification, like simplifying a fairly common use case.

In this case, it feels we would help a relatively small number of users to simplify a relatively niche use case that could also be solved with a relatively simple work-around. All of that at the cost of increasing the cognitive load for the many many users of this library that do not need it."

2. Avoiding user dependencies on internal details that were slated for fundamental rework

Whenever users asked to export Desc fields or add getters, the maintainers held off because Desc was slated for a major redesign in Issue #222 ("Rework Desc and ConstMetrics") intended for a future v2:

  • In Issue #322 ("Expose Desc fields"), when @AlekSi requested read access to fields like fqName:

    "The way Desc work will change fundamentally with Rework Desc and ConstMetrics #222. Tests in code using client_golang (in contrast to tests that test client_golang itself) must not depend on implementation details of client_golang. Simply making internal fields exported in Desc would only allow users to depend on all those internal implementation details (that are about to change anyway).

    Right now, exposing Desc fields would be a step in the wrong direction."

  • In PR #326 ("Add getter methods for Desc() struct"), when getters were proposed:

    "I'm reworking this for Rework Desc and ConstMetrics #222. I don't want to expose an internal structure in the main repo that is doomed to get changed in a couple of months."

  • In Issue #516 ("feature: get descriptor attributes functions"), when GetName(), GetHelp(), and GetLabels() were proposed:

    "Descs are up for a major rehaul in the upcoming v0.10, see Rework Desc and ConstMetrics #222. I would prefer to not try to make the current Descs more sophisticated at this point, just to break everybody anyway in a few months. Depending on the exact new way Descs will be handled, accessors as you suggest might be something we could add, or they might become not needed (if, for example, a Desc is created by an Opts struct whose fields are exported anyway)."

3. Preserving strict immutability and thread safety

By contract, Desc represents the immutable metadata of a metric shared across collectors and goroutines during scrapes.

  • Exporting fields directly would allow callers to mutate them (e.g., altering variableLabels slices or label values), violating the concurrency guarantees required during Collect() and Gather() operations.
  • In Issue #269, @beorn7 noted that modifying existing descriptors would "violate the contract about Descs being immutable and Write being concurrency-safe."

4. Enforcing registry invariants and preventing anti-patterns

Desc was introduced in 2014 (commit 5d40912f) to calculate precomputed hashes (id and dimHash) so the Registry could detect collisions and validate consistency (e.g. matching label dimensions and help text for identical metric names) at registration time rather than scrape time.

// 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.
Expand Down
110 changes: 110 additions & 0 deletions prometheus/desc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
package prometheus

import (
"errors"
"reflect"
"testing"

dto "github.com/prometheus/client_model/go"
)

func TestNewDescInvalidConstLabelValues(t *testing.T) {
Expand Down Expand Up @@ -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)
}
}
6 changes: 3 additions & 3 deletions prometheus/gauge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
4 changes: 2 additions & 2 deletions prometheus/histogram.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions prometheus/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider deferring close(ch) so the channel is guaranteed to close even if r.Describe panics:

Suggested change
go func() {
go func() {
defer close(ch)
r.Describe(ch)
}()

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
Expand Down
4 changes: 2 additions & 2 deletions prometheus/summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions prometheus/testutil/testutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import (
"fmt"
"io"
"net/http"
"slices"

"github.com/kylelemons/godebug/diff"
dto "github.com/prometheus/client_model/go"
Expand Down Expand Up @@ -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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider deferring close(ch) so ch is closed even if c.Describe panics:

Suggested change
go func() {
go func() {
defer close(ch)
c.Describe(ch)
}()

c.Describe(ch)
close(ch)
}()

var infos []prometheus.DescInfo
for desc := range ch {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a collector emits a nil *Desc, calling desc.Info() will panic and leave the sending goroutine blocked on unbuffered ch. Maybe check if desc == nil before inspecting it?

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
Expand Down
Loading
Loading