From 442236f8515e761c1bd97f2cf3fd6b102bdecb64 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 16 Apr 2026 08:47:11 +0300 Subject: 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 --- internal/daemon/metrics.go | 100 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 internal/daemon/metrics.go (limited to 'internal/daemon/metrics.go') 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 +} -- cgit v1.2.3