From d975c7b2d17c35a91a94ec505ef830446f31a582 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 7 Mar 2026 08:28:03 +0300 Subject: [PATCH] internal/aggregate: implement hourly salt rotation for unique visitors Signed-off-by: NotAShelf Change-Id: I5861c5bb55153349d0710cc07c1595a96a6a6964 --- internal/aggregate/metrics.go | 2 +- internal/aggregate/uniques.go | 69 ++++++++++++++++-------------- internal/aggregate/uniques_test.go | 4 +- 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/internal/aggregate/metrics.go b/internal/aggregate/metrics.go index 055896e..14ef690 100644 --- a/internal/aggregate/metrics.go +++ b/internal/aggregate/metrics.go @@ -109,7 +109,7 @@ func NewMetricsAggregator( refOverflow: refOverflow, eventOverflow: eventOverflow, dailyUniques: dailyUniques, - estimator: NewUniquesEstimator(), + estimator: NewUniquesEstimator(cfg.Site.SaltRotation), stopChan: make(chan struct{}), } diff --git a/internal/aggregate/uniques.go b/internal/aggregate/uniques.go index 8d8149f..c606ecd 100644 --- a/internal/aggregate/uniques.go +++ b/internal/aggregate/uniques.go @@ -12,37 +12,39 @@ import ( "github.com/axiomhq/hyperloglog" ) -// Tracks unique visitors using HyperLogLog with daily salt rotation +// Tracks unique visitors using HyperLogLog with configurable salt rotation type UniquesEstimator struct { - hll *hyperloglog.Sketch - currentDay string - mu sync.Mutex + hll *hyperloglog.Sketch + salt string + rotation string // "daily" or "hourly" + mu sync.Mutex } // Creates a new unique visitor estimator -func NewUniquesEstimator() *UniquesEstimator { +func NewUniquesEstimator(rotation string) *UniquesEstimator { return &UniquesEstimator{ - hll: hyperloglog.New(), - currentDay: dailySalt(time.Now()), + hll: hyperloglog.New(), + salt: generateSalt(time.Now(), rotation), + rotation: rotation, } } // Add records a visitor with privacy-preserving hashing -// Uses IP + UserAgent + daily salt to prevent cross-day correlation +// Uses IP + UserAgent + salt to prevent cross-period correlation func (u *UniquesEstimator) Add(ip, userAgent string) { u.mu.Lock() defer u.mu.Unlock() - // Check if we need to rotate to a new day - today := dailySalt(time.Now()) - if today != u.currentDay { - // Reset HLL for new day + // Check if we need to rotate to a new period + currentSalt := generateSalt(time.Now(), u.rotation) + if currentSalt != u.salt { + // Reset HLL for new period u.hll = hyperloglog.New() - u.currentDay = today + u.salt = currentSalt } - // Hash visitor with daily salt to prevent cross-day tracking - hash := hashVisitor(ip, userAgent, u.currentDay) + // Hash visitor with salt to prevent cross-period tracking + hash := hashVisitor(ip, userAgent, u.salt) u.hll.Insert([]byte(hash)) } @@ -53,12 +55,17 @@ func (u *UniquesEstimator) Estimate() uint64 { return u.hll.Estimate() } -// Generates a deterministic salt based on the current date -// Same day = same salt, different day = different salt -func dailySalt(t time.Time) string { - // Use UTC to ensure consistent rotation regardless of timezone - date := t.UTC().Format("2006-01-02") - h := sha256.Sum256([]byte("watchdog-salt-" + date)) +// Generates a deterministic salt based on the rotation mode +// Daily: same day = same salt, different day = different salt +// Hourly: same hour = same salt, different hour = different salt +func generateSalt(t time.Time, rotation string) string { + var key string + if rotation == "hourly" { + key = t.UTC().Format("2006-01-02T15") + } else { + key = t.UTC().Format("2006-01-02") + } + h := sha256.Sum256([]byte("watchdog-salt-" + key)) return hex.EncodeToString(h[:]) } @@ -73,12 +80,12 @@ func hashVisitor(ip, userAgent, salt string) string { func (u *UniquesEstimator) CurrentSalt() string { u.mu.Lock() defer u.mu.Unlock() - return u.currentDay + return u.salt } // Exported for testing func DailySalt(t time.Time) string { - return dailySalt(t) + return generateSalt(t, "daily") } // Save persists the HLL state to disk @@ -91,8 +98,8 @@ func (u *UniquesEstimator) Save(path string) error { return err } - // Save both HLL data and current day salt - return os.WriteFile(path, append([]byte(u.currentDay+"\n"), data...), 0600) + // Save both HLL data and current salt + return os.WriteFile(path, append([]byte(u.salt+"\n"), data...), 0600) } // Load restores the HLL state from disk @@ -115,16 +122,16 @@ func (u *UniquesEstimator) Load(path string) error { } savedSalt := string(parts[0]) - today := dailySalt(time.Now()) + currentSalt := generateSalt(time.Now(), u.rotation) - // Only restore if it's the same day - if savedSalt == today { - u.currentDay = savedSalt + // Only restore if it's the same period + if savedSalt == currentSalt { + u.salt = savedSalt return u.hll.UnmarshalBinary(parts[1]) } - // Different day, start fresh + // Different period, start fresh u.hll = hyperloglog.New() - u.currentDay = today + u.salt = currentSalt return nil } diff --git a/internal/aggregate/uniques_test.go b/internal/aggregate/uniques_test.go index a556f87..7b1f548 100644 --- a/internal/aggregate/uniques_test.go +++ b/internal/aggregate/uniques_test.go @@ -33,7 +33,7 @@ func TestDailySalt(t *testing.T) { } func TestUniquesEstimator(t *testing.T) { - estimator := NewUniquesEstimator() + estimator := NewUniquesEstimator("daily") // Initially should be zero if count := estimator.Estimate(); count != 0 { @@ -87,7 +87,7 @@ func TestUniquesEstimatorDailyRotation(t *testing.T) { } // Verify estimator uses current day's salt - estimator := NewUniquesEstimator() + estimator := NewUniquesEstimator("daily") currentSalt := estimator.CurrentSalt() expectedSalt := DailySalt(time.Now())