Adds `WaitGroup` to track background goroutine and make Shutdown respect context deadlines Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ia7f074725717f037412dacb93e34105b6a6a6964
439 lines
11 KiB
Go
439 lines
11 KiB
Go
package aggregate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
"notashelf.dev/watchdog/internal/config"
|
|
)
|
|
|
|
func TestMetricsAggregator_RecordPageview(t *testing.T) {
|
|
registry := NewPathRegistry(100)
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
Country: true,
|
|
Device: true,
|
|
Referrer: "domain",
|
|
},
|
|
},
|
|
}
|
|
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Record pageview with all dimensions
|
|
agg.RecordPageview("/home", "US", "desktop", "google.com", "")
|
|
|
|
// Verify metric was recorded
|
|
expected := `
|
|
# HELP web_pageviews_total Total number of pageviews
|
|
# TYPE web_pageviews_total counter
|
|
web_pageviews_total{country="US",device="desktop",path="/home",referrer="google.com"} 1
|
|
`
|
|
if err := testutil.CollectAndCompare(agg.pageviews, strings.NewReader(expected)); err != nil {
|
|
t.Errorf("unexpected metric value: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_RecordPageview_MinimalDimensions(t *testing.T) {
|
|
registry := NewPathRegistry(100)
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
Country: false,
|
|
Device: false,
|
|
Referrer: "off",
|
|
},
|
|
},
|
|
}
|
|
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Record pageview with only path
|
|
agg.RecordPageview("/home", "", "", "", "")
|
|
|
|
// Verify metric was recorded
|
|
expected := `
|
|
# HELP web_pageviews_total Total number of pageviews
|
|
# TYPE web_pageviews_total counter
|
|
web_pageviews_total{path="/home"} 1
|
|
`
|
|
if err := testutil.CollectAndCompare(agg.pageviews, strings.NewReader(expected)); err != nil {
|
|
t.Errorf("unexpected metric value: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_PathOverflow(t *testing.T) {
|
|
// Create registry with limit of 2
|
|
registry := NewPathRegistry(2)
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Add two paths successfully
|
|
registry.Add("/path1")
|
|
registry.Add("/path2")
|
|
|
|
// Try to add third path - should be rejected
|
|
if registry.Add("/path3") {
|
|
t.Error("expected path to be rejected")
|
|
}
|
|
|
|
// Record overflow
|
|
agg.RecordPathOverflow()
|
|
|
|
// Verify overflow metric
|
|
expected := `
|
|
# HELP web_path_overflow_total Paths rejected due to cardinality limit
|
|
# TYPE web_path_overflow_total counter
|
|
web_path_overflow_total 1
|
|
`
|
|
if err := testutil.CollectAndCompare(agg.pathOverflow, strings.NewReader(expected)); err != nil {
|
|
t.Errorf("unexpected metric value: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_RecordCustomEvent(t *testing.T) {
|
|
registry := NewPathRegistry(100)
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
CustomEvents: []string{"signup", "purchase"},
|
|
},
|
|
}
|
|
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Record custom event
|
|
agg.RecordCustomEvent("signup")
|
|
|
|
// Verify metric was recorded
|
|
expected := `
|
|
# HELP web_custom_events_total Total number of custom events
|
|
# TYPE web_custom_events_total counter
|
|
web_custom_events_total{event="signup"} 1
|
|
`
|
|
if err := testutil.CollectAndCompare(agg.customEvents, strings.NewReader(expected)); err != nil {
|
|
t.Errorf("unexpected metric value: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_RecordCustomEvent_MultipleEvents(t *testing.T) {
|
|
registry := NewPathRegistry(100)
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
CustomEvents: []string{"signup", "purchase"},
|
|
},
|
|
}
|
|
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Record multiple events
|
|
agg.RecordCustomEvent("signup")
|
|
agg.RecordCustomEvent("signup")
|
|
agg.RecordCustomEvent("purchase")
|
|
|
|
// Verify metrics
|
|
if err := testutil.CollectAndCompare(agg.customEvents, strings.NewReader(`
|
|
# HELP web_custom_events_total Total number of custom events
|
|
# TYPE web_custom_events_total counter
|
|
web_custom_events_total{event="purchase"} 1
|
|
web_custom_events_total{event="signup"} 2
|
|
`)); err != nil {
|
|
t.Errorf("unexpected metric value: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_MustRegister(t *testing.T) {
|
|
registry := NewPathRegistry(100)
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
promRegistry := prometheus.NewRegistry()
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Register metrics
|
|
agg.MustRegister(promRegistry)
|
|
|
|
// Record some metrics to ensure they show up
|
|
agg.RecordPageview("/test", "", "", "", "")
|
|
agg.RecordPathOverflow()
|
|
|
|
// Verify metrics can be gathered
|
|
metrics, err := promRegistry.Gather()
|
|
if err != nil {
|
|
t.Fatalf("failed to gather metrics: %v", err)
|
|
}
|
|
|
|
// Should have at least pageviews and path overflow metrics
|
|
if len(metrics) < 2 {
|
|
t.Errorf("expected at least 2 metric families, got %d", len(metrics))
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_Shutdown_ConfigurableStatePath(t *testing.T) {
|
|
registry := NewPathRegistry(100)
|
|
|
|
// Create temp directory for test
|
|
tmpDir := t.TempDir()
|
|
statePath := tmpDir + "/custom-hll.state"
|
|
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
SaltRotation: "daily",
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
},
|
|
Server: config.ServerConfig{
|
|
StatePath: statePath,
|
|
},
|
|
}
|
|
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Add some unique visitors so there's state to save
|
|
agg.AddUnique("192.168.1.1", "Mozilla/5.0")
|
|
|
|
// Shutdown should save to configured path
|
|
ctx := context.Background()
|
|
if err := agg.Shutdown(ctx); err != nil {
|
|
t.Fatalf("Shutdown failed: %v", err)
|
|
}
|
|
|
|
// Verify file was created at configured path
|
|
if _, err := os.Stat(statePath); os.IsNotExist(err) {
|
|
t.Errorf("state file was not created at configured path: %s", statePath)
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_Shutdown_DefaultStatePath(t *testing.T) {
|
|
registry := NewPathRegistry(100)
|
|
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Domains: []string{"example.com"}, // Required for validation
|
|
SaltRotation: "daily",
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
},
|
|
Limits: config.LimitsConfig{
|
|
MaxPaths: 1000,
|
|
MaxSources: 500,
|
|
},
|
|
Server: config.ServerConfig{
|
|
// StatePath not set - validation should set default
|
|
StatePath: "",
|
|
},
|
|
}
|
|
|
|
// Validate to apply defaults
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Fatalf("config validation failed: %v", err)
|
|
}
|
|
|
|
// Verify default was applied
|
|
expectedDefault := "/var/lib/watchdog/hll.state"
|
|
if cfg.Server.StatePath != expectedDefault {
|
|
t.Errorf(
|
|
"expected default StatePath %q, got %q",
|
|
expectedDefault,
|
|
cfg.Server.StatePath,
|
|
)
|
|
}
|
|
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Add some unique visitors
|
|
agg.AddUnique("192.168.1.1", "Mozilla/5.0")
|
|
|
|
// Shutdown should save to default path
|
|
ctx := context.Background()
|
|
if err := agg.Shutdown(ctx); err != nil {
|
|
// Might fail due to permissions on /var/lib, which is OK for this test
|
|
// We're just verifying the code path works
|
|
t.Logf("Shutdown returned error (might be expected): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_Shutdown_RespectsContext(t *testing.T) {
|
|
registry := NewPathRegistry(100)
|
|
tmpDir := t.TempDir()
|
|
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
SaltRotation: "daily",
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
},
|
|
Server: config.ServerConfig{
|
|
StatePath: tmpDir + "/hll.state",
|
|
},
|
|
}
|
|
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Create a context with very short timeout
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
|
defer cancel()
|
|
|
|
// Wait for context to expire
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Shutdown should respect context timeout
|
|
err := agg.Shutdown(ctx)
|
|
if err == nil {
|
|
t.Error("expected context deadline exceeded error, got nil")
|
|
}
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Errorf(
|
|
"expected context.DeadlineExceeded, got %v",
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_Shutdown_WaitsForGoroutine(t *testing.T) {
|
|
registry := NewPathRegistry(100)
|
|
tmpDir := t.TempDir()
|
|
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
SaltRotation: "daily",
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
},
|
|
Server: config.ServerConfig{
|
|
StatePath: tmpDir + "/hll.state",
|
|
},
|
|
}
|
|
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Give the goroutine time to start
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Track if goroutine is still running
|
|
done := make(chan struct{})
|
|
go func() {
|
|
ctx := context.Background()
|
|
agg.Shutdown(ctx)
|
|
close(done)
|
|
}()
|
|
|
|
// Shutdown should complete quickly (goroutine should stop)
|
|
select {
|
|
case <-done:
|
|
// Success - shutdown completed
|
|
case <-time.After(1 * time.Second):
|
|
t.Fatal("Shutdown did not complete within timeout - goroutine not stopping")
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_LoadState(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
statePath := tmpDir + "/hll.state"
|
|
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
SaltRotation: "daily",
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
},
|
|
Server: config.ServerConfig{
|
|
StatePath: statePath,
|
|
},
|
|
}
|
|
|
|
// Create first aggregator and add some visitors
|
|
registry1 := NewPathRegistry(100)
|
|
agg1 := NewMetricsAggregator(registry1, NewCustomEventRegistry(100), &cfg)
|
|
agg1.AddUnique("192.168.1.1", "Mozilla/5.0")
|
|
agg1.AddUnique("192.168.1.2", "Mozilla/5.0")
|
|
agg1.AddUnique("192.168.1.3", "Mozilla/5.0")
|
|
|
|
// Get estimate before shutdown
|
|
estimate1 := agg1.estimator.Estimate()
|
|
if estimate1 == 0 {
|
|
t.Fatal("expected non-zero estimate before shutdown")
|
|
}
|
|
|
|
// Shutdown to save state
|
|
ctx := context.Background()
|
|
if err := agg1.Shutdown(ctx); err != nil {
|
|
t.Fatalf("Shutdown failed: %v", err)
|
|
}
|
|
|
|
// Verify state file was created
|
|
if _, err := os.Stat(statePath); os.IsNotExist(err) {
|
|
t.Fatal("state file was not created")
|
|
}
|
|
|
|
// Create second aggregator and load state
|
|
registry2 := NewPathRegistry(100)
|
|
agg2 := NewMetricsAggregator(registry2, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// Load should restore the state
|
|
if err := agg2.LoadState(); err != nil {
|
|
t.Fatalf("LoadState failed: %v", err)
|
|
}
|
|
|
|
// Estimate should match (approximately - HLL is probabilistic)
|
|
estimate2 := agg2.estimator.Estimate()
|
|
if estimate2 != estimate1 {
|
|
t.Errorf("expected estimate %d after load, got %d", estimate1, estimate2)
|
|
}
|
|
}
|
|
|
|
func TestMetricsAggregator_LoadState_NoFile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
statePath := tmpDir + "/nonexistent.state"
|
|
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
SaltRotation: "daily",
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
},
|
|
Server: config.ServerConfig{
|
|
StatePath: statePath,
|
|
},
|
|
}
|
|
|
|
registry := NewPathRegistry(100)
|
|
agg := NewMetricsAggregator(registry, NewCustomEventRegistry(100), &cfg)
|
|
|
|
// LoadState should not error if file doesn't exist (first run)
|
|
if err := agg.LoadState(); err != nil {
|
|
t.Errorf("LoadState should not error on missing file, got: %v", err)
|
|
}
|
|
}
|