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

View File

@@ -10,22 +10,23 @@ import (
)
// 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))
if err != nil {
log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmMediaAgent{
wasmBasePlugin: &wasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin]{
wasmPath: wasmPath,
id: pluginID,
capability: CapabilityMetadataAgent,
loader: loader,
loadFunc: func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) {
wasmBasePlugin: newWasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin](
wasmPath,
pluginID,
CapabilityMetadataAgent,
m.metrics,
loader,
func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) {
return l.Load(ctx, path)
},
},
),
}
}

View File

@@ -23,7 +23,7 @@ var _ = Describe("Adapter Media Agent", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Folder = testDataDir
mgr = createManager(nil)
mgr = createManager(nil, nil)
mgr.ScanPlugins()
})

View File

@@ -9,22 +9,23 @@ import (
)
// 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))
if err != nil {
log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmSchedulerCallback{
wasmBasePlugin: &wasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin]{
wasmPath: wasmPath,
id: pluginID,
capability: CapabilitySchedulerCallback,
loader: loader,
loadFunc: func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) {
wasmBasePlugin: newWasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin](
wasmPath,
pluginID,
CapabilitySchedulerCallback,
m.metrics,
loader,
func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) {
return l.Load(ctx, path)
},
},
),
}
}

View File

@@ -12,22 +12,23 @@ import (
"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))
if err != nil {
log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmScrobblerPlugin{
wasmBasePlugin: &wasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin]{
wasmPath: wasmPath,
id: pluginID,
capability: CapabilityScrobbler,
loader: loader,
loadFunc: func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) {
wasmBasePlugin: newWasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin](
wasmPath,
pluginID,
CapabilityScrobbler,
m.metrics,
loader,
func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) {
return l.Load(ctx, path)
},
},
),
}
}

View File

@@ -9,22 +9,23 @@ import (
)
// 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))
if err != nil {
log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err)
return nil
}
return &wasmWebSocketCallback{
wasmBasePlugin: &wasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin]{
wasmPath: wasmPath,
id: pluginID,
capability: CapabilityWebSocketCallback,
loader: loader,
loadFunc: func(ctx context.Context, l *api.WebSocketCallbackPlugin, path string) (api.WebSocketCallback, error) {
wasmBasePlugin: newWasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin](
wasmPath,
pluginID,
CapabilityWebSocketCallback,
m.metrics,
loader,
func(ctx context.Context, l *api.WebSocketCallbackPlugin, path string) (api.WebSocketCallback, error) {
return l.Load(ctx, path)
},
},
),
}
}

View File

@@ -16,7 +16,7 @@ var _ = Describe("SchedulerService", func() {
)
BeforeEach(func() {
manager = createManager(nil)
manager = createManager(nil, nil)
ss = manager.schedulerService
})

View File

@@ -84,7 +84,7 @@ var _ = Describe("WebSocket Host Service", func() {
DeferCleanup(server.Close)
// Create a new manager and websocket service
manager = createManager(nil)
manager = createManager(nil, nil)
wsService = newWebsocketService(manager)
})

View File

@@ -20,6 +20,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -39,7 +40,7 @@ const (
)
// 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{
CapabilityMetadataAgent: newWasmMediaAgent,
@@ -95,21 +96,23 @@ type Manager struct {
lifecycle *pluginLifecycleManager // Manages plugin lifecycle and initialization
adapters map[string]WasmPlugin // Map of plugin folder name + capability to adapter
ds model.DataStore // DataStore for accessing persistent data
metrics metrics.Metrics
}
// 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 createManager(ds)
return createManager(ds, metrics)
})
}
// 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{
plugins: make(map[string]*plugin),
lifecycle: newPluginLifecycleManager(),
ds: ds,
metrics: metrics,
}
// Create the host services
@@ -174,7 +177,7 @@ func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest
}
continue
}
adapter := constructor(wasmPath, pluginID, customRuntime, mc)
adapter := constructor(wasmPath, pluginID, m, customRuntime, mc)
if adapter == nil {
log.Error("Failed to create plugin adapter", "plugin", pluginID, "capability", capabilityStr, "path", wasmPath)
continue

View File

@@ -27,7 +27,7 @@ var _ = Describe("Plugin Manager", func() {
conf.Server.Plugins.Folder = testDataDir
ctx = GinkgoT().Context()
mgr = createManager(nil)
mgr = createManager(nil, nil)
mgr.ScanPlugins()
})
@@ -85,7 +85,7 @@ var _ = Describe("Plugin Manager", func() {
})
conf.Server.Plugins.Folder = tempPluginsDir
m = createManager(nil)
m = createManager(nil, nil)
})
// Helper to create a complete valid plugin for manager testing

View File

@@ -55,7 +55,7 @@ var _ = Describe("Plugin Permissions", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
mgr = createManager(nil)
mgr = createManager(nil, nil)
tempDir = GinkgoT().TempDir()
})

View File

@@ -40,7 +40,7 @@ var _ = Describe("CachingRuntime", func() {
BeforeEach(func() {
ctx = GinkgoT().Context()
mgr = createManager(nil)
mgr = createManager(nil, nil)
// Add permissions for the test plugin using typed struct
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
@@ -58,6 +58,7 @@ var _ = Describe("CachingRuntime", func() {
plugin = newWasmScrobblerPlugin(
filepath.Join(testDataDir, "fake_scrobbler", "plugin.wasm"),
"fake_scrobbler",
mgr,
rtFunc,
wazero.NewModuleConfig().WithStartFunctions("_initialize"),
).(*wasmScrobblerPlugin)

View File

@@ -2,13 +2,28 @@ package plugins
import (
"context"
"errors"
"fmt"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log"
"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.
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
loader P
loadFunc loaderFunc[S, P]
metrics metrics.Metrics
}
func (w *wasmBasePlugin[S, P]) PluginID() string {
@@ -34,6 +50,10 @@ func (w *wasmBasePlugin[S, P]) serviceName() string {
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.
func (w *wasmBasePlugin[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) {
start := time.Now()
@@ -57,7 +77,9 @@ func (w *wasmBasePlugin[S, P]) getInstance(ctx context.Context, methodName strin
}
type wasmPlugin[S any] interface {
PluginID() string
getInstance(ctx context.Context, methodName string) (S, func(), error)
getMetrics() metrics.Metrics
}
type errorMapper interface {
@@ -73,10 +95,25 @@ func callMethod[S any, R any](ctx context.Context, w wasmPlugin[S], methodName s
if err != nil {
return r, err
}
start := time.Now()
defer done()
r, err = fn(inst)
elapsed := time.Since(start)
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
}