diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-16 08:47:11 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-16 08:47:11 +0300 |
| commit | 442236f8515e761c1bd97f2cf3fd6b102bdecb64 (patch) | |
| tree | dbeb4f48688f7d5bec72f858b2c4fafc1b2fa96d | |
| parent | edeffd05dc62c4c3d747cc41067bf4e1814f300a (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.go | 3 | ||||
| -rw-r--r-- | internal/daemon/daemon.go | 8 | ||||
| -rw-r--r-- | internal/daemon/daemon_test.go | 8 | ||||
| -rw-r--r-- | internal/daemon/metrics.go | 100 | ||||
| -rw-r--r-- | internal/daemon/metrics_test.go | 128 | ||||
| -rw-r--r-- | internal/daemon/upload_test.go | 2 |
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 { |
