1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

..

7 Commits
2.3.0 ... 2.3.1

Author SHA1 Message Date
Ferdinand Mütsch
bbc85de34b chore: metrics performance improvements 2022-03-19 10:30:32 +01:00
Ferdinand Mütsch
ec70d024fa fix: remove user property of diagnostics as sent without auth 2022-03-19 09:27:13 +01:00
Ferdinand Mütsch
eae45baf38 chore: allow heartbeats from one hour into the future to compensate for clock inaccuracies (see #342) 2022-03-19 09:02:15 +01:00
Ferdinand Mütsch
4cea50b5c8 chore: add user project index on heartbeats table 2022-03-19 08:57:33 +01:00
Ferdinand Mütsch
e4814431e0 feat: add database size metric 2022-03-18 18:20:13 +01:00
Ferdinand Mütsch
91b4cb2c13 fix: explicit milliseconds precision of timestamp columns 2022-03-18 13:48:28 +01:00
Ferdinand Mütsch
a3acdc7041 fix: duration aggregation for heartbeats with identical timestamps (resolve #340) 2022-03-18 12:29:43 +01:00
23 changed files with 1007 additions and 773 deletions

File diff suppressed because it is too large Load Diff

17
main.go
View File

@@ -11,7 +11,6 @@ import (
"time" "time"
"github.com/lpar/gzipped/v2" "github.com/lpar/gzipped/v2"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes/relay" "github.com/muety/wakapi/routes/relay"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
@@ -58,6 +57,7 @@ var (
summaryRepository repositories.ISummaryRepository summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository keyValueRepository repositories.IKeyValueRepository
diagnosticsRepository repositories.IDiagnosticsRepository diagnosticsRepository repositories.IDiagnosticsRepository
metricsRepository *repositories.MetricsRepository
) )
var ( var (
@@ -149,6 +149,7 @@ func main() {
summaryRepository = repositories.NewSummaryRepository(db) summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db) keyValueRepository = repositories.NewKeyValueRepository(db)
diagnosticsRepository = repositories.NewDiagnosticsRepository(db) diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
metricsRepository = repositories.NewMetricsRepository(db)
// Services // Services
mailService = mail.NewMailService() mailService = mail.NewMailService()
@@ -178,7 +179,7 @@ func main() {
healthApiHandler := api.NewHealthApiHandler(db) healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService) heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService) summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService) metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService, metricsRepository)
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService) diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
avatarHandler := api.NewAvatarHandler() avatarHandler := api.NewAvatarHandler()
@@ -267,18 +268,6 @@ func main() {
middlewares.NewFileTypeFilterMiddleware([]string{".go"})(staticFileServer), middlewares.NewFileTypeFilterMiddleware([]string{".go"})(staticFileServer),
) )
// Miscellaneous
// Pre-warm projects cache
if !config.IsDev() {
allUsers, err := userService.GetAll()
if err == nil {
logbuch.Info("pre-warming user project cache")
for _, u := range allUsers {
go heartbeatService.GetEntitySetByUser(models.SummaryProject, u)
}
}
}
// Listen HTTP // Listen HTTP
listen(router) listen(router)
} }

View File

@@ -0,0 +1,41 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
func init() {
const name = "20220318-mysql_timestamp_precision"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
if cfg.Db.IsMySQL() {
logbuch.Info("altering heartbeats table, this may take a while (up to hours)")
db.Exec("SET foreign_key_checks=0;")
db.Exec("SET unique_checks=0;")
if err := db.Exec("ALTER TABLE heartbeats MODIFY COLUMN `time` TIMESTAMP(3) NOT NULL").Error; err != nil {
return err
}
if err := db.Exec("ALTER TABLE heartbeats MODIFY COLUMN `created_at` TIMESTAMP(3) NOT NULL").Error; err != nil {
return err
}
db.Exec("SET foreign_key_checks=1;")
db.Exec("SET unique_checks=1;")
logbuch.Info("migrated timestamp columns to millisecond precision")
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -0,0 +1,39 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "202203191-drop_diagnostics_user"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
migrator := db.Migrator()
if migrator.HasColumn(&models.Diagnostics{}, "user_id") {
logbuch.Info("running migration '%s'", name)
if err := migrator.DropConstraint(&models.Diagnostics{}, "fk_diagnostics_user"); err != nil {
logbuch.Warn("failed to drop 'fk_diagnostics_user' constraint (%v)", err)
}
if err := migrator.DropColumn(&models.Diagnostics{}, "user_id"); err != nil {
logbuch.Warn("failed to drop user_id column of diagnostics (%v)", err)
}
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -0,0 +1,35 @@
package migrations
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20220319-add_user_project_idx"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if hasRun(name, db) {
return nil
}
idxName := "idx_user_project"
if !db.Migrator().HasIndex(&models.Heartbeat{}, idxName) {
logbuch.Info("running migration '%s'", name)
if err := db.Exec(fmt.Sprintf("create index %s on heartbeats (user_id, project)", idxName)).Error; err != nil {
logbuch.Warn("failed to create %s", idxName)
}
}
setHasRun(name, db)
return nil
},
}
registerPostMigration(f)
}

