From d63bfb324c110188387c97c2fca9309f903fdb72 Mon Sep 17 00:00:00 2001 From: Ivan Ka <5395690+ivankatliarchuk@users.noreply.github.com> Date: Fri, 13 Jun 2025 10:40:58 +0100 Subject: [PATCH] feat(controller)!: publish metrics for all supported endpoint types (#5516) * feat(controller): add more metrics for all supported endpoint types * feat(controller): add cardinality and labels for records metrics Signed-off-by: ivan katliarchuk * feat(controller): add cardinality and labels for records metrics Signed-off-by: ivan katliarchuk * feat(controller): add cardinality and labels for records metrics Signed-off-by: ivan katliarchuk * feat(controller): add cardinality and labels for records metrics Signed-off-by: ivan katliarchuk * feat(controller): add cardinality and labels for records metrics Signed-off-by: ivan katliarchuk * feat(controller): add cardinality and labels for records metrics Signed-off-by: ivan katliarchuk * feat(controller): add cardinality and labels for records metrics Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com> * feat(controller): add cardinality and labels for records metrics Signed-off-by: ivan katliarchuk * fix rebase Signed-off-by: ivan katliarchuk --------- Signed-off-by: ivan katliarchuk Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com> --- controller/controller.go | 145 +++++----- controller/controller_test.go | 271 +----------------- controller/metrics.go | 54 ++++ controller/metrics_test.go | 376 +++++++++++++++++++++++++ docs/monitoring/metrics.md | 9 +- endpoint/endpoint.go | 13 + internal/gen/docs/metrics/main.go | 1 + internal/gen/docs/metrics/main_test.go | 2 +- internal/testutils/endpoint.go | 28 ++ internal/testutils/metrics.go | 51 ++++ pkg/metrics/metrics.go | 4 +- pkg/metrics/metrics_test.go | 3 +- pkg/metrics/models.go | 35 +++ pkg/metrics/models_test.go | 32 +++ 14 files changed, 670 insertions(+), 354 deletions(-) create mode 100644 controller/metrics.go create mode 100644 controller/metrics_test.go create mode 100644 internal/testutils/metrics.go diff --git a/controller/controller.go b/controller/controller.go index 5ee193fa2..06562da58 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -105,54 +105,37 @@ var ( Help: "Number of Source errors.", }, ) - registryARecords = metrics.NewGaugeWithOpts( + + registryRecords = metrics.NewGaugedVectorOpts( prometheus.GaugeOpts{ Namespace: "external_dns", Subsystem: "registry", - Name: "a_records", - Help: "Number of Registry A records.", + Name: "records", + Help: "Number of registry records partitioned by label name (vector).", }, + []string{"record_type"}, ) - registryAAAARecords = metrics.NewGaugeWithOpts( - prometheus.GaugeOpts{ - Namespace: "external_dns", - Subsystem: "registry", - Name: "aaaa_records", - Help: "Number of Registry AAAA records.", - }, - ) - sourceARecords = metrics.NewGaugeWithOpts( + + sourceRecords = metrics.NewGaugedVectorOpts( prometheus.GaugeOpts{ Namespace: "external_dns", Subsystem: "source", - Name: "a_records", - Help: "Number of Source A records.", + Name: "records", + Help: "Number of source records partitioned by label name (vector).", }, + []string{"record_type"}, ) - sourceAAAARecords = metrics.NewGaugeWithOpts( - prometheus.GaugeOpts{ - Namespace: "external_dns", - Subsystem: "source", - Name: "aaaa_records", - Help: "Number of Source AAAA records.", - }, - ) - verifiedARecords = metrics.NewGaugeWithOpts( + + verifiedRecords = metrics.NewGaugedVectorOpts( prometheus.GaugeOpts{ Namespace: "external_dns", Subsystem: "controller", - Name: "verified_a_records", - Help: "Number of DNS A-records that exists both in source and registry.", - }, - ) - verifiedAAAARecords = metrics.NewGaugeWithOpts( - prometheus.GaugeOpts{ - Namespace: "external_dns", - Subsystem: "controller", - Name: "verified_aaaa_records", - Help: "Number of DNS AAAA-records that exists both in source and registry.", + Name: "verified_records", + Help: "Number of DNS records that exists both in source and registry (vector).", }, + []string{"record_type"}, ) + consecutiveSoftErrors = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Namespace: "external_dns", @@ -173,25 +156,24 @@ func init() { metrics.RegisterMetric.MustRegister(deprecatedRegistryErrors) metrics.RegisterMetric.MustRegister(deprecatedSourceErrors) metrics.RegisterMetric.MustRegister(controllerNoChangesTotal) - metrics.RegisterMetric.MustRegister(registryARecords) - metrics.RegisterMetric.MustRegister(registryAAAARecords) - metrics.RegisterMetric.MustRegister(sourceARecords) - metrics.RegisterMetric.MustRegister(sourceAAAARecords) - metrics.RegisterMetric.MustRegister(verifiedARecords) - metrics.RegisterMetric.MustRegister(verifiedAAAARecords) + + metrics.RegisterMetric.MustRegister(registryRecords) + metrics.RegisterMetric.MustRegister(sourceRecords) + metrics.RegisterMetric.MustRegister(verifiedRecords) + metrics.RegisterMetric.MustRegister(consecutiveSoftErrors) } // Controller is responsible for orchestrating the different components. // It works in the following way: -// * Ask the DNS provider for current list of endpoints. +// * Ask the DNS provider for the current list of endpoints. // * Ask the Source for the desired list of endpoints. -// * Take both lists and calculate a Plan to move current towards desired state. +// * Take both lists and calculate a Plan to move current towards the desired state. // * Tell the DNS provider to apply the changes calculated by the Plan. type Controller struct { Source source.Source Registry registry.Registry - // The policy that defines which changes to DNS records are allowed + // The policy that defines which change to DNS records is allowed Policy plan.Policy // The interval between individual synchronizations Interval time.Duration @@ -207,7 +189,7 @@ type Controller struct { ManagedRecordTypes []string // ExcludeRecordTypes are DNS record types that will be excluded from management. ExcludeRecordTypes []string - // MinEventSyncInterval is used as window for batching events + // MinEventSyncInterval is used as a window for batching events MinEventSyncInterval time.Duration } @@ -219,33 +201,37 @@ func (c *Controller) RunOnce(ctx context.Context) error { c.lastRunAt = time.Now() c.runAtMutex.Unlock() - records, err := c.Registry.Records(ctx) + regMetrics := newMetricsRecorder() + + regRecords, err := c.Registry.Records(ctx) if err != nil { registryErrorsTotal.Counter.Inc() deprecatedRegistryErrors.Counter.Inc() return err } - registryEndpointsTotal.Gauge.Set(float64(len(records))) - regARecords, regAAAARecords := countAddressRecords(records) - registryARecords.Gauge.Set(float64(regARecords)) - registryAAAARecords.Gauge.Set(float64(regAAAARecords)) - ctx = context.WithValue(ctx, provider.RecordsContextKey, records) + registryEndpointsTotal.Gauge.Set(float64(len(regRecords))) - endpoints, err := c.Source.Endpoints(ctx) + countAddressRecords(regMetrics, regRecords, registryRecords) + + ctx = context.WithValue(ctx, provider.RecordsContextKey, regRecords) + + sourceEndpoints, err := c.Source.Endpoints(ctx) if err != nil { sourceErrorsTotal.Counter.Inc() deprecatedSourceErrors.Counter.Inc() return err } - sourceEndpointsTotal.Gauge.Set(float64(len(endpoints))) - srcARecords, srcAAAARecords := countAddressRecords(endpoints) - sourceARecords.Gauge.Set(float64(srcARecords)) - sourceAAAARecords.Gauge.Set(float64(srcAAAARecords)) - vARecords, vAAAARecords := countMatchingAddressRecords(endpoints, records) - verifiedARecords.Gauge.Set(float64(vARecords)) - verifiedAAAARecords.Gauge.Set(float64(vAAAARecords)) - endpoints, err = c.Registry.AdjustEndpoints(endpoints) + + sourceEndpointsTotal.Gauge.Set(float64(len(sourceEndpoints))) + + sourceMetrics := newMetricsRecorder() + countAddressRecords(sourceMetrics, sourceEndpoints, sourceRecords) + + vaMetrics := newMetricsRecorder() + countMatchingAddressRecords(vaMetrics, sourceEndpoints, regRecords, verifiedRecords) + + endpoints, err := c.Registry.AdjustEndpoints(sourceEndpoints) if err != nil { return fmt.Errorf("adjusting endpoints: %w", err) } @@ -253,7 +239,7 @@ func (c *Controller) RunOnce(ctx context.Context) error { plan := &plan.Plan{ Policies: []plan.Policy{c.Policy}, - Current: records, + Current: regRecords, Desired: endpoints, DomainFilter: endpoint.MatchAllDomainFilters{c.DomainFilter, registryFilter}, ManagedRecords: c.ManagedRecordTypes, @@ -298,8 +284,8 @@ func latest(r time.Time, times ...time.Time) time.Time { return r } -// Counts the intersections of A and AAAA records in endpoint and registry. -func countMatchingAddressRecords(endpoints []*endpoint.Endpoint, registryRecords []*endpoint.Endpoint) (int, int) { +// Counts the intersections of records in endpoint and registry. +func countMatchingAddressRecords(rec *metricsRecorder, endpoints []*endpoint.Endpoint, registryRecords []*endpoint.Endpoint, metric metrics.GaugeVecMetric) { recordsMap := make(map[string]map[string]struct{}) for _, regRecord := range registryRecords { if _, found := recordsMap[regRecord.DNSName]; !found { @@ -307,35 +293,32 @@ func countMatchingAddressRecords(endpoints []*endpoint.Endpoint, registryRecords } recordsMap[regRecord.DNSName][regRecord.RecordType] = struct{}{} } - aCount := 0 - aaaaCount := 0 + for _, sourceRecord := range endpoints { if _, found := recordsMap[sourceRecord.DNSName]; found { - if _, found := recordsMap[sourceRecord.DNSName][sourceRecord.RecordType]; found { - switch sourceRecord.RecordType { - case endpoint.RecordTypeA: - aCount++ - case endpoint.RecordTypeAAAA: - aaaaCount++ - } + if _, ok := recordsMap[sourceRecord.DNSName][sourceRecord.RecordType]; ok { + rec.recordEndpointType(sourceRecord.RecordType) } } } - return aCount, aaaaCount + + for _, rt := range endpoint.KnownRecordTypes { + metric.SetWithLabels(rec.loadFloat64(rt), rt) + } } -func countAddressRecords(endpoints []*endpoint.Endpoint) (int, int) { - aCount := 0 - aaaaCount := 0 +// countAddressRecords updates the metricsRecorder with the count of each record type +// found in the provided endpoints slice, and sets the corresponding metrics for each +// known DNS record type using the sourceRecords metric. +func countAddressRecords(rec *metricsRecorder, endpoints []*endpoint.Endpoint, metric metrics.GaugeVecMetric) { + // compute the number of records per type for _, endPoint := range endpoints { - switch endPoint.RecordType { - case endpoint.RecordTypeA: - aCount++ - case endpoint.RecordTypeAAAA: - aaaaCount++ - } + rec.recordEndpointType(endPoint.RecordType) + } + // set metrics for each record type + for _, rt := range endpoint.KnownRecordTypes { + metric.SetWithLabels(rec.loadFloat64(rt), rt) } - return aCount, aaaaCount } // ScheduleRunOnce makes sure execution happens at most once per interval. diff --git a/controller/controller_test.go b/controller/controller_test.go index 6fc5e470c..e26648ab2 100644 --- a/controller/controller_test.go +++ b/controller/controller_test.go @@ -19,15 +19,12 @@ package controller import ( "context" "errors" - "math" "reflect" "sort" "sync" "testing" "time" - "github.com/prometheus/client_golang/prometheus" - "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/pkg/apis/externaldns" @@ -235,8 +232,9 @@ func TestRunOnce(t *testing.T) { // Validate that the mock source was called. source.AssertExpectations(t) // check the verified records - assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedARecords.Gauge)) - assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedAAAARecords.Gauge)) + + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) } // TestRun tests that Run correctly starts and stops @@ -268,14 +266,9 @@ func TestRun(t *testing.T) { // Validate that the mock source was called. source.AssertExpectations(t) - // check the verified records - assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedARecords.Gauge)) - assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedAAAARecords.Gauge)) -} -func valueFromMetric(metric prometheus.Gauge) uint64 { - ref := reflect.ValueOf(metric) - return reflect.Indirect(ref).FieldByName("valBits").Uint() + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) } func TestShouldRunOnce(t *testing.T) { @@ -491,256 +484,6 @@ func TestWhenMultipleControllerConsidersAllFilteredComain(t *testing.T) { ) } -func TestVerifyARecords(t *testing.T) { - testControllerFiltersDomains( - t, - []*endpoint.Endpoint{ - { - DNSName: "create-record.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"1.2.3.4"}, - }, - { - DNSName: "some-record.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"8.8.8.8"}, - }, - }, - endpoint.NewDomainFilter([]string{"used.tld"}), - []*endpoint.Endpoint{ - { - DNSName: "some-record.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"8.8.8.8"}, - }, - { - DNSName: "create-record.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"1.2.3.4"}, - }, - }, - []*plan.Changes{}, - ) - assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedARecords.Gauge)) - - testControllerFiltersDomains( - t, - []*endpoint.Endpoint{ - { - DNSName: "some-record.1.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"1.2.3.4"}, - }, - { - DNSName: "some-record.2.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"8.8.8.8"}, - }, - { - DNSName: "some-record.3.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"24.24.24.24"}, - }, - }, - endpoint.NewDomainFilter([]string{"used.tld"}), - []*endpoint.Endpoint{ - { - DNSName: "some-record.1.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"1.2.3.4"}, - }, - { - DNSName: "some-record.2.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"8.8.8.8"}, - }, - }, - []*plan.Changes{{ - Create: []*endpoint.Endpoint{ - { - DNSName: "some-record.3.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"24.24.24.24"}, - }, - }, - }}, - ) - assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedARecords.Gauge)) - assert.Equal(t, math.Float64bits(0), valueFromMetric(verifiedAAAARecords.Gauge)) -} - -func TestVerifyAAAARecords(t *testing.T) { - testControllerFiltersDomains( - t, - []*endpoint.Endpoint{ - { - DNSName: "create-record.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::1"}, - }, - { - DNSName: "some-record.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::2"}, - }, - }, - endpoint.NewDomainFilter([]string{"used.tld"}), - []*endpoint.Endpoint{ - { - DNSName: "some-record.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::2"}, - }, - { - DNSName: "create-record.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::1"}, - }, - }, - []*plan.Changes{}, - ) - assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedAAAARecords.Gauge)) - - testControllerFiltersDomains( - t, - []*endpoint.Endpoint{ - { - DNSName: "some-record.1.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::1"}, - }, - { - DNSName: "some-record.2.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::2"}, - }, - { - DNSName: "some-record.3.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::3"}, - }, - }, - endpoint.NewDomainFilter([]string{"used.tld"}), - []*endpoint.Endpoint{ - { - DNSName: "some-record.1.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::1"}, - }, - { - DNSName: "some-record.2.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::2"}, - }, - }, - []*plan.Changes{{ - Create: []*endpoint.Endpoint{ - { - DNSName: "some-record.3.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::3"}, - }, - }, - }}, - ) - assert.Equal(t, math.Float64bits(0), valueFromMetric(verifiedARecords.Gauge)) - assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedAAAARecords.Gauge)) -} - -func TestARecords(t *testing.T) { - testControllerFiltersDomains( - t, - []*endpoint.Endpoint{ - { - DNSName: "record1.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"1.2.3.4"}, - }, - { - DNSName: "record2.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"8.8.8.8"}, - }, - { - DNSName: "_mysql-svc._tcp.mysql.used.tld", - RecordType: endpoint.RecordTypeSRV, - Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, - }, - }, - endpoint.NewDomainFilter([]string{"used.tld"}), - []*endpoint.Endpoint{ - { - DNSName: "record1.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"1.2.3.4"}, - }, - { - DNSName: "_mysql-svc._tcp.mysql.used.tld", - RecordType: endpoint.RecordTypeSRV, - Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, - }, - }, - []*plan.Changes{{ - Create: []*endpoint.Endpoint{ - { - DNSName: "record2.used.tld", - RecordType: endpoint.RecordTypeA, - Targets: endpoint.Targets{"8.8.8.8"}, - }, - }, - }}, - ) - assert.Equal(t, math.Float64bits(2), valueFromMetric(sourceARecords.Gauge)) - assert.Equal(t, math.Float64bits(1), valueFromMetric(registryARecords.Gauge)) -} - -func TestAAAARecords(t *testing.T) { - testControllerFiltersDomains( - t, - []*endpoint.Endpoint{ - { - DNSName: "record1.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::1"}, - }, - { - DNSName: "record2.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::2"}, - }, - { - DNSName: "_mysql-svc._tcp.mysql.used.tld", - RecordType: endpoint.RecordTypeSRV, - Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, - }, - }, - endpoint.NewDomainFilter([]string{"used.tld"}), - []*endpoint.Endpoint{ - { - DNSName: "record1.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::1"}, - }, - { - DNSName: "_mysql-svc._tcp.mysql.used.tld", - RecordType: endpoint.RecordTypeSRV, - Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, - }, - }, - []*plan.Changes{{ - Create: []*endpoint.Endpoint{ - { - DNSName: "record2.used.tld", - RecordType: endpoint.RecordTypeAAAA, - Targets: endpoint.Targets{"2001:DB8::2"}, - }, - }, - }}, - ) - assert.Equal(t, math.Float64bits(2), valueFromMetric(sourceAAAARecords.Gauge)) - assert.Equal(t, math.Float64bits(1), valueFromMetric(registryAAAARecords.Gauge)) -} - type toggleRegistry struct { registry.NoopRegistry failCount int @@ -749,7 +492,7 @@ type toggleRegistry struct { const toggleRegistryFailureCount = 3 -func (r *toggleRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { +func (r *toggleRegistry) Records(_ context.Context) ([]*endpoint.Endpoint, error) { r.failCountMu.Lock() defer r.failCountMu.Unlock() if r.failCount < toggleRegistryFailureCount { @@ -759,7 +502,7 @@ func (r *toggleRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, err return []*endpoint.Endpoint{}, nil } -func (r *toggleRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error { +func (r *toggleRegistry) ApplyChanges(_ context.Context, changes *plan.Changes) error { return nil } diff --git a/controller/metrics.go b/controller/metrics.go new file mode 100644 index 000000000..940addb29 --- /dev/null +++ b/controller/metrics.go @@ -0,0 +1,54 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import "sigs.k8s.io/external-dns/endpoint" + +type metricsRecorder struct { + counterPerEndpointType map[string]int +} + +func newMetricsRecorder() *metricsRecorder { + return &metricsRecorder{ + counterPerEndpointType: map[string]int{ + endpoint.RecordTypeA: 0, + endpoint.RecordTypeAAAA: 0, + endpoint.RecordTypeCNAME: 0, + endpoint.RecordTypeTXT: 0, + endpoint.RecordTypeSRV: 0, + endpoint.RecordTypeNS: 0, + endpoint.RecordTypePTR: 0, + endpoint.RecordTypeMX: 0, + endpoint.RecordTypeNAPTR: 0, + }, + } +} + +func (m *metricsRecorder) recordEndpointType(endpointType string) { + m.counterPerEndpointType[endpointType]++ +} + +func (m *metricsRecorder) getEndpointTypeCount(endpointType string) int { + if count, ok := m.counterPerEndpointType[endpointType]; ok { + return count + } + return 0 +} + +func (m *metricsRecorder) loadFloat64(endpointType string) float64 { + return float64(m.getEndpointTypeCount(endpointType)) +} diff --git a/controller/metrics_test.go b/controller/metrics_test.go new file mode 100644 index 000000000..8a468c32b --- /dev/null +++ b/controller/metrics_test.go @@ -0,0 +1,376 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/internal/testutils" + "sigs.k8s.io/external-dns/pkg/apis/externaldns" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/registry" +) + +func TestRecordKnownEndpointType(t *testing.T) { + mr := newMetricsRecorder() + + // Recording a built-in type should start at 1 and increment + mr.recordEndpointType(endpoint.RecordTypeA) + assert.Equal(t, 1, mr.getEndpointTypeCount(endpoint.RecordTypeA)) + + mr.recordEndpointType(endpoint.RecordTypeA) + assert.Equal(t, 2, mr.getEndpointTypeCount(endpoint.RecordTypeA)) +} + +func TestRecordUnknownEndpointType(t *testing.T) { + mr := newMetricsRecorder() + const customType = "CUSTOM" + + // Unknown types start at zero + assert.Equal(t, 0, mr.getEndpointTypeCount(customType)) + + // First record sets to 1 + mr.recordEndpointType(customType) + assert.Equal(t, 1, mr.getEndpointTypeCount(customType)) + + // Subsequent records increment + mr.recordEndpointType(customType) + assert.Equal(t, 2, mr.getEndpointTypeCount(customType)) +} + +func TestLoadFloat64(t *testing.T) { + mr := newMetricsRecorder() + + // loadFloat64 should return the float64 representation of the count + mr.recordEndpointType(endpoint.RecordTypeAAAA) + assert.InDelta(t, float64(1), mr.loadFloat64(endpoint.RecordTypeAAAA), 0.0001) +} + +func TestVerifyARecords(t *testing.T) { + testControllerFiltersDomains( + t, + []*endpoint.Endpoint{ + { + DNSName: "create-record.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"1.2.3.4"}, + }, + { + DNSName: "some-record.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"8.8.8.8"}, + }, + }, + endpoint.NewDomainFilter([]string{"used.tld"}), + []*endpoint.Endpoint{ + { + DNSName: "some-record.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "create-record.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"1.2.3.4"}, + }, + }, + []*plan.Changes{}, + ) + + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) + + testControllerFiltersDomains( + t, + []*endpoint.Endpoint{ + { + DNSName: "some-record.1.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"1.2.3.4"}, + }, + { + DNSName: "some-record.2.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "some-record.3.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"24.24.24.24"}, + }, + }, + endpoint.NewDomainFilter([]string{"used.tld"}), + []*endpoint.Endpoint{ + { + DNSName: "some-record.1.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"1.2.3.4"}, + }, + { + DNSName: "some-record.2.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"8.8.8.8"}, + }, + }, + []*plan.Changes{{ + Create: []*endpoint.Endpoint{ + { + DNSName: "some-record.3.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"24.24.24.24"}, + }, + }, + }}, + ) + + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) +} + +func TestVerifyAAAARecords(t *testing.T) { + testControllerFiltersDomains( + t, + []*endpoint.Endpoint{ + { + DNSName: "create-record.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::1"}, + }, + { + DNSName: "some-record.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::2"}, + }, + }, + endpoint.NewDomainFilter([]string{"used.tld"}), + []*endpoint.Endpoint{ + { + DNSName: "some-record.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::2"}, + }, + { + DNSName: "create-record.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::1"}, + }, + }, + []*plan.Changes{}, + ) + + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) + + testControllerFiltersDomains( + t, + []*endpoint.Endpoint{ + { + DNSName: "some-record.1.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::1"}, + }, + { + DNSName: "some-record.2.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::2"}, + }, + { + DNSName: "some-record.3.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::3"}, + }, + }, + endpoint.NewDomainFilter([]string{"used.tld"}), + []*endpoint.Endpoint{ + { + DNSName: "some-record.1.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::1"}, + }, + { + DNSName: "some-record.2.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::2"}, + }, + }, + []*plan.Changes{{ + Create: []*endpoint.Endpoint{ + { + DNSName: "some-record.3.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::3"}, + }, + }, + }}, + ) + + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) +} + +func TestARecords(t *testing.T) { + testControllerFiltersDomains( + t, + []*endpoint.Endpoint{ + { + DNSName: "record1.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"1.2.3.4"}, + }, + { + DNSName: "record2.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "_mysql-svc._tcp.mysql.used.tld", + RecordType: endpoint.RecordTypeSRV, + Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, + }, + }, + endpoint.NewDomainFilter([]string{"used.tld"}), + []*endpoint.Endpoint{ + { + DNSName: "record1.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"1.2.3.4"}, + }, + { + DNSName: "_mysql-svc._tcp.mysql.used.tld", + RecordType: endpoint.RecordTypeSRV, + Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, + }, + }, + []*plan.Changes{{ + Create: []*endpoint.Endpoint{ + { + DNSName: "record2.used.tld", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"8.8.8.8"}, + }, + }, + }}, + ) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) +} + +func TestAAAARecords(t *testing.T) { + testControllerFiltersDomains( + t, + []*endpoint.Endpoint{ + { + DNSName: "record1.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::1"}, + }, + { + DNSName: "record2.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::2"}, + }, + { + DNSName: "_mysql-svc._tcp.mysql.used.tld", + RecordType: endpoint.RecordTypeSRV, + Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, + }, + }, + endpoint.NewDomainFilter([]string{"used.tld"}), + []*endpoint.Endpoint{ + { + DNSName: "record1.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::1"}, + }, + { + DNSName: "_mysql-svc._tcp.mysql.used.tld", + RecordType: endpoint.RecordTypeSRV, + Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, + }, + }, + []*plan.Changes{{ + Create: []*endpoint.Endpoint{ + { + DNSName: "record2.used.tld", + RecordType: endpoint.RecordTypeAAAA, + Targets: endpoint.Targets{"2001:DB8::2"}, + }, + }, + }}, + ) + + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, sourceRecords.Gauge, map[string]string{"record_type": "a"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, sourceRecords.Gauge, map[string]string{"record_type": "aaaa"}) + + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) +} + +func TestGaugeMetricsWithMixedRecords(t *testing.T) { + configuredEndpoints := testutils.GenerateTestEndpointsByType(map[string]int{ + endpoint.RecordTypeA: 534, + endpoint.RecordTypeAAAA: 324, + endpoint.RecordTypeCNAME: 2, + endpoint.RecordTypeTXT: 56, + endpoint.RecordTypeSRV: 11, + endpoint.RecordTypeNS: 3, + }) + + providerEndpoints := testutils.GenerateTestEndpointsByType(map[string]int{ + endpoint.RecordTypeA: 5334, + endpoint.RecordTypeAAAA: 324, + endpoint.RecordTypeCNAME: 23, + endpoint.RecordTypeTXT: 6, + endpoint.RecordTypeSRV: 25, + endpoint.RecordTypeNS: 1, + endpoint.RecordTypePTR: 43, + }) + + cfg := externaldns.NewConfig() + cfg.ManagedDNSRecordTypes = endpoint.KnownRecordTypes + + source := new(testutils.MockSource) + source.On("Endpoints").Return(configuredEndpoints, nil) + + provider := &filteredMockProvider{ + RecordsStore: providerEndpoints, + } + r, err := registry.NewNoopRegistry(provider) + + require.NoError(t, err) + + ctrl := &Controller{ + Source: source, + Registry: r, + Policy: &plan.SyncPolicy{}, + DomainFilter: endpoint.NewDomainFilter([]string{}), + ManagedRecordTypes: cfg.ManagedDNSRecordTypes, + } + + assert.NoError(t, ctrl.RunOnce(t.Context())) + + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 534, sourceRecords.Gauge, map[string]string{"record_type": "a"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 324, sourceRecords.Gauge, map[string]string{"record_type": "aaaa"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, sourceRecords.Gauge, map[string]string{"record_type": "cname"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 11, sourceRecords.Gauge, map[string]string{"record_type": "srv"}) + + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 5334, registryRecords.Gauge, map[string]string{"record_type": "a"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 324, registryRecords.Gauge, map[string]string{"record_type": "aaaa"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, registryRecords.Gauge, map[string]string{"record_type": "mx"}) + testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 43, registryRecords.Gauge, map[string]string{"record_type": "ptr"}) +} diff --git a/docs/monitoring/metrics.md b/docs/monitoring/metrics.md index aa38ae9ee..c80d39333 100644 --- a/docs/monitoring/metrics.md +++ b/docs/monitoring/metrics.md @@ -24,18 +24,15 @@ curl https://localhost:7979/metrics | last_reconcile_timestamp_seconds | Gauge | controller | Timestamp of last attempted sync with the DNS provider | | last_sync_timestamp_seconds | Gauge | controller | Timestamp of last successful sync with the DNS provider | | no_op_runs_total | Counter | controller | Number of reconcile loops ending up with no changes on the DNS provider side. | -| verified_a_records | Gauge | controller | Number of DNS A-records that exists both in source and registry. | -| verified_aaaa_records | Gauge | controller | Number of DNS AAAA-records that exists both in source and registry. | +| verified_records | Gauge | controller | Number of DNS records that exists both in source and registry (vector). | | cache_apply_changes_calls | Counter | provider | Number of calls to the provider cache ApplyChanges. | | cache_records_calls | Counter | provider | Number of calls to the provider cache Records list. | -| a_records | Gauge | registry | Number of Registry A records. | -| aaaa_records | Gauge | registry | Number of Registry AAAA records. | | endpoints_total | Gauge | registry | Number of Endpoints in the registry | | errors_total | Counter | registry | Number of Registry errors. | -| a_records | Gauge | source | Number of Source A records. | -| aaaa_records | Gauge | source | Number of Source AAAA records. | +| records | Gauge | registry | Number of registry records partitioned by label name (vector). | | endpoints_total | Gauge | source | Number of Endpoints in all sources | | errors_total | Counter | source | Number of Source errors. | +| records | Gauge | source | Number of source records partitioned by label name (vector). | | adjustendpoints_errors_total | Gauge | webhook_provider | Errors with AdjustEndpoints method | | adjustendpoints_requests_total | Gauge | webhook_provider | Requests with AdjustEndpoints method | | applychanges_errors_total | Gauge | webhook_provider | Errors with ApplyChanges method | diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go index e6e1cc41a..5dcdd0089 100644 --- a/endpoint/endpoint.go +++ b/endpoint/endpoint.go @@ -47,6 +47,19 @@ const ( RecordTypeNAPTR = "NAPTR" ) +var ( + KnownRecordTypes = []string{ + RecordTypeA, + RecordTypeAAAA, + RecordTypeTXT, + RecordTypeSRV, + RecordTypeNS, + RecordTypePTR, + RecordTypeMX, + RecordTypeNAPTR, + } +) + // TTL is a structure defining the TTL of a DNS record type TTL int64 diff --git a/internal/gen/docs/metrics/main.go b/internal/gen/docs/metrics/main.go index f529a60c6..b3e4f6a3a 100644 --- a/internal/gen/docs/metrics/main.go +++ b/internal/gen/docs/metrics/main.go @@ -70,6 +70,7 @@ func generateMarkdownTable(m *metrics.MetricRegistry, withRuntime bool) (string, "process_network_receive_bytes_total", "process_network_transmit_bytes_total", }...) + sort.Strings(runtimeMetrics) } else { runtimeMetrics = []string{} } diff --git a/internal/gen/docs/metrics/main_test.go b/internal/gen/docs/metrics/main_test.go index 6f23c4344..a6d1efc27 100644 --- a/internal/gen/docs/metrics/main_test.go +++ b/internal/gen/docs/metrics/main_test.go @@ -37,7 +37,7 @@ func TestComputeMetrics(t *testing.T) { t.Errorf("Expected not empty metrics registry, got %d", len(reg.Metrics)) } - assert.Len(t, reg.Metrics, 22) + assert.Len(t, reg.Metrics, 19) } func TestGenerateMarkdownTableRenderer(t *testing.T) { diff --git a/internal/testutils/endpoint.go b/internal/testutils/endpoint.go index 76697e690..0a33d3a21 100644 --- a/internal/testutils/endpoint.go +++ b/internal/testutils/endpoint.go @@ -17,9 +17,12 @@ limitations under the License. package testutils import ( + "fmt" + "math/rand" "net/netip" "reflect" "sort" + "strings" "sigs.k8s.io/external-dns/endpoint" ) @@ -132,3 +135,28 @@ func NewTargetsFromAddr(targets []netip.Addr) endpoint.Targets { } return t } + +// GenerateTestEndpointsByType generates a shuffled slice of test Endpoints for each record type and count specified in typeCounts. +// Usage example: +// +// endpoints := GenerateTestEndpointsByType(map[string]int{"A": 2, "CNAME": 1}) +// // endpoints will contain 2 A records and 1 CNAME record with unique DNS names and targets. +func GenerateTestEndpointsByType(typeCounts map[string]int) []*endpoint.Endpoint { + var result []*endpoint.Endpoint + idx := 0 + for rt, count := range typeCounts { + for i := 0; i < count; i++ { + result = append(result, &endpoint.Endpoint{ + DNSName: fmt.Sprintf("%s-%d.example.com", strings.ToLower(rt), idx), + Targets: endpoint.Targets{fmt.Sprintf("192.0.2.%d", idx)}, + RecordType: rt, + RecordTTL: 300, + }) + idx++ + } + } + rand.Shuffle(len(result), func(i, j int) { + result[i], result[j] = result[j], result[i] + }) + return result +} diff --git a/internal/testutils/metrics.go b/internal/testutils/metrics.go new file mode 100644 index 000000000..fb96c5fd0 --- /dev/null +++ b/internal/testutils/metrics.go @@ -0,0 +1,51 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package testutils + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/assert" +) + +// TestHelperVerifyMetricsGaugeVectorWithLabels verifies that a prometheus.GaugeVec metric with specific labels has the expected value. +// +// Example usage: +// +// labels := map[string]string{"method": "GET", "status": "200"} +// TestHelperVerifyMetricsGaugeVectorWithLabels(t, 42.0, myGaugeVec, labels) +func TestHelperVerifyMetricsGaugeVectorWithLabels(t *testing.T, expected float64, metric prometheus.GaugeVec, labels map[string]string) { + TestHelperVerifyMetricsGaugeVectorWithLabelsFunc(t, expected, assert.Equal, metric, labels) +} + +// TestHelperVerifyMetricsGaugeVectorWithLabelsFunc is a helper function that verifies a prometheus.GaugeVec metric with specific labels using a custom assertion function. +func TestHelperVerifyMetricsGaugeVectorWithLabelsFunc(t *testing.T, expected float64, aFunc assert.ComparisonAssertionFunc, metric prometheus.GaugeVec, labels map[string]string) { + t.Helper() + + g, err := metric.MetricVec.GetMetricWith(labels) + assert.NoError(t, err) + + var m dto.Metric + err = g.Write(&m) + assert.NoError(t, err) + + assert.NotNil(t, m.Gauge) + + aFunc(t, expected, *m.Gauge.Value, "Expected gauge value does not match the actual value", labels) +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 028e78aae..d65e172ea 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -54,7 +54,7 @@ func NewMetricsRegister() *MetricRegistry { // } func (m *MetricRegistry) MustRegister(cs IMetric) { switch v := cs.(type) { - case CounterMetric, GaugeMetric, CounterVecMetric: + case CounterMetric, GaugeMetric, CounterVecMetric, GaugeVecMetric: if _, exists := m.mName[cs.Get().FQDN]; exists { return } else { @@ -66,6 +66,8 @@ func (m *MetricRegistry) MustRegister(cs IMetric) { m.Registerer.MustRegister(metric.Counter) case GaugeMetric: m.Registerer.MustRegister(metric.Gauge) + case GaugeVecMetric: + m.Registerer.MustRegister(metric.Gauge) case CounterVecMetric: m.Registerer.MustRegister(metric.CounterVec) } diff --git a/pkg/metrics/metrics_test.go b/pkg/metrics/metrics_test.go index 9c8164203..16cf52f57 100644 --- a/pkg/metrics/metrics_test.go +++ b/pkg/metrics/metrics_test.go @@ -60,8 +60,9 @@ func TestMustRegister(t *testing.T) { NewGaugeWithOpts(prometheus.GaugeOpts{Name: "test_gauge_3"}), NewCounterWithOpts(prometheus.CounterOpts{Name: "test_counter_3"}), NewCounterVecWithOpts(prometheus.CounterOpts{Name: "test_counter_vec_3"}, []string{"label"}), + NewGaugedVectorOpts(prometheus.GaugeOpts{Name: "test_gauge_v_3"}, []string{"label"}), }, - expected: 3, + expected: 4, }, { name: "unsupported metric", diff --git a/pkg/metrics/models.go b/pkg/metrics/models.go index 8878a5887..1aaf708e7 100644 --- a/pkg/metrics/models.go +++ b/pkg/metrics/models.go @@ -18,6 +18,7 @@ package metrics import ( "fmt" + "strings" "github.com/prometheus/client_golang/prometheus" ) @@ -68,6 +69,24 @@ func (g CounterVecMetric) Get() *Metric { return &g.Metric } +type GaugeVecMetric struct { + Metric + Gauge prometheus.GaugeVec +} + +func (g GaugeVecMetric) Get() *Metric { + return &g.Metric +} + +// SetWithLabels sets the value of the Gauge metric for the specified label values. +// All label values are converted to lowercase before being applied. +func (g GaugeVecMetric) SetWithLabels(value float64, lvs ...string) { + for i, v := range lvs { + lvs[i] = strings.ToLower(v) + } + g.Gauge.WithLabelValues(lvs...).Set(value) +} + func NewGaugeWithOpts(opts prometheus.GaugeOpts) GaugeMetric { return GaugeMetric{ Metric: Metric{ @@ -82,6 +101,22 @@ func NewGaugeWithOpts(opts prometheus.GaugeOpts) GaugeMetric { } } +// NewGaugedVectorOpts creates a new GaugeVec based on the provided GaugeOpts and +// partitioned by the given label names. +func NewGaugedVectorOpts(opts prometheus.GaugeOpts, labelNames []string) GaugeVecMetric { + return GaugeVecMetric{ + Metric: Metric{ + Type: "gauge", + Name: opts.Name, + FQDN: fmt.Sprintf("%s_%s", opts.Subsystem, opts.Name), + Namespace: opts.Namespace, + Subsystem: opts.Subsystem, + Help: opts.Help, + }, + Gauge: *prometheus.NewGaugeVec(opts, labelNames), + } +} + func NewCounterWithOpts(opts prometheus.CounterOpts) CounterMetric { return CounterMetric{ Metric: Metric{ diff --git a/pkg/metrics/models_test.go b/pkg/metrics/models_test.go index aad967c4a..70f50ea1a 100644 --- a/pkg/metrics/models_test.go +++ b/pkg/metrics/models_test.go @@ -19,6 +19,8 @@ package metrics import ( "testing" + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" ) @@ -81,3 +83,33 @@ func TestNewCounterVecWithOpts(t *testing.T) { assert.Equal(t, "test_subsystem_test_counter_vec", counterVecMetric.FQDN) assert.NotNil(t, counterVecMetric.CounterVec) } + +func TestGaugeV_SetWithLabels(t *testing.T) { + opts := prometheus.GaugeOpts{ + Name: "test_gauge", + Namespace: "test_ns", + Subsystem: "test_sub", + Help: "help text", + } + gv := NewGaugedVectorOpts(opts, []string{"label1", "label2"}) + + gv.SetWithLabels(1.23, "Alpha", "BETA") + + g, err := gv.Gauge.GetMetricWithLabelValues("alpha", "beta") + assert.NoError(t, err) + + var m dto.Metric + err = g.Write(&m) + assert.NoError(t, err) + assert.NotNil(t, m.Gauge) + assert.InDelta(t, 1.23, *m.Gauge.Value, 0.01) + + // Override the value + gv.SetWithLabels(4.56, "ALPHA", "beta") + // reuse g (same label combination) + err = g.Write(&m) + assert.NoError(t, err) + assert.InDelta(t, 4.56, *m.Gauge.Value, 0.01) + + assert.Len(t, m.Label, 2) +}