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>
This commit is contained in:
Kendall Garner
2025-06-28 02:13:57 +00:00
committed by GitHub
parent 709714cfc0
commit 0cd15c1ddc
22 changed files with 246 additions and 89 deletions
+13 -11
View File
@@ -67,7 +67,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache() fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New() fFmpeg := ffmpeg.New()
manager := plugins.GetManager(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager) agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents) provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
@@ -79,11 +80,10 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker() broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore) playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore) playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router return router
} }
@@ -92,7 +92,8 @@ func CreatePublicRouter() *public.Router {
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache() fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New() fFmpeg := ffmpeg.New()
manager := plugins.GetManager(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager) agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents) provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
@@ -128,7 +129,7 @@ func CreateInsights() metrics.Insights {
func CreatePrometheus() metrics.Metrics { func CreatePrometheus() metrics.Metrics {
sqlDB := db.Db() sqlDB := db.Db()
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
metricsMetrics := metrics.NewPrometheusInstance(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore)
return metricsMetrics return metricsMetrics
} }
@@ -137,14 +138,14 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache() fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New() fFmpeg := ffmpeg.New()
manager := plugins.GetManager(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager) agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents) provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker() broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore) playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
return scannerScanner return scannerScanner
} }
@@ -154,14 +155,14 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache() fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New() fFmpeg := ffmpeg.New()
manager := plugins.GetManager(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager) agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents) provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker() broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore) playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.NewWatcher(dataStore, scannerScanner) watcher := scanner.NewWatcher(dataStore, scannerScanner)
return watcher return watcher
@@ -177,13 +178,14 @@ func GetPlaybackServer() playback.PlaybackServer {
func getPluginManager() *plugins.Manager { func getPluginManager() *plugins.Manager {
sqlDB := db.Db() sqlDB := db.Db()
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
manager := plugins.GetManager(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
return manager return manager
} }
// wire_injectors.go: // wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.NewPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager))) var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)))
func GetPluginManager(ctx context.Context) *plugins.Manager { func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager() manager := getPluginManager()
+1 -1
View File
@@ -40,7 +40,7 @@ var allProviders = wire.NewSet(
scanner.New, scanner.New,
scanner.NewWatcher, scanner.NewWatcher,
plugins.GetManager, plugins.GetManager,
metrics.NewPrometheusInstance, metrics.GetPrometheusInstance,
db.Db, db.Db,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
+104 -26
View File
@@ -2,7 +2,6 @@ package metrics
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"sync" "sync"
@@ -13,6 +12,7 @@ import (
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
) )
@@ -20,6 +20,8 @@ import (
type Metrics interface { type Metrics interface {
WriteInitialMetrics(ctx context.Context) WriteInitialMetrics(ctx context.Context)
WriteAfterScanMetrics(ctx context.Context, success bool) 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 GetHandler() http.Handler
} }
@@ -27,11 +29,14 @@ type metrics struct {
ds model.DataStore ds model.DataStore
} }
func NewPrometheusInstance(ds model.DataStore) Metrics { func GetPrometheusInstance(ds model.DataStore) Metrics {
if conf.Server.Prometheus.Enabled { if !conf.Server.Prometheus.Enabled {
return &metrics{ds: ds} return noopMetrics{}
} }
return noopMetrics{}
return singleton.GetInstance(func() *metrics {
return &metrics{ds: ds}
})
} }
func NewNoopInstance() Metrics { func NewNoopInstance() Metrics {
@@ -51,6 +56,38 @@ func (m *metrics) WriteAfterScanMetrics(ctx context.Context, success bool) {
getPrometheusMetrics().mediaScansCounter.With(scanLabels).Inc() 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 { func (m *metrics) GetHandler() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
@@ -59,20 +96,31 @@ func (m *metrics) GetHandler() http.Handler {
consts.PrometheusAuthUser: conf.Server.Prometheus.Password, consts.PrometheusAuthUser: conf.Server.Prometheus.Password,
})) }))
} }
r.Handle("/", promhttp.Handler())
// 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 return r
} }
type prometheusMetrics struct { type prometheusMetrics struct {
dbTotal *prometheus.GaugeVec dbTotal *prometheus.GaugeVec
versionInfo *prometheus.GaugeVec versionInfo *prometheus.GaugeVec
lastMediaScan *prometheus.GaugeVec lastMediaScan *prometheus.GaugeVec
mediaScansCounter *prometheus.CounterVec mediaScansCounter *prometheus.CounterVec
httpRequestCounter *prometheus.CounterVec
httpRequestDuration *prometheus.SummaryVec
pluginRequestCounter *prometheus.CounterVec
pluginRequestDuration *prometheus.SummaryVec
} }
// Prometheus' metrics requires initialization. But not more than once // Prometheus' metrics requires initialization. But not more than once
var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics { 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{ instance := &prometheusMetrics{
dbTotal: prometheus.NewGaugeVec( dbTotal: prometheus.NewGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
@@ -102,23 +150,49 @@ var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics {
}, },
[]string{"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"},
),
} }
err := prometheus.DefaultRegisterer.Register(instance.dbTotal)
if err != nil { prometheus.DefaultRegisterer.MustRegister(
log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register db_model_totals metrics: %w", err)) instance.dbTotal,
} instance.versionInfo,
err = prometheus.DefaultRegisterer.Register(instance.versionInfo) instance.lastMediaScan,
if err != nil { instance.mediaScansCounter,
log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register navidrome_info metrics: %w", err)) instance.httpRequestCounter,
} instance.httpRequestDuration,
err = prometheus.DefaultRegisterer.Register(instance.lastMediaScan) instance.pluginRequestCounter,
if err != nil { instance.pluginRequestDuration,
log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register media_scan_last metrics: %w", err)) )
}
err = prometheus.DefaultRegisterer.Register(instance.mediaScansCounter)
if err != nil {
log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register media_scans metrics: %w", err))
}
return instance return instance
}) })
@@ -159,4 +233,8 @@ func (n noopMetrics) WriteInitialMetrics(context.Context) {}
func (n noopMetrics) WriteAfterScanMetrics(context.Context, bool) {} 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 } func (n noopMetrics) GetHandler() http.Handler { return nil }
+9 -8
View File
@@ -10,22 +10,23 @@ import (
) )
// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin // NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin
func newWasmMediaAgent(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { func newWasmMediaAgent(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil { if err != nil {
log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err) log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
return nil return nil
} }
return &wasmMediaAgent{ return &wasmMediaAgent{
wasmBasePlugin: &wasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin]{ wasmBasePlugin: newWasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin](
wasmPath: wasmPath, wasmPath,
id: pluginID, pluginID,
capability: CapabilityMetadataAgent, CapabilityMetadataAgent,
loader: loader, m.metrics,
loadFunc: func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) { loader,
func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) {
return l.Load(ctx, path) return l.Load(ctx, path)
}, },
}, ),
} }
} }
+1 -1
View File
@@ -23,7 +23,7 @@ var _ = Describe("Adapter Media Agent", func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Folder = testDataDir conf.Server.Plugins.Folder = testDataDir
mgr = createManager(nil) mgr = createManager(nil, nil)
mgr.ScanPlugins() mgr.ScanPlugins()
}) })
+9 -8
View File
@@ -9,22 +9,23 @@ import (
) )
// newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin // newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin
func newWasmSchedulerCallback(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { func newWasmSchedulerCallback(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil { if err != nil {
log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err) log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err)
return nil return nil
} }
return &wasmSchedulerCallback{ return &wasmSchedulerCallback{
wasmBasePlugin: &wasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin]{ wasmBasePlugin: newWasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin](
wasmPath: wasmPath, wasmPath,
id: pluginID, pluginID,
capability: CapabilitySchedulerCallback, CapabilitySchedulerCallback,
loader: loader, m.metrics,
loadFunc: func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) { loader,
func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) {
return l.Load(ctx, path) return l.Load(ctx, path)
}, },
}, ),
} }
} }
+9 -8
View File
@@ -12,22 +12,23 @@ import (
"github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero"
) )
func newWasmScrobblerPlugin(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { func newWasmScrobblerPlugin(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil { if err != nil {
log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err) log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err)
return nil return nil
} }
return &wasmScrobblerPlugin{ return &wasmScrobblerPlugin{
wasmBasePlugin: &wasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin]{ wasmBasePlugin: newWasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin](
wasmPath: wasmPath, wasmPath,
id: pluginID, pluginID,
capability: CapabilityScrobbler, CapabilityScrobbler,
loader: loader, m.metrics,
loadFunc: func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) { loader,
func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) {
return l.Load(ctx, path) return l.Load(ctx, path)
}, },
}, ),
} }
} }
+9 -8
View File
@@ -9,22 +9,23 @@ import (
) )
// newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin // newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin
func newWasmWebSocketCallback(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { func newWasmWebSocketCallback(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
if err != nil { if err != nil {
log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err) log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err)
return nil return nil
} }
return &wasmWebSocketCallback{ return &wasmWebSocketCallback{
wasmBasePlugin: &wasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin]{ wasmBasePlugin: newWasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin](
wasmPath: wasmPath, wasmPath,
id: pluginID, pluginID,
capability: CapabilityWebSocketCallback, CapabilityWebSocketCallback,
loader: loader, m.metrics,
loadFunc: func(ctx context.Context, l *api.WebSocketCallbackPlugin, path string) (api.WebSocketCallback, error) { loader,
func(ctx context.Context, l *api.WebSocketCallbackPlugin, path string) (api.WebSocketCallback, error) {
return l.Load(ctx, path) return l.Load(ctx, path)
}, },
}, ),
} }
} }
+1 -1
View File
@@ -16,7 +16,7 @@ var _ = Describe("SchedulerService", func() {
) )
BeforeEach(func() { BeforeEach(func() {
manager = createManager(nil) manager = createManager(nil, nil)
ss = manager.schedulerService ss = manager.schedulerService
}) })
+1 -1
View File
@@ -84,7 +84,7 @@ var _ = Describe("WebSocket Host Service", func() {
DeferCleanup(server.Close) DeferCleanup(server.Close)
// Create a new manager and websocket service // Create a new manager and websocket service
manager = createManager(nil) manager = createManager(nil, nil)
wsService = newWebsocketService(manager) wsService = newWebsocketService(manager)
}) })
+8 -5
View File
@@ -20,6 +20,7 @@ import (
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@@ -39,7 +40,7 @@ const (
) )
// pluginCreators maps capability types to their respective creator functions // pluginCreators maps capability types to their respective creator functions
type pluginConstructor func(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin type pluginConstructor func(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin
var pluginCreators = map[string]pluginConstructor{ var pluginCreators = map[string]pluginConstructor{
CapabilityMetadataAgent: newWasmMediaAgent, CapabilityMetadataAgent: newWasmMediaAgent,
@@ -95,21 +96,23 @@ type Manager struct {
lifecycle *pluginLifecycleManager // Manages plugin lifecycle and initialization lifecycle *pluginLifecycleManager // Manages plugin lifecycle and initialization
adapters map[string]WasmPlugin // Map of plugin folder name + capability to adapter adapters map[string]WasmPlugin // Map of plugin folder name + capability to adapter
ds model.DataStore // DataStore for accessing persistent data ds model.DataStore // DataStore for accessing persistent data
metrics metrics.Metrics
} }
// GetManager returns the singleton instance of Manager // GetManager returns the singleton instance of Manager
func GetManager(ds model.DataStore) *Manager { func GetManager(ds model.DataStore, metrics metrics.Metrics) *Manager {
return singleton.GetInstance(func() *Manager { return singleton.GetInstance(func() *Manager {
return createManager(ds) return createManager(ds, metrics)
}) })
} }
// createManager creates a new Manager instance. Used in tests // createManager creates a new Manager instance. Used in tests
func createManager(ds model.DataStore) *Manager { func createManager(ds model.DataStore, metrics metrics.Metrics) *Manager {
m := &Manager{ m := &Manager{
plugins: make(map[string]*plugin), plugins: make(map[string]*plugin),
lifecycle: newPluginLifecycleManager(), lifecycle: newPluginLifecycleManager(),
ds: ds, ds: ds,
metrics: metrics,
} }
// Create the host services // Create the host services
@@ -174,7 +177,7 @@ func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest
} }
continue continue
} }
adapter := constructor(wasmPath, pluginID, customRuntime, mc) adapter := constructor(wasmPath, pluginID, m, customRuntime, mc)
if adapter == nil { if adapter == nil {
log.Error("Failed to create plugin adapter", "plugin", pluginID, "capability", capabilityStr, "path", wasmPath) log.Error("Failed to create plugin adapter", "plugin", pluginID, "capability", capabilityStr, "path", wasmPath)
continue continue
+2 -2
View File
@@ -27,7 +27,7 @@ var _ = Describe("Plugin Manager", func() {
conf.Server.Plugins.Folder = testDataDir conf.Server.Plugins.Folder = testDataDir
ctx = GinkgoT().Context() ctx = GinkgoT().Context()
mgr = createManager(nil) mgr = createManager(nil, nil)
mgr.ScanPlugins() mgr.ScanPlugins()
}) })
@@ -85,7 +85,7 @@ var _ = Describe("Plugin Manager", func() {
}) })
conf.Server.Plugins.Folder = tempPluginsDir conf.Server.Plugins.Folder = tempPluginsDir
m = createManager(nil) m = createManager(nil, nil)
}) })
// Helper to create a complete valid plugin for manager testing // Helper to create a complete valid plugin for manager testing
+1 -1
View File
@@ -55,7 +55,7 @@ var _ = Describe("Plugin Permissions", func() {
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
ctx = context.Background() ctx = context.Background()
mgr = createManager(nil) mgr = createManager(nil, nil)
tempDir = GinkgoT().TempDir() tempDir = GinkgoT().TempDir()
}) })
+2 -1
View File
@@ -40,7 +40,7 @@ var _ = Describe("CachingRuntime", func() {
BeforeEach(func() { BeforeEach(func() {
ctx = GinkgoT().Context() ctx = GinkgoT().Context()
mgr = createManager(nil) mgr = createManager(nil, nil)
// Add permissions for the test plugin using typed struct // Add permissions for the test plugin using typed struct
permissions := schema.PluginManifestPermissions{ permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{ Http: &schema.PluginManifestPermissionsHttp{
@@ -58,6 +58,7 @@ var _ = Describe("CachingRuntime", func() {
plugin = newWasmScrobblerPlugin( plugin = newWasmScrobblerPlugin(
filepath.Join(testDataDir, "fake_scrobbler", "plugin.wasm"), filepath.Join(testDataDir, "fake_scrobbler", "plugin.wasm"),
"fake_scrobbler", "fake_scrobbler",
mgr,
rtFunc, rtFunc,
wazero.NewModuleConfig().WithStartFunctions("_initialize"), wazero.NewModuleConfig().WithStartFunctions("_initialize"),
).(*wasmScrobblerPlugin) ).(*wasmScrobblerPlugin)
+38 -1
View File
@@ -2,13 +2,28 @@ package plugins
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/id"
) )
// newWasmBasePlugin creates a new instance of wasmBasePlugin with the required parameters.
func newWasmBasePlugin[S any, P any](wasmPath, id, capability string, m metrics.Metrics, loader P, loadFunc loaderFunc[S, P]) *wasmBasePlugin[S, P] {
return &wasmBasePlugin[S, P]{
wasmPath: wasmPath,
id: id,
capability: capability,
loader: loader,
loadFunc: loadFunc,
metrics: m,
}
}
// LoaderFunc is a generic function type that loads a plugin instance. // LoaderFunc is a generic function type that loads a plugin instance.
type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error) type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error)
@@ -20,6 +35,7 @@ type wasmBasePlugin[S any, P any] struct {
capability string capability string
loader P loader P
loadFunc loaderFunc[S, P] loadFunc loaderFunc[S, P]
metrics metrics.Metrics
} }
func (w *wasmBasePlugin[S, P]) PluginID() string { func (w *wasmBasePlugin[S, P]) PluginID() string {
@@ -34,6 +50,10 @@ func (w *wasmBasePlugin[S, P]) serviceName() string {
return w.id + "_" + w.capability return w.id + "_" + w.capability
} }
func (w *wasmBasePlugin[S, P]) getMetrics() metrics.Metrics {
return w.metrics
}
// getInstance loads a new plugin instance and returns a cleanup function. // getInstance loads a new plugin instance and returns a cleanup function.
func (w *wasmBasePlugin[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) { func (w *wasmBasePlugin[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) {
start := time.Now() start := time.Now()
@@ -57,7 +77,9 @@ func (w *wasmBasePlugin[S, P]) getInstance(ctx context.Context, methodName strin
} }
type wasmPlugin[S any] interface { type wasmPlugin[S any] interface {
PluginID() string
getInstance(ctx context.Context, methodName string) (S, func(), error) getInstance(ctx context.Context, methodName string) (S, func(), error)
getMetrics() metrics.Metrics
} }
type errorMapper interface { type errorMapper interface {
@@ -73,10 +95,25 @@ func callMethod[S any, R any](ctx context.Context, w wasmPlugin[S], methodName s
if err != nil { if err != nil {
return r, err return r, err
} }
start := time.Now()
defer done() defer done()
r, err = fn(inst) r, err = fn(inst)
elapsed := time.Since(start)
if em, ok := any(w).(errorMapper); ok { if em, ok := any(w).(errorMapper); ok {
return r, em.mapError(err) mappedErr := em.mapError(err)
if !errors.Is(mappedErr, agents.ErrNotFound) {
id := w.PluginID()
isOk := mappedErr == nil
metrics := w.getMetrics()
if metrics != nil {
metrics.RecordPluginRequest(ctx, id, methodName, isOk, elapsed.Milliseconds())
}
log.Trace(ctx, "callMethod", "plugin", id, "method", methodName, "ok", isOk, elapsed)
}
return r, mappedErr
} }
return r, err return r, err
} }
+1 -1
View File
@@ -25,7 +25,7 @@ var _ = Describe("Album Lists", func() {
BeforeEach(func() { BeforeEach(func() {
ds = &tests.MockDataStore{} ds = &tests.MockDataStore{}
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
}) })
+10 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
@@ -43,11 +44,13 @@ type Router struct {
scrobbler scrobbler.PlayTracker scrobbler scrobbler.PlayTracker
share core.Share share core.Share
playback playback.PlaybackServer playback playback.PlaybackServer
metrics metrics.Metrics
} }
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver, func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker, players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics,
) *Router { ) *Router {
r := &Router{ r := &Router{
ds: ds, ds: ds,
@@ -62,6 +65,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame
scrobbler: scrobbler, scrobbler: scrobbler,
share: share, share: share,
playback: playback, playback: playback,
metrics: metrics,
} }
r.Handler = r.routes() r.Handler = r.routes()
return r return r
@@ -69,6 +73,11 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame
func (api *Router) routes() http.Handler { func (api *Router) routes() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
if conf.Server.Prometheus.Enabled {
r.Use(recordStats(api.metrics))
}
r.Use(postFormToQueryParams) r.Use(postFormToQueryParams)
// Public // Public
@@ -223,7 +232,7 @@ func h(r chi.Router, path string, f handler) {
}) })
} }
// Add a Subsonic handler that requires a http.ResponseWriter (ex: stream, getCoverArt...) // Add a Subsonic handler that requires an http.ResponseWriter (ex: stream, getCoverArt...)
func hr(r chi.Router, path string, f handlerRaw) { func hr(r chi.Router, path string, f handlerRaw) {
handle := func(w http.ResponseWriter, r *http.Request) { handle := func(w http.ResponseWriter, r *http.Request) {
res, err := f(w, r) res, err := f(w, r)
+1 -1
View File
@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
ds = &tests.MockDataStore{} ds = &tests.MockDataStore{}
playTracker = &fakePlayTracker{} playTracker = &fakePlayTracker{}
eventBroker = &fakeEventBroker{} eventBroker = &fakeEventBroker{}
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil) router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
}) })
Describe("Scrobble", func() { Describe("Scrobble", func() {
+1 -1
View File
@@ -33,7 +33,7 @@ var _ = Describe("MediaRetrievalController", func() {
MockedMediaFile: mockRepo, MockedMediaFile: mockRepo,
} }
artwork = &fakeArtwork{data: "image data"} artwork = &fakeArtwork{data: "image data"}
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc" conf.Server.LyricsPriority = "embedded,.lrc"
+23
View File
@@ -11,12 +11,15 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
"github.com/go-chi/chi/v5/middleware"
ua "github.com/mileusna/useragent" ua "github.com/mileusna/useragent"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
@@ -222,3 +225,23 @@ func playerIDCookieName(userName string) string {
cookieName := fmt.Sprintf("nd-player-%x", userName) cookieName := fmt.Sprintf("nd-player-%x", userName)
return cookieName return cookieName
} }
func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
defer func() {
// We want to get the client name (even if not present for certain endpoints)
p := req.Params(r)
client, _ := p.String("c")
metrics.RecordRequest(r.Context(), strings.Replace(r.URL.Path, ".view", "", 1), r.Method, client, ww.Status(), time.Since(start).Milliseconds())
}()
next.ServeHTTP(ww, r)
}
return http.HandlerFunc(fn)
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
) )
BeforeEach(func() { BeforeEach(func() {
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
}) })
+1 -1
View File
@@ -20,7 +20,7 @@ var _ = Describe("UpdatePlaylist", func() {
BeforeEach(func() { BeforeEach(func() {
ds = &tests.MockDataStore{} ds = &tests.MockDataStore{}
playlists = &fakePlaylists{} playlists = &fakePlaylists{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil) router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
}) })
It("clears the comment when parameter is empty", func() { It("clears the comment when parameter is empty", func() {