diff --git a/cmd/root.go b/cmd/root.go index da088e76..35a5743f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/resources" @@ -91,6 +92,8 @@ func startServer(ctx context.Context) func() error { a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter()) } if conf.Server.Prometheus.Enabled { + // blocking call because takes <1ms but useful if fails + core.WriteInitialMetrics() a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler()) } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { diff --git a/core/metrics.go b/core/metrics.go new file mode 100644 index 00000000..bcbd0833 --- /dev/null +++ b/core/metrics.go @@ -0,0 +1,123 @@ +package core + +import ( + "context" + "fmt" + "strconv" + "sync" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/prometheus/client_golang/prometheus" +) + +func WriteInitialMetrics() { + getPrometheusMetrics().versionInfo.With(prometheus.Labels{"version": consts.Version}).Set(1) +} + +func WriteAfterScanMetrics(ctx context.Context, dataStore model.DataStore, success bool) { + processSqlAggregateMetrics(ctx, dataStore, getPrometheusMetrics().dbTotal) + + scanLabels := prometheus.Labels{"success": strconv.FormatBool(success)} + getPrometheusMetrics().lastMediaScan.With(scanLabels).SetToCurrentTime() + getPrometheusMetrics().mediaScansCounter.With(scanLabels).Inc() +} + +// Prometheus' metrics requires initialization. But not more than once +var ( + prometheusMetricsInstance *prometheusMetrics + prometheusOnce sync.Once +) + +type prometheusMetrics struct { + dbTotal *prometheus.GaugeVec + versionInfo *prometheus.GaugeVec + lastMediaScan *prometheus.GaugeVec + mediaScansCounter *prometheus.CounterVec +} + +func getPrometheusMetrics() *prometheusMetrics { + prometheusOnce.Do(func() { + var err error + prometheusMetricsInstance, err = newPrometheusMetrics() + if err != nil { + log.Fatal("Unable to create Prometheus metrics instance.", err) + } + }) + return prometheusMetricsInstance +} + +func newPrometheusMetrics() (*prometheusMetrics, error) { + res := &prometheusMetrics{ + dbTotal: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "db_model_totals", + Help: "Total number of DB items per model", + }, + []string{"model"}, + ), + versionInfo: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "navidrome_info", + Help: "Information about Navidrome version", + }, + []string{"version"}, + ), + lastMediaScan: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "media_scan_last", + Help: "Last media scan timestamp by success", + }, + []string{"success"}, + ), + mediaScansCounter: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "media_scans", + Help: "Total success media scans by success", + }, + []string{"success"}, + ), + } + + err := prometheus.DefaultRegisterer.Register(res.dbTotal) + if err != nil { + return nil, fmt.Errorf("unable to register db_model_totals metrics: %w", err) + } + err = prometheus.DefaultRegisterer.Register(res.versionInfo) + if err != nil { + return nil, fmt.Errorf("unable to register navidrome_info metrics: %w", err) + } + err = prometheus.DefaultRegisterer.Register(res.lastMediaScan) + if err != nil { + return nil, fmt.Errorf("unable to register media_scan_last metrics: %w", err) + } + err = prometheus.DefaultRegisterer.Register(res.mediaScansCounter) + if err != nil { + return nil, fmt.Errorf("unable to register media_scans metrics: %w", err) + } + return res, nil +} + +func processSqlAggregateMetrics(ctx context.Context, dataStore model.DataStore, targetGauge *prometheus.GaugeVec) { + albumsCount, err := dataStore.Album(ctx).CountAll() + if err != nil { + log.Warn("album CountAll error", err) + return + } + targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount)) + + songsCount, err := dataStore.MediaFile(ctx).CountAll() + if err != nil { + log.Warn("media CountAll error", err) + return + } + targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount)) + + usersCount, err := dataStore.User(ctx).CountAll() + if err != nil { + log.Warn("user CountAll error", err) + return + } + targetGauge.With(prometheus.Labels{"model": "user"}).Set(float64(usersCount)) +} diff --git a/model/player.go b/model/player.go index af2199e4..372ad570 100644 --- a/model/player.go +++ b/model/player.go @@ -24,4 +24,5 @@ type PlayerRepository interface { Get(id string) (*Player, error) FindMatch(userName, client, typ string) (*Player, error) Put(p *Player) error + // TODO: Add CountAll method. Useful at least for metrics. } diff --git a/scanner/scanner.go b/scanner/scanner.go index 0b656151..b9b28338 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -151,8 +151,10 @@ func (s *scanner) RescanAll(ctx context.Context, fullRescan bool) error { } if hasError { log.Error("Errors while scanning media. Please check the logs") + core.WriteAfterScanMetrics(ctx, s.ds, false) return ErrScanError } + core.WriteAfterScanMetrics(ctx, s.ds, true) return nil }