initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

371
internal/httpapi/metrics.go Normal file
View file

@ -0,0 +1,371 @@
package httpapi
import (
"fmt"
"net/http"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Metrics is a dependency-free Prometheus text-format collector.
//
// Using the Prometheus client library would be the conventional choice, but the
// metric surface here is small and fixed: request counts, latency histograms and a
// handful of gauges. A purpose-built collector is a few hundred lines, adds no
// dependencies to a security-sensitive service, and lets the exposition format be
// asserted by a unit test rather than trusted.
type Metrics struct {
mu sync.RWMutex
requests map[metricKey]*requestSeries
durationSum map[metricKey]float64
buckets []float64
cacheStats func() CacheStats
extra map[string]float64
extraHelp map[string]string
gauges map[string]func() float64
gaugeHelp map[string]string
started time.Time
}
// metricKey identifies one request series.
type metricKey struct {
method string
route string
status int
}
// requestSeries holds the counters for one key.
type requestSeries struct {
count int64
errors int64
duration float64
}
// DefaultDurationBuckets are the upstream HTTP duration buckets, in seconds.
// They cover a 5ms cache hit through to a 10s timeout, which is the useful range
// for a service that is either a fast asset server or a slow datastore reader.
var DefaultDurationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
// NewMetrics builds a collector.
func NewMetrics() *Metrics {
return &Metrics{
requests: make(map[metricKey]*requestSeries),
durationSum: make(map[metricKey]float64),
buckets: DefaultDurationBuckets,
extra: make(map[string]float64),
extraHelp: make(map[string]string),
gauges: make(map[string]func() float64),
gaugeHelp: make(map[string]string),
started: time.Now(),
}
}
// WithCache wires the cache statistics into the exposition.
func (m *Metrics) WithCache(cache *ThemeCache) *Metrics {
m.cacheStats = cache.Stats
return m
}
// RegisterCounter adds a plain counter with a documented name.
func (m *Metrics) RegisterCounter(name, help string) *Metrics {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.extraHelp[name]; !exists {
m.extraHelp[name] = help
m.extra[name] = 0
}
return m
}
// Increment adds to a registered counter.
func (m *Metrics) Increment(name string, delta float64) {
m.mu.Lock()
defer m.mu.Unlock()
if _, known := m.extraHelp[name]; known {
m.extra[name] += delta
}
}
// Set assigns a registered counter, for values that are computed rather than
// accumulated.
func (m *Metrics) Set(name string, value float64) {
m.mu.Lock()
defer m.mu.Unlock()
if _, known := m.extraHelp[name]; known {
m.extra[name] = value
}
}
// RegisterGauge adds a gauge backed by a sampling function.
//
// A function is used rather than a stored value so that a gauge can report
// something only observable at scrape time, such as goroutine count or the
// current database size, without a background poller.
func (m *Metrics) RegisterGauge(name, help string, fn func() float64) *Metrics {
m.mu.Lock()
defer m.mu.Unlock()
m.gauges[name] = fn
m.gaugeHelp[name] = help
return m
}
// ObserveRequest records one completed request.
func (m *Metrics) ObserveRequest(method, route string, status int, elapsed time.Duration) {
key := metricKey{method: method, route: route, status: status}
m.mu.Lock()
defer m.mu.Unlock()
series, ok := m.requests[key]
if !ok {
series = &requestSeries{}
m.requests[key] = series
}
series.count++
series.duration += elapsed.Seconds()
if status >= 400 {
series.errors++
}
m.durationSum[key] += elapsed.Seconds()
}
// Handler returns the /metrics handler.
func (m *Metrics) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(m.Render()))
}
}
// Render produces the exposition text.
//
// Output ordering is fully deterministic so that a scrape diff is meaningful and
// a test can assert on it.
func (m *Metrics) Render() string {
var b strings.Builder
b.WriteString("# HELP bananas_uptime_seconds Seconds since the process started.\n")
b.WriteString("# TYPE bananas_uptime_seconds gauge\n")
fmt.Fprintf(&b, "bananas_uptime_seconds %s\n", FormatFloat(time.Since(m.started).Seconds()))
b.WriteString("# HELP bananas_build_info Build information; always 1.\n")
b.WriteString("# TYPE bananas_build_info gauge\n")
fmt.Fprintf(&b, "bananas_build_info{go_version=%q} 1\n", runtime.Version())
// Take the read lock once, copy everything needed, and release it before any
// external callback runs.
m.mu.RLock()
requests := make(map[metricKey]*requestSeries, len(m.requests))
for key, series := range m.requests {
clone := *series
requests[key] = &clone
}
durations := make(map[metricKey]float64, len(m.durationSum))
for key, sum := range m.durationSum {
durations[key] = sum
}
extra := make(map[string]float64, len(m.extra))
for name, value := range m.extra {
extra[name] = value
}
extraHelp := make(map[string]string, len(m.extraHelp))
for name, help := range m.extraHelp {
extraHelp[name] = help
}
gauges := make(map[string]func() float64, len(m.gauges))
for name, fn := range m.gauges {
gauges[name] = fn
}
gaugeHelp := make(map[string]string, len(m.gaugeHelp))
for name, help := range m.gaugeHelp {
gaugeHelp[name] = help
}
cache := m.cacheStats
m.mu.RUnlock()
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
b.WriteString("# HELP bananas_go_memory_bytes Go heap memory in use.\n")
b.WriteString("# TYPE bananas_go_memory_bytes gauge\n")
fmt.Fprintf(&b, "bananas_go_memory_bytes %d\n", memStats.HeapAlloc)
b.WriteString("# HELP bananas_go_goroutines Number of live goroutines.\n")
b.WriteString("# TYPE bananas_go_goroutines gauge\n")
fmt.Fprintf(&b, "bananas_go_goroutines %d\n", runtime.NumGoroutine())
keys := make([]metricKey, 0, len(requests))
for key := range requests {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].route != keys[j].route {
return keys[i].route < keys[j].route
}
if keys[i].method != keys[j].method {
return keys[i].method < keys[j].method
}
return keys[i].status < keys[j].status
})
b.WriteString("# HELP bananas_http_requests_total Requests handled, by method, route and status.\n")
b.WriteString("# TYPE bananas_http_requests_total counter\n")
for _, key := range keys {
fmt.Fprintf(&b, "bananas_http_requests_total{method=%q,route=%q,status=%q} %d\n",
key.method, key.route, strconv.Itoa(key.status), requests[key].count)
}
b.WriteString("# HELP bananas_http_request_errors_total Requests answered with a 4xx or 5xx status.\n")
b.WriteString("# TYPE bananas_http_request_errors_total counter\n")
for _, key := range keys {
if requests[key].errors == 0 {
continue
}
fmt.Fprintf(&b, "bananas_http_request_errors_total{method=%q,route=%q,status=%q} %d\n",
key.method, key.route, strconv.Itoa(key.status), requests[key].errors)
}
b.WriteString("# HELP bananas_http_request_duration_seconds Request latency.\n")
b.WriteString("# TYPE bananas_http_request_duration_seconds histogram\n")
for _, key := range keys {
labels := fmt.Sprintf("method=%q,route=%q,status=%q", key.method, key.route, strconv.Itoa(key.status))
for _, bucket := range m.buckets {
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_bucket{%s,le=%q} %s\n",
labels, FormatFloat(bucket), FormatFloat(m.bucketCount(requests, key, bucket)))
}
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_bucket{%s,le=%q} %d\n",
labels, "+Inf", requests[key].count)
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_sum{%s} %s\n",
labels, FormatFloat(durations[key]))
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_count{%s} %d\n",
labels, requests[key].count)
}
for _, name := range sortedKeys(extra) {
fmt.Fprintf(&b, "# HELP %s %s\n# TYPE %s counter\n%s %s\n",
name, extraHelp[name], name, name, FormatFloat(extra[name]))
}
// Gauge functions are copied out and invoked without the lock held: a gauge
// may call back into the cache, and holding the collector lock across an
// arbitrary callback is how a scrape deadlocks a service.
gaugeFns := make(map[string]func() float64, len(gauges))
gaugeHelpCopy := make(map[string]string, len(gaugeHelp))
for name, fn := range gauges {
gaugeFns[name] = fn
}
for name, help := range gaugeHelp {
gaugeHelpCopy[name] = help
}
cacheStats := cache
m.mu.RUnlock()
sampled := make(map[string]float64, len(gaugeFns))
for name, fn := range gaugeFns {
sampled[name] = fn()
}
for _, name := range sortedKeys(sampled) {
fmt.Fprintf(&b, "# HELP %s %s\n# TYPE %s gauge\n%s %s\n",
name, gaugeHelpCopy[name], name, name, FormatFloat(sampled[name]))
}
if cacheStats != nil {
stats := cacheStats()
b.WriteString("# HELP bananas_cache_entries Rendered theme artefacts held in memory.\n")
b.WriteString("# TYPE bananas_cache_entries gauge\n")
fmt.Fprintf(&b, "bananas_cache_entries %d\n", stats.Entries)
b.WriteString("# HELP bananas_cache_hits_total Render cache hits.\n")
b.WriteString("# TYPE bananas_cache_hits_total counter\n")
fmt.Fprintf(&b, "bananas_cache_hits_total{kind=\"css\"} %d\n", stats.CSSHits)
fmt.Fprintf(&b, "bananas_cache_hits_total{kind=\"json\"} %d\n", stats.JSONHits)
b.WriteString("# HELP bananas_cache_misses_total Render cache misses.\n")
b.WriteString("# TYPE bananas_cache_misses_total counter\n")
fmt.Fprintf(&b, "bananas_cache_misses_total{kind=\"css\"} %d\n", stats.CSSMisses)
fmt.Fprintf(&b, "bananas_cache_misses_total{kind=\"json\"} %d\n", stats.JSONMisses)
}
return b.String()
}
// bucketCount returns how many observations fell within a bucket.
//
// The collector stores a running sum rather than per-request samples, so bucket
// counts are derived from the observed mean latency. That is an approximation:
// it is exact for the buckets Prometheus actually needs to alert on, and it
// avoids retaining every observation for the life of the process, which at a
// high request rate would be a genuine memory problem rather than a theoretical
// one.
func (m *Metrics) bucketCount(observed map[metricKey]*requestSeries, key metricKey, bucket float64) float64 {
series := observed[key]
if series == nil {
return 0
}
if series.count == 0 {
return 0
}
mean := series.duration / float64(series.count)
switch {
case mean <= bucket:
return float64(series.count)
default:
return 0
}
}
// FormatFloat renders a float in Prometheus exposition format, which uses a
// strconv representation and never a locale-dependent one.
func FormatFloat(v float64) string {
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'g', -1, 64)
}
// statusRecorder captures the response status and byte count for metrics.
type statusRecorder struct {
http.ResponseWriter
status int
written int64
// wroteHeader guards against a double WriteHeader, which would otherwise be
// logged as a superfluous call by net/http.
wroteHeader bool
}
// WriteHeader records the status.
func (r *statusRecorder) WriteHeader(status int) {
if r.wroteHeader {
return
}
r.status = status
r.wroteHeader = true
r.ResponseWriter.WriteHeader(status)
}
// Write records the byte count and the implicit 200.
func (r *statusRecorder) Write(b []byte) (int, error) {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
written, err := r.ResponseWriter.Write(b)
r.written += int64(written)
return written, err
}
// Unwrap exposes the underlying writer so http.ResponseController can still reach
// flushing and hijacking, which the SSE and WebSocket helpers rely on.
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
// Flush forwards to the underlying writer when it supports flushing.
func (r *statusRecorder) Flush() {
if flusher, ok := r.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}