mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* feat(insights): report the app store or hosting platform via ND_PLATFORM Insights had no way to tell where an instance is deployed. The existing `os.package` field is written only by our own packagers and holds just `deb`, `rpm` or `msi`, so it answers "which installer", not "which platform". Overloading it would mix two unrelated dimensions in the same field. This adds a separate top-level `platform` field, self-declared by the deployer through the `ND_PLATFORM` environment variable. App stores and hosting providers (ZimaOS, PikaPods, TrueNAS, Unraid, and others) generally deploy our container image unmodified and can only inject environment variables, so an env var is the one marker they can all set. It is deliberately not a config option: it is a packager marker, not something users should tune, and it stays out of the config surface. Both values are now whitespace-trimmed. The msi packager writes the file with `echo`, so `os.package` has been arriving as `"msi\n"` and sorting separately from `"msi"` in any aggregation. * test(insights): isolate hostingPlatform specs from an inherited ND_PLATFORM The spec asserting an empty result read the real environment, so it failed on any machine that already had ND_PLATFORM set. Unset it per-spec, using Setenv first so Ginkgo restores the original value on cleanup.
349 lines
11 KiB
Go
349 lines
11 KiB
Go
package metrics
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"runtime/debug"
|
|
"strings"
|
|
"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"
|
|
"github.com/navidrome/navidrome/server/events"
|
|
"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
|
|
lastRun atomic.Int64
|
|
lastStatus atomic.Bool
|
|
}
|
|
|
|
func GetInstance(ds model.DataStore) 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}
|
|
})
|
|
}
|
|
|
|
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) //nolint:gosec
|
|
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
|
|
}
|
|
|
|
// installedPackage returns the official installer format used, as written by our own packagers.
|
|
func installedPackage() string {
|
|
data, _ := os.ReadFile(filepath.Join(conf.Server.DataFolder.String(), ".package"))
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
// hostingPlatform is env-based, not a file, as app stores can only inject env vars into our image.
|
|
func hostingPlatform() string {
|
|
return strings.TrimSpace(os.Getenv("ND_PLATFORM"))
|
|
}
|
|
|
|
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
|
|
data.OS.Package = installedPackage()
|
|
data.Platform = hostingPlatform()
|
|
|
|
// 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.String())
|
|
if conf.Server.CacheFolder.String() != "" {
|
|
data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String())
|
|
}
|
|
if conf.Server.Backup.Path.String() != "" {
|
|
data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String())
|
|
}
|
|
|
|
// 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.EnableArtworkUpload = conf.Server.EnableArtworkUpload
|
|
data.Config.CoverArtQuality = conf.Server.CoverArtQuality
|
|
data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding
|
|
data.Config.UICoverArtSize = conf.Server.UICoverArtSize
|
|
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.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.Search.FullString
|
|
data.Config.SearchBackend = conf.Server.Search.Backend
|
|
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.ScannerExtractor = conf.Server.Scanner.Extractor
|
|
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.EnableScheduledDBAnalyze = conf.Server.EnableScheduledDBAnalyze
|
|
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
|
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
|
|
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)
|
|
}
|
|
data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix()
|
|
if err != nil {
|
|
log.Trace(ctx, "Error reading file suffixes 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 {
|
|
// TODO Fix import/inject cycles
|
|
manager := plugins.GetManager(c.ds, events.GetBroker(), nil)
|
|
info := manager.GetPluginInfo()
|
|
|
|
result := make(map[string]insights.PluginInfo, len(info))
|
|
for name, p := range info {
|
|
result[name] = insights.PluginInfo{
|
|
Name: p.Name,
|
|
Version: p.Version,
|
|
}
|
|
}
|
|
return result
|
|
}
|