View File

@@ -20,8 +20,8 @@ func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error
return args.Error(0) return args.Error(0)
} }
func (m *HeartbeatServiceMock) Count() (int64, error) { func (m *HeartbeatServiceMock) Count(a bool) (int64, error) {
args := m.Called() args := m.Called(a)
return int64(args.Int(0)), args.Error(1) return int64(args.Int(0)), args.Error(1)
} }

View File

@@ -2,8 +2,6 @@ package models
type Diagnostics struct { type Diagnostics struct {
ID uint `gorm:"primary_key"` ID uint `gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_diagnostics_user"`
Platform string `json:"platform"` Platform string `json:"platform"`
Architecture string `json:"architecture"` Architecture string `json:"architecture"`
Plugin string `json:"plugin"` Plugin string `json:"plugin"`

View File

@@ -11,11 +11,11 @@ import (
type Heartbeat struct { type Heartbeat struct {
ID uint64 `gorm:"primary_key" hash:"ignore"` ID uint64 `gorm:"primary_key" hash:"ignore"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"` User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"` UserID string `json:"-" gorm:"not null; index:idx_time_user,idx_user_project"` // idx_user_project is for quickly fetching a user's project list (settings page)
Entity string `json:"entity" gorm:"not null"` Entity string `json:"entity" gorm:"not null"`
Type string `json:"type"` Type string `json:"type"`
Category string `json:"category"` Category string `json:"category"`
Project string `json:"project" gorm:"index:idx_project"` Project string `json:"project" gorm:"index:idx_project,idx_user_project"`
Branch string `json:"branch" gorm:"index:idx_branch"` Branch string `json:"branch" gorm:"index:idx_branch"`
Language string `json:"language" gorm:"index:idx_language"` Language string `json:"language" gorm:"index:idx_language"`
IsWrite bool `json:"is_write"` IsWrite bool `json:"is_write"`
@@ -23,11 +23,11 @@ type Heartbeat struct {
OperatingSystem string `json:"operating_system" gorm:"index:idx_operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime OperatingSystem string `json:"operating_system" gorm:"index:idx_operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" gorm:"index:idx_machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently Machine string `json:"machine" gorm:"index:idx_machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
UserAgent string `json:"user_agent" hash:"ignore" gorm:"type:varchar(255)"` UserAgent string `json:"user_agent" hash:"ignore" gorm:"type:varchar(255)"`
Time CustomTime `json:"time" gorm:"type:timestamp; index:idx_time,idx_time_user" swaggertype:"primitive,number"` Time CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time,idx_time_user" swaggertype:"primitive,number"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"` Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"` Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"` OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp(3)" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
} }
func (h *Heartbeat) Valid() bool { func (h *Heartbeat) Valid() bool {
@@ -36,7 +36,7 @@ func (h *Heartbeat) Valid() bool {
func (h *Heartbeat) Timely(maxAge time.Duration) bool { func (h *Heartbeat) Timely(maxAge time.Duration) bool {
now := time.Now() now := time.Now()
return now.Sub(h.Time.T()) <= maxAge && h.Time.T().Before(now) return now.Sub(h.Time.T()) <= maxAge && h.Time.T().Sub(now) < 1*time.Hour
} }
func (h *Heartbeat) Augment(languageMappings map[string]string) { func (h *Heartbeat) Augment(languageMappings map[string]string) {

View File

@@ -4,7 +4,7 @@ import "fmt"
type CounterMetric struct { type CounterMetric struct {
Name string Name string
Value int Value int64
Desc string Desc string
Labels Labels Labels Labels
} }

View File

@@ -1,6 +1,7 @@
package repositories package repositories
import ( import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -9,10 +10,11 @@ import (
type HeartbeatRepository struct { type HeartbeatRepository struct {
db *gorm.DB db *gorm.DB
config *conf.Config
} }
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository { func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
return &HeartbeatRepository{db: db} return &HeartbeatRepository{config: conf.Get(), db: db}
} }
// Use with caution!! // Use with caution!!
@@ -116,12 +118,19 @@ func (r *HeartbeatRepository) GetLastByUsers() ([]*models.TimeByUser, error) {
return result, nil return result, nil
} }
func (r *HeartbeatRepository) Count() (int64, error) { func (r *HeartbeatRepository) Count(approximate bool) (count int64, err error) {
var count int64 if r.config.Db.IsMySQL() && approximate {
if err := r.db. err = r.db.Table("information_schema.tables").
Select("table_rows").
Where("table_schema = ?", r.config.Db.Name).
Where("table_name = 'heartbeats'").
Scan(&count).Error
}
if count == 0 {
err = r.db.
Model(&models.Heartbeat{}). Model(&models.Heartbeat{}).
Count(&count).Error; err != nil { Count(&count).Error
return 0, err
} }
return count, nil return count, nil
} }
@@ -145,6 +154,10 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
userIds[i] = u.ID userIds[i] = u.ID
} }
if len(userIds) == 0 {
return counts, nil
}
if err := r.db. if err := r.db.
Model(&models.Heartbeat{}). Model(&models.Heartbeat{}).
Select("user_id as user, count(id) as count"). Select("user_id as user, count(id) as count").
@@ -153,6 +166,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
Find(&counts).Error; err != nil { Find(&counts).Error; err != nil {
return counts, err return counts, err
} }
return counts, nil return counts, nil
} }

43
repositories/metrics.go Normal file
View File

@@ -0,0 +1,43 @@
package repositories
import (
"github.com/muety/wakapi/config"
"gorm.io/gorm"
)
type MetricsRepository struct {
config *config.Config
db *gorm.DB
}
const sizeTplMysql = `
SELECT SUM(data_length + index_length)
FROM information_schema.tables
WHERE table_schema = ?
GROUP BY table_schema`
const sizeTplPostgres = `SELECT pg_database_size('%s');`
const sizeTplSqlite = `
SELECT page_count * page_size as size
FROM pragma_page_count(), pragma_page_size();`
func NewMetricsRepository(db *gorm.DB) *MetricsRepository {
return &MetricsRepository{config: config.Get(), db: db}
}
func (srv *MetricsRepository) GetDatabaseSize() (size int64, err error) {
cfg := srv.config.Db
query := srv.db.Raw("SELECT 0")
if cfg.IsMySQL() {
query = srv.db.Raw(sizeTplMysql, cfg.Name)
} else if cfg.IsPostgres() {
query = srv.db.Raw(sizeTplPostgres, cfg.Name)
} else if cfg.IsSQLite() {
query = srv.db.Raw(sizeTplSqlite)
}
err = query.Scan(&size).Error
return size, err
}

View File

@@ -25,7 +25,7 @@ type IHeartbeatRepository interface {
GetLastByUsers() ([]*models.TimeByUser, error) GetLastByUsers() ([]*models.TimeByUser, error)
GetLatestByUser(*models.User) (*models.Heartbeat, error) GetLatestByUser(*models.User) (*models.Heartbeat, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error) GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
Count() (int64, error) Count(bool) (int64, error)
CountByUser(*models.User) (int64, error) CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error) CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetEntitySetByUser(uint8, *models.User) ([]string, error) GetEntitySetByUser(uint8, *models.User) ([]string, error)

View File

@@ -98,10 +98,9 @@ func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error)
// Returns a list of user ids, whose last heartbeat is not older than t // Returns a list of user ids, whose last heartbeat is not older than t
// NOTE: Only ID field will be populated // NOTE: Only ID field will be populated
func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, error) { func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, error) {
subQuery1 := r.db.Model(&models.User{}). subQuery1 := r.db.Model(&models.Heartbeat{}).
Select("users.id as user, max(time) as time"). Select("user_id as user, max(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id"). Group("user_id")
Group("user")
var userIds []string var userIds []string
if err := r.db. if err := r.db.

View File

@@ -46,20 +46,12 @@ func (h *DiagnosticsApiHandler) RegisterRoutes(router *mux.Router) {
func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) { func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
var diagnostics models.Diagnostics var diagnostics models.Diagnostics
user := middlewares.GetPrincipal(r)
if user == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(conf.ErrUnauthorized))
return
}
if err := json.NewDecoder(r.Body).Decode(&diagnostics); err != nil { if err := json.NewDecoder(r.Body).Decode(&diagnostics); err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(conf.ErrBadRequest)) w.Write([]byte(conf.ErrBadRequest))
conf.Log().Request(r).Error("failed to parse diagnostics for user %s - %v", err) conf.Log().Request(r).Error("failed to parse diagnostics for user %s - %v", err)
return return
} }
diagnostics.UserID = user.ID
if _, err := h.diagnosticsSrvc.Create(&diagnostics); err != nil { if _, err := h.diagnosticsSrvc.Create(&diagnostics); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)

View File

@@ -9,6 +9,7 @@ import (
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
mm "github.com/muety/wakapi/models/metrics" mm "github.com/muety/wakapi/models/metrics"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"net/http" "net/http"
@@ -39,6 +40,7 @@ const (
DescMemAllocTotal = "Total number of bytes allocated for heap" DescMemAllocTotal = "Total number of bytes allocated for heap"
DescMemSysTotal = "Total number of bytes obtained from the OS" DescMemSysTotal = "Total number of bytes obtained from the OS"
DescGoroutines = "Total number of running goroutines" DescGoroutines = "Total number of running goroutines"
DescDatabaseSize = "Total database size in bytes"
) )
type MetricsHandler struct { type MetricsHandler struct {
@@ -47,14 +49,16 @@ type MetricsHandler struct {
summarySrvc services.ISummaryService summarySrvc services.ISummaryService
heartbeatSrvc services.IHeartbeatService heartbeatSrvc services.IHeartbeatService
keyValueSrvc services.IKeyValueService keyValueSrvc services.IKeyValueService
metricsRepo *repositories.MetricsRepository
} }
func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService) *MetricsHandler { func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService, metricsRepo *repositories.MetricsRepository) *MetricsHandler {
return &MetricsHandler{ return &MetricsHandler{
userSrvc: userService, userSrvc: userService,
summarySrvc: summaryService, summarySrvc: summaryService,
heartbeatSrvc: heartbeatService, heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService, keyValueSrvc: keyValueService,
metricsRepo: metricsRepo,
config: conf.Get(), config: conf.Get(),
} }
} }
@@ -141,21 +145,21 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_cumulative_seconds_total", Name: MetricsPrefix + "_cumulative_seconds_total",
Desc: DescAllTime, Desc: DescAllTime,
Value: int(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds), Value: int64(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_seconds_total", Name: MetricsPrefix + "_seconds_total",
Desc: DescTotal, Desc: DescTotal,
Value: int(summaryToday.TotalTime().Seconds()), Value: int64(summaryToday.TotalTime().Seconds()),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_heartbeats_total", Name: MetricsPrefix + "_heartbeats_total",
Desc: DescHeartbeats, Desc: DescHeartbeats,
Value: int(heartbeatCount), Value: int64(heartbeatCount),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
@@ -163,7 +167,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_project_seconds_total", Name: MetricsPrefix + "_project_seconds_total",
Desc: DescProjects, Desc: DescProjects,
Value: int(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: p.Key}}, Labels: []mm.Label{{Key: "name", Value: p.Key}},
}) })
} }
@@ -172,7 +176,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_language_seconds_total", Name: MetricsPrefix + "_language_seconds_total",
Desc: DescLanguages, Desc: DescLanguages,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: l.Key}}, Labels: []mm.Label{{Key: "name", Value: l.Key}},
}) })
} }
@@ -181,7 +185,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_editor_seconds_total", Name: MetricsPrefix + "_editor_seconds_total",
Desc: DescEditors, Desc: DescEditors,
Value: int(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: e.Key}}, Labels: []mm.Label{{Key: "name", Value: e.Key}},
}) })
} }
@@ -190,7 +194,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_operating_system_seconds_total", Name: MetricsPrefix + "_operating_system_seconds_total",
Desc: DescOperatingSystems, Desc: DescOperatingSystems,
Value: int(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: o.Key}}, Labels: []mm.Label{{Key: "name", Value: o.Key}},
}) })
} }
@@ -199,7 +203,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_machine_seconds_total", Name: MetricsPrefix + "_machine_seconds_total",
Desc: DescMachines, Desc: DescMachines,
Value: int(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}}, Labels: []mm.Label{{Key: "name", Value: m.Key}},
}) })
} }
@@ -208,7 +212,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_label_seconds_total", Name: MetricsPrefix + "_label_seconds_total",
Desc: DescLabels, Desc: DescLabels,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()), Value: int64(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}}, Labels: []mm.Label{{Key: "name", Value: m.Key}},
}) })
} }
@@ -220,21 +224,34 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_goroutines_total", Name: MetricsPrefix + "_goroutines_total",
Desc: DescGoroutines, Desc: DescGoroutines,
Value: runtime.NumGoroutine(), Value: int64(runtime.NumGoroutine()),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_mem_alloc_total", Name: MetricsPrefix + "_mem_alloc_total",
Desc: DescMemAllocTotal, Desc: DescMemAllocTotal,
Value: int(memStats.Alloc), Value: int64(memStats.Alloc),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_mem_sys_total", Name: MetricsPrefix + "_mem_sys_total",
Desc: DescMemSysTotal, Desc: DescMemSysTotal,
Value: int(memStats.Sys), Value: int64(memStats.Sys),
Labels: []mm.Label{},
})
// Database metrics
dbSize, err := h.metricsRepo.GetDatabaseSize()
if err != nil {
logbuch.Warn("failed to get database size (%v)", err)
}
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_db_total_bytes",
Desc: DescDatabaseSize,
Value: dbSize,
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
@@ -256,7 +273,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
} }
totalUsers, _ := h.userSrvc.Count() totalUsers, _ := h.userSrvc.Count()
totalHeartbeats, _ := h.heartbeatSrvc.Count() totalHeartbeats, _ := h.heartbeatSrvc.Count(true)
activeUsers, err := h.userSrvc.GetActive(false) activeUsers, err := h.userSrvc.GetActive(false)
if err != nil { if err != nil {
@@ -267,28 +284,28 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_seconds_total", Name: MetricsPrefix + "_admin_seconds_total",
Desc: DescAdminTotalTime, Desc: DescAdminTotalTime,
Value: totalSeconds, Value: int64(totalSeconds),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_heartbeats_total", Name: MetricsPrefix + "_admin_heartbeats_total",
Desc: DescAdminTotalHeartbeats, Desc: DescAdminTotalHeartbeats,
Value: int(totalHeartbeats), Value: totalHeartbeats,
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_users_total", Name: MetricsPrefix + "_admin_users_total",
Desc: DescAdminTotalUsers, Desc: DescAdminTotalUsers,
Value: int(totalUsers), Value: totalUsers,
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_users_active_total", Name: MetricsPrefix + "_admin_users_active_total",
Desc: DescAdminActiveUsers, Desc: DescAdminActiveUsers,
Value: len(activeUsers), Value: int64(len(activeUsers)),
Labels: []mm.Label{}, Labels: []mm.Label{},
}) })
@@ -304,7 +321,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
metrics = append(metrics, &mm.CounterMetric{ metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_user_heartbeats_total", Name: MetricsPrefix + "_admin_user_heartbeats_total",
Desc: DescAdminUserHeartbeats, Desc: DescAdminUserHeartbeats,
Value: int(uc.Count), Value: uc.Count,
Labels: []mm.Label{{Key: "user", Value: uc.User}}, Labels: []mm.Label{{Key: "user", Value: uc.User}},
}) })
} }

