Backend: Expose prometheus-style metrics endpoint
Expose a new API which can be scraped by prometheus to gather useful metrics from an instance. The new endpoint exposes photoprism build version information, golang version, edition and various count metrics.
This commit is contained in:
parent
0935d9fab0
commit
3d962e2382
6 changed files with 175 additions and 1 deletions
9
go.mod
9
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
|
||||
|
|
14
go.sum
14
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=
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
82
internal/api/metrics.go
Normal file
82
internal/api/metrics.go
Normal file
|
@ -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)
|
||||
}
|
47
internal/api/metrics_test.go
Normal file
47
internal/api/metrics_test.go
Normal file
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -166,4 +166,5 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
|||
api.SendFeedback(APIv1)
|
||||
api.Connect(APIv1)
|
||||
api.WebSocket(APIv1)
|
||||
api.GetMetrics(APIv1)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue