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

This commit is contained in:
2025-12-08 16:16:23 +01:00
commit c251f174ed
1349 changed files with 194301 additions and 0 deletions

330
core/metrics/insights.go Normal file
View 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
}

View 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"`
}

View 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
}

View 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") }

View 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
}

View 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
View 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 }