feat: add daily heartbeat request for counting Pocket ID instances (#578)

This commit is contained in:
Elias Schneider
2025-05-28 11:19:45 +02:00
committed by GitHub
parent d29fca155e
commit e0ec607198
14 changed files with 123 additions and 47 deletions

View File

@@ -17,6 +17,7 @@ require (
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1
github.com/mileusna/useragent v1.3.5

View File

@@ -40,7 +40,7 @@ func Bootstrap() error {
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
err = registerScheduledJobs(ctx, db, svc, scheduler)
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)
}
@@ -48,7 +48,7 @@ func Bootstrap() error {
// Init the router
router := initRouter(db, svc)
// Run all background serivces
// Run all background services
// This call blocks until the context is canceled
err = utils.
NewServiceRunner(router, scheduler.Run).

View File

@@ -3,13 +3,14 @@ package bootstrap
import (
"context"
"fmt"
"net/http"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/job"
)
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error {
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, scheduler *job.Scheduler) error {
err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService)
if err != nil {
return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err)
@@ -30,6 +31,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, sche
if err != nil {
return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err)
}
err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient)
if err != nil {
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
}
return nil
}

View File

@@ -39,6 +39,7 @@ type EnvConfigSchema struct {
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
}
var EnvConfig = &EnvConfigSchema{
@@ -57,6 +58,7 @@ var EnvConfig = &EnvConfigSchema{
MetricsEnabled: false,
TracingEnabled: false,
TrustProxy: false,
AnalyticsDisabled: false,
}
func init() {

View File

@@ -0,0 +1,61 @@
package job
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
jobs := &AnalyticsJob{appConfig: appConfig, httpClient: httpClient}
return s.registerJob(ctx, "SendHeartbeat", "0 0 * * *", jobs.sendHeartbeat, true)
}
type AnalyticsJob struct {
appConfig *service.AppConfigService
httpClient *http.Client
}
// sendHeartbeat sends a heartbeat to the analytics service
func (j *AnalyticsJob) sendHeartbeat(ctx context.Context) error {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
}
body := struct {
Version string `json:"version"`
InstanceID string `json:"instance_id"`
}{
Version: common.Version,
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("failed to create heartbeat request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send heartbeat request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("heartbeat request failed with status code: %d", resp.StatusCode)
}
return nil
}

View File

@@ -18,7 +18,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
appConfigService: appConfigService,
}
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys)
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys, false)
}
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {

View File

@@ -15,11 +15,11 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
jobs := &DbCleanupJobs{db: db}
return errors.Join(
s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions),
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes),
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens),
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs),
s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions, false),
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens, false),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes, false),
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens, false),
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs, false),
)
}

View File

@@ -17,7 +17,7 @@ import (
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &FileCleanupJobs{db: db}
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures)
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures, false)
}
type FileCleanupJobs struct {

View File

@@ -2,8 +2,6 @@ package job
import (
"context"
"log"
"time"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -22,22 +20,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Register the job to run every day, at 5 minutes past midnight
err := s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB)
if err != nil {
return err
}
// Run the job immediately on startup, with a 1s delay
go func() {
time.Sleep(time.Second)
err = jobs.updateGoeLiteDB(ctx)
if err != nil {
// Log the error only, but don't return it
log.Printf("Failed to Update GeoLite database: %v", err)
}
}()
return nil
return s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB, true)
}
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -2,7 +2,6 @@ package job
import (
"context"
"log"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -16,19 +15,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour
err := s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap)
if err != nil {
return err
}
// Run the job immediately on startup
err = jobs.syncLdap(ctx)
if err != nil {
// Log the error only, but don't return it
log.Printf("Failed to sync LDAP: %v", err)
}
return nil
return s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap, true)
}
func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -43,10 +43,8 @@ func (s *Scheduler) Run(ctx context.Context) error {
return nil
}
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error) error {
_, err := s.scheduler.NewJob(
gocron.CronJob(interval, false),
gocron.NewTask(job),
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error, runImmediately bool) error {
jobOptions := []gocron.JobOption{
gocron.WithContext(ctx),
gocron.WithEventListeners(
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
@@ -56,6 +54,16 @@ func (s *Scheduler) registerJob(ctx context.Context, name string, interval strin
log.Printf("Job %q failed with error: %v", name, err)
}),
),
}
if runImmediately {
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
}
_, err := s.scheduler.NewJob(
gocron.CronJob(interval, false),
gocron.NewTask(job),
jobOptions...,
)
if err != nil {

View File

@@ -41,6 +41,7 @@ type AppConfig struct {
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email
SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable `key:"smtpPort"`

View File

@@ -103,7 +103,7 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
// Verify every AppConfig field has a matching DTO field with the same name
for fieldName, keyName := range appConfigFields {
if strings.HasSuffix(fieldName, "ImageType") {
if strings.HasSuffix(fieldName, "ImageType") || keyName == "instanceId" {
// Skip internal fields that shouldn't be in the DTO
continue
}

View File

@@ -13,6 +13,8 @@ import (
"sync/atomic"
"time"
"github.com/hashicorp/go-uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -37,6 +39,11 @@ func NewAppConfigService(initCtx context.Context, db *gorm.DB) *AppConfigService
log.Fatalf("Failed to initialize app config service: %v", err)
}
err = service.initInstanceID(initCtx)
if err != nil {
log.Fatalf("Failed to initialize instance ID: %v", err)
}
return service
}
@@ -65,6 +72,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
InstanceID: model.AppConfigVariable{Value: ""},
// Email
SmtpHost: model.AppConfigVariable{},
SmtpPort: model.AppConfigVariable{},
@@ -440,3 +448,23 @@ func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB
return dest, nil
}
func (s *AppConfigService) initInstanceID(ctx context.Context) error {
// Check if the instance ID is already set
instanceID := s.GetDbConfig().InstanceID.Value
if instanceID != "" {
return nil
}
newInstanceID, err := uuid.GenerateUUID()
if err != nil {
return fmt.Errorf("failed to generate new instance ID: %w", err)
}
err = s.UpdateAppConfigValues(ctx, "instanceId", newInstanceID)
if err != nil {
return fmt.Errorf("failed to update instance ID in the database: %w", err)
}
return nil
}