Files
Pete/internal/storage/metrics.go
prosolis e65ffb1373 Add server-side web usage metrics with !petestats command
Track per-page/per-channel view counts and a privacy-preserving daily
unique-visitor estimate (salted IP+UA hash, salt rotated daily and never
persisted). No third-party analytics, no JS beacon. Surfaced via the
admin-gated !petestats Matrix command (named to avoid an existing !stats
bot in the rooms).
2026-06-22 01:54:34 -07:00

115 lines
3.4 KiB
Go

package storage
import (
"log/slog"
"sort"
)
const secondsPerDay = 86400
// unixDay returns the current UTC day number (floor(unix / 86400)).
func unixDay() int64 { return nowUnix() / secondsPerDay }
// RecordPageView increments the all-time and per-day view counter for a coarse
// path label ("home", a channel slug, …). Fire-and-forget: a failure here must
// never affect serving a page, so errors are logged and swallowed.
func RecordPageView(path string) {
exec("record page view",
`INSERT INTO page_views (path, day, views) VALUES (?, ?, 1)
ON CONFLICT(path, day) DO UPDATE SET views = views + 1`,
path, unixDay())
}
// RecordVisitor records a (already-hashed, salted) visitor token for today's
// unique estimate. Duplicate tokens within the same day are ignored.
func RecordVisitor(token string) {
exec("record visitor",
`INSERT OR IGNORE INTO daily_visitors (day, visitor) VALUES (?, ?)`,
unixDay(), token)
}
// PathStat is one row of the per-page usage breakdown.
type PathStat struct {
Path string
Total int // all-time views
Today int // views since the start of the current UTC day
}
// DayStat is one day's unique-visitor estimate.
type DayStat struct {
Day int64 // unix day number
Uniques int
}
// MetricsSummary is the aggregate usage readout surfaced by !petestats.
type MetricsSummary struct {
Pages []PathStat // per-path, busiest first
TotalViews int // all-time, all paths
ViewsToday int // all paths, current UTC day
UniquesToday int // distinct visitor tokens, current UTC day
Last7Days []DayStat // daily uniques, oldest → newest (note: salt rotates
// daily, so these CANNOT be summed into a cross-day unique count)
}
// GetMetricsSummary assembles the usage readout. Best-effort: any sub-query
// failure leaves that field at its zero value rather than failing the whole
// command.
func GetMetricsSummary() MetricsSummary {
today := unixDay()
var m MetricsSummary
rows, err := Get().Query(
`SELECT path,
SUM(views) AS total,
COALESCE(SUM(CASE WHEN day = ? THEN views END), 0) AS today
FROM page_views
GROUP BY path`, today)
if err != nil {
slog.Error("metrics: page_views query failed", "err", err)
} else {
defer rows.Close()
for rows.Next() {
var p PathStat
if err := rows.Scan(&p.Path, &p.Total, &p.Today); err != nil {
slog.Error("metrics: scan page stat failed", "err", err)
continue
}
m.Pages = append(m.Pages, p)
m.TotalViews += p.Total
m.ViewsToday += p.Today
}
}
sort.Slice(m.Pages, func(i, j int) bool {
if m.Pages[i].Total != m.Pages[j].Total {
return m.Pages[i].Total > m.Pages[j].Total
}
return m.Pages[i].Path < m.Pages[j].Path
})
if err := Get().QueryRow(
`SELECT COUNT(*) FROM daily_visitors WHERE day = ?`, today,
).Scan(&m.UniquesToday); err != nil {
slog.Error("metrics: uniques-today query failed", "err", err)
}
since := today - 6 // inclusive 7-day window
vrows, err := Get().Query(
`SELECT day, COUNT(*) FROM daily_visitors
WHERE day >= ? GROUP BY day ORDER BY day`, since)
if err != nil {
slog.Error("metrics: 7-day uniques query failed", "err", err)
} else {
defer vrows.Close()
for vrows.Next() {
var d DayStat
if err := vrows.Scan(&d.Day, &d.Uniques); err != nil {
slog.Error("metrics: scan day stat failed", "err", err)
continue
}
m.Last7Days = append(m.Last7Days, d)
}
}
return m
}