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