AI: Enhance "GET /api/v1/metrics" endpoint with additional stats #213

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-10-31 15:38:10 +01:00
parent 0a1b5071fd
commit ef1f0f3bb2
5 changed files with 259 additions and 37 deletions

View file

@ -57,6 +57,7 @@ HTTP API
- Use full `/api/v1/...` in every `@Router` annotation (match the group prefix).
- Annotate only public handlers; skip internal helpers to avoid stray generic paths.
- `make swag-json` runs a stabilization step (`swaggerfix`) removing duplicated enums for `time.Duration`; API uses integer nanoseconds for durations.
- `/api/v1/metrics` (see `internal/api/metrics.go`) exposes Prometheus metrics, including cached filesystem/account usage derived from `config.Usage()`, registered user/guest totals, and portal cluster node counts when `NodeRole=portal`; the handler returns the standard Prometheus exposition content type (`text/plain; version=0.0.4`).
- Common groups in `routes.go`: sessions, OAuth/OIDC, config, users, services, thumbnails, video, downloads/zip, index/import, photos/files/labels/subjects/faces, batch ops, cluster, technical (metrics, status, echo).
Configuration & Flags

View file

@ -9,7 +9,6 @@ import (
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
)
// ClusterMetrics returns lightweight metrics about the cluster.
@ -34,22 +33,12 @@ func ClusterMetrics(router *gin.RouterGroup) {
return
}
regy, err := reg.NewClientRegistryWithConfig(conf)
counts, err := clusterNodeCounts(conf)
if err != nil {
AbortUnexpectedError(c)
return
}
nodes, _ := regy.List()
counts := map[string]int{"total": len(nodes)}
for _, node := range nodes {
role := node.Role
if role == "" {
role = "unknown"
}
counts[role]++
}
resp := cluster.MetricsResponse{
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),

View file

