navidrome/core/metrics/prometheus.go
Kendall Garner 0cd15c1ddc
feat(prometheus): add metrics to Subsonic API and Plugins (#4266)
* Add prometheus metrics to subsonic and plugins

* address feedback, do not log error if operation is not supported

* add missing timestamp and client to stats

* remove .view from subsonic route

* directly inject DataStore in Prometheus, to avoid having to pass it in every call

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-06-27 22:13:57 -04:00

241 lines
7.1 KiB
Go

package metrics
import (
"context"
"net/http"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
type Metrics interface {
WriteInitialMetrics(ctx context.Context)
WriteAfterScanMetrics(ctx context.Context, success bool)
RecordRequest(ctx context.Context, endpoint, method, client string, status int, elapsed int64)
RecordPluginRequest(ctx context.Context, plugin, method string, ok bool, elapsed int64)
GetHandler() http.Handler
}
type metrics struct {
ds model.DataStore
}
func GetPrometheusInstance(ds model.DataStore) Metrics {
if !conf.Server.Prometheus.Enabled {
return noopMetrics{}
}
return singleton.GetInstance(func() *metrics {
return &metrics{ds: ds}
})
}
func NewNoopInstance() Metrics {
return noopMetrics{}
}
func (m *metrics) WriteInitialMetrics(ctx context.Context) {
getPrometheusMetrics().versionInfo.With(prometheus.Labels{"version": consts.Version}).Set(1)
processSqlAggregateMetrics(ctx, m.ds, getPrometheusMetrics().dbTotal)
}
func (m *metrics) WriteAfterScanMetrics(ctx context.Context, success bool) {
processSqlAggregateMetrics(ctx, m.ds, getPrometheusMetrics().dbTotal)
scanLabels := prometheus.Labels{"success": strconv.FormatBool(success)}
getPrometheusMetrics().lastMediaScan.With(scanLabels).SetToCurrentTime()
getPrometheusMetrics().mediaScansCounter.With(scanLabels).Inc()
}
func (m *metrics) RecordRequest(_ context.Context, endpoint, method, client string, status int, elapsed int64) {
httpLabel := prometheus.Labels{
"endpoint": endpoint,
"method": method,
"client": client,
"status": strconv.FormatInt(int64(status), 10),
}
getPrometheusMetrics().httpRequestCounter.With(httpLabel).Inc()
httpLatencyLabel := prometheus.Labels{
"endpoint": endpoint,
"method": method,
"client": client,
}
getPrometheusMetrics().httpRequestDuration.With(httpLatencyLabel).Observe(float64(elapsed))
}
func (m *metrics) RecordPluginRequest(_ context.Context, plugin, method string, ok bool, elapsed int64) {
pluginLabel := prometheus.Labels{
"plugin": plugin,
"method": method,
"ok": strconv.FormatBool(ok),
}
getPrometheusMetrics().pluginRequestCounter.With(pluginLabel).Inc()
pluginLatencyLabel := prometheus.Labels{
"plugin": plugin,
"method": method,
}
getPrometheusMetrics().pluginRequestDuration.With(pluginLatencyLabel).Observe(float64(elapsed))
}
func (m *metrics) GetHandler() http.Handler {
r := chi.NewRouter()
if conf.Server.Prometheus.Password != "" {
r.Use(middleware.BasicAuth("metrics", map[string]string{
consts.PrometheusAuthUser: conf.Server.Prometheus.Password,
}))
}
// Enable created at timestamp to handle zero counter on create.
// This requires --enable-feature=created-timestamp-zero-ingestion to be passed in Prometheus
r.Handle("/", promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
EnableOpenMetrics: true,
EnableOpenMetricsTextCreatedSamples: true,
}))
return r
}
type prometheusMetrics struct {
dbTotal *prometheus.GaugeVec
versionInfo *prometheus.GaugeVec
lastMediaScan *prometheus.GaugeVec
mediaScansCounter *prometheus.CounterVec
httpRequestCounter *prometheus.CounterVec
httpRequestDuration *prometheus.SummaryVec
pluginRequestCounter *prometheus.CounterVec
pluginRequestDuration *prometheus.SummaryVec
}
// Prometheus' metrics requires initialization. But not more than once
var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics {
quartilesToEstimate := map[float64]float64{0.5: 0.05, 0.75: 0.025, 0.9: 0.01, 0.99: 0.001}
instance := &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"},
),
httpRequestCounter: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_request_count",
Help: "Request types by status",
},
[]string{"endpoint", "method", "client", "status"},
),
httpRequestDuration: prometheus.NewSummaryVec(
prometheus.SummaryOpts{
Name: "http_request_latency",
Help: "Latency (in ms) of HTTP requests",
Objectives: quartilesToEstimate,
},
[]string{"endpoint", "method", "client"},
),
pluginRequestCounter: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "plugin_request_count",
Help: "Plugin requests by method/status",
},
[]string{"plugin", "method", "ok"},
),
pluginRequestDuration: prometheus.NewSummaryVec(
prometheus.SummaryOpts{
Name: "plugin_request_latency",
Help: "Latency (in ms) of plugin requests",
Objectives: quartilesToEstimate,
},
[]string{"plugin", "method"},
),
}
prometheus.DefaultRegisterer.MustRegister(
instance.dbTotal,
instance.versionInfo,
instance.lastMediaScan,
instance.mediaScansCounter,
instance.httpRequestCounter,
instance.httpRequestDuration,
instance.pluginRequestCounter,
instance.pluginRequestDuration,
)
return instance
})
func processSqlAggregateMetrics(ctx context.Context, ds model.DataStore, targetGauge *prometheus.GaugeVec) {
albumsCount, err := ds.Album(ctx).CountAll()
if err != nil {
log.Warn("album CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount))
artistCount, err := ds.Artist(ctx).CountAll()
if err != nil {
log.Warn("artist CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "artist"}).Set(float64(artistCount))
songsCount, err := ds.MediaFile(ctx).CountAll()
if err != nil {
log.Warn("media CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount))
usersCount, err := ds.User(ctx).CountAll()
if err != nil {
log.Warn("user CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "user"}).Set(float64(usersCount))
}
type noopMetrics struct {
}
func (n noopMetrics) WriteInitialMetrics(context.Context) {}
func (n noopMetrics) WriteAfterScanMetrics(context.Context, bool) {}
func (n noopMetrics) RecordRequest(context.Context, string, string, string, int, int64) {}
func (n noopMetrics) RecordPluginRequest(context.Context, string, string, bool, int64) {}
func (n noopMetrics) GetHandler() http.Handler { return nil }