diff --git a/src/cmd/collerd/README.md b/src/cmd/collerd/README.md index e51ba03..cfffda6 100644 --- a/src/cmd/collerd/README.md +++ b/src/cmd/collerd/README.md @@ -27,5 +27,9 @@ The file format is **JSON**: * **expiration** (int): Default expiration time in seconds * **max_upload_size** (int): Maximum number of bytes received by the server for notes (the base64 encoding may use more space than the real size of the note) * **show_version** (bool): Show version on the website +* **enable_metrics** (bool): Enable Prometheus endpoint (default false) +* **prometheus_route** (string): Route to expose Prometheus metrics (default "/metrics") +* **prometheus_notes_metric** (string): Name of the notes count metric (default "collerd_notes") +* **observation_internal** (int): Number of seconds to wait between two observations (default 60) The configuration file is not required but the service might not be exposed to the public. \ No newline at end of file diff --git a/src/cmd/collerd/main.go b/src/cmd/collerd/main.go index 0d56ff7..0b9aef7 100644 --- a/src/cmd/collerd/main.go +++ b/src/cmd/collerd/main.go @@ -7,6 +7,7 @@ import ( "git.riou.xyz/jriou/coller/internal" "git.riou.xyz/jriou/coller/server" + "github.com/prometheus/client_golang/prometheus" ) var ( @@ -75,6 +76,16 @@ func handleMain() int { srv.SetIDLength(config.IDLength) srv.SetPasswordLength(config.PasswordLength) + if config.EnableMetrics { + reg := prometheus.NewRegistry() + metrics, err := server.NewMetrics(logger, reg, config, db) + if err != nil { + logger.Error("could not register metrics", slog.Any("error", err)) + return internal.RC_ERROR + } + srv.SetMetrics(metrics) + } + err = srv.Start() if err != nil { logger.Error("could not start server", slog.Any("error", err)) diff --git a/src/go.mod b/src/go.mod index 2ab7994..35d8e39 100644 --- a/src/go.mod +++ b/src/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.6 require ( github.com/gorilla/mux v1.8.1 + github.com/prometheus/client_golang v1.23.0 golang.design/x/clipboard v0.7.1 golang.org/x/crypto v0.41.0 golang.org/x/term v0.34.0 @@ -15,6 +16,8 @@ require ( ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -22,10 +25,15 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect golang.org/x/image v0.28.0 // indirect golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect ) diff --git a/src/go.sum b/src/go.sum index 8618304..2f772f9 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,6 +1,12 @@ +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -15,15 +21,31 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= @@ -42,6 +64,8 @@ golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/server/config.go b/src/server/config.go index 28caca4..dfe344f 100644 --- a/src/server/config.go +++ b/src/server/config.go @@ -7,18 +7,22 @@ import ( ) type Config struct { - Title string `json:"title"` - DatabaseType string `json:"database_type"` - DatabaseDsn string `json:"database_dsn"` - IDLength int `json:"id_length"` - PasswordLength int `json:"password_length"` - ExpirationInterval int `json:"expiration_interval"` - ListenAddress string `json:"listen_address"` - ListenPort int `json:"listen_port"` - Expirations []int `json:"expirations"` - Expiration int `json:"expiration"` - MaxUploadSize int64 `json:"max_upload_size"` - ShowVersion bool `json:"show_version"` + Title string `json:"title"` + DatabaseType string `json:"database_type"` + DatabaseDsn string `json:"database_dsn"` + IDLength int `json:"id_length"` + PasswordLength int `json:"password_length"` + ExpirationInterval int `json:"expiration_interval"` + ListenAddress string `json:"listen_address"` + ListenPort int `json:"listen_port"` + Expirations []int `json:"expirations"` + Expiration int `json:"expiration"` + MaxUploadSize int64 `json:"max_upload_size"` + ShowVersion bool `json:"show_version"` + EnableMetrics bool `json:"enable_metrics"` + PrometheusRoute string `json:"prometheus_route"` + PrometheusNotesMetric string `json:"prometheus_notes_metric"` + ObservationInterval int `json:"observation_internal"` } func NewConfig() *Config { @@ -38,9 +42,13 @@ func NewConfig() *Config { 604800, // 7 days 18144000, // 30 days }, - Expiration: 604800, // 7 days - MaxUploadSize: 10485760, // 10MiB (encoded) - ShowVersion: false, + Expiration: 604800, // 7 days + MaxUploadSize: 10485760, // 10MiB (encoded) + ShowVersion: false, + EnableMetrics: false, + PrometheusRoute: "/metrics", + PrometheusNotesMetric: "collerd_notes", + ObservationInterval: 60, } } diff --git a/src/server/metrics.go b/src/server/metrics.go new file mode 100644 index 0000000..064878d --- /dev/null +++ b/src/server/metrics.go @@ -0,0 +1,55 @@ +package server + +import ( + "fmt" + "log/slog" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +type Metrics struct { + logger *slog.Logger + notes prometheus.Gauge + db *Database + reg *prometheus.Registry + interval int +} + +func NewMetrics(logger *slog.Logger, reg *prometheus.Registry, config *Config, db *Database) (*Metrics, error) { + l := logger.With("module", "metrics") + m := &Metrics{ + logger: l, + db: db, + notes: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: config.PrometheusNotesMetric, + }), + reg: reg, + interval: config.ObservationInterval, + } + if err := reg.Register(m.notes); err != nil { + return nil, err + } + + go m.ObserveThread() + + return m, nil +} + +func (m *Metrics) ObserveThread() { + m.logger.Debug("starting observations") + for { + m.logger.Debug("counting notes") + var count int64 + m.db.db.Model(&Note{}).Count(&count) + m.notes.Set(float64(count)) + m.logger.Debug("counted notes", slog.Any("count", count)) + + wording := "second" + if m.interval > 1 { + wording += "s" + } + m.logger.Debug(fmt.Sprintf("waiting for %d %s before next observation", m.interval, wording)) + time.Sleep(time.Duration(m.interval) * time.Second) + } +} diff --git a/src/server/server.go b/src/server/server.go index 2b2c44e..8f19d7d 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -14,6 +14,7 @@ import ( "git.riou.xyz/jriou/coller/internal" "github.com/gorilla/mux" + "github.com/prometheus/client_golang/prometheus/promhttp" ) var passwordLength = internal.MIN_PASSWORD_LENGTH @@ -23,6 +24,7 @@ type Server struct { db *Database config *Config version string + metrics *Metrics } func NewServer(logger *slog.Logger, db *Database, config *Config, version string) (*Server, error) { @@ -44,6 +46,10 @@ func (s *Server) SetPasswordLength(length int) { passwordLength = length } +func (s *Server) SetMetrics(metrics *Metrics) { + s.metrics = metrics +} + type ErrorResponse struct { Message string `json:"message"` Error string `json:"error"` @@ -312,8 +318,15 @@ var templatesFS embed.FS func (s *Server) Start() error { r := mux.NewRouter().StrictSlash(true) - // API + // Healthchecks r.HandleFunc("/health", HealthHandler) + + // Metrics + if s.metrics != nil && s.metrics.reg != nil { + r.Path(s.config.PrometheusRoute).Handler(promhttp.HandlerFor(s.metrics.reg, promhttp.HandlerOpts{Registry: s.metrics.reg})).Methods("GET") + } + + // API r.Path("/api/note").Handler(&CreateNoteHandler{logger: s.logger, db: s.db, maxUploadSize: s.config.MaxUploadSize}).Methods("POST") r.Path("/{id:[a-zA-Z0-9]+}/{password:[a-zA-Z0-9]+}").Handler(&GetProtectedNoteHandler{logger: s.logger, db: s.db}).Methods("GET") r.Path("/{id:[a-zA-Z0-9]+}").Handler(&GetNoteHandler{logger: s.logger, db: s.db}).Methods("GET")