Files
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

104 lines
2.5 KiB
Go

package metrics
import (
"encoding/json"
"strings"
"sync"
)
// LogRecord is one structured log line — the native, prometheus-free, Loki-free
// log signal. Bodies are stored verbatim; labels are the indexed dimensions.
type LogRecord struct {
TsNs int64 `json:"t"`
Level string `json:"level,omitempty"`
Body string `json:"body"`
Labels map[string]string `json:"labels,omitempty"`
}
// LogStore is the native log store: an append-only bounded ring with label +
// time-range + case-insensitive substring query. Durable via the shared WAL.
type LogStore struct {
mu sync.RWMutex
records []LogRecord
max int
wal *WAL
}
// NewLogStore returns an empty store retaining up to 1Mi records in memory.
func NewLogStore() *LogStore { return &LogStore{max: 1 << 20} }
// EnableDurability opens and replays a WAL at path (e.g. <DataDir>/logs/logs.wal).
func (s *LogStore) EnableDurability(path string) error {
w, err := OpenWAL(path)
if err != nil {
return err
}
if err := w.Replay(func(rec []byte) {
var lr LogRecord
if json.Unmarshal(rec, &lr) == nil {
s.appendMem(lr)
}
}); err != nil {
return err
}
s.mu.Lock()
s.wal = w
s.mu.Unlock()
return nil
}
// Append stores one log record (and durably logs it when durability is on).
func (s *LogStore) Append(lr LogRecord) {
s.appendMem(lr)
if s.wal != nil {
if rec, err := json.Marshal(lr); err == nil {
_ = s.wal.Append(rec)
}
}
}
func (s *LogStore) appendMem(lr LogRecord) {
s.mu.Lock()
defer s.mu.Unlock()
s.records = append(s.records, lr)
if len(s.records) > s.max {
s.records = s.records[len(s.records)-s.max:]
}
}
// Query returns up to limit records (newest first) matching labels, the
// [startNs,endNs] range, and an optional case-insensitive substring of Body.
func (s *LogStore) Query(matchers map[string]string, startNs, endNs int64, contains string, limit int) []LogRecord {
if limit <= 0 {
limit = 100
}
contains = strings.ToLower(contains)
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]LogRecord, 0, limit)
for i := len(s.records) - 1; i >= 0 && len(out) < limit; i-- {
r := s.records[i]
if startNs != 0 && r.TsNs < startNs {
continue
}
if endNs != 0 && r.TsNs > endNs {
continue
}
if !labelsSupersetOf(r.Labels, matchers) {
continue
}
if contains != "" && !strings.Contains(strings.ToLower(r.Body), contains) {
continue
}
out = append(out, r)
}
return out
}
// Count reports the number of records held.
func (s *LogStore) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.records)
}