@ -14,16 +14,18 @@ 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"
)
// GetMetrics provides a Prometheus-compatible metrics stream for monitoring.
// 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
// @Id GetMetrics
// @Tags Metrics
// @Produce event-stream
// @Produce plain
// @Success 200 {object} []dto.MetricFamily
// @Failure 401,403 {object} i18n.Response
// @Router /api/v1/metrics [get]
@ -38,8 +40,9 @@ func GetMetrics(router *gin.RouterGroup) {
conf := get.Config()
counts := conf.ClientUser(false).Count
usage := conf.Usage()
header.SetContentType(c.Request, header.ContentTypePrometheus)
c.Header(header.ContentType, header.ContentTypePrometheus)
c.Stream(func(w io.Writer) bool {
reg := prometheus.NewRegistry()
@ -49,6 +52,8 @@ func GetMetrics(router *gin.RouterGroup) {
registerCountMetrics(factory, counts)
registerBuildInfoMetric(factory, conf.ClientPublic())
registerUsageMetrics(factory, usage)
registerClusterMetrics(factory, conf)
var metrics []*dto.MetricFamily
var err error
@ -72,27 +77,60 @@ func GetMetrics(router *gin.RouterGroup) {
})
}
// registerCountMetrics registers metrics that can be monitored with the /api/v1/metrics endpoint.=
// registerCountMetrics registers media count metrics exposed via /api/v1/metrics.
func registerCountMetrics(factory promauto.Factory, counts config.ClientCounts) {
metric := factory.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "photoprism",
Subsystem: "statistics",
Name: "media_count",
Help: "media statistics for this photoprism instance",
Help: "media statistics for this PhotoPrism instance",
}, []string{"stat"},
)
metric.With(prometheus.Labels{"stat": "all"}).Set(float64(counts.All))
metric.With(prometheus.Labels{"stat": "photos"}).Set(float64(counts.Photos))
metric.With(prometheus.Labels{"stat": "media"}).Set(float64(counts.Media))
metric.With(prometheus.Labels{"stat": "live"}).Set(float64(counts.Live))
metric.With(prometheus.Labels{"stat": "videos"}).Set(float64(counts.Videos))
metric.With(prometheus.Labels{"stat": "audio"}).Set(float64(counts.Audio))
metric.With(prometheus.Labels{"stat": "documents"}).Set(float64(counts.Documents))
metric.With(prometheus.Labels{"stat": "albums"}).Set(float64(counts.Albums))
metric.With(prometheus.Labels{"stat": "folders"}).Set(float64(counts.Folders))
metric.With(prometheus.Labels{"stat": "files"}).Set(float64(counts.Files))
stats := []struct {
label string
value int
}{
{"all", counts.All},
{"photos", counts.Photos},
{"media", counts.Media},
{"animated", counts.Animated},
{"live", counts.Live},
{"audio", counts.Audio},
{"videos", counts.Videos},
{"documents", counts.Documents},
{"cameras", counts.Cameras},
{"lenses", counts.Lenses},
{"countries", counts.Countries},
{"hidden", counts.Hidden},
{"archived", counts.Archived},
{"favorites", counts.Favorites},
{"review", counts.Review},
{"stories", counts.Stories},
{"private", counts.Private},
{"albums", counts.Albums},
{"private_albums", counts.PrivateAlbums},
{"moments", counts.Moments},
{"private_moments", counts.PrivateMoments},
{"months", counts.Months},
{"private_months", counts.PrivateMonths},
{"states", counts.States},
{"private_states", counts.PrivateStates},
{"folders", counts.Folders},
{"private_folders", counts.PrivateFolders},
{"files", counts.Files},
{"people", counts.People},
{"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 {
metric.With(prometheus.Labels{"stat": stat.label}).Set(float64(stat.value))
}
}
// registerBuildInfoMetric registers a metric that provides build information.
@ -109,3 +147,107 @@ func registerBuildInfoMetric(factory promauto.Factory, conf *config.ClientConfig
"version": conf.Version,
}).Set(1.0)
}
// registerUsageMetrics registers filesystem and account usage metrics derived from the active configuration.
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"},
)
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))
filesPercent := 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"},
)
filesPercent.With(prometheus.Labels{"state": "used"}).Set(float64(usage.FilesUsedPct))
filesPercent.With(prometheus.Labels{"state": "free"}).Set(float64(usage.FilesFreePct))
accountsPercent := factory.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "photoprism",
Subsystem: "usage",
Name: "accounts_percent",
Help: "account quota usage in percent for this PhotoPrism instance",
}, []string{"state"},
)
accountsPercent.With(prometheus.Labels{"state": "used"}).Set(float64(usage.UsersUsedPct))
accountsPercent.With(prometheus.Labels{"state": "free"}).Set(float64(usage.UsersFreePct))
}
// registerClusterMetrics exports cluster-specific metrics when running as a portal instance.
func registerClusterMetrics(factory promauto.Factory, conf *config.Config) {
if !conf.Portal() {
return
}
counts, err := clusterNodeCounts(conf)
if err != nil {
logErr("metrics", err)
return
}
nodeMetric := factory.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "photoprism",
Subsystem: "cluster",
Name: "nodes",
Help: "registered cluster nodes grouped by role",
}, []string{"role"},
)
for role, value := range counts {
nodeMetric.With(prometheus.Labels{"role": role}).Set(float64(value))
}
infoMetric := factory.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "photoprism",
Subsystem: "cluster",
Name: "info",
Help: "cluster metadata for this PhotoPrism portal",
}, []string{"uuid", "cidr"},
)
infoMetric.With(prometheus.Labels{
"uuid": conf.ClusterUUID(),
"cidr": conf.ClusterCIDR(),
}).Set(1.0)
}
// clusterNodeCounts returns cluster node counts keyed by role plus a total entry.
func clusterNodeCounts(conf *config.Config) (map[string]int, error) {
regy, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
return nil, err
}
nodes, err := regy.List()
if err != nil {
return nil, err
}
counts := map[string]int{"total": len(nodes)}
for _, node := range nodes {
role := node.Role
if role == "" {
role = "unknown"
}
counts[role]++
}
return counts, nil
}

View file

@ -6,6 +6,11 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestGetMetrics(t *testing.T) {
@ -21,13 +26,34 @@ func TestGetMetrics(t *testing.T) {
}
body := resp.Body.String()
floatPattern := `[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?`
stats := []string{
"all",
"photos",
"media",
"animated",
"live",
"videos",
"audio",
"documents",
"albums",
"private_albums",
"folders",
"private_folders",
"files",
"hidden",
"favorites",
"private",
"people",
"labels",
"label_max_photos",
"users",
"guests",
}
assert.Regexp(t, regexp.MustCompile(`photoprism_statistics_media_count{stat="all"} \d+`), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_statistics_media_count{stat="photos"} \d+`), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_statistics_media_count{stat="videos"} \d+`), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_statistics_media_count{stat="albums"} \d+`), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_statistics_media_count{stat="folders"} \d+`), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_statistics_media_count{stat="files"} \d+`), body)
for _, stat := range stats {
assert.Regexp(t, regexp.MustCompile(`photoprism_statistics_media_count{stat="`+stat+`"} `+floatPattern), body)
}
})
t.Run("ExposeBuildInformation", func(t *testing.T) {
app, router, _ := NewApiTest()
@ -44,6 +70,72 @@ func TestGetMetrics(t *testing.T) {
assert.Regexp(t, regexp.MustCompile(`photoprism_build_info{edition=".+",goversion=".+",version=".+"} 1`), body)
})
t.Run("ExposeUsageMetrics", func(t *testing.T) {
app, router, _ := NewApiTest()
GetMetrics(router)
resp := PerformRequestWithStream(app, "GET", "/api/v1/metrics")
if resp.Code != http.StatusOK {
t.Fatal(resp.Body.String())
}
body := resp.Body.String()
floatPattern := `[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?`
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)
})
t.Run("ExposeClusterMetricsForPortal", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
GetMetrics(router)
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
nodeDefs := []struct {
name string
role string
}{
{"metrics-instance-1", string(cluster.RoleInstance)},
{"metrics-service-1", string(cluster.RoleService)},
}
var cleanupIDs []string
for _, def := range nodeDefs {
n := &reg.Node{Node: cluster.Node{Name: def.name, Role: def.role, UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n))
cleanupIDs = append(cleanupIDs, n.UUID)
}
t.Cleanup(func() {
for _, uuid := range cleanupIDs {
_ = regy.Delete(uuid)
}
})
resp := PerformRequestWithStream(app, "GET", "/api/v1/metrics")
if resp.Code != http.StatusOK {
t.Fatal(resp.Body.String())
}
body := resp.Body.String()
floatPattern := `[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?`
assert.Regexp(t, regexp.MustCompile(`photoprism_cluster_nodes{role="total"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_cluster_nodes{role="instance"} `+floatPattern), body)
assert.Regexp(t, regexp.MustCompile(`photoprism_cluster_nodes{role="service"} `+floatPattern), body)
infoPattern := `photoprism_cluster_info\{(?:cidr="[^"]*",[^}]*uuid="[^"]*"|uuid="[^"]*",[^}]*cidr="[^"]*")\} 1`
assert.Regexp(t, regexp.MustCompile(infoPattern), body)
})
t.Run("HasPrometheusExpositionFormatAsContentType", func(t *testing.T) {
app, router, _ := NewApiTest()
@ -53,8 +145,6 @@ func TestGetMetrics(t *testing.T) {
if resp.Code != http.StatusOK {
t.Fatal(resp.Body.String())
}
if contentType := resp.Result().Header.Get("Content-Type"); contentType != "" {
t.Fatalf("unexpected response content-type: %s", contentType)
}
assert.Equal(t, header.ContentTypePrometheus, resp.Result().Header.Get("Content-Type"))
})
}

View file

@ -8532,7 +8532,7 @@
"get": {
"operationId": "GetMetrics",
"produces": [
"text/event-stream"
"text/plain"
],
"responses": {
"200": {