Skip to content

Commit

Permalink
Merge pull request #492 from bitcoin-sv/feat-576-request-metrics
Browse files Browse the repository at this point in the history
feat(BUX-576) request metrics
  • Loading branch information
chris-4chain authored Mar 14, 2024
2 parents a5563e0 + fe26ba5 commit eff4e35
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 15 deletions.
1 change: 1 addition & 0 deletions engine/metrics/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ type Collector interface {
RegisterGauge(name string) prometheus.Gauge
RegisterGaugeVec(name string, labels ...string) *prometheus.GaugeVec
RegisterHistogramVec(name string, labels ...string) *prometheus.HistogramVec
RegisterCounterVec(name string, labels ...string) *prometheus.CounterVec
}
16 changes: 14 additions & 2 deletions metrics/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ type PrometheusCollector struct {
reg prometheus.Registerer
}

// NewPrometheusCollector creates a new PrometheusCollector.
func NewPrometheusCollector(reg prometheus.Registerer) enginemetrics.Collector {
func newPrometheusCollector(reg prometheus.Registerer) enginemetrics.Collector {
return &PrometheusCollector{reg: reg}
}

Expand Down Expand Up @@ -52,3 +51,16 @@ func (c *PrometheusCollector) RegisterHistogramVec(name string, labels ...string
c.reg.MustRegister(h)
return h
}

// RegisterCounterVec creates a new CounterVec and registers it with the collector.
func (c *PrometheusCollector) RegisterCounterVec(name string, labels ...string) *prometheus.CounterVec {
counter := prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: name,
Help: "CounterVec of " + name,
},
labels,
)
c.reg.MustRegister(counter)
return counter
}
38 changes: 38 additions & 0 deletions metrics/gin_middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package metrics

import (
"github.com/gin-gonic/gin"
)

var notFoundContextKey = "routeNotFound"

func requestMetricsMiddleware() gin.HandlerFunc {
if metrics, enabled := Get(); enabled {
return func(c *gin.Context) {
tracker := metrics.httpRequests.Track(c.Request.Method, c.Request.URL.Path)
tracker.Start()
defer func() {
if _, noRoute := c.Get(notFoundContextKey); noRoute {
tracker.EndWithNoRoute()
} else {
// note that the status code will be correct only if higher middleware doesn't change the status code;
// order of middlewares matters
tracker.End(c.Writer.Status())
}
}()

c.Next()
}
}

return func(c *gin.Context) {
c.Next()
}
}

// NoRoute is a middleware to set a flag in the context if the route is actually not found
// this is needed to distinguish no-route 404 from other 404s
func NoRoute(c *gin.Context) {
c.Set(notFoundContextKey, true)
c.Next()
}
15 changes: 13 additions & 2 deletions metrics/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,28 @@ package metrics

import (
enginemetrics "github.com/bitcoin-sv/spv-wallet/engine/metrics"
"github.com/gin-gonic/gin"
)

var metrics *Metrics

// EnableMetrics will enable the metrics for the application
func EnableMetrics() enginemetrics.Collector {
metrics = newMetrics()
return NewPrometheusCollector(metrics.registerer)
var collector enginemetrics.Collector
metrics, collector = newMetrics()
return collector
}

// Get will return the metrics if enabled
func Get() (m *Metrics, enabled bool) {
return metrics, metrics != nil
}

