package telemetry import ( "crypto/sha256" "crypto/subtle" "net/http" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" "go.uber.org/fx" "sneak.berlin/go/simplexcalc/internal/config" ) // durationBuckets span a fast in-process handler through a slow // upstream call. Prometheus's defaults top out at 10s, which hides the // tail this service's request timeout permits. // //nolint:gochecknoglobals // a bucket list is a declaration, not mutable state. var durationBuckets = []float64{ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, } // MetricsParams defines dependencies for Metrics. type MetricsParams struct { fx.In Config *config.Config } // Metrics owns the registry and the HTTP series. A private registry, // not the global default: what this process exports is then exactly // what this code registered, and a linked library cannot quietly add to // it. type Metrics struct { registry *prometheus.Registry requests *prometheus.CounterVec duration *prometheus.HistogramVec inflight prometheus.Gauge user string password string } // NewMetrics builds the registry and registers the collectors. func NewMetrics(params MetricsParams) (*Metrics, error) { m := &Metrics{ registry: prometheus.NewRegistry(), user: params.Config.MetricsUser, password: params.Config.MetricsPassword, } m.requests = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "http_requests_total", Help: "Total HTTP requests by method, route pattern and status code.", }, // The route PATTERN, never the path: labelling by path turns // every distinct URL into a new time series, and a crawler // then owns the memory of the process. []string{"method", "route", "code"}, ) m.duration = prometheus.NewHistogramVec( prometheus.HistogramOpts{ Name: "http_request_duration_seconds", Help: "HTTP request duration by method and route pattern.", Buckets: durationBuckets, }, []string{"method", "route"}, ) m.inflight = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "http_requests_in_flight", Help: "HTTP requests currently being served.", }) m.registry.MustRegister( m.requests, m.duration, m.inflight, collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), ) return m, nil } // Observe records one finished request. func (m *Metrics) Observe(method, route, code string, d time.Duration) { m.requests.WithLabelValues(method, route, code).Inc() m.duration.WithLabelValues(method, route).Observe(d.Seconds()) } // InFlightAdd adjusts the in-flight gauge. func (m *Metrics) InFlightAdd(delta float64) { m.inflight.Add(delta) } // Registry exposes the registry so tests can gather what was recorded. func (m *Metrics) Registry() *prometheus.Registry { return m.registry } // AuthRequired reports whether /metrics is credential-gated. func (m *Metrics) AuthRequired() bool { return m.user != "" && m.password != "" } // Handler serves the exposition format, behind HTTP basic auth when // credentials are configured. // // Metrics are not public: they leak route names, traffic volume, // version and process memory layout. When no credentials are set the // endpoint is served open, which is correct for a private network and // documented as such in the README; config refuses the half-configured // case, so "open" is always something the operator chose rather than // something a typo produced. func (m *Metrics) Handler() http.Handler { h := promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{ // A collector that errors should not take the scrape down // with a 500 the operator has to go and interpret. ErrorHandling: promhttp.ContinueOnError, }) if !m.AuthRequired() { return h } return m.basicAuth(h) } // basicAuth gates h. Comparison is over SHA-256 digests through // subtle.ConstantTimeCompare: comparing the raw strings would leak the // credential length and the position of the first wrong byte through // timing, and hashing first makes the comparison fixed-width. func (m *Metrics) basicAuth(h http.Handler) http.Handler { wantUser := sha256.Sum256([]byte(m.user)) wantPass := sha256.Sum256([]byte(m.password)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() if ok { gotUser := sha256.Sum256([]byte(user)) gotPass := sha256.Sum256([]byte(pass)) userOK := subtle.ConstantTimeCompare(gotUser[:], wantUser[:]) == 1 passOK := subtle.ConstantTimeCompare(gotPass[:], wantPass[:]) == 1 if userOK && passOK { h.ServeHTTP(w, r) return } } w.Header().Set("WWW-Authenticate", `Basic realm="metrics", charset="UTF-8"`) http.Error(w, "unauthorized", http.StatusUnauthorized) }) }