Week 4 Mini Project: In-Memory Metrics Server
Build a self-contained metrics collection and query server. This is the observability layer you'll wire into your security proxy.
// metrics/server.go
package main
import (
"encoding/json"
"expvar"
"fmt"
"math"
"net/http"
_ "net/http/pprof"
"sort"
"sync"
"time"
)
type Sample struct {
Value float64
At time.Time
}
type MetricStore struct {
mu sync.RWMutex
metrics map[string]*RingBuffer[Sample]
maxAge time.Duration
}
func NewMetricStore(maxAge time.Duration) *MetricStore {
return &MetricStore{
metrics: make(map[string]*RingBuffer[Sample]),
maxAge: maxAge,
}
}
func (ms *MetricStore) Record(name string, value float64) {
ms.mu.Lock()
defer ms.mu.Unlock()
if _, ok := ms.metrics[name]; !ok {
ms.metrics[name] = NewRingBuffer[Sample](10000)
}
ms.metrics[name].Add(Sample{Value: value, At: time.Now()})
}
type Stats struct {
Count int `json:"count"`
Min float64 `json:"min"`
P50 float64 `json:"p50"`
P95 float64 `json:"p95"`
P99 float64 `json:"p99"`
Max float64 `json:"max"`
}
func (ms *MetricStore) Query(name string, window time.Duration) Stats {
ms.mu.RLock()
defer ms.mu.RUnlock()
buf, ok := ms.metrics[name]
if !ok { return Stats{} }
cutoff := time.Now().Add(-window)
var values []float64
for _, s := range buf.All() {
if s.At.After(cutoff) {
values = append(values, s.Value)
}
}
if len(values) == 0 { return Stats{} }
sort.Float64s(values)
pct := func(p float64) float64 {
idx := int(math.Ceil(p/100*float64(len(values)))) - 1
if idx < 0 { idx = 0 }
return values[idx]
}
return Stats{
Count: len(values),
Min: values[0],
P50: pct(50),
P95: pct(95),
P99: pct(99),
Max: values[len(values)-1],
}
}
func main() {
store := NewMetricStore(24 * time.Hour)
// expvar for live metrics
expvar.Publish("metric_names", expvar.Func(func() any {
return len(store.metrics)
}))
mux := http.NewServeMux()
// POST /metrics — ingest
mux.HandleFunc("POST /metrics", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Value float64 `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid JSON", 400)
return
}
store.Record(body.Name, body.Value)
w.WriteHeader(http.StatusNoContent)
})
// GET /metrics/{name}?window=60s — query
mux.HandleFunc("GET /metrics/", func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/metrics/")
windowStr := r.URL.Query().Get("window")
window, err := time.ParseDuration(windowStr)
if err != nil { window = time.Minute }
stats := store.Query(name, window)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
})
fmt.Println("metrics server on :8080")
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
srv.ListenAndServe()
}
Test it:
# Ingest metrics
curl -X POST http://localhost:8080/metrics \
-d '{"name":"request_latency_ms","value":45.2}'
# Query with 60s window
curl "http://localhost:8080/metrics/request_latency_ms?window=60s"
# {"count":1,"min":45.2,"p50":45.2,"p95":45.2,"p99":45.2,"max":45.2}
Engineer Takeaway
This is a miniature Prometheus in ~200 lines. You now know why observability systems need ring buffers (bounded memory), percentile queries (p99 is what users experience, not average), and structured ingestion endpoints. This metrics server wires into every component you build in Phases 2 and 3.