mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
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:
@@ -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)
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ var _ = Describe("SchedulerService", func() {
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
manager = createManager(nil)
|
||||
manager = createManager(nil, nil)
|
||||
ss = manager.schedulerService
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user