The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
163 lines
4.8 KiB
Go
163 lines
4.8 KiB
Go
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)
|
|
})
|
|
}
|