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 <ivan.katliarchuk@gmail.com>

* feat(controller): add cardinality and labels for records metrics

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(controller): add cardinality and labels for records metrics

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(controller): add cardinality and labels for records metrics

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(controller): add cardinality and labels for records metrics

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(controller): add cardinality and labels for records metrics

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* 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 <ivan.katliarchuk@gmail.com>

* fix rebase

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com>
This commit is contained in:
Ivan Ka 2025-06-13 10:40:58 +01:00 committed by GitHub
parent 662fb3652d
commit d63bfb324c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 670 additions and 354 deletions

View File

@ -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.

View File

@ -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
}

54
controller/metrics.go Normal file
View File

@ -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))
}

376
controller/metrics_test.go Normal file
View File

@ -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"})
}

View File

@ -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 |

View File

@ -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

View File

@ -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{}
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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",

View File

@ -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{

View File

@ -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)
}