diff --git a/go.mod b/go.mod index b089ec27d..bef9880b0 100644 --- a/go.mod +++ b/go.mod @@ -75,6 +75,8 @@ require github.com/go-ldap/ldap/v3 v3.4.6 require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/iasm v0.9.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect @@ -95,11 +97,16 @@ require ( github.com/leodido/go-urn v1.2.4 // indirect github.com/mandykoh/go-parallel v0.1.0 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.17.0 // indirect + github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/go.sum b/go.sum index 222ab2303..18fdc9322 100644 --- a/go.sum +++ b/go.sum @@ -621,6 +621,8 @@ github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4x github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= @@ -633,6 +635,7 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= @@ -1014,6 +1017,8 @@ github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71 github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw= github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -1049,9 +1054,17 @@ github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= @@ -1062,6 +1075,7 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 28eabd51d..fb829ccb7 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -15,6 +15,19 @@ import ( "github.com/photoprism/photoprism/internal/get" ) +type CloseableResponseRecorder struct { + *httptest.ResponseRecorder + closeCh chan bool +} + +func (r *CloseableResponseRecorder) CloseNotify() <-chan bool { + return r.closeCh +} + +func (r *CloseableResponseRecorder) closeClient() { + r.closeCh <- true +} + func TestMain(m *testing.M) { log = logrus.StandardLogger() log.SetLevel(logrus.TraceLevel) @@ -60,3 +73,13 @@ func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest return w } + +// Executes an API request with a stream response. +func PerformRequestWithStream(r http.Handler, method, path string) *CloseableResponseRecorder { + req, _ := http.NewRequest(method, path, nil) + w := &CloseableResponseRecorder{httptest.NewRecorder(), make(chan bool, 1)} + + r.ServeHTTP(w, req) + + return w +} diff --git a/internal/api/metrics.go b/internal/api/metrics.go new file mode 100644 index 000000000..25cec9b24 --- /dev/null +++ b/internal/api/metrics.go @@ -0,0 +1,82 @@ +package api + +import ( + "io" + "runtime" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/common/expfmt" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/get" +) + +// GET /api/v1/metrics +func GetMetrics(router *gin.RouterGroup) { + router.GET("/metrics", func(c *gin.Context) { + conf := get.Config() + counts := conf.ClientPublic().Count + + c.Stream(func(w io.Writer) bool { + reg := prometheus.NewRegistry() + reg.MustRegister(collectors.NewGoCollector()) + + factory := promauto.With(reg) + + registerCountMetrics(factory, counts) + registerBuildInfoMetric(factory, conf.ClientPublic()) + + metrics, err := reg.Gather() + if err != nil { + logError("metrics", err) + return false + } + + for _, metric := range metrics { + if _, err := expfmt.MetricFamilyToText(w, metric); err != nil { + logError("metrics", err) + return false + } + } + + return false + }) + }) +} + +// Register metrics that exposes various statistics for this instance. +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", + }, []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": "videos"}).Set(float64(counts.Videos)) + 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)) +} + +// Register a metric that exposes build information for this instance. +func registerBuildInfoMetric(factory promauto.Factory, conf config.ClientConfig) { + factory.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "photoprism", + Name: "build_info", + Help: "information about the photoprism instance", + }, []string{"edition", "goversion", "version"}, + ).With(prometheus.Labels{ + "edition": conf.Edition, + "goversion": runtime.Version(), + "version": conf.Version, + }).Set(1.0) +} diff --git a/internal/api/metrics_test.go b/internal/api/metrics_test.go new file mode 100644 index 000000000..3ad3f3d19 --- /dev/null +++ b/internal/api/metrics_test.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + "testing" + "regexp" + + "github.com/stretchr/testify/assert" +) + +func TestGetMetrics(t *testing.T) { + t.Run("expose count statistics", 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() + + 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) + }) + t.Run("expose build information", 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() + + assert.Regexp(t, regexp.MustCompile(`photoprism_build_info{edition=".+",goversion=".+",version=".+"} 1`), body) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index b9bea6378..68952afc0 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -166,4 +166,5 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.SendFeedback(APIv1) api.Connect(APIv1) api.WebSocket(APIv1) + api.GetMetrics(APIv1) }