如何测试自定义Prometheus Exporter中的动态指标注册?
问题
我正在为changedetection.io开发自定义Prometheus Exporter,用于暴露所有已注册监控项的抓取和价格指标。完成可行原型后,我正在完善项目以开源,期间编写测试时遇到了动态注册新监控项的测试问题。
为实现Exporter无需重启即可识别新增监控项,我在每次采集时通过API检查新增项,相关priceCollector的Collect函数及newPriceMetric函数代码如下:
func (c *priceCollector) Collect(ch chan<- prometheus.Metric) { // check for new watches before collecting metrics watches, err := c.ApiClient.getWatches() if err != nil { log.Errorf("error while fetching watches: %v", err) } else { for id, watch := range watches { if _, ok := c.priceMetrics[id]; !ok { // create new metric and register it on the DefaultRegisterer c.priceMetrics[id] = newPriceMetric(prometheus.Labels{"title": watch.Title}, c.ApiClient, id) prometheus.MustRegister(c.priceMetrics[id]) log.Infof("Picked up new watch %s, registered as metric %s", watch.Title, id) } } } // collect all registered metrics for _, metric := range c.priceMetrics { metric.Collect(ch) } }
func newPriceMetric(labels prometheus.Labels, apiClient *ApiClient, uuid string) priceMetric { return priceMetric{ desc: prometheus.NewDesc( prometheus.BuildFQName(namespace, "watch", "price"), "Current price of an offer type watch", nil, labels, ), apiClient: apiClient, UUID: uuid, } }
默认行为测试正常,但测试新增监控项的动态注册时失败,测试代码如下:
func TestAutoregisterPriceCollector(t *testing.T) { watchDb := createCollectorTestDb() server := testutil.CreateTestApiServer(t, watchDb) defer server.Close() c, err := NewPriceCollector(server.URL(), "foo-bar-key") if err != nil { t.Fatal(err) } expectMetricCount(t, c, 2, "changedetectionio_watch_price") // now add a new watch and expect the collector to pick it up uuid, newItem := testutil.NewTestItem("Item 3", 300, "USD") watchDb[uuid] = newItem expectMetrics(t, c, "price_metrics_autoregister.prom", "changedetectionio_watch_price") expectMetricCount(t, c, 3, "changedetectionio_watch_price") }
错误信息为:
collector_test.go:23: Unexpected metrics returned: gathering metrics failed: collected metric changedetectionio_watch_price label:{name:"title" value:"Item 3"} gauge:{value:300} with unregistered descriptor Desc{fqName: "changedetectionio_watch_price", help: "Current price of an offer type watch", constLabels: {title="Item 3"}, variableLabels: {}}
我猜测该错误与testutil.CollectAnd*的内部机制有关,根据函数注释,它们会将采集器注册到新创建的严格Registry,可能导致无法识别延迟注册的描述符。请问该如何解决这个问题?
解决方法
1. 改用动态生成Metric的规范实现(推荐)
Prometheus的严格Registry禁止在采集过程中注册新描述符,这是测试失败的核心原因。当前把每个监控项做成独立Collector并注册到全局Registry的方式,既不符合官方规范,也容易引发测试冲突。
正确的做法是只保留一个主Collector,在Collect方法中直接为每个监控项生成Metric并发送到通道:
首先调整priceCollector结构和初始化逻辑:
type priceCollector struct { ApiClient *ApiClient priceDesc *prometheus.Desc } func NewPriceCollector(apiURL, apiKey string) (*priceCollector, error) { client, err := NewApiClient(apiURL, apiKey) if err != nil { return nil, err } return &priceCollector{ ApiClient: client, priceDesc: prometheus.NewDesc( prometheus.BuildFQName(namespace, "watch", "price"), "Current price of an offer type watch", []string{"title", "uuid"}, // 用可变标签区分不同监控项 nil, ), }, nil }
然后修改Collect和Describe方法:
func (c *priceCollector) Collect(ch chan<- prometheus.Metric) { watches, err := c.ApiClient.getWatches() if err != nil { log.Errorf("error while fetching watches: %v", err) return } for uuid, watch := range watches { // 直接生成Metric并发送,无需注册子Collector ch <- prometheus.MustNewConstMetric( c.priceDesc, prometheus.GaugeValue, float64(watch.Price), watch.Title, uuid, ) } } func (c *priceCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.priceDesc }
这种方式只注册一个主Collector,所有监控项共享同一个描述符(通过可变标签区分),完全符合Prometheus规范,测试时不会出现描述符未注册的问题,同时生产环境也更稳定。
2. 手动管理测试用Registry(兼容原架构)
如果必须保留子Collector的架构,可以绕开testutil的默认逻辑,手动创建并管理Registry:
func TestAutoregisterPriceCollector(t *testing.T) { watchDb := createCollectorTestDb() server := testutil.CreateTestApiServer(t, watchDb) defer server.Close() c, err := NewPriceCollector(server.URL(), "foo-bar-key") if err != nil { t.Fatal(err) } // 创建自定义Registry,替代testutil的严格Registry reg := prometheus.NewRegistry() reg.MustRegister(c) // 第一次采集验证初始数量 metrics, err := reg.Gather() if err != nil { t.Fatal(err) } count := countMetrics(metrics, "changedetectionio_watch_price") if count != 2 { t.Errorf("expected 2 metrics, got %d", count) } // 添加新监控项 uuid, newItem := testutil.NewTestItem("Item 3", 300, "USD") watchDb[uuid] = newItem // 第二次采集验证新增项 metrics, err = reg.Gather() if err != nil { t.Fatal(err) } count = countMetrics(metrics, "changedetectionio_watch_price") if count != 3 { t.Errorf("expected 3 metrics, got %d", count) } // 可选:对比预期输出 expected, err := os.ReadFile("price_metrics_autoregister.prom") if err != nil { t.Fatal(err) } actual, err := prometheusToText(metrics) if err != nil { t.Fatal(err) } if !strings.EqualFold(string(expected), actual) { t.Errorf("metrics mismatch:\nexpected:\n%s\nactual:\n%s", expected, actual) } } // 辅助函数:统计指定名称的Metric数量 func countMetrics(metrics []*dto.MetricFamily, name string) int { count := 0 for _, mf := range metrics { if mf.GetName() == name { count += len(mf.GetMetric()) } } return count } // 辅助函数:将MetricFamily转换为Prometheus文本格式 func prometheusToText(metrics []*dto.MetricFamily) (string, error) { var buf bytes.Buffer enc := text.NewEncoder(&buf) if err := enc.Encode(metrics); err != nil { return "", err } return buf.String(), nil }
这种方式手动控制Registry的生命周期,确保动态注册的子Collector能被正确识别,避免testutil严格Registry的限制。
3. 关键注意事项
- 官方不推荐在
Collect方法中注册新Collector或描述符,这会导致Registry状态不稳定,生产环境可能引发竞态条件。 - 使用可变标签区分监控项是Prometheus最佳实践,能避免生成大量重复的描述符。
- 测试时尽量使用自定义Registry隔离环境,避免依赖全局Registry。
内容的提问来源于stack exchange,提问作者schaermu