View File

@@ -0,0 +1,9 @@
SELECT project, language, editor, operating_system, machine, branch, SUM(GREATEST(1, diff)) as 'sum'
FROM (
SELECT project, language, editor, operating_system, machine, branch, TIME_TO_SEC(LEAST(TIMEDIFF(time, LAG(time) over w), '00:02:00')) as 'diff'
FROM heartbeats
WHERE user_id = 'n1try'
WINDOW w AS (ORDER BY time)
) s2
WHERE diff IS NOT NULL
GROUP BY project, language, editor, operating_system, machine, branch;

View File

@@ -0,0 +1,12 @@
DELETE t1
FROM heartbeats t1
INNER JOIN heartbeats t2
WHERE t1.id < t2.id
AND t1.time = t2.time
AND t1.entity = t2.entity
AND t1.is_write = t2.is_write
AND t1.branch = t2.branch
AND t1.editor = t2.editor
AND t1.machine = t2.machine
AND t1.operating_system = t2.operating_system
AND t1.user_id = t2.user_id;

View File

@@ -0,0 +1,10 @@
SELECT s2.user_id, sum(c) as count, total, (sum(c) / total) as ratio
FROM (
SELECT time, user_id, entity, is_write, branch, editor, machine, operating_system, COUNT(time) as c
FROM heartbeats
GROUP BY time, user_id, entity, is_write, branch, editor, machine, operating_system
HAVING COUNT(time) > 1
) s2
LEFT JOIN (SELECT user_id, count(id) AS total FROM heartbeats GROUP BY user_id) s3 ON s2.user_id = s3.user_id
GROUP BY user_id
ORDER BY count DESC;

