Metrics: Add file system and account usage info #5355

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-11-30 10:43:58 +01:00
parent 314987f5ba
commit dc19035d8f
5 changed files with 172 additions and 46 deletions

View File

@@ -14,12 +14,25 @@ import (
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/photoprism/get"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/http/header"
)
const (
metricsNamespace = "photoprism"
metricsUsageSubsystem = "usage"
metricsLabelState = "state"
metricFilesBytes = "files_bytes"
metricFilesRatio = "files_ratio"
metricAccountsRatio = "accounts_ratio"
metricAccountsActive = "accounts_active"
metricsAccountsHelp = "active user and guest accounts on this PhotoPrism instance"
metricsFilesBytesHelp = "filesystem usage in bytes for files indexed by this PhotoPrism instance"
metricsFilesRatioHelp = "filesystem usage for files indexed by this PhotoPrism instance"
metricsAccountsRatioHelp = "account quota usage for this PhotoPrism instance"
)
// GetMetrics provides a Prometheus-compatible metrics endpoint for monitoring the instance, including usage details and portal cluster metrics.
//
// @Summary a prometheus-compatible metrics endpoint for monitoring this instance
@@ -45,10 +58,10 @@ func GetMetrics(router *gin.RouterGroup) {
c.Header(header.ContentType, header.ContentTypePrometheus)
c.Stream(func(w io.Writer) bool {
reg := prometheus.NewRegistry()
reg.MustRegister(collectors.NewGoCollector())
registry := prometheus.NewRegistry()
registry.MustRegister(collectors.NewGoCollector())
factory := promauto.With(reg)
factory := promauto.With(registry)
registerCountMetrics(factory, counts)
registerBuildInfoMetric(factory, conf.ClientPublic())
@@ -58,7 +71,7 @@ func GetMetrics(router *gin.RouterGroup) {
var metrics []*dto.MetricFamily
var err error
metrics, err = reg.Gather()
metrics, err = registry.Gather()
if err != nil {
logErr("metrics", err)
@@ -124,8 +137,6 @@ func registerCountMetrics(factory promauto.Factory, counts config.ClientCounts)
{"places", counts.Places},
{"labels", counts.Labels},
{"label_max_photos", counts.LabelMaxPhotos},
{"users", query.CountUsers(true, true, nil, []string{"guest"})},
{"guests", query.CountUsers(true, true, []string{"guest"}, nil)},
}
for _, stat := range stats {
@@ -149,43 +160,58 @@ func registerBuildInfoMetric(factory promauto.Factory, conf *config.ClientConfig
}
// registerUsageMetrics registers filesystem and account usage metrics derived from the active configuration.
// Ratios follow Prometheus best practices (0..1) instead of percentages.
func registerUsageMetrics(factory promauto.Factory, usage config.Usage) {
filesBytes := factory.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "photoprism",
Subsystem: "usage",
Name: "files_bytes",
Help: "filesystem usage in bytes for files indexed by this PhotoPrism instance",
}, []string{"state"},
Namespace: metricsNamespace,
Subsystem: metricsUsageSubsystem,
Name: metricFilesBytes,
Help: metricsFilesBytesHelp,
}, []string{metricsLabelState},
)
filesBytes.With(prometheus.Labels{"state": "used"}).Set(float64(usage.FilesUsed))
filesBytes.With(prometheus.Labels{"state": "free"}).Set(float64(usage.FilesFree))
filesBytes.With(prometheus.Labels{"state": "total"}).Set(float64(usage.FilesTotal))
filesBytes.With(prometheus.Labels{metricsLabelState: "used"}).Set(float64(usage.FilesUsed))
filesBytes.With(prometheus.Labels{metricsLabelState: "free"}).Set(float64(usage.FilesFree))
filesBytes.With(prometheus.Labels{metricsLabelState: "total"}).Set(float64(usage.FilesTotal))
filesPercent := factory.NewGaugeVec(
filesRatio := factory.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "photoprism",
Subsystem: "usage",
Name: "files_percent",
Help: "filesystem usage in percent for files indexed by this PhotoPrism instance",
}, []string{"state"},
Namespace: metricsNamespace,
Subsystem: metricsUsageSubsystem,
Name: metricFilesRatio,
Help: metricsFilesRatioHelp,
}, []string{metricsLabelState},
)
filesPercent.With(prometheus.Labels{"state": "used"}).Set(float64(usage.FilesUsedPct))
filesPercent.With(prometheus.Labels{"state": "free"}).Set(float64(usage.FilesFreePct))
filesUsed := usage.FilesUsedRatio()
filesRatio.With(prometheus.Labels{metricsLabelState: "used"}).Set(filesUsed)
filesRatio.With(prometheus.Labels{metricsLabelState: "free"}).Set(1 - filesUsed)
accountsPercent := factory.NewGaugeVec(
accountsActive := factory.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "photoprism",
Subsystem: "usage",
Name: "accounts_percent",
Help: "account quota usage in percent for this PhotoPrism instance",
}, []string{"state"},
Namespace: metricsNamespace,
Subsystem: metricsUsageSubsystem,
Name: metricAccountsActive,
Help: metricsAccountsHelp,
}, []string{metricsLabelState},
)
accountsPercent.With(prometheus.Labels{"state": "used"}).Set(float64(usage.UsersUsedPct))
accountsPercent.With(prometheus.Labels{"state": "free"}).Set(float64(usage.UsersFreePct))
accountsActive.With(prometheus.Labels{metricsLabelState: "users"}).Set(float64(usage.UsersActive))
accountsActive.With(prometheus.Labels{metricsLabelState: "guests"}).Set(float64(usage.GuestsActive))
accountsRatio := factory.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsNamespace,
Subsystem: metricsUsageSubsystem,
Name: metricAccountsRatio,
Help: metricsAccountsRatioHelp,
}, []string{metricsLabelState},
)
accountsUsed := usage.UsersUsedRatio()
accountsRatio.With(prometheus.Labels{metricsLabelState: "used"}).Set(accountsUsed)
accountsRatio.With(prometheus.Labels{metricsLabelState: "free"}).Set(1 - accountsUsed)
}
// registerClusterMetrics exports cluster-specific metrics when running as a portal instance.

