mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
AI: Enhance "GET /api/v1/metrics" endpoint with additional stats #213
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
0a1b5071fd
commit
ef1f0f3bb2
5 changed files with 259 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 := ®.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"))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8532,7 +8532,7 @@
|
|||
"get": {
|
||||
"operationId": "GetMetrics",
|
||||
"produces": [
|
||||
"text/event-stream"
|
||||
"text/plain"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue