Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Expose certificate metrics #16

Merged
merged 16 commits into from
Jun 3, 2024
20 changes: 9 additions & 11 deletions internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,24 @@ func NewGoCertRouter(env *Environment) http.Handler {
apiV1Router.HandleFunc("POST /certificate_requests/{id}/certificate/reject", RejectCertificate(env))
apiV1Router.HandleFunc("DELETE /certificate_requests/{id}/certificate", DeleteCertificate(env))

metricsHandler := metrics.NewPrometheusMetricsHandler()
m := metrics.NewMetricsSubsystem(env.DB)
frontendHandler := newFrontendFileServer()

router := http.NewServeMux()
router.HandleFunc("/status", HealthCheck)
router.Handle("/metrics", metricsHandler)
router.Handle("/metrics", m.Handler)
router.Handle("/api/v1/", http.StripPrefix("/api/v1", apiV1Router))
router.Handle("/", frontendHandler)

return logging(router)
ctx := middlewareContext{metrics: m}
middleware := createMiddlewareStack(
metricsMiddleware(&ctx),
loggingMiddleware(&ctx),
)
return middleware(router)
}

// newFrontendFileServer uses the embedded ui output files as the base for a file server
func newFrontendFileServer() http.Handler {
frontendFS, err := fs.Sub(ui.FrontendFS, "out")
if err != nil {
Expand Down Expand Up @@ -213,14 +219,6 @@ func DeleteCertificate(env *Environment) http.HandlerFunc {
}
}

// The logging middleware captures any http request coming through, and logs it
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
log.Println(r.Method, r.URL.Path)
})
}

// logErrorAndWriteResponse is a helper function that logs any error and writes it back as an http response
func logErrorAndWriteResponse(msg string, status int, w http.ResponseWriter) {
errMsg := fmt.Sprintf("error: %s", msg)
Expand Down
80 changes: 80 additions & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package server

import (
"log"
"net/http"

"github.com/canonical/gocert/internal/metrics"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

type middleware func(http.Handler) http.Handler

// The middlewareContext type helps middleware receive and pass along information through the middleware chain.
type middlewareContext struct {
responseStatusCode int
metrics *metrics.PrometheusMetrics
}

// The responseWriterCloner struct wraps the http.ResponseWriter struct, and extracts the status
// code of the response writer for the middleware to read
type responseWriterCloner struct {
http.ResponseWriter
statusCode int
}
gruyaume marked this conversation as resolved.
Show resolved Hide resolved

// newResponseWriter returns a new ResponseWriterCloner struct
// it returns http.StatusOK by default because the http.ResponseWriter defaults to that header
// if the WriteHeader() function is never called.
func newResponseWriter(w http.ResponseWriter) *responseWriterCloner {
return &responseWriterCloner{w, http.StatusOK}
}

// WriteHeader overrides the ResponseWriter method to duplicate the status code into the wrapper struct
func (rwc *responseWriterCloner) WriteHeader(code int) {
rwc.statusCode = code
rwc.ResponseWriter.WriteHeader(code)
}

// createMiddlewareStack chains the given middleware functions to wrap the api.
// Each middleware functions calls next.ServeHTTP in order to resume the chain of execution.
// The order the middleware functions are given to createMiddlewareStack matters.
// Any code before next.ServeHTTP is called is executed in the given middleware's order.
// Any code after next.ServeHTTP is called is executed in the given middleware's reverse order.
func createMiddlewareStack(middleware ...middleware) middleware {
return func(next http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
mw := middleware[i]
next = mw(next)
}
return next
}
}

// The Metrics middleware captures any request relevant to a metric and records it for prometheus.
func metricsMiddleware(ctx *middlewareContext) middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := promhttp.InstrumentHandlerCounter(
&ctx.metrics.RequestsTotal,
promhttp.InstrumentHandlerDuration(
&ctx.metrics.RequestsDuration,
next,
),
)
base.ServeHTTP(w, r)
})
}
}

