update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
330
core/metrics/insights.go
Normal file
330
core/metrics/insights.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/metrics/insights"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
type Insights interface {
|
||||
Run(ctx context.Context)
|
||||
LastRun(ctx context.Context) (timestamp time.Time, success bool)
|
||||
}
|
||||
|
||||
var (
|
||||
insightsID string
|
||||
)
|
||||
|
||||
type insightsCollector struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
lastRun atomic.Int64
|
||||
lastStatus atomic.Bool
|
||||
}
|
||||
|
||||
// PluginLoader defines an interface for loading plugins
|
||||
type PluginLoader interface {
|
||||
PluginList() map[string]schema.PluginManifest
|
||||
}
|
||||
|
||||
func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
|
||||
return singleton.GetInstance(func() *insightsCollector {
|
||||
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
|
||||
if err != nil {
|
||||
log.Trace("Could not get Insights ID from DB. Creating one", err)
|
||||
id = uuid.NewString()
|
||||
err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id)
|
||||
if err != nil {
|
||||
log.Trace("Could not save Insights ID to DB", err)
|
||||
}
|
||||
}
|
||||
insightsID = id
|
||||
return &insightsCollector{ds: ds, pluginLoader: pluginLoader}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *insightsCollector) Run(ctx context.Context) {
|
||||
for {
|
||||
// Refresh admin context on each iteration to handle cases where
|
||||
// admin user wasn't available on previous runs
|
||||
insightsCtx := auth.WithAdminUser(ctx, c.ds)
|
||||
u, _ := request.UserFrom(insightsCtx)
|
||||
if !u.IsAdmin {
|
||||
log.Trace(insightsCtx, "No admin user available, skipping insights collection")
|
||||
} else {
|
||||
c.sendInsights(insightsCtx)
|
||||
}
|
||||
select {
|
||||
case <-time.After(consts.InsightsUpdateInterval):
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, success bool) {
|
||||
t := c.lastRun.Load()
|
||||
return time.UnixMilli(t), c.lastStatus.Load()
|
||||
}
|
||||
|
||||
func (c *insightsCollector) sendInsights(ctx context.Context) {
|
||||
count, err := c.ds.User(ctx).CountAll(model.QueryOptions{})
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not check user count", err)
|
||||
return
|
||||
}
|
||||
if count == 0 {
|
||||
log.Trace(ctx, "No users found, skipping Insights data collection")
|
||||
return
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
data := c.collect(ctx)
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
body := bytes.NewReader(data)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", consts.InsightsEndpoint, body)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not create Insights request", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not send Insights data", err)
|
||||
return
|
||||
}
|
||||
log.Info(ctx, "Sent Insights data (for details see http://navidrome.org/docs/getting-started/insights", "data",
|
||||
string(data), "server", consts.InsightsEndpoint, "status", resp.Status)
|
||||
c.lastRun.Store(time.Now().UnixMilli())
|
||||
c.lastStatus.Store(resp.StatusCode < 300)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func buildInfo() (map[string]string, string) {
|
||||
bInfo := map[string]string{}
|
||||
var version string
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, setting := range info.Settings {
|
||||
if setting.Value == "" {
|
||||
continue
|
||||
}
|
||||
bInfo[setting.Key] = setting.Value
|
||||
}
|
||||
version = info.GoVersion
|
||||
}
|
||||
return bInfo, version
|
||||
}
|
||||
|
||||
func getFSInfo(path string) *insights.FSInfo {
|
||||
var info insights.FSInfo
|
||||
|
||||
// Normalize the path
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
absPath = filepath.Clean(absPath)
|
||||
|
||||
fsType, err := getFilesystemType(absPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
info.Type = fsType
|
||||
return &info
|
||||
}
|
||||
|
||||
var staticData = sync.OnceValue(func() insights.Data {
|
||||
// Basic info
|
||||
data := insights.Data{
|
||||
InsightsID: insightsID,
|
||||
Version: consts.Version,
|
||||
}
|
||||
|
||||
// Build info
|
||||
data.Build.Settings, data.Build.GoVersion = buildInfo()
|
||||
data.OS.Containerized = consts.InContainer
|
||||
|
||||
// Install info
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
|
||||
packageFileData, err := os.ReadFile(packageFilename)
|
||||
if err == nil {
|
||||
data.OS.Package = string(packageFileData)
|
||||
}
|
||||
|
||||
// OS info
|
||||
data.OS.Type = runtime.GOOS
|
||||
data.OS.Arch = runtime.GOARCH
|
||||
data.OS.NumCPU = runtime.NumCPU()
|
||||
data.OS.Version, data.OS.Distro = getOSVersion()
|
||||
|
||||
// FS info
|
||||
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
|
||||
data.FS.Data = getFSInfo(conf.Server.DataFolder)
|
||||
if conf.Server.CacheFolder != "" {
|
||||
data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
|
||||
}
|
||||
if conf.Server.Backup.Path != "" {
|
||||
data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
|
||||
}
|
||||
|
||||
// Config info
|
||||
data.Config.LogLevel = conf.Server.LogLevel
|
||||
data.Config.LogFileConfigured = conf.Server.LogFile != ""
|
||||
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
|
||||
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
|
||||
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
|
||||
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
||||
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||
data.Config.EnableSharing = conf.Server.EnableSharing
|
||||
data.Config.EnableStarRating = conf.Server.EnableStarRating
|
||||
data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != ""
|
||||
data.Config.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != ""
|
||||
data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
|
||||
data.Config.EnableDeezer = conf.Server.Deezer.Enabled
|
||||
data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
|
||||
data.Config.EnableJukebox = conf.Server.Jukebox.Enabled
|
||||
data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled
|
||||
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
|
||||
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
|
||||
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
|
||||
data.Config.SearchFullString = conf.Server.SearchFullString
|
||||
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
|
||||
data.Config.PreferSortTags = conf.Server.PreferSortTags
|
||||
data.Config.BackupSchedule = conf.Server.Backup.Schedule
|
||||
data.Config.BackupCount = conf.Server.Backup.Count
|
||||
data.Config.DevActivityPanel = conf.Server.DevActivityPanel
|
||||
data.Config.ScannerEnabled = conf.Server.Scanner.Enabled
|
||||
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
|
||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
|
||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||
|
||||
return data
|
||||
})
|
||||
|
||||
func (c *insightsCollector) collect(ctx context.Context) []byte {
|
||||
data := staticData()
|
||||
data.Uptime = time.Since(consts.ServerStart).Milliseconds() / 1000
|
||||
|
||||
// Library info
|
||||
var err error
|
||||
data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading tracks count", err)
|
||||
}
|
||||
data.Library.Albums, err = c.ds.Album(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading albums count", err)
|
||||
}
|
||||
data.Library.Artists, err = c.ds.Artist(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading artists count", err)
|
||||
}
|
||||
data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading playlists count", err)
|
||||
}
|
||||
data.Library.Shares, err = c.ds.Share(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading shares count", err)
|
||||
}
|
||||
data.Library.Radios, err = c.ds.Radio(ctx).Count()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading radios count", err)
|
||||
}
|
||||
data.Library.Libraries, err = c.ds.Library(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading libraries count", err)
|
||||
}
|
||||
data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
|
||||
})
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading active users count", err)
|
||||
}
|
||||
|
||||
// Check for smart playlists
|
||||
data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error checking for smart playlists", err)
|
||||
}
|
||||
|
||||
// Collect plugins if permitted and enabled
|
||||
if conf.Server.DevEnablePluginsInsights && conf.Server.Plugins.Enabled {
|
||||
data.Plugins = c.collectPlugins(ctx)
|
||||
}
|
||||
|
||||
// Collect active players if permitted
|
||||
if conf.Server.DevEnablePlayerInsights {
|
||||
data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{
|
||||
Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)},
|
||||
})
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading active players count", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory info
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
data.Mem.Alloc = m.Alloc
|
||||
data.Mem.TotalAlloc = m.TotalAlloc
|
||||
data.Mem.Sys = m.Sys
|
||||
data.Mem.NumGC = m.NumGC
|
||||
|
||||
// Marshal to JSON
|
||||
resp, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not marshal Insights data", err)
|
||||
return nil
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// hasSmartPlaylists checks if there are any smart playlists (playlists with rules)
|
||||
func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) {
|
||||
count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}},
|
||||
})
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// collectPlugins collects information about installed plugins
|
||||
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
|
||||
plugins := make(map[string]insights.PluginInfo)
|
||||
for id, manifest := range c.pluginLoader.PluginList() {
|
||||
plugins[id] = insights.PluginInfo{
|
||||
Name: manifest.Name,
|
||||
Version: manifest.Version,
|
||||
}
|
||||
}
|
||||
return plugins
|
||||
}
|
||||
90
core/metrics/insights/data.go
Normal file
90
core/metrics/insights/data.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package insights
|
||||
|
||||
type Data struct {
|
||||
InsightsID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Build struct {
|
||||
// build settings used by the Go compiler
|
||||
Settings map[string]string `json:"settings"`
|
||||
GoVersion string `json:"goVersion"`
|
||||
} `json:"build"`
|
||||
OS struct {
|
||||
Type string `json:"type"`
|
||||
Distro string `json:"distro,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Containerized bool `json:"containerized"`
|
||||
Arch string `json:"arch"`
|
||||
NumCPU int `json:"numCPU"`
|
||||
Package string `json:"package,omitempty"`
|
||||
} `json:"os"`
|
||||
Mem struct {
|
||||
Alloc uint64 `json:"alloc"`
|
||||
TotalAlloc uint64 `json:"totalAlloc"`
|
||||
Sys uint64 `json:"sys"`
|
||||
NumGC uint32 `json:"numGC"`
|
||||
} `json:"mem"`
|
||||
FS struct {
|
||||
Music *FSInfo `json:"music,omitempty"`
|
||||
Data *FSInfo `json:"data,omitempty"`
|
||||
Cache *FSInfo `json:"cache,omitempty"`
|
||||
Backup *FSInfo `json:"backup,omitempty"`
|
||||
} `json:"fs"`
|
||||
Library struct {
|
||||
Tracks int64 `json:"tracks"`
|
||||
Albums int64 `json:"albums"`
|
||||
Artists int64 `json:"artists"`
|
||||
Playlists int64 `json:"playlists"`
|
||||
Shares int64 `json:"shares"`
|
||||
Radios int64 `json:"radios"`
|
||||
Libraries int64 `json:"libraries"`
|
||||
ActiveUsers int64 `json:"activeUsers"`
|
||||
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
|
||||
} `json:"library"`
|
||||
Config struct {
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
|
||||
TLSConfigured bool `json:"tlsConfigured,omitempty"`
|
||||
ScannerEnabled bool `json:"scannerEnabled,omitempty"`
|
||||
ScanSchedule string `json:"scanSchedule,omitempty"`
|
||||
ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"`
|
||||
ScanOnStartup bool `json:"scanOnStartup,omitempty"`
|
||||
TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"`
|
||||
ImageCacheSize string `json:"imageCacheSize,omitempty"`
|
||||
EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"`
|
||||
EnableDownloads bool `json:"enableDownloads,omitempty"`
|
||||
EnableSharing bool `json:"enableSharing,omitempty"`
|
||||
EnableStarRating bool `json:"enableStarRating,omitempty"`
|
||||
EnableLastFM bool `json:"enableLastFM,omitempty"`
|
||||
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
|
||||
EnableDeezer bool `json:"enableDeezer,omitempty"`
|
||||
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||
EnableSpotify bool `json:"enableSpotify,omitempty"`
|
||||
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||
BackupCount int `json:"backupCount,omitempty"`
|
||||
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
|
||||
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
|
||||
HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"`
|
||||
ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"`
|
||||
HasCustomPID bool `json:"hasCustomPID,omitempty"`
|
||||
HasCustomTags bool `json:"hasCustomTags,omitempty"`
|
||||
} `json:"config"`
|
||||
Plugins map[string]PluginInfo `json:"plugins,omitempty"`
|
||||
}
|
||||
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type FSInfo struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
37
core/metrics/insights_darwin.go
Normal file
37
core/metrics/insights_darwin.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getOSVersion() (string, string) {
|
||||
cmd := exec.Command("sw_vers", "-productVersion")
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(output)), ""
|
||||
}
|
||||
|
||||
func getFilesystemType(path string) (string, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert the filesystem type name from [16]int8 to string
|
||||
fsType := make([]byte, 0, 16)
|
||||
for _, c := range stat.Fstypename {
|
||||
if c == 0 {
|
||||
break
|
||||
}
|
||||
fsType = append(fsType, byte(c))
|
||||
}
|
||||
|
||||
return string(fsType), nil
|
||||
}
|
||||
9
core/metrics/insights_default.go
Normal file
9
core/metrics/insights_default.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !linux && !windows && !darwin
|
||||
|
||||
package metrics
|
||||
|
||||
import "errors"
|
||||
|
||||
func getOSVersion() (string, string) { return "", "" }
|
||||
|
||||
func getFilesystemType(_ string) (string, error) { return "", errors.New("not implemented") }
|
||||
102
core/metrics/insights_linux.go
Normal file
102
core/metrics/insights_linux.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getOSVersion() (string, string) {
|
||||
file, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
osRelease, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
lines := strings.Split(string(osRelease), "\n")
|
||||
version := ""
|
||||
distro := ""
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "VERSION_ID=") {
|
||||
version = strings.ReplaceAll(strings.TrimPrefix(line, "VERSION_ID="), "\"", "")
|
||||
}
|
||||
if strings.HasPrefix(line, "ID=") {
|
||||
distro = strings.ReplaceAll(strings.TrimPrefix(line, "ID="), "\"", "")
|
||||
}
|
||||
}
|
||||
return version, distro
|
||||
}
|
||||
|
||||
// MountInfo represents an entry from /proc/self/mountinfo
|
||||
type MountInfo struct {
|
||||
MountPoint string
|
||||
FSType string
|
||||
}
|
||||
|
||||
var fsTypeMap = map[int64]string{
|
||||
0x5346414f: "afs",
|
||||
0x187: "autofs",
|
||||
0x61756673: "aufs",
|
||||
0x9123683E: "btrfs",
|
||||
0xc36400: "ceph",
|
||||
0xff534d42: "cifs",
|
||||
0x28cd3d45: "cramfs",
|
||||
0x64626720: "debugfs",
|
||||
0xf15f: "ecryptfs",
|
||||
0x2011bab0: "exfat",
|
||||
0x0000EF53: "ext2/ext3/ext4",
|
||||
0xf2f52010: "f2fs",
|
||||
0x6a656a63: "fakeowner", // FS inside a container
|
||||
0x65735546: "fuse",
|
||||
0x4244: "hfs",
|
||||
0x482b: "hfs+",
|
||||
0x9660: "iso9660",
|
||||
0x3153464a: "jfs",
|
||||
0x00006969: "nfs",
|
||||
0x5346544e: "ntfs", // NTFS_SB_MAGIC
|
||||
0x7366746e: "ntfs",
|
||||
0x794c7630: "overlayfs",
|
||||
0x9fa0: "proc",
|
||||
0x517b: "smb",
|
||||
0xfe534d42: "smb2",
|
||||
0x73717368: "squashfs",
|
||||
0x62656572: "sysfs",
|
||||
0x01021994: "tmpfs",
|
||||
0x01021997: "v9fs",
|
||||
0x786f4256: "vboxsf",
|
||||
0x4d44: "vfat",
|
||||
0xca451a4e: "virtiofs",
|
||||
0x58465342: "xfs",
|
||||
0x2FC12FC1: "zfs",
|
||||
0x7c7c6673: "prlfs", // Parallels Shared Folders
|
||||
|
||||
// Signed/unsigned conversion issues (negative hex values converted to uint32)
|
||||
-0x6edc97c2: "btrfs", // 0x9123683e
|
||||
-0x1acb2be: "smb2", // 0xfe534d42
|
||||
-0xacb2be: "cifs", // 0xff534d42
|
||||
-0xd0adff0: "f2fs", // 0xf2f52010
|
||||
}
|
||||
|
||||
func getFilesystemType(path string) (string, error) {
|
||||
var fsStat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &fsStat)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fsType := fsStat.Type
|
||||
|
||||
fsName, exists := fsTypeMap[int64(fsType)] //nolint:unconvert
|
||||
if !exists {
|
||||
fsName = fmt.Sprintf("unknown(0x%x)", fsType)
|
||||
}
|
||||
|
||||
return fsName, nil
|
||||
}
|
||||
53
core/metrics/insights_windows.go
Normal file
53
core/metrics/insights_windows.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Ex: Microsoft Windows [Version 10.0.26100.1742]
|
||||
var winVerRegex = regexp.MustCompile(`Microsoft Windows \[.+\s([\d\.]+)\]`)
|
||||
|
||||
func getOSVersion() (version string, _ string) {
|
||||
cmd := exec.Command("cmd", "/c", "ver")
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
matches := winVerRegex.FindStringSubmatch(string(output))
|
||||
if len(matches) != 2 {
|
||||
return string(output), ""
|
||||
}
|
||||
return matches[1], ""
|
||||
}
|
||||
|
||||
func getFilesystemType(path string) (string, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var volumeName, filesystemName [windows.MAX_PATH + 1]uint16
|
||||
var serialNumber uint32
|
||||
var maxComponentLen, filesystemFlags uint32
|
||||
|
||||
err = windows.GetVolumeInformation(
|
||||
pathPtr,
|
||||
&volumeName[0],
|
||||
windows.MAX_PATH,
|
||||
&serialNumber,
|
||||
&maxComponentLen,
|
||||
&filesystemFlags,
|
||||
&filesystemName[0],
|
||||
windows.MAX_PATH)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return windows.UTF16ToString(filesystemName[:]), nil
|
||||
}
|
||||
240
core/metrics/prometheus.go
Normal file
240
core/metrics/prometheus.go
Normal file
@@ -0,0 +1,240 @@
|
||||
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 int32, 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 int32, 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, int32, int64) {}
|
||||
|
||||
func (n noopMetrics) RecordPluginRequest(context.Context, string, string, bool, int64) {}
|
||||
|
||||
func (n noopMetrics) GetHandler() http.Handler { return nil }
|
||||
Reference in New Issue
Block a user