diff --git a/CODEMAP.md b/CODEMAP.md index bf940ec6f..2b9f2082b 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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 diff --git a/internal/api/cluster_metrics.go b/internal/api/cluster_metrics.go index ec9e6b906..046d97eed 100644 --- a/internal/api/cluster_metrics.go +++ b/internal/api/cluster_metrics.go @@ -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(), diff --git a/internal/api/metrics.go b/internal/api/metrics.go index fea3cefca..3c10623c8 100644 --- a/internal/api/metrics.go +++ b/internal/api/metrics.go @@ -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 +} diff --git a/internal/api/metrics_test.go b/internal/api/metrics_test.go index 04a663f47..c0573e1a0 100644 --- a/internal/api/metrics_test.go +++ b/internal/api/metrics_test.go @@ -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")) }) } diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 3a911fd91..91d1d53a9 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -8532,7 +8532,7 @@ "get": { "operationId": "GetMetrics", "produces": [ - "text/event-stream" + "text/plain" ], "responses": { "200": {