Files
o11y/metrics/store.go
zeekay a6c7c4ec94 feat(metrics): the retired module's door and store land here, under /v1/metrics
github.com/hanzoai/metrics carries a NOTICE naming this module its successor,
and it is archived and read-only. What the NOTICE did not say is that the code
was still running: hanzoai/cloud went on importing the archive and mounting it
for /v1/metrics, /v1/logs and /v1/traces, so a repository nobody can push to
held eleven live routes and the store behind them.

A succession is only finished when the successor has the code. It arrives whole
— the per-tenant WAL store, the ZAP MetricBatch receiver, the routes and their
tests — and the tests pass here unchanged but for the addresses.

The addresses fold per HIP-1241: every route is under /v1/metrics, so the two
foreign roots the archive answered on become /v1/metrics/logs/* and
/v1/metrics/traces/*. Forward only; nothing answers at the old spelling.

This store is its own. The event.* columnar plane the rest of this module reads
is a different store of a different shape, and the succession note's claim that
o11y already held this signal store was true only of that plane — these eleven
routes had no home here until now. They share the module and nothing else.

The tenant decision still comes from the host (Deps.Org), so the boundary that
authenticates keeps naming the org and this package never reads a header.
2026-08-20 20:32:19 -07:00

174 lines
5.1 KiB
Go

// Package metrics is the native, ZAP-native, prometheus-free time-series store
// for the Hanzo cloud — the metrics capability of HIP-1241, served at
// /v1/metrics. It replaces the vendored Grafana/Prometheus observability
// backends with a small in-process store that ingests luxfi/metric.MetricBatch
// (the same wire shape the ZAP MsgMetricBatch transport carries) and serves
// range queries under /v1/metrics/*.
//
// The storage API is deliberately tiny (Append + Query + SeriesCount) so a
// durable per-tenant backend (DataDir-backed, columnar) can replace the in-memory
// map without changing mount.go or ingest.go. There is ZERO prometheus here.
package metrics
import (
"encoding/json"
"sort"
"strings"
"sync"
)
// Sample is a single timestamped value. Ts is nanoseconds since the Unix epoch
// to match luxfi/metric.MetricBatch.TimestampNs.
type Sample struct {
TsNs int64 `json:"t"`
Value float64 `json:"v"`
}
// Series is a named, labeled append-only stream of samples.
type Series struct {
Name string `json:"name"`
Labels map[string]string `json:"labels,omitempty"`
Samples []Sample `json:"samples"`
}
// Store is the native in-memory time-series store. Each series is bounded to
// maxPerSeries samples (oldest evicted first). Safe for concurrent use.
type Store struct {
mu sync.RWMutex
series map[string]*Series
maxPerSeries int
wal *WAL // nil = in-memory only
}
// NewStore returns an empty store with a default per-series retention of 64Ki
// samples (a real deployment sets this from config / per-tenant quota).
func NewStore() *Store {
return &Store{series: make(map[string]*Series), maxPerSeries: 1 << 16}
}
// seriesKey is the canonical identity of a series: name plus label set in a
// stable (sorted) order. NUL separators keep it injective.
func seriesKey(name string, labels map[string]string) string {
if len(labels) == 0 {
return name
}
keys := make([]string, 0, len(labels))
for k := range labels {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString(name)
for _, k := range keys {
b.WriteByte(0)
b.WriteString(k)
b.WriteByte('=')
b.WriteString(labels[k])
}
return b.String()
}
// EnableDurability opens a write-ahead log at path and replays it into the
// store so samples survive restart. Replayed samples are not re-logged. Pass a
// per-deployment path (e.g. <DataDir>/metrics/metrics.wal).
func (s *Store) EnableDurability(path string) error {
w, err := OpenWAL(path)
if err != nil {
return err
}
if err := w.Replay(func(rec []byte) {
var ws walSample
if json.Unmarshal(rec, &ws) == nil {
s.appendMem(ws.Name, ws.Labels, ws.Sample)
}
}); err != nil {
return err
}
s.mu.Lock()
s.wal = w
s.mu.Unlock()
return nil
}
// walSample is the on-disk record for one appended sample.
type walSample struct {
Name string `json:"n"`
Labels map[string]string `json:"l,omitempty"`
Sample Sample `json:"s"`
}
// Append adds one sample to the series identified by (name, labels), creating
// the series on first write and evicting the oldest sample past retention. When
// durability is enabled the sample is also written to the WAL.
func (s *Store) Append(name string, labels map[string]string, smp Sample) {
s.appendMem(name, labels, smp)
if s.wal != nil {
if rec, err := json.Marshal(walSample{Name: name, Labels: labels, Sample: smp}); err == nil {
_ = s.wal.Append(rec)
}
}
}
// appendMem applies a sample to the in-memory index only (used by Append and by
// WAL replay, which must not re-log).
func (s *Store) appendMem(name string, labels map[string]string, smp Sample) {
k := seriesKey(name, labels)
s.mu.Lock()
defer s.mu.Unlock()
ser := s.series[k]
if ser == nil {
ser = &Series{Name: name, Labels: labels}
s.series[k] = ser
}
ser.Samples = append(ser.Samples, smp)
if len(ser.Samples) > s.maxPerSeries {
ser.Samples = ser.Samples[len(ser.Samples)-s.maxPerSeries:]
}
}
// Query returns copies of every series whose Name equals name (or all, if name
// is "") and whose labels are a superset of matchers, with samples restricted to
// [startNs, endNs] (a zero bound is treated as unbounded).
func (s *Store) Query(name string, matchers map[string]string, startNs, endNs int64) []Series {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Series, 0, 8)
for _, ser := range s.series {
if name != "" && ser.Name != name {
continue
}
if !labelsSupersetOf(ser.Labels, matchers) {
continue
}
smps := make([]Sample, 0, len(ser.Samples))
for _, smp := range ser.Samples {
if startNs != 0 && smp.TsNs < startNs {
continue
}
if endNs != 0 && smp.TsNs > endNs {
continue
}
smps = append(smps, smp)
}
out = append(out, Series{Name: ser.Name, Labels: ser.Labels, Samples: smps})
}
return out
}
// labelsSupersetOf reports whether have contains every key/value pair in want.
func labelsSupersetOf(have, want map[string]string) bool {
for k, v := range want {
if have[k] != v {
return false
}
}
return true
}
// SeriesCount reports the number of distinct series held (surfaced on /health).
func (s *Store) SeriesCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.series)
}