// The Logging middleware captures any http request coming through and the response status code, and logs it.
func loggingMiddleware(ctx *middlewareContext) middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clonedWwriter := newResponseWriter(w)
next.ServeHTTP(w, r)
log.Println(r.Method, r.URL.Path, clonedWwriter.statusCode, http.StatusText(clonedWwriter.statusCode))
ctx.responseStatusCode = clonedWwriter.statusCode
})
}
}
2 changes: 2 additions & 0 deletions internal/certdb/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func ValidateCertificateRequest(csr string) error {
if err != nil {
return err
}
// TODO: We should validate the actual certificate request parameters here too. (Has the required fields etc)
return nil
}

Expand All @@ -40,6 +41,7 @@ func ValidateCertificate(cert string) error {
if err != nil {
return err
}
// TODO: We should validate the actual certificate parameters here too. (Has the required fields etc)
return nil
}

Expand Down
219 changes: 213 additions & 6 deletions internal/metrics/metrics.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,224 @@
package metrics

import (
"crypto/x509"
"encoding/pem"
"errors"
"log"
"net/http"
"time"

"github.com/canonical/gocert/internal/certdb"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

// Returns an HTTP handler for Prometheus metrics.
func NewPrometheusMetricsHandler() http.Handler {
reg := prometheus.NewRegistry()
reg.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
prometheusHandler := promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
return prometheusHandler
type PrometheusMetrics struct {
http.Handler
registry *prometheus.Registry
CertificateRequests prometheus.Gauge
OutstandingCertificateRequests prometheus.Gauge
Certificates prometheus.Gauge
CertificatesExpiringIn1Day prometheus.Gauge
CertificatesExpiringIn7Days prometheus.Gauge
CertificatesExpiringIn30Days prometheus.Gauge
CertificatesExpiringIn90Days prometheus.Gauge
ExpiredCertificates prometheus.Gauge

RequestsTotal prometheus.CounterVec
RequestsDuration prometheus.HistogramVec
}

// NewMetricsSubsystem returns the metrics endpoint HTTP handler and the Prometheus metrics for the server and middleware.
kayra1 marked this conversation as resolved.
Show resolved Hide resolved
func NewMetricsSubsystem(db *certdb.CertificateRequestsRepository) *PrometheusMetrics {
csrs, err := db.RetrieveAll()
ghislainbourgeois marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
log.Println(errors.Join(errors.New("error generating metrics repository: "), err))
}
metricsBackend := newPrometheusMetrics()
metricsBackend.generateMetrics(csrs)
ticker := time.NewTicker(120 * time.Second)
go func() {
for range ticker.C {
metricsBackend.generateMetrics(csrs)
}
}()
metricsBackend.Handler = promhttp.HandlerFor(metricsBackend.registry, promhttp.HandlerOpts{})
return metricsBackend
}

// newPrometheusMetrics reads the status of the database, calculates all of the values of the metrics,
// registers these metrics to the prometheus registry, and returns the registry and the metrics.
// The registry and metrics can be modified from this struct from anywhere in the codebase.
func newPrometheusMetrics() *PrometheusMetrics {
m := &PrometheusMetrics{
registry: prometheus.NewRegistry(),
CertificateRequests: certificateRequestsMetric(),
OutstandingCertificateRequests: outstandingCertificateRequestsMetric(),
Certificates: certificatesMetric(),
ExpiredCertificates: expiredCertificatesMetric(),
CertificatesExpiringIn1Day: certificatesExpiringIn1DayMetric(),
CertificatesExpiringIn7Days: certificatesExpiringIn7DaysMetric(),
CertificatesExpiringIn30Days: certificatesExpiringIn30DaysMetric(),
CertificatesExpiringIn90Days: certificatesExpiringIn90DaysMetric(),

RequestsTotal: requestsTotalMetric(),
RequestsDuration: requestDurationMetric(),
}
m.registry.MustRegister(m.CertificateRequests)
m.registry.MustRegister(m.OutstandingCertificateRequests)
m.registry.MustRegister(m.Certificates)
m.registry.MustRegister(m.ExpiredCertificates)
m.registry.MustRegister(m.CertificatesExpiringIn1Day)
saltiyazan marked this conversation as resolved.
Show resolved Hide resolved
m.registry.MustRegister(m.CertificatesExpiringIn7Days)
m.registry.MustRegister(m.CertificatesExpiringIn30Days)
m.registry.MustRegister(m.CertificatesExpiringIn90Days)

m.registry.MustRegister(m.RequestsTotal)
m.registry.MustRegister(m.RequestsDuration)

m.registry.MustRegister(collectors.NewGoCollector())
m.registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
return m
}

// generateMetrics receives the live list of csrs to calculate the most recent values for the metrics
// defined for prometheus
func (pm *PrometheusMetrics) generateMetrics(csrs []certdb.CertificateRequest) {
var csrCount float64 = float64(len(csrs))
var outstandingCSRCount float64
var certCount float64
var expiredCertCount float64
var expiringIn1DayCertCount float64
var expiringIn7DaysCertCount float64
var expiringIn30DaysCertCount float64
var expiringIn90DaysCertCount float64
for _, entry := range csrs {
if entry.Certificate == "" {
outstandingCSRCount += 1
continue
}
if entry.Certificate == "rejected" {
continue
}
certCount += 1
expiryDate := certificateExpiryDate(entry.Certificate)
daysRemaining := time.Until(expiryDate).Hours() / 24
if daysRemaining < 0 {
expiredCertCount += 1
} else {
if daysRemaining < 1 {
expiringIn1DayCertCount += 1
}
if daysRemaining < 7 {
expiringIn7DaysCertCount += 1
}
if daysRemaining < 30 {
expiringIn30DaysCertCount += 1
}
if daysRemaining < 90 {
expiringIn90DaysCertCount += 1
}
}
}
pm.CertificateRequests.Set(csrCount)
pm.OutstandingCertificateRequests.Set(outstandingCSRCount)
pm.Certificates.Set(certCount)
saltiyazan marked this conversation as resolved.
Show resolved Hide resolved
pm.ExpiredCertificates.Set(expiredCertCount)
pm.CertificatesExpiringIn1Day.Set(expiringIn1DayCertCount)
pm.CertificatesExpiringIn7Days.Set(expiringIn7DaysCertCount)
pm.CertificatesExpiringIn30Days.Set(expiringIn30DaysCertCount)
pm.CertificatesExpiringIn90Days.Set(expiringIn90DaysCertCount)
}

func certificateRequestsMetric() prometheus.Gauge {
metric := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "certificate_requests",
Help: "Total number of certificate requests",
})
return metric
}

