Files
hanzo-dev 282c39b509 refactor(metrics): HIP-0106 New/Mount — drop cloud + old-zip imports
Convert the metrics subsystem to the explicit composition-root form:
Mount(app *zip.App, deps metrics.Deps) with a narrow Deps{Logger,DataDir,Brand}.
Drops github.com/hanzoai/cloud and github.com/hanzoai/zip; imports zap-proto/zip
+ luxfi only. No init(), no cloud.Register. go build/vet/test green; dependency
graph free of hanzoai/cloud and hanzoai/zip.
2026-07-08 06:41:17 -07:00

191 lines
7.1 KiB
Go

// HIP-0106 native observability subsystem — New/Mount composition-root form.
//
// import "github.com/hanzoai/metrics"
// metrics.Mount(app, metrics.Deps{Logger: log, DataDir: dir, Brand: brand})
//
// One subsystem serves all three signals — metrics, logs, traces — under
// /v1/{metrics,logs,traces}/* on the shared zip.App. Storage is native and
// WAL-durable; ingest for metrics is luxfi/metric.MetricBatch (the ZAP
// MsgMetricBatch payload). Every request is scoped to a tenant via the
// gateway-minted X-Org-Id header (falling back to the deployment brand), so the
// same binary serves any tenant with hard data isolation. There is no
// prometheus, no Grafana, no scrape endpoint, no /api/ path.
//
// This package imports ONLY zap-proto/zip + luxfi (no hanzoai/cloud): it depends
// on the 3 things it uses — a logger, a data dir, and the brand — which it
// declares in its own Deps. The composition root (cmd/cloud) constructs those and
// calls Mount explicitly; there is no global registry and no init() side effect.
package metrics
import (
"net/http"
"os"
"strconv"
"strings"
luxlog "github.com/luxfi/log"
metric "github.com/luxfi/metric"
"github.com/zap-proto/zip"
)
// Version is surfaced on the /health routes.
const Version = "0.4.0"
// reg is the process-global per-tenant store registry, initialised in Mount.
var reg *Registry
// brand is the deployment brand, used as the default org when a request carries
// no X-Org-Id (single-tenant deployments).
var brand string
// Deps is the NARROW dependency surface this subsystem declares — only the three
// things it uses. The composition root builds it from Config and passes it to
// Mount. No hanzoai/cloud import, no god-struct: a subsystem depends on what it
// needs, nothing more.
type Deps struct {
// Logger is the canonical Hanzo logger; Mount derives a scoped child.
Logger luxlog.Logger
// DataDir is the per-deployment data root; per-org WALs land under it.
DataDir string
// Brand is the default tenant when a request omits X-Org-Id.
Brand string
}
// orgOf resolves the tenant for a request: the gateway-minted X-Org-Id header,
// else the deployment brand, else "default".
func orgOf(c *zip.Ctx) string {
if org := c.Header("X-Org-Id"); org != "" {
return org
}
if brand != "" {
return brand
}
return "default"
}
// Mount registers the native observability routes on the shared cloud App.
func Mount(app *zip.App, deps Deps) error {
log := deps.Logger.New("subsystem", "o11y")
reg = NewRegistry(deps.DataDir)
brand = deps.Brand
// Optional ZAP push ingest — bind a luxfi/zap node accepting MsgMetricBatch
// when O11Y_ZAP_PORT is set. HTTP /v1/metrics/batch carries the same wire
// shape, so this is a transport optimisation, not a requirement.
if p := os.Getenv("O11Y_ZAP_PORT"); p != "" {
if port, err := strconv.Atoi(p); err == nil {
if _, err := startZAPReceiver(port, "o11y-metrics-"+brand, log); err != nil {
log.Warn("zap metric receiver disabled", "err", err)
}
}
}
// --- Metrics ---
app.Get("/v1/metrics/health", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]any{
"status": "ok", "service": "metrics", "version": Version,
"series": reg.For(orgOf(c)).Metrics.SeriesCount(), "org": orgOf(c),
})
})
// Batch ingest — luxfi/metric.MetricBatch (the ZAP MsgMetricBatch wire shape).
app.Post("/v1/metrics/batch", func(c *zip.Ctx) error {
var b metric.MetricBatch
if err := c.Bind(&b); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid metric batch"})
}
return c.JSON(http.StatusOK, map[string]any{"written": reg.For(orgOf(c)).Metrics.IngestBatch(&b)})
})
app.Post("/v1/metrics/write", func(c *zip.Ctx) error {
var req struct {
Series []Series `json:"series"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid write request"})
}
st := reg.For(orgOf(c)).Metrics
n := 0
for _, ser := range req.Series {
for _, smp := range ser.Samples {
st.Append(ser.Name, ser.Labels, smp)
n++
}
}
return c.JSON(http.StatusOK, map[string]any{"written": n})
})
app.Get("/v1/metrics/query", func(c *zip.Ctx) error {
start, _ := strconv.ParseInt(c.Query("start"), 10, 64)
end, _ := strconv.ParseInt(c.Query("end"), 10, 64)
res := reg.For(orgOf(c)).Metrics.Query(c.Query("name"), parseMatchers(c.Query("match")), start, end)
return c.JSON(http.StatusOK, map[string]any{"count": len(res), "series": res})
})
// --- Logs (native, Loki-free) ---
app.Get("/v1/logs/health", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]any{"status": "ok", "service": "logs", "version": Version, "records": reg.For(orgOf(c)).Logs.Count()})
})
app.Post("/v1/logs/write", func(c *zip.Ctx) error {
var req struct {
Records []LogRecord `json:"records"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid logs write"})
}
st := reg.For(orgOf(c)).Logs
for _, r := range req.Records {
st.Append(r)
}
return c.JSON(http.StatusOK, map[string]any{"written": len(req.Records)})
})
app.Get("/v1/logs/query", func(c *zip.Ctx) error {
start, _ := strconv.ParseInt(c.Query("start"), 10, 64)
end, _ := strconv.ParseInt(c.Query("end"), 10, 64)
limit, _ := strconv.Atoi(c.Query("limit"))
res := reg.For(orgOf(c)).Logs.Query(parseMatchers(c.Query("match")), start, end, c.Query("contains"), limit)
return c.JSON(http.StatusOK, map[string]any{"count": len(res), "records": res})
})
// --- Traces (native, Tempo-free) ---
app.Get("/v1/traces/health", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]any{"status": "ok", "service": "traces", "version": Version, "spans": reg.For(orgOf(c)).Traces.Count()})
})
app.Post("/v1/traces/write", func(c *zip.Ctx) error {
var req struct {
Spans []Span `json:"spans"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid traces write"})
}
st := reg.For(orgOf(c)).Traces
for _, sp := range req.Spans {
st.Append(sp)
}
return c.JSON(http.StatusOK, map[string]any{"written": len(req.Spans)})
})
app.Get("/v1/traces/trace", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]any{"spans": reg.For(orgOf(c)).Traces.ByTrace(c.Query("id"))})
})
app.Get("/v1/traces/query", func(c *zip.Ctx) error {
start, _ := strconv.ParseInt(c.Query("start"), 10, 64)
end, _ := strconv.ParseInt(c.Query("end"), 10, 64)
limit, _ := strconv.Atoi(c.Query("limit"))
res := reg.For(orgOf(c)).Traces.Recent(start, end, limit)
return c.JSON(http.StatusOK, map[string]any{"count": len(res), "spans": res})
})
log.Info("mounted native ZAP observability store (metrics+logs+traces)",
"version", Version, "durable", deps.DataDir != "", "brand", brand,
"routes", "/v1/{metrics,logs,traces}/*", "tenancy", "X-Org-Id")
return nil
}
// parseMatchers turns "k=v,k2=v2" into a label matcher map.
func parseMatchers(s string) map[string]string {
m := map[string]string{}
for _, pair := range strings.Split(s, ",") {
if k, v, ok := strings.Cut(pair, "="); ok && k != "" {
m[k] = v
}
}
return m
}