Repository: kubernetes-sigs/external-dns

Issue:
Metrics for record creation/deletion
Can we please have a counter/gauge for when changes are made to DNS, such as the value from:

```
msg="2 record(s) in zone domain.com. were successfully updated"
```

I had an issue where two `external-dns`s were fighting over a subdomain (one deleting then recreating the record a minute later) and it would be neat if I could have tied this is a Prometheus alert.

Discussion:
@fejta-bot: Issues go stale after 90d of inactivity.
Mark the issue as fresh with `/remove-lifecycle stale`.
Stale issues rot after an additional 30d of inactivity and eventually close.

If this issue is safe to close now please do so with `/close`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/lifecycle stale@fejta-bot: Stale issues rot after 30d of inactivity.
Mark the issue as fresh with `/remove-lifecycle rotten`.
Rotten issues close after an additional 30d of inactivity.

If this issue is safe to close now please do so with `/close`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/lifecycle rotten@linki: /remove-lifecycle rotten@fejta-bot: Issues go stale after 90d of inactivity.
Mark the issue as fresh with `/remove-lifecycle stale`.
Stale issues rot after an additional 30d of inactivity and eventually close.

If this issue is safe to close now please do so with `/close`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/lifecycle stale@fejta-bot: Stale issues rot after 30d of inactivity.
Mark the issue as fresh with `/remove-lifecycle rotten`.
Rotten issues close after an additional 30d of inactivity.

If this issue is safe to close now please do so with `/close`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/lifecycle rotten@fejta-bot: Rotten issues close after 30d of inactivity.
Reopen the issue with `/reopen`.
Mark the issue as fresh with `/remove-lifecycle rotten`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/close@k8s-ci-robot: @fejta-bot: Closing this issue.

<details>

In response to [this](https://github.com/kubernetes-sigs/external-dns/issues/962#issuecomment-570198747):

>Rotten issues close after 30d of inactivity.
>Reopen the issue with `/reopen`.
>Mark the issue as fresh with `/remove-lifecycle rotten`.
>
>Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
>/close


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>@ivankatliarchuk: /assign

Not too sure about how easy it to achive. Need to have a look as one of the follow-ups@ivankatliarchuk: /reopen@k8s-ci-robot: @ivankatliarchuk: Reopened this issue.

<details>

In response to [this](https://github.com/kubernetes-sigs/external-dns/issues/962#issuecomment-2969839158):

>/reopen


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>@ivankatliarchuk: fixed https://github.com/kubernetes-sigs/external-dns/pull/5516@ivankatliarchuk: /close@k8s-ci-robot: @ivankatliarchuk: Closing this issue.

<details>

In response to [this](https://github.com/kubernetes-sigs/external-dns/issues/962#issuecomment-3036978858):

>/close


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>

Solution patch:
diff --git a/controller/controller.go b/controller/controller.go
index 5ee193fa..06562da5 100644
--- a/controller/controller.go
+++ b/controller/controller.go
@@ -105,54 +105,37 @@ var (
 			Help:      "Number of Source errors.",
 		},
 	)
-	registryARecords = metrics.NewGaugeWithOpts(
-		prometheus.GaugeOpts{
-			Namespace: "external_dns",
-			Subsystem: "registry",
-			Name:      "a_records",
-			Help:      "Number of Registry A records.",
-		},
-	)
-	registryAAAARecords = metrics.NewGaugeWithOpts(
+
+	registryRecords = metrics.NewGaugedVectorOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "registry",
-			Name:      "aaaa_records",
-			Help:      "Number of Registry AAAA records.",
+			Name:      "records",
+			Help:      "Number of registry records partitioned by label name (vector).",
 		},
+		[]string{"record_type"},
 	)
-	sourceARecords = metrics.NewGaugeWithOpts(
-		prometheus.GaugeOpts{
-			Namespace: "external_dns",
-			Subsystem: "source",
-			Name:      "a_records",
-			Help:      "Number of Source A records.",
-		},
-	)
-	sourceAAAARecords = metrics.NewGaugeWithOpts(
+
+	sourceRecords = metrics.NewGaugedVectorOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "source",
-			Name:      "aaaa_records",
-			Help:      "Number of Source AAAA records.",
-		},
-	)
-	verifiedARecords = metrics.NewGaugeWithOpts(
-		prometheus.GaugeOpts{
-			Namespace: "external_dns",
-			Subsystem: "controller",
-			Name:      "verified_a_records",
-			Help:      "Number of DNS A-records that exists both in source and registry.",
+			Name:      "records",
+			Help:      "Number of source records partitioned by label name (vector).",
 		},
+		[]string{"record_type"},
 	)
-	verifiedAAAARecords = metrics.NewGaugeWithOpts(
+
+	verifiedRecords = metrics.NewGaugedVectorOpts(
 		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/metrics.go b/controller/metrics.go
new file mode 100644
index 00000000..940addb2
--- /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/docs/monitoring/metrics.md b/docs/monitoring/metrics.md
index aa38ae9e..c80d3933 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 e6e1cc41..5dcdd008 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 f529a60c..b3e4f6a3 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/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
index 028e78aa..d65e172e 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/models.go b/pkg/metrics/models.go
index 8878a588..1aaf708e 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{



Test patch:
diff --git a/controller/controller_test.go b/controller/controller_test.go
index 6fc5e470..e26648ab 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_test.go b/controller/metrics_test.go
new file mode 100644
index 00000000..8a468c32
--- /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/internal/gen/docs/metrics/main_test.go b/internal/gen/docs/metrics/main_test.go
index 6f23c434..a6d1efc2 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 76697e69..0a33d3a2 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 00000000..fb96c5fd
--- /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_test.go b/pkg/metrics/metrics_test.go
index 9c816420..16cf52f5 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_test.go b/pkg/metrics/models_test.go
index aad967c4..70f50ea1 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)
+}