diff --git a/proxy/internal/acme/manager.go b/proxy/internal/acme/manager.go index d491d65a3..ebc15314b 100644 --- a/proxy/internal/acme/manager.go +++ b/proxy/internal/acme/manager.go @@ -42,6 +42,10 @@ type domainInfo struct { err string } +type metricsRecorder interface { + RecordCertificateIssuance(duration time.Duration) +} + // Manager wraps autocert.Manager with domain tracking and cross-replica // coordination via a pluggable locking strategy. The locker prevents // duplicate ACME requests when multiple replicas share a certificate cache. @@ -55,6 +59,7 @@ type Manager struct { certNotifier certificateNotifier logger *log.Logger + metrics metricsRecorder } // NewManager creates a new ACME certificate manager. The certDir is used @@ -63,7 +68,7 @@ type Manager struct { // eabKID and eabHMACKey are optional External Account Binding credentials // required for some CAs like ZeroSSL. The eabHMACKey should be the base64 // URL-encoded string provided by the CA. -func NewManager(certDir, acmeURL, eabKID, eabHMACKey string, notifier certificateNotifier, logger *log.Logger, lockMethod CertLockMethod) *Manager { +func NewManager(certDir, acmeURL, eabKID, eabHMACKey string, notifier certificateNotifier, logger *log.Logger, lockMethod CertLockMethod, metrics metricsRecorder) *Manager { if logger == nil { logger = log.StandardLogger() } @@ -73,6 +78,7 @@ func NewManager(certDir, acmeURL, eabKID, eabHMACKey string, notifier certificat domains: make(map[domain.Domain]*domainInfo), certNotifier: notifier, logger: logger, + metrics: metrics, } var eab *acme.ExternalAccountBinding @@ -161,6 +167,10 @@ func (mgr *Manager) prefetchCertificate(d domain.Domain) { return } + if mgr.metrics != nil { + mgr.metrics.RecordCertificateIssuance(elapsed) + } + mgr.setDomainState(d, domainReady, "") now := time.Now() diff --git a/proxy/internal/acme/manager_test.go b/proxy/internal/acme/manager_test.go index f7efe5933..30a27c612 100644 --- a/proxy/internal/acme/manager_test.go +++ b/proxy/internal/acme/manager_test.go @@ -10,7 +10,7 @@ import ( ) func TestHostPolicy(t *testing.T) { - mgr := NewManager(t.TempDir(), "https://acme.example.com/directory", "", "", nil, nil, "") + mgr := NewManager(t.TempDir(), "https://acme.example.com/directory", "", "", nil, nil, "", nil) mgr.AddDomain("example.com", "acc1", "rp1") // Wait for the background prefetch goroutine to finish so the temp dir @@ -70,7 +70,7 @@ func TestHostPolicy(t *testing.T) { } func TestDomainStates(t *testing.T) { - mgr := NewManager(t.TempDir(), "https://acme.example.com/directory", "", "", nil, nil, "") + mgr := NewManager(t.TempDir(), "https://acme.example.com/directory", "", "", nil, nil, "", nil) assert.Equal(t, 0, mgr.PendingCerts(), "initially zero") assert.Equal(t, 0, mgr.TotalDomains(), "initially zero domains") diff --git a/proxy/internal/metrics/metrics.go b/proxy/internal/metrics/metrics.go index 525fdebc2..68ff55fe5 100644 --- a/proxy/internal/metrics/metrics.go +++ b/proxy/internal/metrics/metrics.go @@ -13,13 +13,14 @@ import ( ) type Metrics struct { - ctx context.Context - requestsTotal metric.Int64Counter - activeRequests metric.Int64UpDownCounter - configuredDomains metric.Int64UpDownCounter - totalPaths metric.Int64UpDownCounter - requestDuration metric.Int64Histogram - backendDuration metric.Int64Histogram + ctx context.Context + requestsTotal metric.Int64Counter + activeRequests metric.Int64UpDownCounter + configuredDomains metric.Int64UpDownCounter + totalPaths metric.Int64UpDownCounter + requestDuration metric.Int64Histogram + backendDuration metric.Int64Histogram + certificateIssueDuration metric.Int64Histogram mappingsMux sync.Mutex mappingPaths map[string]int @@ -80,15 +81,25 @@ func New(ctx context.Context, meter metric.Meter) (*Metrics, error) { return nil, err } + certificateIssueDuration, err := meter.Int64Histogram( + "proxy.certificate.issue.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration of ACME certificate issuance"), + ) + if err != nil { + return nil, err + } + return &Metrics{ - ctx: ctx, - requestsTotal: requestsTotal, - activeRequests: activeRequests, - configuredDomains: configuredDomains, - totalPaths: totalPaths, - requestDuration: requestDuration, - backendDuration: backendDuration, - mappingPaths: make(map[string]int), + ctx: ctx, + requestsTotal: requestsTotal, + activeRequests: activeRequests, + configuredDomains: configuredDomains, + totalPaths: totalPaths, + requestDuration: requestDuration, + backendDuration: backendDuration, + certificateIssueDuration: certificateIssueDuration, + mappingPaths: make(map[string]int), }, nil } @@ -179,3 +190,8 @@ func (m *Metrics) RemoveMapping(mapping proxy.Mapping) { delete(m.mappingPaths, mapping.Host) } + +// RecordCertificateIssuance records the duration of a certificate issuance. +func (m *Metrics) RecordCertificateIssuance(duration time.Duration) { + m.certificateIssueDuration.Record(m.ctx, duration.Milliseconds()) +} diff --git a/proxy/server.go b/proxy/server.go index f9b854e59..123b14648 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -437,7 +437,7 @@ func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) { "acme_server": s.ACMEDirectory, "challenge_type": s.ACMEChallengeType, }).Debug("ACME certificates enabled, configuring certificate manager") - s.acme = acme.NewManager(s.CertificateDirectory, s.ACMEDirectory, s.ACMEEABKID, s.ACMEEABHMACKey, s, s.Logger, s.CertLockMethod) + s.acme = acme.NewManager(s.CertificateDirectory, s.ACMEDirectory, s.ACMEEABKID, s.ACMEEABHMACKey, s, s.Logger, s.CertLockMethod, s.meter) if s.ACMEChallengeType == "http-01" { s.http = &http.Server{