- proxy/go.mod: github.com/ardenone/zai-proxy → git.ardenone.com/jedarden/zai-proxy - dashboard/go.mod: github.com/ardenone/ardenone-cluster/containers/zai-proxy-dashboard → git.ardenone.com/jedarden/zai-proxy/dashboard - Update all Go import paths in proxy/ and dashboard/ to match new module paths - Add proxy/evaluation/ package (was missing from initial commit) - Add docs/plan/plan.md with architecture, security model, telemetry design, and migration checklist Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
367 lines
8.6 KiB
Go
367 lines
8.6 KiB
Go
package collector
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
|
|
"git.ardenone.com/jedarden/zai-proxy/dashboard/model"
|
|
)
|
|
|
|
func TestParser_ParseCounter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
metric string
|
|
wantVal float64
|
|
wantLen int
|
|
}{
|
|
{
|
|
name: "simple counter",
|
|
input: `# HELP test_counter A test counter
|
|
# TYPE test_counter counter
|
|
test_counter 42`,
|
|
metric: "test_counter",
|
|
wantVal: 42,
|
|
wantLen: 1,
|
|
},
|
|
{
|
|
name: "counter with labels",
|
|
input: `# TYPE http_requests_total counter
|
|
http_requests_total{method="GET",status="200"} 100
|
|
http_requests_total{method="POST",status="201"} 50`,
|
|
metric: "http_requests_total",
|
|
wantVal: 100, // First value
|
|
wantLen: 2,
|
|
},
|
|
{
|
|
name: "counter with special characters in labels",
|
|
input: `# TYPE api_requests counter
|
|
api_requests{endpoint="/api/v1/users",method="GET"} 1000`,
|
|
metric: "api_requests",
|
|
wantVal: 1000,
|
|
wantLen: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := NewParser()
|
|
result, err := p.Parse(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
|
|
values, ok := result[tt.metric]
|
|
if !ok {
|
|
t.Fatalf("metric %s not found in result", tt.metric)
|
|
}
|
|
if len(values) != tt.wantLen {
|
|
t.Errorf("got %d values, want %d", len(values), tt.wantLen)
|
|
}
|
|
if values[0].Value != tt.wantVal {
|
|
t.Errorf("got value %f, want %f", values[0].Value, tt.wantVal)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParser_ParseGauge(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
metric string
|
|
wantVal float64
|
|
}{
|
|
{
|
|
name: "simple gauge",
|
|
input: `# TYPE temperature gauge
|
|
temperature 23.5`,
|
|
metric: "temperature",
|
|
wantVal: 23.5,
|
|
},
|
|
{
|
|
name: "gauge with negative value",
|
|
input: `# TYPE temperature gauge
|
|
temperature -10.5`,
|
|
metric: "temperature",
|
|
wantVal: -10.5,
|
|
},
|
|
{
|
|
name: "gauge with labels",
|
|
input: `# TYPE memory_bytes gauge
|
|
memory_bytes{type="used"} 1048576
|
|
memory_bytes{type="free"} 2097152`,
|
|
metric: "memory_bytes",
|
|
wantVal: 1048576,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := NewParser()
|
|
result, err := p.Parse(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
|
|
values, ok := result[tt.metric]
|
|
if !ok {
|
|
t.Fatalf("metric %s not found in result", tt.metric)
|
|
}
|
|
if values[0].Value != tt.wantVal {
|
|
t.Errorf("got value %f, want %f", values[0].Value, tt.wantVal)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParser_ParseHistogram(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
metric string
|
|
wantCount float64
|
|
wantSum float64
|
|
wantBuckets int
|
|
}{
|
|
{
|
|
name: "standard histogram",
|
|
input: `# TYPE http_request_duration_seconds histogram
|
|
http_request_duration_seconds_bucket{le="0.1"} 10
|
|
http_request_duration_seconds_bucket{le="0.5"} 25
|
|
http_request_duration_seconds_bucket{le="1"} 45
|
|
http_request_duration_seconds_bucket{le="+Inf"} 50
|
|
http_request_duration_seconds_sum 25.5
|
|
http_request_duration_seconds_count 50`,
|
|
metric: "http_request_duration_seconds",
|
|
wantCount: 50,
|
|
wantSum: 25.5,
|
|
wantBuckets: 4,
|
|
},
|
|
{
|
|
name: "histogram with labels",
|
|
input: `# TYPE request_size_bytes histogram
|
|
request_size_bytes_bucket{le="100"} 5
|
|
request_size_bytes_bucket{le="1000"} 15
|
|
request_size_bytes_bucket{le="+Inf"} 20
|
|
request_size_bytes_sum 5000
|
|
request_size_bytes_count 20`,
|
|
metric: "request_size_bytes",
|
|
wantCount: 20,
|
|
wantSum: 5000,
|
|
wantBuckets: 3,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := NewParser()
|
|
result, err := p.Parse(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
|
|
hist, err := p.ParseHistogram(result, tt.metric, nil)
|
|
if err != nil {
|
|
t.Fatalf("ParseHistogram() error = %v", err)
|
|
}
|
|
|
|
if hist.Count != tt.wantCount {
|
|
t.Errorf("got count %f, want %f", hist.Count, tt.wantCount)
|
|
}
|
|
if hist.Sum != tt.wantSum {
|
|
t.Errorf("got sum %f, want %f", hist.Sum, tt.wantSum)
|
|
}
|
|
if len(hist.Buckets) != tt.wantBuckets {
|
|
t.Errorf("got %d buckets, want %d", len(hist.Buckets), tt.wantBuckets)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParser_ParseEmpty(t *testing.T) {
|
|
p := NewParser()
|
|
result, err := p.Parse("")
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected empty result, got %d metrics", len(result))
|
|
}
|
|
}
|
|
|
|
func TestParser_ParseComments(t *testing.T) {
|
|
input := `# This is a comment
|
|
# HELP some_metric Help text
|
|
# TYPE some_metric gauge
|
|
# Another comment`
|
|
|
|
p := NewParser()
|
|
result, err := p.Parse(input)
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
// Should have the metric even with comments
|
|
if len(result) != 1 {
|
|
t.Errorf("expected 1 metric, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
func TestHistogramQuantile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
q float64
|
|
buckets []model.HistogramBucket
|
|
wantNaN bool
|
|
minValue float64 // If not NaN, value should be >= this
|
|
maxValue float64 // If not NaN, value should be <= this
|
|
}{
|
|
{
|
|
name: "empty buckets",
|
|
q: 0.5,
|
|
buckets: nil,
|
|
wantNaN: true,
|
|
},
|
|
{
|
|
name: "single bucket +Inf only",
|
|
q: 0.5,
|
|
buckets: []model.HistogramBucket{
|
|
{UpperBound: math.Inf(1), Count: 100},
|
|
},
|
|
wantNaN: true, // Can't compute quantile with only +Inf bucket
|
|
},
|
|
{
|
|
name: "single finite bucket",
|
|
q: 0.5,
|
|
buckets: []model.HistogramBucket{
|
|
{UpperBound: 1.0, Count: 100},
|
|
{UpperBound: math.Inf(1), Count: 100},
|
|
},
|
|
minValue: 0,
|
|
maxValue: 1.0,
|
|
},
|
|
{
|
|
name: "standard histogram p50",
|
|
q: 0.5,
|
|
buckets: []model.HistogramBucket{
|
|
{UpperBound: 0.1, Count: 10},
|
|
{UpperBound: 0.5, Count: 25},
|
|
{UpperBound: 1.0, Count: 45},
|
|
{UpperBound: math.Inf(1), Count: 50},
|
|
},
|
|
minValue: 0.5,
|
|
maxValue: 1.0,
|
|
},
|
|
{
|
|
name: "standard histogram p95",
|
|
q: 0.95,
|
|
buckets: []model.HistogramBucket{
|
|
{UpperBound: 0.1, Count: 10},
|
|
{UpperBound: 0.5, Count: 25},
|
|
{UpperBound: 1.0, Count: 45},
|
|
{UpperBound: 2.0, Count: 48},
|
|
{UpperBound: math.Inf(1), Count: 50},
|
|
},
|
|
minValue: 1.0,
|
|
maxValue: 2.0,
|
|
},
|
|
{
|
|
name: "standard histogram p99",
|
|
q: 0.99,
|
|
buckets: []model.HistogramBucket{
|
|
{UpperBound: 0.1, Count: 10},
|
|
{UpperBound: 0.5, Count: 25},
|
|
{UpperBound: 1.0, Count: 45},
|
|
{UpperBound: 2.0, Count: 49},
|
|
{UpperBound: math.Inf(1), Count: 50},
|
|
},
|
|
minValue: 1.0,
|
|
maxValue: 2.0,
|
|
},
|
|
{
|
|
name: "all values in first bucket",
|
|
q: 0.5,
|
|
buckets: []model.HistogramBucket{
|
|
{UpperBound: 0.1, Count: 100},
|
|
{UpperBound: math.Inf(1), Count: 100},
|
|
},
|
|
minValue: 0,
|
|
maxValue: 0.1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := HistogramQuantile(tt.q, tt.buckets)
|
|
|
|
if tt.wantNaN {
|
|
if !math.IsNaN(result) {
|
|
t.Errorf("expected NaN, got %f", result)
|
|
}
|
|
return
|
|
}
|
|
|
|
if math.IsNaN(result) {
|
|
t.Errorf("unexpected NaN")
|
|
return
|
|
}
|
|
|
|
if result < tt.minValue || result > tt.maxValue {
|
|
t.Errorf("result %f not in expected range [%f, %f]", result, tt.minValue, tt.maxValue)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHistogramQuantile_EdgeCases(t *testing.T) {
|
|
t.Run("zero quantile", func(t *testing.T) {
|
|
buckets := []model.HistogramBucket{
|
|
{UpperBound: 0.1, Count: 10},
|
|
{UpperBound: math.Inf(1), Count: 50},
|
|
}
|
|
result := HistogramQuantile(0, buckets)
|
|
if math.IsNaN(result) || result < 0 {
|
|
t.Errorf("unexpected result for q=0: %f", result)
|
|
}
|
|
})
|
|
|
|
t.Run("quantile 1.0", func(t *testing.T) {
|
|
buckets := []model.HistogramBucket{
|
|
{UpperBound: 0.1, Count: 10},
|
|
{UpperBound: 1.0, Count: 45},
|
|
{UpperBound: math.Inf(1), Count: 50},
|
|
}
|
|
result := HistogramQuantile(1.0, buckets)
|
|
// At q=1.0, we should get close to the last finite bucket
|
|
if math.IsNaN(result) {
|
|
t.Errorf("unexpected NaN for q=1.0")
|
|
}
|
|
})
|
|
|
|
t.Run("buckets with zero count", func(t *testing.T) {
|
|
buckets := []model.HistogramBucket{
|
|
{UpperBound: 0.1, Count: 0},
|
|
{UpperBound: 0.5, Count: 0},
|
|
{UpperBound: 1.0, Count: 10},
|
|
{UpperBound: math.Inf(1), Count: 10},
|
|
}
|
|
result := HistogramQuantile(0.5, buckets)
|
|
// Should still compute something reasonable
|
|
if math.IsNaN(result) {
|
|
t.Errorf("unexpected NaN for buckets with zero counts")
|
|
}
|
|
})
|
|
|
|
t.Run("all values in +Inf bucket", func(t *testing.T) {
|
|
buckets := []model.HistogramBucket{
|
|
{UpperBound: 0.1, Count: 0},
|
|
{UpperBound: math.Inf(1), Count: 100},
|
|
}
|
|
result := HistogramQuantile(0.5, buckets)
|
|
// All values are > 0.1 but we don't know the upper bound
|
|
// Should return something >= 0.1
|
|
if result < 0.1 {
|
|
t.Errorf("expected result >= 0.1, got %f", result)
|
|
}
|
|
})
|
|
}
|