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:
85
main.go
85
main.go
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user