View File

@@ -3,6 +3,7 @@ package api
import (
"net/http"
"regexp"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
@@ -47,8 +48,6 @@ func TestGetMetrics(t *testing.T) {
"people",
"labels",
"label_max_photos",
"users",
"guests",
}
for _, stat := range stats {
@@ -87,10 +86,34 @@ func TestGetMetrics(t *testing.T) {
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_bytes{state="used"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_bytes{state="free"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_bytes{state="total"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_percent{state="used"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_percent{state="free"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_percent{state="used"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_percent{state="free"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_ratio{state="used"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_files_ratio{state="free"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_active{state="users"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_active{state="guests"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_ratio{state="used"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_usage_accounts_ratio{state="free"} `+floatPattern), body)
t.Run("RatiosSumToOne", func(t *testing.T) {
parseValue := func(pattern string) float64 {
m := regexp.MustCompile(pattern).FindStringSubmatch(body)
if len(m) != 2 {
t.Fatalf("metric not found: %s", pattern)
}
v, err := strconv.ParseFloat(m[1], 64)
if err != nil {
t.Fatalf("parse metric: %v", err)
}
return v
}
filesUsed := parseValue(`photoprism_usage_files_ratio{state="used"} (` + floatPattern + `)`)
filesFree := parseValue(`photoprism_usage_files_ratio{state="free"} (` + floatPattern + `)`)
assert.InEpsilon(t, 1.0, filesUsed+filesFree, 0.01)
accountsUsed := parseValue(`photoprism_usage_accounts_ratio{state="used"} (` + floatPattern + `)`)
accountsFree := parseValue(`photoprism_usage_accounts_ratio{state="free"} (` + floatPattern + `)`)
assert.InEpsilon(t, 1.0, accountsUsed+accountsFree, 0.01)
})
})
t.Run("ExposeClusterMetricsForPortal", func(t *testing.T) {
app, router, conf := NewApiTest()

View File

@@ -20,14 +20,51 @@ func FlushUsageCache() {
}
// Usage represents storage usage information.
// Percent fields remain for UI/backward compatibility, but metrics should
// derive their values from the ratio helpers to align with Prometheus
// conventions and avoid rounding artifacts.
type Usage struct {
// File storage usage and quota (total).
FilesUsed uint64 `json:"filesUsed"`
FilesUsedPct int `json:"filesUsedPct"`
FilesFree uint64 `json:"filesFree"`
FilesFreePct int `json:"filesFreePct"`
FilesTotal uint64 `json:"filesTotal"`
UsersUsedPct int `json:"usersUsedPct"`
UsersFreePct int `json:"usersFreePct"`
// UsersQuota is the configured account quota; kept internal because the
// public client config should not expose capacity limits directly.
UsersQuota int `json:"-"`
// UsersActive and GuestsActive report how many registered accounts are
// currently enabled; kept internal so metrics can use them without leaking
// into client-visible JSON.
UsersActive int `json:"-"`
GuestsActive int `json:"-"`
UsersUsedPct int `json:"usersUsedPct"`
UsersFreePct int `json:"usersFreePct"`
}
// FilesUsedRatio calculates the file storage usage ratio.
func (info *Usage) FilesUsedRatio() float64 {
if info.FilesUsed == 0 {
return 0
}
if info.FilesTotal == 0 {
// Return a tiny non-zero value to avoid emitting NaN in metrics when
// totals are unknown (e.g. quota disabled or filesystem probe failed).
return 0.01
}
return float64(info.FilesUsed) / float64(info.FilesTotal)
}
// UsersUsedRatio calculates the user account usage ratio.
func (info *Usage) UsersUsedRatio() float64 {
if info.UsersActive == 0 || info.UsersQuota == 0 {
return 0
}
return float64(info.UsersActive) / float64(info.UsersQuota)
}
// Usage returns the used, free and total storage size in bytes and caches the result.
@@ -67,7 +104,7 @@ func (c *Config) Usage() Usage {
}
if info.FilesTotal > 0 {
info.FilesUsedPct = int(math.RoundToEven(float64(info.FilesUsed) / float64(info.FilesTotal) * 100))
info.FilesUsedPct = int(math.RoundToEven(info.FilesUsedRatio() * 100))
}
if info.FilesUsed > 0 && info.FilesUsedPct <= 0 {
@@ -80,14 +117,19 @@ func (c *Config) Usage() Usage {
info.FilesFreePct = 0
}
if usersTotal := c.UsersQuota(); usersTotal > 0 {
usersUsed := query.CountUsers(true, true, nil, []string{"guest"})
info.UsersUsedPct = int(math.Floor(float64(usersUsed) / float64(usersTotal) * 100))
info.UsersActive = query.CountUsers(true, true, nil, []string{"guest"})
info.GuestsActive = query.CountUsers(true, true, []string{"guest"}, nil)
if info.UsersQuota = c.UsersQuota(); info.UsersQuota > 0 {
info.UsersUsedPct = int(math.Floor(info.UsersUsedRatio() * 100))
info.UsersFreePct = 100 - info.UsersUsedPct
if info.UsersFreePct < 0 {
info.UsersFreePct = 0
}
} else {
info.UsersUsedPct = 0
info.UsersFreePct = 0
}
usageCache.SetDefault(originalsPath, info)

View File

@@ -42,6 +42,41 @@ func TestConfig_Usage(t *testing.T) {
assert.Equal(t, c.Usage().FilesUsed, uint64(0))
}
func TestUsage_Ratios(t *testing.T) {
t.Run("FilesUsedRatio", func(t *testing.T) {
t.Run("ZeroUsage", func(t *testing.T) {
assert.Zero(t, (&Usage{}).FilesUsedRatio())
})
t.Run("WithTotals", func(t *testing.T) {
assert.InEpsilon(t, 0.5, (&Usage{FilesUsed: 50, FilesTotal: 100}).FilesUsedRatio(), 0.001)
})
t.Run("MissingTotals", func(t *testing.T) {
assert.InEpsilon(t, 0.01, (&Usage{FilesUsed: 1, FilesTotal: 0}).FilesUsedRatio(), 0.001)
})
})
t.Run("UsersUsedRatio", func(t *testing.T) {
t.Run("NoQuota", func(t *testing.T) {
assert.Zero(t, (&Usage{UsersActive: 5, UsersQuota: 0}).UsersUsedRatio())
})
t.Run("NoActive", func(t *testing.T) {
assert.Zero(t, (&Usage{UsersActive: 0, UsersQuota: 10}).UsersUsedRatio())
})
t.Run("WithQuota", func(t *testing.T) {
assert.InEpsilon(t, 0.5, (&Usage{UsersActive: 3, UsersQuota: 6}).UsersUsedRatio(), 0.001)
})
})
t.Run("ActiveCountsNonNegative", func(t *testing.T) {
c := TestConfig()
FlushUsageCache()
c.options.UsersQuota = 0
usage := c.Usage()
assert.GreaterOrEqual(t, usage.UsersActive, 0)
assert.GreaterOrEqual(t, usage.GuestsActive, 0)
})
}
func TestConfig_Quota(t *testing.T) {
c := TestConfig()

View File

@@ -18,7 +18,7 @@ func RegisteredUsers() (result entity.Users) {
}
// CountUsers returns the number of users based on the specified filter options.
func CountUsers(registered, active bool, roles, excludeRoles []string) (count int) {
func CountUsers(registered, active bool, includeRoles, excludeRoles []string) (count int) {
if !Db().HasTable("auth_users") {
return 0
}
@@ -33,8 +33,8 @@ func CountUsers(registered, active bool, roles, excludeRoles []string) (count in
stmt = stmt.Where("(can_login > 0 OR webdav > 0) AND user_name <> ''")
}
if len(roles) > 0 {
stmt = stmt.Where("user_role IN (?)", roles)
if len(includeRoles) > 0 {
stmt = stmt.Where("user_role IN (?)", includeRoles)
} else if len(excludeRoles) > 0 {
stmt = stmt.Where("user_role NOT IN (?)", excludeRoles)
}