You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何测试自定义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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.26 05:50:25