Recipes
Application metrics
The pkg/metrics package gives every Stanza app a built-in time-series database — no Prometheus, no Grafana, no external services. This recipe shows how to wire it into a Stanza app, instrument HTTP requests, record custom business metrics, and expose admin API endpoints for querying.
Wiring the metrics store
Create and start the store in your lifecycle setup. Point it at a subdirectory of your data dir:
func provideMetricsStore(dir datadir.Dir, logger *log.Logger,
lc *lifecycle.Lifecycle) *metrics.Store {
store := metrics.New(dir.Metrics,
metrics.WithSystemMetrics(),
metrics.WithLogger(logger),
)
lc.Append(lifecycle.Hook{
OnStart: store.Start,
OnStop: store.Stop,
})
return store
}
Add the data directory field in your datadir struct:
type Dir struct {
Root string
Metrics string // e.g. ~/.stanza/metrics/
// ...
}
The store starts collecting Go runtime metrics automatically (goroutines, heap, GC) and flushes buffered samples to disk every 5 seconds.
HTTP metrics middleware
Record every HTTP request as two metrics — a request counter and a duration measurement:
func httpMetricsRecorder(store *metrics.Store) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(rec, r)
path := normalizePath(r.URL.Path)
status := strconv.Itoa(rec.status)
elapsed := float64(time.Since(start).Milliseconds())
store.Record("http_requests", 1,
"method", r.Method, "path", path, "status", status)
store.Record("http_request_duration_ms", elapsed,
"method", r.Method, "path", path, "status", status)
})
}
}
Status recorder
Capture the response status code without breaking the response chain. Include Flush() support so SSE streaming works through the middleware:
type statusRecorder struct {
http.ResponseWriter
status int
wroteHeader bool
}
func (r *statusRecorder) WriteHeader(code int) {
if !r.wroteHeader {
r.status = code
r.wroteHeader = true
}
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
Path normalization
Replace numeric IDs and UUIDs in paths with {id} to keep label cardinality bounded:
func normalizePath(p string) string {
parts := strings.Split(p, "/")
for i, part := range parts {
if isID(part) {
parts[i] = "{id}"
}
}
return strings.Join(parts, "/")
}
func isID(s string) bool {
if s == "" {
return false
}
// Numeric IDs.
allDigit := true
for _, c := range s {
if c < '0' || c > '9' {
allDigit = false
break
}
}
if allDigit {
return true
}
// UUIDs (8-4-4-4-12 hex pattern).
if len(s) == 36 && s[8] == '-' && s[13] == '-' && s[18] == '-' && s[23] == '-' {
return true
}
return false
}
Without normalization, /api/users/1, /api/users/2, /api/users/3 would create three separate series. With it, they all collapse to /api/users/{id}.
Register the middleware
Place it after any Prometheus middleware but before the request logger, so timing is accurate:
router.Use(httpMetricsRecorder(store))
Custom business metrics
Record any event that matters to your application. Business metrics follow the same Record API:
// Track signups by plan.
store.Record("user_signups", 1, "plan", "pro")
// Track revenue.
store.Record("order_revenue", 49.99, "currency", "USD", "tier", "premium")
// Track feature usage.
store.Record("feature_used", 1, "feature", "export", "format", "csv")
// Track error rates by type.
store.Record("app_errors", 1, "type", "validation", "module", "checkout")
Record is an in-memory append — it's cheap enough to call on every request, every event, every error. The background flush goroutine handles disk I/O.
Admin metrics API
Expose four endpoints under the admin scope for the admin panel to query metrics:
package adminmetrics
import (
"strings"
"time"
"github.com/stanza-go/framework/pkg/http"
"github.com/stanza-go/framework/pkg/metrics"
)
func Register(admin *http.Group, store *metrics.Store) {
admin.HandleFunc("GET /metrics/names", listNames(store))
admin.HandleFunc("GET /metrics/labels", listLabels(store))
admin.HandleFunc("GET /metrics/query", queryMetrics(store))
admin.HandleFunc("GET /metrics/stats", getStats(store))
}
List metric names
func listNames(store *metrics.Store) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
http.WriteJSON(w, http.StatusOK, map[string]any{
"names": store.Names(),
})
}
}
List label values
func listLabels(store *metrics.Store) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
key := r.URL.Query().Get("key")
if name == "" || key == "" {
http.WriteError(w, http.StatusBadRequest, "name and key required")
return
}
http.WriteJSON(w, http.StatusOK, map[string]any{
"values": store.LabelValues(name, key),
})
}
}
Query time-series
Parse time range, step, aggregation function, and optional label filters from query parameters:
func queryMetrics(store *metrics.Store) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
name := q.Get("name")
if name == "" {
http.WriteError(w, http.StatusBadRequest, "name required")
return
}
start, err := time.Parse(time.RFC3339, q.Get("start"))
if err != nil {
http.WriteError(w, http.StatusBadRequest, "invalid start time")
return
}
end, err := time.Parse(time.RFC3339, q.Get("end"))
if err != nil {
http.WriteError(w, http.StatusBadRequest, "invalid end time")
return
}
step, _ := time.ParseDuration(q.Get("step"))
if step == 0 {
step = 1 * time.Minute
}
fn := parseAggFn(q.Get("fn"))
labels := parseLabels(q.Get("labels"))
result, err := store.Query(metrics.Query{
Name: name,
Start: start,
End: end,
Step: step,
Fn: fn,
Labels: labels,
})
if err != nil {
http.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
// Map to JSON-friendly response.
series := make([]map[string]any, len(result.Series))
for i, s := range result.Series {
points := make([]map[string]any, len(s.Points))
for j, p := range s.Points {
points[j] = map[string]any{"t": p.T, "v": p.V}
}
series[i] = map[string]any{
"name": s.Name,
"labels": s.Labels,
"points": points,
}
}
http.WriteJSON(w, http.StatusOK, map[string]any{"series": series})
}
}
Store statistics
func getStats(store *metrics.Store) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
s := store.Stats()
http.WriteJSON(w, http.StatusOK, map[string]any{
"series_count": s.SeriesCount,
"partition_count": s.PartitionCount,
"disk_bytes": s.DiskBytes,
"oldest_date": s.OldestDate,
"newest_date": s.NewestDate,
})
}
}
Helper functions
func parseAggFn(s string) metrics.AggFn {
switch strings.ToLower(s) {
case "sum":
return metrics.Sum
case "min":
return metrics.Min
case "max":
return metrics.Max
case "count":
return metrics.Count
case "last":
return metrics.Last
default:
return metrics.Avg
}
}
func parseLabels(s string) map[string]string {
if s == "" {
return nil
}
labels := make(map[string]string)
for _, pair := range strings.Split(s, ",") {
kv := strings.SplitN(pair, "=", 2)
if len(kv) == 2 {
labels[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
}
return labels
}
Register under admin scope
In main.go, register the metrics module under the base admin scope (read-only system data, like the dashboard):
adminmetrics.Register(admin, store)
Querying with curl
Once the admin API is wired, test it:
# List all metric names.
curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:23710/api/admin/metrics/names | jq .
{
"names": [
"go_alloc_bytes_total",
"go_frees_total",
"go_gc_pause_ns",
"go_gc_runs",
"go_goroutines",
"go_heap_alloc_bytes",
"go_heap_inuse_bytes",
"go_heap_objects",
"go_mallocs_total",
"go_stack_inuse_bytes",
"go_sys_bytes",
"http_request_duration_ms",
"http_requests"
]
}
# Query HTTP requests in the last hour, summed per minute.
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:23710/api/admin/metrics/query?name=http_requests&start=$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ)&end=$(date -u +%Y-%m-%dT%H:%M:%SZ)&step=1m&fn=sum" | jq .
# Filter by label — only GET requests.
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:23710/api/admin/metrics/query?name=http_requests&start=2026-03-23T00:00:00Z&end=2026-03-23T23:59:59Z&step=5m&fn=sum&labels=method=GET" | jq .
# Average request duration for POST requests.
curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:23710/api/admin/metrics/query?name=http_request_duration_ms&start=2026-03-23T00:00:00Z&end=2026-03-23T23:59:59Z&step=5m&fn=avg&labels=method=POST" | jq .
Tips
- Normalize paths before recording. Without normalization, every unique URL with an ID creates a separate series. Use
{id}placeholders to keep cardinality manageable. - Label values must be bounded. Good labels:
method,status,plan,tier. Bad labels:user_id,request_id,email. Unbounded labels create series explosion. - Record counts as
1, not durations. For counting events (requests, signups, errors), record a value of1and useSumaggregation. For timing, record the actual duration and useAvgor percentile-style queries. - Use
Lastfor gauge-style metrics. System metrics (goroutines, heap size) are gauges —Lastgives you the most recent value in each bucket.Sumon a gauge is meaningless. - Admin API is read-only. The admin metrics endpoints only query data — they don't record metrics. Recording happens automatically via middleware and explicit
Recordcalls in your business logic.
See the Metrics reference for the full API, and the Client-Side Analytics recipe for frontend event tracking.