summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-16 08:47:11 +0300
committerPaul Buetow <paul@buetow.org>2026-04-16 08:47:11 +0300
commit442236f8515e761c1bd97f2cf3fd6b102bdecb64 (patch)
treedbeb4f48688f7d5bec72f858b2c4fafc1b2fa96d
parentedeffd05dc62c4c3d747cc41067bf4e1814f300a (diff)
Add /metrics Prometheus endpoint to daemon mode
Exposes a gauge per host tracking the Unix timestamp of the last records file update. Excluded hosts (from the excluded_host table) are labeled excluded="true" so alerting rules can filter them out. Uses manual Prometheus text format to avoid adding a new dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/cli/cmd_daemon.go3
-rw-r--r--internal/daemon/daemon.go8
-rw-r--r--internal/daemon/daemon_test.go8
-rw-r--r--internal/daemon/metrics.go100
-rw-r--r--internal/daemon/metrics_test.go128
-rw-r--r--internal/daemon/upload_test.go2
6 files changed, 240 insertions, 9 deletions
diff --git a/internal/cli/cmd_daemon.go b/internal/cli/cmd_daemon.go
index 2bd74a8..111968f 100644
--- a/internal/cli/cmd_daemon.go
+++ b/internal/cli/cmd_daemon.go
@@ -25,6 +25,7 @@ func runDaemon(args []string) error {
statsDir := fs.String("stats-dir", os.Getenv("GOPRECORDS_STATS_DIR"), "Uptimed stats directory (required; env GOPRECORDS_STATS_DIR)")
listen := fs.String("listen", defaultListenFromEnv(), "TCP listen address (env GOPRECORDS_LISTEN, default :8080)")
authDB := fs.String("auth-db", "", "SQLite file for upload API keys (default: <stats-dir>/goprecords-auth.db)")
+ db := fs.String("db", os.Getenv("GOPRECORDS_DB"), "SQLite database path for excluded hosts used by /metrics (env GOPRECORDS_DB)")
if err := fs.Parse(args); err != nil {
return err
}
@@ -35,7 +36,7 @@ func runDaemon(args []string) error {
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
- err := daemon.Run(ctx, daemon.Config{StatsDir: *statsDir, Addr: *listen, AuthDB: *authDB})
+ err := daemon.Run(ctx, daemon.Config{StatsDir: *statsDir, Addr: *listen, AuthDB: *authDB, DB: *db})
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go
index e4f07f6..326224e 100644
--- a/internal/daemon/daemon.go
+++ b/internal/daemon/daemon.go
@@ -31,6 +31,7 @@ type Config struct {
StatsDir string
Addr string
AuthDB string
+ DB string
LogOutput io.Writer
}
@@ -41,16 +42,17 @@ func NewHandler(statsDir string) (http.Handler, error) {
if err != nil {
return nil, fmt.Errorf("auth db: %w", err)
}
- return routes(statsDir, "", store), nil
+ return routes(statsDir, "", "", store), nil
}
-func routes(statsDir, authDB string, store *authkeys.Store) http.Handler {
+func routes(statsDir, authDB, db string, store *authkeys.Store) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", root(statsDir))
mux.HandleFunc("/health", health)
mux.HandleFunc("/livez", health)
mux.HandleFunc("/readyz", readiness(statsDir, authDB))
mux.HandleFunc("/report", report(statsDir))
+ mux.HandleFunc("/metrics", metricsHandler(statsDir, db))
mux.Handle("/upload/", uploadHandler(statsDir, store))
return mux
}
@@ -151,7 +153,7 @@ func Run(ctx context.Context, cfg Config) error {
return fmt.Errorf("auth db: %w", err)
}
defer store.Close()
- srv := newDaemonHTTPServer(cfg.Addr, withAccessLog(slogLog, routes(cfg.StatsDir, cfg.AuthDB, store)),
+ srv := newDaemonHTTPServer(cfg.Addr, withAccessLog(slogLog, routes(cfg.StatsDir, cfg.AuthDB, cfg.DB, store)),
slog.NewLogLogger(textHandler, slog.LevelError))
slogLog.Info("daemon_listen", "addr", cfg.Addr)
errCh := make(chan error, 1)
diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go
index 44f9888..462e322 100644
--- a/internal/daemon/daemon_test.go
+++ b/internal/daemon/daemon_test.go
@@ -542,7 +542,7 @@ func TestAccessLogLineToWriter(t *testing.T) {
t.Fatal(err)
}
defer store.Close()
- srv := httptest.NewServer(withAccessLog(log, routes(statsDir, "", store)))
+ srv := httptest.NewServer(withAccessLog(log, routes(statsDir, "", "", store)))
defer srv.Close()
res, err := http.Get(srv.URL + "/health")
if err != nil {
@@ -591,7 +591,7 @@ func TestUploadRequiresBearerWhenKeysExist(t *testing.T) {
if _, err := store.CreateKey(ctx, "myhost"); err != nil {
t.Fatal(err)
}
- srv := httptest.NewServer(routes(statsDir, "", store))
+ srv := httptest.NewServer(routes(statsDir, "", "", store))
defer srv.Close()
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/myhost/txt", strings.NewReader("x"))
res, err := http.DefaultClient.Do(req)
@@ -616,7 +616,7 @@ func TestUploadWithValidBearer(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- srv := httptest.NewServer(routes(statsDir, "", store))
+ srv := httptest.NewServer(routes(statsDir, "", "", store))
defer srv.Close()
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/myhost/os.txt", strings.NewReader("os"))
req.Header.Set("Authorization", "Bearer "+tok)
@@ -642,7 +642,7 @@ func TestUploadWrongHostForbidden(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- srv := httptest.NewServer(routes(statsDir, "", store))
+ srv := httptest.NewServer(routes(statsDir, "", "", store))
defer srv.Close()
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/upload/other/txt", strings.NewReader("x"))
req.Header.Set("Authorization", "Bearer "+tok)
diff --git a/internal/daemon/metrics.go b/internal/daemon/metrics.go
new file mode 100644
index 0000000..192db77
--- /dev/null
+++ b/internal/daemon/metrics.go
@@ -0,0 +1,100 @@
+package daemon
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "codeberg.org/snonux/goprecords/internal/recordsdir"
+ "codeberg.org/snonux/goprecords/internal/storage"
+)
+
+func metricsHandler(statsDir, dbPath string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ body, err := buildMetrics(r.Context(), statsDir, dbPath)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(body)
+ }
+}
+
+func buildMetrics(ctx context.Context, statsDir, dbPath string) ([]byte, error) {
+ entries, err := recordsdir.ListNonEmptyFiles(statsDir)
+ if err != nil {
+ return nil, fmt.Errorf("list records: %w", err)
+ }
+ excluded, err := loadExcludedSet(ctx, dbPath)
+ if err != nil {
+ return nil, fmt.Errorf("load excluded hosts: %w", err)
+ }
+ var sb strings.Builder
+ writeMetricHeader(&sb)
+ for _, e := range entries {
+ mtime, err := recordsMtime(statsDir, e.Host)
+ if err != nil {
+ continue
+ }
+ isExcluded := excluded[e.Host]
+ writeHostMetric(&sb, e.Host, mtime, isExcluded)
+ }
+ return []byte(sb.String()), nil
+}
+
+func writeMetricHeader(sb *strings.Builder) {
+ sb.WriteString("# HELP goprecords_host_records_last_update_timestamp_seconds Unix timestamp of the last records file update for each host.\n")
+ sb.WriteString("# TYPE goprecords_host_records_last_update_timestamp_seconds gauge\n")
+}
+
+func writeHostMetric(sb *strings.Builder, host string, mtime int64, excluded bool) {
+ excludedVal := "false"
+ if excluded {
+ excludedVal = "true"
+ }
+ fmt.Fprintf(sb, "goprecords_host_records_last_update_timestamp_seconds{host=%q,excluded=%q} %d\n",
+ host, excludedVal, mtime)
+}
+
+func recordsMtime(statsDir, host string) (int64, error) {
+ path := filepath.Join(statsDir, host+".records")
+ fi, err := os.Stat(path)
+ if err != nil {
+ return 0, err
+ }
+ return fi.ModTime().Unix(), nil
+}
+
+func loadExcludedSet(ctx context.Context, dbPath string) (map[string]bool, error) {
+ if dbPath == "" {
+ return map[string]bool{}, nil
+ }
+ db, err := storage.Open(ctx, dbPath)
+ if err != nil {
+ return map[string]bool{}, nil
+ }
+ defer db.Close()
+ return loadExcludedHosts(ctx, db)
+}
+
+func loadExcludedHosts(ctx context.Context, db *sql.DB) (map[string]bool, error) {
+ hosts, err := storage.LoadExcludedHosts(ctx, db)
+ if err != nil {
+ return nil, err
+ }
+ set := make(map[string]bool, len(hosts))
+ for _, h := range hosts {
+ set[h.Host] = true
+ }
+ return set, nil
+}
diff --git a/internal/daemon/metrics_test.go b/internal/daemon/metrics_test.go
new file mode 100644
index 0000000..536f72c
--- /dev/null
+++ b/internal/daemon/metrics_test.go
@@ -0,0 +1,128 @@
+package daemon
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/goprecords/internal/storage"
+)
+
+func TestMetricsEndpointEmpty(t *testing.T) {
+ statsDir := t.TempDir()
+ srv := httptest.NewServer(testHandler(t, statsDir))
+ defer srv.Close()
+ res, err := http.Get(srv.URL + "/metrics")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("status %d", res.StatusCode)
+ }
+ ct := res.Header.Get("Content-Type")
+ if !strings.HasPrefix(ct, "text/plain") {
+ t.Fatalf("Content-Type %q", ct)
+ }
+ body, _ := io.ReadAll(res.Body)
+ if !strings.Contains(string(body), "goprecords_host_records_last_update_timestamp_seconds") {
+ t.Fatalf("missing metric name in body: %q", body)
+ }
+}
+
+func TestMetricsEndpointWithHost(t *testing.T) {
+ statsDir := t.TempDir()
+ recordsFile := filepath.Join(statsDir, "myhost.records")
+ if err := os.WriteFile(recordsFile, []byte("1:1:Linux 5.0\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(testHandler(t, statsDir))
+ defer srv.Close()
+ res, err := http.Get(srv.URL + "/metrics")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("status %d", res.StatusCode)
+ }
+ body, _ := io.ReadAll(res.Body)
+ s := string(body)
+ if !strings.Contains(s, `host="myhost"`) {
+ t.Fatalf("missing host label: %q", s)
+ }
+ if !strings.Contains(s, `excluded="false"`) {
+ t.Fatalf("missing excluded=false label: %q", s)
+ }
+}
+
+func TestMetricsEndpointWithExcludedHost(t *testing.T) {
+ statsDir := t.TempDir()
+ dbPath := filepath.Join(t.TempDir(), "test.db")
+ recordsFile := filepath.Join(statsDir, "exchost.records")
+ if err := os.WriteFile(recordsFile, []byte("1:1:Linux 5.0\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ db, err := storage.Open(ctx, dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if err := storage.CreateSchema(ctx, db); err != nil {
+ t.Fatal(err)
+ }
+ if err := storage.AddExcludedHost(ctx, db, "exchost", "test"); err != nil {
+ t.Fatal(err)
+ }
+ db.Close()
+ h := metricsHandler(statsDir, dbPath)
+ req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
+ w := httptest.NewRecorder()
+ h(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d", w.Code)
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, `excluded="true"`) {
+ t.Fatalf("expected excluded=true for exchost: %q", body)
+ }
+}
+
+func TestMetricsMethodNotAllowed(t *testing.T) {
+ statsDir := t.TempDir()
+ srv := httptest.NewServer(testHandler(t, statsDir))
+ defer srv.Close()
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/metrics", nil)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if res.StatusCode != http.StatusMethodNotAllowed {
+ t.Fatalf("status %d want 405", res.StatusCode)
+ }
+}
+
+func TestBuildMetricsNoDBPath(t *testing.T) {
+ statsDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(statsDir, "h1.records"), []byte("1:1:Linux 5.0\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ body, err := buildMetrics(context.Background(), statsDir, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(body)
+ if !strings.Contains(s, `host="h1"`) {
+ t.Fatalf("missing h1 in output: %q", s)
+ }
+ if !strings.Contains(s, `excluded="false"`) {
+ t.Fatalf("missing excluded=false: %q", s)
+ }
+}
diff --git a/internal/daemon/upload_test.go b/internal/daemon/upload_test.go
index 2cc52f3..761fd62 100644
--- a/internal/daemon/upload_test.go
+++ b/internal/daemon/upload_test.go
@@ -207,7 +207,7 @@ func TestUploadAuthBearerNegativeTable(t *testing.T) {
if _, err := store.CreateKey(ctx, "myhost"); err != nil {
t.Fatal(err)
}
- srv := httptest.NewServer(routes(statsDir, "", store))
+ srv := httptest.NewServer(routes(statsDir, "", "", store))
defer srv.Close()
url := srv.URL + "/upload/myhost/txt"
tests := []struct {