View File

@@ -36,6 +36,8 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
} }
// Aggregation // Aggregation
// the below logic is approximately equivalent to the SQL query at scripts/aggregate_durations.sql,
// but unfortunately we cannot use it, as it features mysql-specific functions (lag(), timediff(), ...)
var count int var count int
var latest *models.Duration var latest *models.Duration
@@ -80,12 +82,20 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
for _, list := range mapping { for _, list := range mapping {
for _, d := range list { for _, d := range list {
// will only happen if two heartbeats with different hashes (e.g. different project) have the same timestamp
// that, in turn, will most likely only happen for mysql, where `time` column's precision was set to second for a while
// assume that two non-identical heartbeats with identical time are sub-second apart from each other, so round up to expectancy value
// also see https://github.com/muety/wakapi/issues/340
if d.Duration == 0 { if d.Duration == 0 {
d.Duration = HeartbeatDiffThreshold d.Duration = 500 * time.Millisecond
} }
durations = append(durations, d) durations = append(durations, d)
} }
} }
if len(heartbeats) == 1 && len(durations) == 1 {
durations[0].Duration = HeartbeatDiffThreshold
}
return durations.Sorted(), nil return durations.Sorted(), nil
} }

View File

@@ -65,6 +65,17 @@ func (suite *DurationServiceTestSuite) SetupSuite() {
Machine: TestMachine1, Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)), // 0:30 Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)), // 0:30
}, },
// duplicate of previous one
{
ID: rand.Uint64(),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)), // 0:30
},
{ {
ID: rand.Uint64(), ID: rand.Uint64(),
UserID: TestUserId, UserID: TestUserId,
@@ -160,7 +171,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() {
assert.Equal(suite.T(), TestEditorGoland, durations[0].Editor) assert.Equal(suite.T(), TestEditorGoland, durations[0].Editor)
assert.Equal(suite.T(), TestEditorGoland, durations[1].Editor) assert.Equal(suite.T(), TestEditorGoland, durations[1].Editor)
assert.Equal(suite.T(), TestEditorVscode, durations[2].Editor) assert.Equal(suite.T(), TestEditorVscode, durations[2].Editor)
assert.Equal(suite.T(), 2, durations[0].NumHeartbeats) assert.Equal(suite.T(), 3, durations[0].NumHeartbeats)
assert.Equal(suite.T(), 1, durations[1].NumHeartbeats) assert.Equal(suite.T(), 1, durations[1].NumHeartbeats)
assert.Equal(suite.T(), 3, durations[2].NumHeartbeats) assert.Equal(suite.T(), 3, durations[2].NumHeartbeats)
} }

View File

@@ -73,12 +73,12 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
return err return err
} }
func (srv *HeartbeatService) Count() (int64, error) { func (srv *HeartbeatService) Count(approximate bool) (int64, error) {
result, ok := srv.cache.Get(srv.countTotalCacheKey()) result, ok := srv.cache.Get(srv.countTotalCacheKey())
if ok { if ok {
return result.(int64), nil return result.(int64), nil
} }
count, err := srv.repository.Count() count, err := srv.repository.Count(approximate)
if err == nil { if err == nil {
srv.cache.Set(srv.countTotalCacheKey(), count, srv.countCacheTtl()) srv.cache.Set(srv.countTotalCacheKey(), count, srv.countCacheTtl())
} }

View File

@@ -29,7 +29,7 @@ type IAliasService interface {
type IHeartbeatService interface { type IHeartbeatService interface {
Insert(*models.Heartbeat) error Insert(*models.Heartbeat) error
InsertBatch([]*models.Heartbeat) error InsertBatch([]*models.Heartbeat) error
Count() (int64, error) Count(bool) (int64, error)
CountByUser(*models.User) (int64, error) CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error) CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)

View File

@@ -1 +1 @@
2.3.0 2.3.1