mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
It installs no middleware anywhere, so registering routes is all it does and it has no reason to hold the host. Taking the group says that, and it is what a confined subsystem has to give. Co-authored-by: Hanzo Dev <[email protected]>
334 lines
13 KiB
Go
334 lines
13 KiB
Go
package metrics
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
luxlog "github.com/luxfi/log"
|
|
metric "github.com/luxfi/metric"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// newTestApp mounts the subsystem on a real zip.App rooted at a fresh temp
|
|
// DataDir, so every test exercises routing, binding and JSON through zip — not
|
|
// the handler funcs in isolation.
|
|
func newTestApp(t *testing.T) *zip.App {
|
|
t.Helper()
|
|
log := luxlog.NewNoOpLogger()
|
|
app := zip.New(zip.Config{AppName: "metrics-test", Logger: log})
|
|
if err := Use(app.Group(""), Deps{Logger: log, DataDir: t.TempDir(), Brand: "hanzo", Org: testOrg}); err != nil {
|
|
t.Fatalf("Mount: %v", err)
|
|
}
|
|
return app
|
|
}
|
|
|
|
// testOrg is the same shape as the decision cloud passes in (principal.Org): an
|
|
// org counts only ALONGSIDE a validated principal. Mirroring it here — rather
|
|
// than accepting the org header alone — is what makes these tests exercise the
|
|
// rule that actually runs in production; a laxer stand-in would let the very
|
|
// hole this package just closed pass its own suite.
|
|
func testOrg(c *zip.Ctx) (string, bool) {
|
|
if strings.TrimSpace(c.Header("X-User-Id")) == "" {
|
|
return "", false
|
|
}
|
|
org := strings.TrimSpace(c.Header("X-Org-Id"))
|
|
if org == "" {
|
|
return "", false
|
|
}
|
|
return org, true
|
|
}
|
|
|
|
// do issues one request through the app's router and returns the decoded JSON
|
|
// body, failing the test on any non-200.
|
|
func do(t *testing.T, app *zip.App, method, path, org string, body any) map[string]any {
|
|
t.Helper()
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal body: %v", err)
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req := httptest.NewRequest(method, path, rdr)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if org != "" {
|
|
req.Header.Set("X-Org-Id", org)
|
|
// The identity boundary mints both, and the org is worth nothing without
|
|
// the user — so a test that sends one and not the other is testing a
|
|
// request the boundary never produces.
|
|
req.Header.Set("X-User-Id", "u-"+org)
|
|
}
|
|
resp, err := app.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("%s %s: status %d body %s", method, path, resp.StatusCode, raw)
|
|
}
|
|
out := map[string]any{}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
t.Fatalf("%s %s: decode %q: %v", method, path, raw, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func num(t *testing.T, m map[string]any, k string) float64 {
|
|
t.Helper()
|
|
v, ok := m[k].(float64)
|
|
if !ok {
|
|
t.Fatalf("field %q missing or not a number in %v", k, m)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestMetricsWriteQuery(t *testing.T) {
|
|
app := newTestApp(t)
|
|
in := map[string]any{"series": []Series{{
|
|
Name: "http_requests_total",
|
|
Labels: map[string]string{"route": "/v1/x"},
|
|
Samples: []Sample{{TsNs: 100, Value: 1}, {TsNs: 200, Value: 2}},
|
|
}}}
|
|
if got := num(t, do(t, app, "POST", "/v1/metrics/write", "acme", in), "written"); got != 2 {
|
|
t.Fatalf("written = %v, want 2", got)
|
|
}
|
|
|
|
res := do(t, app, "GET", "/v1/metrics/query?name=http_requests_total&match=route=/v1/x", "acme", nil)
|
|
if got := num(t, res, "count"); got != 1 {
|
|
t.Fatalf("count = %v, want 1", got)
|
|
}
|
|
// Range bounds must clip: [150,∞) keeps only the second sample.
|
|
res = do(t, app, "GET", "/v1/metrics/query?name=http_requests_total&start=150", "acme", nil)
|
|
series := res["series"].([]any)
|
|
smps := series[0].(map[string]any)["samples"].([]any)
|
|
if len(smps) != 1 || smps[0].(map[string]any)["v"].(float64) != 2 {
|
|
t.Fatalf("range query samples = %v, want the single v=2 sample", smps)
|
|
}
|
|
// A non-matching label matcher selects nothing.
|
|
if got := num(t, do(t, app, "GET", "/v1/metrics/query?match=route=/nope", "acme", nil), "count"); got != 0 {
|
|
t.Fatalf("count for non-matching matcher = %v, want 0", got)
|
|
}
|
|
// Counts come from the tenant's own query, not from health — health is
|
|
// liveness for an unauthenticated probe and carries no per-org figure.
|
|
if got := num(t, do(t, app, "GET", "/v1/metrics/query?name=http_requests_total", "acme", nil), "count"); got != 1 {
|
|
t.Fatalf("owner series count = %v, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestMetricsBatchIngest(t *testing.T) {
|
|
app := newTestApp(t)
|
|
val, sum, cnt := 7.0, 12.5, uint64(3)
|
|
batch := metric.MetricBatch{
|
|
TimestampNs: 1_000,
|
|
Families: []metric.MetricFamilyWire{
|
|
{Name: "cpu_seconds", Type: "gauge", Metrics: []metric.MetricWire{{Value: &val}}},
|
|
{Name: "req_latency", Type: "histogram", Metrics: []metric.MetricWire{{SampleSum: &sum, SampleCount: &cnt}}},
|
|
},
|
|
}
|
|
// One value + one _sum + one _count = 3 samples written.
|
|
if got := num(t, do(t, app, "POST", "/v1/metrics/batch", "acme", batch), "written"); got != 3 {
|
|
t.Fatalf("written = %v, want 3", got)
|
|
}
|
|
if got := num(t, do(t, app, "GET", "/v1/metrics/query?name=req_latency_count", "acme", nil), "count"); got != 1 {
|
|
t.Fatalf("derived _count series not queryable: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestLogsAndTraces(t *testing.T) {
|
|
app := newTestApp(t)
|
|
logs := map[string]any{"records": []LogRecord{
|
|
{TsNs: 10, Level: "info", Body: "Started ingest", Labels: map[string]string{"svc": "o11y"}},
|
|
{TsNs: 20, Level: "error", Body: "disk full", Labels: map[string]string{"svc": "o11y"}},
|
|
}}
|
|
if got := num(t, do(t, app, "POST", "/v1/metrics/logs/write", "acme", logs), "written"); got != 2 {
|
|
t.Fatalf("logs written = %v, want 2", got)
|
|
}
|
|
// Substring match is case-insensitive and label-scoped.
|
|
if got := num(t, do(t, app, "GET", "/v1/metrics/logs/query?match=svc=o11y&contains=STARTED", "acme", nil), "count"); got != 1 {
|
|
t.Fatalf("logs contains-query count = %v, want 1", got)
|
|
}
|
|
if got := num(t, do(t, app, "GET", "/v1/metrics/logs/query?limit=10", "acme", nil), "count"); got != 2 {
|
|
t.Fatalf("owner log count = %v, want 2", got)
|
|
}
|
|
|
|
spans := map[string]any{"spans": []Span{
|
|
{TraceID: "t1", SpanID: "s1", Name: "root", StartNs: 1, EndNs: 9},
|
|
{TraceID: "t1", SpanID: "s2", Parent: "s1", Name: "child", StartNs: 2, EndNs: 8},
|
|
{TraceID: "t2", SpanID: "s3", Name: "other", StartNs: 3, EndNs: 7},
|
|
}}
|
|
if got := num(t, do(t, app, "POST", "/v1/metrics/traces/write", "acme", spans), "written"); got != 3 {
|
|
t.Fatalf("spans written = %v, want 3", got)
|
|
}
|
|
if got := len(do(t, app, "GET", "/v1/metrics/traces/trace?id=t1", "acme", nil)["spans"].([]any)); got != 2 {
|
|
t.Fatalf("trace t1 waterfall = %d spans, want 2", got)
|
|
}
|
|
if got := num(t, do(t, app, "GET", "/v1/metrics/traces/query?limit=2", "acme", nil), "count"); got != 2 {
|
|
t.Fatalf("traces query count = %v, want 2 (limit honoured)", got)
|
|
}
|
|
if got := num(t, do(t, app, "GET", "/v1/metrics/traces/query?limit=10", "acme", nil), "count"); got != 3 {
|
|
t.Fatalf("owner span count = %v, want 3", got)
|
|
}
|
|
}
|
|
|
|
// TestTenantIsolation pins the hard guarantee: data written under one org is
|
|
// invisible to every other org, and health reports no per-tenant figure at all.
|
|
func TestTenantIsolation(t *testing.T) {
|
|
app := newTestApp(t)
|
|
in := map[string]any{"series": []Series{{Name: "s", Samples: []Sample{{TsNs: 1, Value: 1}}}}}
|
|
do(t, app, "POST", "/v1/metrics/write", "acme", in)
|
|
|
|
if got := num(t, do(t, app, "GET", "/v1/metrics/query?name=s", "acme", nil), "count"); got != 1 {
|
|
t.Fatalf("acme sees its own series count = %v, want 1", got)
|
|
}
|
|
if got := num(t, do(t, app, "GET", "/v1/metrics/query?name=s", "other", nil), "count"); got != 0 {
|
|
t.Fatalf("cross-tenant leak: other sees %v series, want 0", got)
|
|
}
|
|
// Health is liveness and NOTHING else. It answers an unauthenticated probe,
|
|
// so anything per-org it reported would be per-org data handed to exactly
|
|
// the caller who proved nothing: it used to return the tenant's series count
|
|
// and the resolved org name.
|
|
h := do(t, app, "GET", "/v1/metrics/health", "", nil)
|
|
for _, leak := range []string{"org", "series", "records", "spans"} {
|
|
if _, ok := h[leak]; ok {
|
|
t.Fatalf("health leaks per-tenant field %q: %v", leak, h)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAnonymousCallerCannotNameATenant is the production defect, as a test.
|
|
//
|
|
// Measured against api.hanzo.ai before the fix, with no credential at any step:
|
|
// POST /v1/logs/write carrying an invented X-Org-Id answered {"written":1}, and
|
|
// GET /v1/logs/query with that header read the record straight back. The header
|
|
// WAS the tenant boundary, so every org's metrics, logs and traces were readable
|
|
// and forgeable by anyone who could guess a name.
|
|
//
|
|
// Every data route is covered, not just the one that was probed: the hole was
|
|
// never in a handler, it was in the resolver they all shared, so a fix verified
|
|
// on one route says nothing about the other nine.
|
|
func TestAnonymousCallerCannotNameATenant(t *testing.T) {
|
|
app := newTestApp(t)
|
|
|
|
// Seed as a properly authenticated caller, so the refusals below are about
|
|
// the credential and not about an empty store.
|
|
do(t, app, "POST", "/v1/metrics/logs/write", "acme",
|
|
map[string]any{"records": []LogRecord{{TsNs: 1, Body: "secret"}}})
|
|
do(t, app, "POST", "/v1/metrics/write", "acme",
|
|
map[string]any{"series": []Series{{Name: "s", Samples: []Sample{{TsNs: 1, Value: 1}}}}})
|
|
do(t, app, "POST", "/v1/metrics/traces/write", "acme",
|
|
map[string]any{"spans": []Span{{TraceID: "t1", SpanID: "s1", Name: "op", StartNs: 1, EndNs: 2}}})
|
|
|
|
for _, tc := range []struct {
|
|
method, path string
|
|
body any
|
|
}{
|
|
{"GET", "/v1/metrics/logs/query?limit=10", nil},
|
|
{"GET", "/v1/metrics/query?name=s", nil},
|
|
{"GET", "/v1/metrics/traces/query?limit=10", nil},
|
|
{"GET", "/v1/metrics/traces/trace?id=t1", nil},
|
|
{"POST", "/v1/metrics/logs/write", map[string]any{"records": []LogRecord{{TsNs: 2, Body: "forged"}}}},
|
|
{"POST", "/v1/metrics/write", map[string]any{"series": []Series{{Name: "s", Samples: []Sample{{TsNs: 2, Value: 9}}}}}},
|
|
{"POST", "/v1/metrics/traces/write", map[string]any{"spans": []Span{{TraceID: "t2", SpanID: "s2", Name: "op", StartNs: 1, EndNs: 2}}}},
|
|
{"POST", "/v1/metrics/batch", map[string]any{}},
|
|
} {
|
|
// The org header alone — exactly what the live probe sent, and what the
|
|
// old resolver honoured.
|
|
if code := status(t, app, tc.method, tc.path, map[string]string{"X-Org-Id": "acme"}, tc.body); code != http.StatusForbidden {
|
|
t.Errorf("%s %s with a bare X-Org-Id: got %d, want 403 — the header is not a credential",
|
|
tc.method, tc.path, code)
|
|
}
|
|
// Nothing at all.
|
|
if code := status(t, app, tc.method, tc.path, nil, tc.body); code != http.StatusForbidden {
|
|
t.Errorf("%s %s anonymous: got %d, want 403", tc.method, tc.path, code)
|
|
}
|
|
}
|
|
|
|
// And the seeded tenant still reads its own data, so the gate refuses
|
|
// callers rather than the feature.
|
|
if got := num(t, do(t, app, "GET", "/v1/metrics/logs/query?limit=10", "acme", nil), "count"); got != 1 {
|
|
t.Fatalf("authenticated owner sees %v records, want 1 — the fix must not break the product", got)
|
|
}
|
|
}
|
|
|
|
// TestMountRefusesWithoutATenantDecision pins the fail-closed boot. A nil Org
|
|
// could only default to something, and every default is a tenant an anonymous
|
|
// caller gets to name — so the store must be unreachable rather than open.
|
|
func TestMountRefusesWithoutATenantDecision(t *testing.T) {
|
|
log := luxlog.NewNoOpLogger()
|
|
app := zip.New(zip.Config{AppName: "metrics-test", Logger: log})
|
|
if err := Use(app.Group(""), Deps{Logger: log, DataDir: t.TempDir(), Brand: "hanzo"}); err == nil {
|
|
t.Fatal("Mount with no Org decision succeeded — it must refuse, not pick a default tenant")
|
|
}
|
|
}
|
|
|
|
// status issues a request with explicit headers and returns the status code
|
|
// only. `do` fatals on any non-200, which is precisely what a refusal test
|
|
// needs to observe rather than die on.
|
|
func status(t *testing.T, app *zip.App, method, path string, headers map[string]string, body any) int {
|
|
t.Helper()
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req := httptest.NewRequest(method, path, rdr)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := app.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
// TestWALDurability proves the per-org WAL replays: a second Registry over the
|
|
// same DataDir recovers what the first wrote.
|
|
func TestWALDurability(t *testing.T) {
|
|
dir := t.TempDir()
|
|
r1 := NewRegistry(dir)
|
|
ts := r1.For("acme")
|
|
ts.Metrics.Append("m", map[string]string{"a": "b"}, Sample{TsNs: 5, Value: 42})
|
|
ts.Logs.Append(LogRecord{TsNs: 5, Body: "hello"})
|
|
ts.Traces.Append(Span{TraceID: "t", SpanID: "s", StartNs: 5})
|
|
|
|
r2 := NewRegistry(dir)
|
|
got := r2.For("acme")
|
|
if n := got.Metrics.SeriesCount(); n != 1 {
|
|
t.Fatalf("replayed series = %d, want 1", n)
|
|
}
|
|
if q := got.Metrics.Query("m", map[string]string{"a": "b"}, 0, 0); len(q) != 1 || q[0].Samples[0].Value != 42 {
|
|
t.Fatalf("replayed samples = %v, want one v=42", q)
|
|
}
|
|
if n := got.Logs.Count(); n != 1 {
|
|
t.Fatalf("replayed logs = %d, want 1", n)
|
|
}
|
|
if n := len(got.Traces.ByTrace("t")); n != 1 {
|
|
t.Fatalf("replayed spans for trace t = %d, want 1", n)
|
|
}
|
|
// Isolation on disk: a different org has its own empty WAL tree.
|
|
if n := r2.For("zoo").Metrics.SeriesCount(); n != 0 {
|
|
t.Fatalf("org zoo replayed %d series from acme's WAL, want 0", n)
|
|
}
|
|
if _, err := filepath.Glob(filepath.Join(dir, "orgs", "acme", "o11y", "*.wal")); err != nil {
|
|
t.Fatalf("glob per-org wal dir: %v", err)
|
|
}
|
|
}
|