feat: add prometheus metrics without external standalone exporter

This commit is contained in:
Ferdinand Mütsch 2021-02-12 18:37:30 +01:00
parent 8191a52ce1
commit 88eb68b1a9
21 changed files with 385 additions and 17 deletions

View File

@ -164,7 +164,9 @@ You can specify configuration options either via a config file (default: `config
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE ` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
| `security.allow_signup` | `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
| `security.expose_metrics` | `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/metrics` |
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
@ -196,13 +198,37 @@ $ swag init -o static/docs
## 🤝 Integrations
### Prometheus Export
If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**.
You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how.
[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff)](https://github.com/MacroPower/wakatime_exporter)
```bash
# 1. Start Wakapi with the feature enabled
$ export WAKAPI_EXPOSE_METRICS=true
$ ./wakapi
It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible.
# 2. Get your API key and hash it
$ echo "<YOUR_API_KEY>" | base64
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly.
# 3. Add a Prometheus scrape config to your prometheus.yml (see below)
```
#### Scrape config example
```yml
# prometheus.yml
# (assuming your Wakapi instance listens at localhost, port 3000)
scrape_configs:
- job_name: 'wakapi'
scrape_interval: 1m
metrics_path: '/api/metrics'
bearer_token: '<YOUR_BASE64_HASHED_TOKEN>'
static_configs:
- targets: ['localhost:3000']
```
#### Grafana
There is also a [nice Grafana dashboard](https://grafana.com/grafana/dashboards/12790), provided by the author of [wakatime_exporter](https://github.com/MacroPower/wakatime_exporter).
![](https://grafana.com/api/dashboards/12790/images/8741/image)
### WakaTime Integration
Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page.

View File

@ -29,4 +29,5 @@ security:
password_salt: # CHANGE !
insecure_cookies: false
cookie_max_age: 172800
allow_signup: true
allow_signup: true
expose_metrics: false

View File

@ -47,7 +47,6 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location")
type appConfig struct {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
CountingTime string `yaml:"counting_time" default:"05:15" env:"WAKAPI_COUNTING_TIME"`
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"`
CustomLanguages map[string]string `yaml:"custom_languages"`
@ -55,7 +54,8 @@ type appConfig struct {
}
type securityConfig struct {
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`

View File

@ -149,6 +149,7 @@ func main() {
healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
// Compat Handlers
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
@ -189,6 +190,7 @@ func main() {
summaryApiHandler.RegisterRoutes(apiRouter)
healthApiHandler.RegisterRoutes(apiRouter)
heartbeatApiHandler.RegisterRoutes(apiRouter)
metricsHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)

View File

@ -2,7 +2,6 @@ package middlewares
import (
"context"
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
@ -15,6 +14,7 @@ type AuthenticateMiddleware struct {
config *conf.Config
userSrvc services.IUserService
optionalForPaths []string
redirectTarget string // optional
}
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
@ -30,6 +30,11 @@ func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) *AuthenticateMi
return m
}
func (m *AuthenticateMiddleware) WithRedirectTarget(path string) *AuthenticateMiddleware {
m.redirectTarget = path
return m
}
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.ServeHTTP(w, r, h.ServeHTTP)
@ -50,11 +55,12 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
return
}
if strings.HasPrefix(r.URL.Path, "/api") {
if m.redirectTarget == "" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("401 unauthorized"))
} else {
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
http.Redirect(w, r, m.redirectTarget, http.StatusFound)
}
return
}

View File

@ -20,6 +20,11 @@ func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error
return args.Error(0)
}
func (m *HeartbeatServiceMock) Count() (int64, error) {
args := m.Called()
return int64(args.Int(0)), args.Error(1)
}
func (m *HeartbeatServiceMock) CountByUser(user *models.User) (int64, error) {
args := m.Called(user)
return args.Get(0).(int64), args.Error(0)

View File

@ -0,0 +1,22 @@
package metrics
import "fmt"
type CounterMetric struct {
Name string
Value int
Desc string
Labels Labels
}
func (c CounterMetric) Key() string {
return c.Name
}
func (c CounterMetric) Print() string {
return fmt.Sprintf("%s%s %d", c.Name, c.Labels.Print(), c.Value)
}
func (c CounterMetric) Header() string {
return fmt.Sprintf("# HELP %s %s\n# TYPE %s counter", c.Name, c.Desc, c.Name)
}

28
models/metrics/label.go Normal file
View File

@ -0,0 +1,28 @@
package metrics
import (
"fmt"
"strings"
)
type Labels []Label
type Label struct {
Key string
Value string
}
func (l Labels) Print() string {
printedLabels := make([]string, len(l))
for i, e := range l {
printedLabels[i] = e.Print()
}
if len(l) == 0 {
return ""
}
return fmt.Sprintf("{%s}", strings.Join(printedLabels, ","))
}
func (l Label) Print() string {
return fmt.Sprintf("%s=\"%s\"", l.Key, l.Value)
}

43
models/metrics/metric.go Normal file
View File

@ -0,0 +1,43 @@
package metrics
import (
"fmt"
"strings"
)
// Hand-crafted Prometheus metrics
// Since we're only using very simple counters in this application,
// we don't actually need the official client SDK as a dependency
type Metrics []Metric
func (m Metrics) Print() (output string) {
printedMetrics := make(map[string]bool)
for _, m := range m {
if _, ok := printedMetrics[m.Key()]; !ok {
output += fmt.Sprintf("%s\n", m.Header())
printedMetrics[m.Key()] = true
}
output += fmt.Sprintf("%s\n", m.Print())
}
return output
}
func (m Metrics) Len() int {
return len(m)
}
func (m Metrics) Less(i, j int) bool {
return strings.Compare(m[i].Key(), m[j].Key()) < 0
}
func (m Metrics) Swap(i, j int) {
m[i], m[j] = m[j], m[i]
}
type Metric interface {
Key() string
Header() string
Print() string
}

View File

@ -26,6 +26,16 @@ func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error
return nil
}
func (r *HeartbeatRepository) Count() (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) {
var count int64
if err := r.db.

View File

@ -17,6 +17,7 @@ type IAliasRepository interface {
type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error
Count() (int64, error)
CountByUser(*models.User) (int64, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)

208
routes/api/metrics.go Normal file
View File

@ -0,0 +1,208 @@
package api
import (
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
mm "github.com/muety/wakapi/models/metrics"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"sort"
"strconv"
"time"
)
const (
MetricsPrefix = "wakatime"
DescAllTime = "Total seconds (all time)."
DescTotal = "Total seconds."
DescEditors = "Total seconds for each editor."
DescProjects = "Total seconds for each project."
DescLanguages = "Total seconds for each language."
DescOperatingSystems = "Total seconds for each operating system."
DescMachines = "Total seconds for each machine."
DescAdminTotalTime = "Total seconds (all users, all time)"
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
DescAdminTotalUser = "Total number of registered users"
)
type MetricsHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
heartbeatSrvc services.IHeartbeatService
keyValueSrvc services.IKeyValueService
}
func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService) *MetricsHandler {
return &MetricsHandler{
userSrvc: userService,
summarySrvc: summaryService,
heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService,
config: conf.Get(),
}
}
func (h *MetricsHandler) RegisterRoutes(router *mux.Router) {
if !h.config.Security.ExposeMetrics {
return
}
logbuch.Info("exposing prometheus metrics under /api/metrics")
r := router.PathPrefix("/metrics").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
var metrics mm.Metrics
user := r.Context().Value(models.UserKey).(*models.User)
if user == nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
from, to := utils.MustResolveIntervalRaw("today")
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
// User Metrics
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_cumulative_seconds_total",
Desc: DescAllTime,
Value: int(v1.NewAllTimeFrom(summaryAllTime, &models.Filters{}).Data.TotalSeconds),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_seconds_total",
Desc: DescTotal,
Value: int(summaryToday.TotalTime().Seconds()),
Labels: []mm.Label{},
})
for _, p := range summaryToday.Projects {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_project_seconds_total",
Desc: DescProjects,
Value: int(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: p.Key}},
})
}
for _, l := range summaryToday.Languages {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_language_seconds_total",
Desc: DescLanguages,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: l.Key}},
})
}
for _, e := range summaryToday.Editors {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_editor_seconds_total",
Desc: DescEditors,
Value: int(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: e.Key}},
})
}
for _, o := range summaryToday.OperatingSystems {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_operating_system_seconds_total",
Desc: DescOperatingSystems,
Value: int(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: o.Key}},
})
}
for _, m := range summaryToday.Machines {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_machine_seconds_total",
Desc: DescMachines,
Value: int(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}},
})
}
// Admin metrics
if user.IsAdmin {
var (
totalSeconds int
totalUsers int
totalHeartbeats int
)
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && t != nil && t.Value != "" {
if d, err := time.ParseDuration(t.Value); err == nil {
totalSeconds = int(d.Seconds())
}
}
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalUsers); err == nil && t != nil && t.Value != "" {
if d, err := strconv.Atoi(t.Value); err == nil {
totalUsers = d
}
}
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalUsers); err == nil && t != nil && t.Value != "" {
if d, err := strconv.Atoi(t.Value); err == nil {
totalUsers = d
}
}
if t, err := h.heartbeatSrvc.Count(); err == nil {
totalHeartbeats = int(t)
}
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_seconds_total",
Desc: DescAdminTotalTime,
Value: totalSeconds,
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_users_total",
Desc: DescAdminTotalUser,
Value: totalUsers,
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_heartbeats_total",
Desc: DescAdminTotalHeartbeats,
Value: totalHeartbeats,
Labels: []mm.Label{},
})
}
sort.Sort(metrics)
w.Header().Set("content-type", "text/plain; charset=utf-8")
w.Write([]byte(metrics.Print()))
}

View File

@ -102,3 +102,7 @@ func typeName(t uint8) string {
}
return "unknown"
}
func defaultErrorRedirectTarget() string {
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
}

View File

@ -57,7 +57,7 @@ func NewSettingsHandler(
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/settings").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
r.Methods(http.MethodPost).HandlerFunc(h.PostIndex)

View File

@ -29,7 +29,7 @@ func NewSummaryHandler(summaryService services.ISummaryService, userService serv
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/summary").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
}

View File

@ -30,6 +30,10 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
return srv.repository.InsertBatch(heartbeats)
}
func (srv *HeartbeatService) Count() (int64, error) {
return srv.repository.Count()
}
func (srv *HeartbeatService) CountByUser(user *models.User) (int64, error) {
return srv.repository.CountByUser(user)
}

View File

@ -46,7 +46,7 @@ func (srv *MiscService) ScheduleCountTotalTime() {
}
s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.CountingTime).Do(srv.runCountTotalTime)
s.Every(1).Hour().Do(srv.runCountTotalTime)
s.StartBlocking()
}

View File

@ -29,6 +29,7 @@ type IHeartbeatService interface {
Insert(*models.Heartbeat) error
InsertBatch([]*models.Heartbeat) error
CountByUser(*models.User) (int64, error)
Count() (int64, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)

View File

@ -16,6 +16,11 @@ func ParseInterval(interval string) (*models.IntervalKey, error) {
return nil, errors.New("not a valid interval")
}
func MustResolveIntervalRaw(interval string) (from, to time.Time) {
_, from, to = ResolveIntervalRaw(interval)
return from, to
}
func ResolveIntervalRaw(interval string) (err error, from, to time.Time) {
parsed, err := ParseInterval(interval)
if err != nil {
@ -74,6 +79,8 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
if interval := params.Get("interval"); interval != "" {
err, from, to = ResolveIntervalRaw(interval)
} else if start := params.Get("start"); start != "" {
err, from, to = ResolveIntervalRaw(start)
} else {
from, err = ParseDate(params.Get("from"))
if err != nil {

View File

@ -1 +1 @@
1.23.7
1.24.0

View File

@ -27,11 +27,11 @@
<p class="text-center text-gray-500 text-xl my-4">
<span class="mr-1">💡 The system has tracked a total of </span>
{{ range $d := .TotalHours | printf "%d" | toRunes }}
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalHours }} hours (updated once a day)">{{ $d }}</span>
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalHours }} hours (updated every hour)">{{ $d }}</span>
{{ end }}
<span class="mx-1">hours of coding from</span>
{{ range $d := .TotalUsers | printf "%d" | toRunes }}
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalUsers }} users (updated once a day)">{{ $d }}</span>
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalUsers }} users (updated every hour)">{{ $d }}</span>
{{ end }}
<span class="ml-1">users.</span>
</p>