internal/aggergate: make HLL state path configurable

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8ff8ef25ad945aae918bea97ee39d7ea6a6a6964
This commit is contained in:
raf 2026-03-01 19:23:19 +03:00
commit 2ef2dabf93
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
5 changed files with 167 additions and 63 deletions

View file

@ -98,3 +98,6 @@ server:
metrics_path: "/metrics" metrics_path: "/metrics"
# Event ingestion endpoint # Event ingestion endpoint
ingestion_path: "/api/event" ingestion_path: "/api/event"
# Path to persist HyperLogLog state (default: /var/lib/watchdog/hll.state)
# Ensures unique visitor counts survive restarts
state_path: "/var/lib/watchdog/hll.state"

View file

@ -223,7 +223,7 @@ func (m *MetricsAggregator) Shutdown(ctx context.Context) error {
m.Stop() m.Stop()
// Persist HLL state if configured // Persist HLL state if configured
if m.cfg.Site.SaltRotation != "" { if m.cfg.Site.SaltRotation != "" {
return m.estimator.Save("/tmp/watchdog-hll.state") return m.estimator.Save(m.cfg.Server.StatePath)
} }
return nil return nil
} }

View file

@ -1,6 +1,8 @@
package aggregate package aggregate
import ( import (
"context"
"os"
"strings" "strings"
"testing" "testing"
@ -190,3 +192,89 @@ func TestMetricsAggregator_MustRegister(t *testing.T) {
t.Errorf("expected at least 2 metric families, got %d", len(metrics)) 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)
}
}

View file

@ -83,6 +83,7 @@ type ServerConfig struct {
ListenAddr string `yaml:"listen_addr"` ListenAddr string `yaml:"listen_addr"`
MetricsPath string `yaml:"metrics_path"` MetricsPath string `yaml:"metrics_path"`
IngestionPath string `yaml:"ingestion_path"` IngestionPath string `yaml:"ingestion_path"`
StatePath string `yaml:"state_path"` // path to persist HLL state
} }
// YAML configuration file // YAML configuration file
@ -170,6 +171,9 @@ func (c *Config) Validate() error {
if c.Server.IngestionPath == "" { if c.Server.IngestionPath == "" {
c.Server.IngestionPath = "/api/event" c.Server.IngestionPath = "/api/event"
} }
if c.Server.StatePath == "" {
c.Server.StatePath = "/var/lib/watchdog/hll.state"
}
if c.Security.MetricsAuth.Enabled { if c.Security.MetricsAuth.Enabled {
if c.Security.MetricsAuth.Username == "" || c.Security.MetricsAuth.Password == "" { if c.Security.MetricsAuth.Username == "" || c.Security.MetricsAuth.Password == "" {

View file

@ -1,19 +1,22 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Watchdog Beacon Test</title> <title>Watchdog Beacon Test</title>
<style> <style>
body { body {
font-family: system-ui, -apple-system, sans-serif; font-family:
system-ui,
-apple-system,
sans-serif;
max-width: 600px; max-width: 600px;
margin: 50px auto; margin: 50px auto;
padding: 20px; padding: 20px;
line-height: 1.6; line-height: 1.6;
} }
button { button {
background: #4CAF50; background: #4caf50;
color: white; color: white;
padding: 12px 24px; padding: 12px 24px;
border: none; border: none;
@ -35,7 +38,7 @@
background: #e8e8e8; background: #e8e8e8;
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
font-family: 'Courier New', monospace; font-family: "Courier New", monospace;
} }
</style> </style>
</head> </head>
@ -43,16 +46,19 @@
<h1>Watchdog Analytics Beacon Test</h1> <h1>Watchdog Analytics Beacon Test</h1>
<div class="info"> <div class="info">
<p><strong>Instructions:</strong> <p>
This page automatically tracks a pageview on load. <strong>Instructions:</strong> This page automatically tracks a pageview
Click the buttons below to test custom event tracking.</p> on load. Click the buttons below to test custom event tracking.
</p>
</div> </div>
<h2>Custom Events</h2> <h2>Custom Events</h2>
<button onclick="watchdog.track('signup')">Track Signup</button> <button onclick="watchdog.track('signup')">Track Signup</button>
<button onclick="watchdog.track('purchase')">Track Purchase</button> <button onclick="watchdog.track('purchase')">Track Purchase</button>
<button onclick="watchdog.track('download')">Track Download</button> <button onclick="watchdog.track('download')">Track Download</button>
<button onclick="watchdog.track('newsletter_subscribe')">Track Newsletter</button> <button onclick="watchdog.track('newsletter_subscribe')">
Track Newsletter
</button>
<div class="info"> <div class="info">
<p><strong>API Usage:</strong></p> <p><strong>API Usage:</strong></p>
@ -61,7 +67,10 @@
</div> </div>
<h2>Console</h2> <h2>Console</h2>
<p>Open your browser's developer console (F12) and network tab to see beacon requests.</p> <p>
Open your browser's developer console (F12) and network tab to see beacon
requests.
</p>
<!-- Load the beacon script --> <!-- Load the beacon script -->
<script src="/web/beacon.js"></script> <script src="/web/beacon.js"></script>