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).
This commit is contained in:
prosolis
2026-06-22 01:54:34 -07:00
parent aaf6e551a0
commit e65ffb1373
8 changed files with 367 additions and 3 deletions

85
main.go
View File

@@ -3,9 +3,12 @@ package main
import (
"context"
"flag"
"fmt"
"html/template"
"log/slog"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
@@ -98,15 +101,28 @@ func main() {
}
}
// Wire !post command: force-publish next queued story for the room's channel.
// Wire in-room commands. All are gated to the admin allowlist; the room
// rooms are public so anything not from an admin is silently ignored.
mx.SetMessageHandler(func(channel string, roomID id.RoomID, eventID id.EventID, sender id.UserID, body string) {
if strings.TrimSpace(body) != "!post" {
cmd := strings.TrimSpace(body)
if cmd != "!post" && cmd != "!petestats" {
return
}
if !admins[sender] {
slog.Warn("!post ignored: sender not in matrix.admins allowlist", "channel", channel, "sender", sender)
slog.Warn("command ignored: sender not in matrix.admins allowlist", "cmd", cmd, "channel", channel, "sender", sender)
return
}
if cmd == "!petestats" {
slog.Info("!petestats requested", "channel", channel, "sender", sender)
plain, htmlBody := formatStats(storage.GetMetricsSummary())
if err := mx.PostThreadedReply(channel, eventID, plain, htmlBody); err != nil {
slog.Warn("!petestats: failed to send reply", "err", err)
}
return
}
// !post: force-publish the next queued story for the room's channel.
slog.Info("!post requested", "channel", channel, "sender", sender)
if queue.ForcePost(channel) {
return
@@ -236,6 +252,69 @@ func main() {
slog.Info("pete stopped")
}
// formatStats renders a usage summary for the !petestats reply, returning a
// plain-text and an HTML body. Daily uniques are reported per-day only: the
// privacy salt rotates each day, so they cannot be honestly summed across days.
func formatStats(m storage.MetricsSummary) (plain, htmlBody string) {
const topN = 8
var p, h strings.Builder
p.WriteString("📊 Pete usage\n")
h.WriteString("📊 <b>Pete usage</b><br>")
fmt.Fprintf(&p, "Today: %s views · ~%s unique visitors\n",
comma(m.ViewsToday), comma(m.UniquesToday))
fmt.Fprintf(&h, "Today: <b>%s</b> views · ~<b>%s</b> unique visitors<br>",
comma(m.ViewsToday), comma(m.UniquesToday))
fmt.Fprintf(&p, "All time: %s views\n", comma(m.TotalViews))
fmt.Fprintf(&h, "All time: <b>%s</b> views<br>", comma(m.TotalViews))
if len(m.Pages) > 0 {
pages := m.Pages
if len(pages) > topN {
pages = pages[:topN]
}
parts := make([]string, 0, len(pages))
for _, pg := range pages {
parts = append(parts, fmt.Sprintf("%s %s", pg.Path, comma(pg.Total)))
}
fmt.Fprintf(&p, "Top pages (all time): %s\n", strings.Join(parts, " · "))
fmt.Fprintf(&h, "Top pages (all time): %s<br>",
template.HTMLEscapeString(strings.Join(parts, " · ")))
}
if len(m.Last7Days) > 0 {
parts := make([]string, 0, len(m.Last7Days))
for _, d := range m.Last7Days {
label := time.Unix(d.Day*86400, 0).UTC().Format("Mon")
parts = append(parts, fmt.Sprintf("%s %s", label, comma(d.Uniques)))
}
fmt.Fprintf(&p, "Daily uniques (7d): %s", strings.Join(parts, " · "))
fmt.Fprintf(&h, "Daily uniques (7d): %s",
template.HTMLEscapeString(strings.Join(parts, " · ")))
}
return p.String(), h.String()
}
// comma formats a non-negative int with thousands separators (e.g. 12503 →
// "12,503").
func comma(n int) string {
s := strconv.Itoa(n)
if n < 0 {
return s // not expected for counts; leave untouched
}
var b strings.Builder
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteRune(c)
}
return b.String()
}
func runLocal(cfg *config.Config) {
cfg.Web.Enabled = true