372 lines
12 KiB
Go
372 lines
12 KiB
Go
|
|
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()
|
||
|
|
}
|
||
|
|
}
|