func outstandingCertificateRequestsMetric() prometheus.Gauge {
metric := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "outstanding_certificate_requests",
Help: "Number of outstanding certificate requests",
})
return metric
}

func certificatesMetric() prometheus.Gauge {
metric := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "certificates",
Help: "Total number of certificates provided to certificate requests",
})
return metric
}

func expiredCertificatesMetric() prometheus.Gauge {
metric := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "certificates_expired",
Help: "Number of expired certificates",
})
return metric
}

func certificatesExpiringIn1DayMetric() prometheus.Gauge {
metric := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "certificates_expiring_in_1_day",
Help: "Number of certificates expiring in less than 1 day",
})
return metric
}

func certificatesExpiringIn7DaysMetric() prometheus.Gauge {
metric := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "certificates_expiring_in_7_days",
Help: "Number of certificates expiring in less than 7 days",
})
return metric
}
func certificatesExpiringIn30DaysMetric() prometheus.Gauge {
metric := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "certificates_expiring_in_30_days",
Help: "Number of certificates expiring in less than 30 days",
})
return metric
}

func certificatesExpiringIn90DaysMetric() prometheus.Gauge {
metric := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "certificates_expiring_in_90_days",
Help: "Number of certificates expiring in less than 90 days",
})
return metric
}

func requestsTotalMetric() prometheus.CounterVec {
metric := prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Tracks the number of HTTP requests.",
}, []string{"method", "code"},
)
return *metric
}

func requestDurationMetric() prometheus.HistogramVec {
metric := prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Tracks the latencies for HTTP requests.",
Buckets: prometheus.ExponentialBuckets(0.1, 1.5, 5),
}, []string{"method", "code"},
)
return *metric
}

func certificateExpiryDate(certString string) time.Time {
certBlock, _ := pem.Decode([]byte(certString))
cert, _ := x509.ParseCertificate(certBlock.Bytes)
// TODO: cert.NotAfter can exist in a wrong cert. We should catch that at the db level validation
gruyaume marked this conversation as resolved.
Show resolved Hide resolved
return cert.NotAfter
}
Loading
Loading