// SetupGin will register the metrics with the gin engine
// NOTE: Remember to add the metrics.NoRoute function to ginEngine.NoRoute
func SetupGin(ginEngine *gin.Engine) {
if metrics != nil {
ginEngine.Use(requestMetricsMiddleware())
ginEngine.GET("/metrics", gin.WrapH(metrics.HTTPHandler()))
}
}
17 changes: 11 additions & 6 deletions metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,33 @@ package metrics
import (
"net/http"

enginemetrics "github.com/bitcoin-sv/spv-wallet/engine/metrics"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

// Metrics is the metrics collector
type Metrics struct {
gatherer prometheus.Gatherer
registerer prometheus.Registerer
gatherer prometheus.Gatherer
registerer prometheus.Registerer
httpRequests *RequestMetrics
}

// newMetrics is private to ensure that only one global-instance is created
func newMetrics() *Metrics {
func newMetrics() (*Metrics, enginemetrics.Collector) {
registry := prometheus.NewRegistry()
constLabels := prometheus.Labels{"app": appName}
registererWithLabels := prometheus.WrapRegistererWith(constLabels, registry)

collector := newPrometheusCollector(registererWithLabels)

m := &Metrics{
gatherer: registry,
registerer: registererWithLabels,
gatherer: registry,
registerer: registererWithLabels,
httpRequests: registerRequestMetrics(collector),
}

return m
return m, collector
}

// HTTPHandler will return the http.Handler for the metrics
Expand Down
6 changes: 6 additions & 0 deletions metrics/naming.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
package metrics

const appName = "spv-wallet"

const (
requestMetricBaseName = "http_request"
requestCounterName = requestMetricBaseName + "_total"
requestDurationSecName = requestMetricBaseName + "_duration_seconds"
)
75 changes: 75 additions & 0 deletions metrics/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package metrics

import (
"fmt"
"time"

enginemetrics "github.com/bitcoin-sv/spv-wallet/engine/metrics"
"github.com/prometheus/client_golang/prometheus"
)

// RequestMetrics is the metrics for the http requests
type RequestMetrics struct {
requestsTotal *prometheus.CounterVec
requestDuration *prometheus.HistogramVec
}

func registerRequestMetrics(collector enginemetrics.Collector) *RequestMetrics {
requestsTotal := collector.RegisterCounterVec(requestCounterName, "method", "path", "status", "classification")
requestDuration := collector.RegisterHistogramVec(requestDurationSecName, "method", "path")

return &RequestMetrics{
requestsTotal: requestsTotal,
requestDuration: requestDuration,
}
}

// Track will return a RequestTracker to track the request
func (m *RequestMetrics) Track(method, path string) *RequestTracker {
return &RequestTracker{
method: method,
path: path,
metrics: m,
}
}

// RequestTracker is used to track the duration and status of a request
type RequestTracker struct {
method string
path string
startTime time.Time
metrics *RequestMetrics
}

// Start will start the tracking of the request
func (r *RequestTracker) Start() {
r.startTime = time.Now()
}

// End will end the tracking of the request
func (r *RequestTracker) End(status int) {
r.writeCounter(status, r.path)
r.writeDuration()
}

// EndWithNoRoute will end the tracking of the request with a 404 status
func (r *RequestTracker) EndWithNoRoute() {
// This is a safeguard against attacks where the server is flooded with requests having unique paths,
// which would lead to the creation of a large number of metrics
r.writeCounter(404, "UNKNOWN_ROUTE")
}

func (r *RequestTracker) writeCounter(status int, path string) {
r.metrics.requestsTotal.WithLabelValues(r.method, path, fmt.Sprint(status), requestClassification(status)).Inc()
}

func (r *RequestTracker) writeDuration() {
r.metrics.requestDuration.WithLabelValues(r.method, r.path).Observe(time.Since(r.startTime).Seconds())
}

func requestClassification(status int) string {
if status >= 200 && status < 400 {
return "success"
}
return "failure"
}
8 changes: 3 additions & 5 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ func (s *Server) Handlers() *gin.Engine {
engine.Use(logging.GinMiddleware(&httpLogger), gin.Recovery())
engine.Use(auth.CorsMiddleware())

metrics.SetupGin(engine)

s.Router = engine

segment.End()
Expand Down Expand Up @@ -177,17 +179,13 @@ func SetupServerRoutes(appConfig *config.AppConfig, services *config.AppServices
services.SpvWalletEngine.GetPaymailConfig().RegisterRoutes(engine)

// Set the 404 handler (any request not detected)
engine.NoRoute(actions.NotFound)
engine.NoRoute(metrics.NoRoute, actions.NotFound)

// Set the method not allowed
engine.NoMethod(actions.MethodNotAllowed)

registerSwaggerEndpoints(engine)

if metrics, enabled := metrics.Get(); enabled {
engine.GET("/metrics", gin.WrapH(metrics.HTTPHandler()))
}

if appConfig.DebugProfiling {
pprof.Register(engine, "debug/pprof")
}
Expand Down

0 comments on commit eff4e35

Please sign in to comment.