mirror of
https://github.com/NotAShelf/watchdog.git
synced 2026-03-07 13:56:01 +00:00
internal/aggregate: add HyperLogLog unique visitor tracking
Extracts IP from X-Forwarded-For/X-Real-IP/RemoteAddr. Only active when `config.Site.SaltRotation` is set. Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ieef93b81e9894fc2e9e129451bf2dfdf6a6a6964
This commit is contained in:
parent
b6f2380a20
commit
993e47e603
6 changed files with 269 additions and 0 deletions
79
internal/aggregate/uniques.go
Normal file
79
internal/aggregate/uniques.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package aggregate
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axiomhq/hyperloglog"
|
||||
)
|
||||
|
||||
// UniquesEstimator tracks unique visitors using HyperLogLog with daily salt rotation
|
||||
type UniquesEstimator struct {
|
||||
hll *hyperloglog.Sketch
|
||||
currentDay string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewUniquesEstimator creates a new unique visitor estimator
|
||||
func NewUniquesEstimator() *UniquesEstimator {
|
||||
return &UniquesEstimator{
|
||||
hll: hyperloglog.New(),
|
||||
currentDay: dailySalt(time.Now()),
|
||||
}
|
||||
}
|
||||
|
||||
// Add records a visitor with privacy-preserving hashing
|
||||
// Uses IP + UserAgent + daily salt to prevent cross-day 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
|
||||
u.hll = hyperloglog.New()
|
||||
u.currentDay = today
|
||||
}
|
||||
|
||||
// Hash visitor with daily salt to prevent cross-day tracking
|
||||
hash := hashVisitor(ip, userAgent, u.currentDay)
|
||||
u.hll.Insert([]byte(hash))
|
||||
}
|
||||
|
||||
// Estimate returns the estimated number of unique visitors
|
||||
func (u *UniquesEstimator) Estimate() uint64 {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return u.hll.Estimate()
|
||||
}
|
||||
|
||||
// dailySalt 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))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// hashVisitor creates a privacy-preserving hash of visitor identity
|
||||
func hashVisitor(ip, userAgent, salt string) string {
|
||||
combined := ip + "|" + userAgent + "|" + salt
|
||||
h := sha256.Sum256([]byte(combined))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// CurrentSalt returns the current salt for testing
|
||||
func (u *UniquesEstimator) CurrentSalt() string {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return u.currentDay
|
||||
}
|
||||
|
||||
// DailySalt is exported for testing
|
||||
func DailySalt(t time.Time) string {
|
||||
return dailySalt(t)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue