Files
simplexcalc/internal/telemetry/metrics.go
T
clawbot f8ce8cef83 Seed from go-template-repo, renamed to simplexcalc
The template's files at a77fd30, without its history or LICENSE, after
script/rename simplexcalc.

Model: opus-5-5
2026-09-26 21:38:57 +00:00

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)
})
}