diff --git a/internal/core/analytics.go b/internal/core/analytics.go
index 0a2c29d..947931e 100644
--- a/internal/core/analytics.go
+++ b/internal/core/analytics.go
@@ -48,6 +48,16 @@ func (s *Handlers) pageAnalytics(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load analytics", http.StatusInternalServerError)
return
}
+ hosts, err := s.Store.AnalyticsTopHosts(r.Context(), days, 15)
+ if err != nil {
+ http.Error(w, "could not load analytics", http.StatusInternalServerError)
+ return
+ }
+ visitors, err := s.Store.AnalyticsTopVisitors(r.Context(), days, 15)
+ if err != nil {
+ http.Error(w, "could not load analytics", http.StatusInternalServerError)
+ return
+ }
var maxReq, maxVis, totalReq int64
for _, d := range daily {
@@ -98,11 +108,46 @@ func (s *Handlers) pageAnalytics(w http.ResponseWriter, r *http.Request) {
pathRows = append(pathRows, analyticsRow{Label: p.Path, Value: p.Hits, W: barPct(p.Hits, maxHits)})
}
+ var maxHostReq int64
+ for _, h := range hosts {
+ if h.Requests > maxHostReq {
+ maxHostReq = h.Requests
+ }
+ }
+ hostRows := make([]analyticsRow, 0, len(hosts))
+ for _, h := range hosts {
+ label := h.Host
+ if label == "" {
+ label = "(none)"
+ }
+ hostRows = append(hostRows, analyticsRow{Label: label, Value: h.Requests, W: barPct(h.Requests, maxHostReq)})
+ }
+
+ var maxVisReq int64
+ for _, v := range visitors {
+ if v.Requests > maxVisReq {
+ maxVisReq = v.Requests
+ }
+ }
+ visitorRows := make([]visitorRow, 0, len(visitors))
+ for _, v := range visitors {
+ visitorRows = append(visitorRows, visitorRow{
+ Visitor: v.Visitor,
+ Surface: v.Surface,
+ LastPath: v.LastPath,
+ LastSeen: v.LastSeen.Format("Jan 2 15:04"),
+ Requests: v.Requests,
+ W: barPct(v.Requests, maxVisReq),
+ })
+ }
+
s.Render(w, r, "analytics.html", map[string]any{
"Days": days,
"Bars": bars,
"Surfaces": surfaceRows,
"Paths": pathRows,
+ "Hosts": hostRows,
+ "Visitors": visitorRows,
"TotalReq": totalReq,
"TodayReq": todayReq,
"TodayVis": todayVis,
@@ -110,6 +155,16 @@ func (s *Handlers) pageAnalytics(w http.ResponseWriter, r *http.Request) {
})
}
+// visitorRow is one (daily-rotating) visitor hash for the "traffic by user" table.
+type visitorRow struct {
+ Visitor string
+ Surface string
+ LastPath string
+ LastSeen string
+ Requests int64
+ W int
+}
+
// barPct scales v to a 0–100 height/width, with a small floor so non-zero values
// stay visible.
func barPct(v, max int64) int {
diff --git a/internal/core/templates/analytics.html b/internal/core/templates/analytics.html
index 589c065..7f2bdbc 100644
--- a/internal/core/templates/analytics.html
+++ b/internal/core/templates/analytics.html
@@ -88,6 +88,40 @@
+
+
+
+
+
+
+
+
“custom” is any request whose host isn't your configured app/auth/root host — e.g. the bare IP, a health probe, or a crawler. These are the actual hosts seen.
+ {{if .Hosts}}{{range .Hosts}}{{template "value-bar" .}}{{end}}
+ {{else}}
No data.
{{end}}
+
+
+
+
+
+
+
+
Anonymous, daily-rotating IDs (a salted hash of IP + browser). The same person gets a fresh ID each day, so a heavy daily visitor shows once per day.
+ {{if .Visitors}}
+ {{range .Visitors}}
+
+
+ {{.Visitor}}
+ {{.Requests}}
+
+
+
{{.Surface}} {{.LastPath}} · {{.LastSeen}}
+
+ {{end}}
+ {{else}}
No data.
{{end}}
+
+
+
+
{{end}}
{{template "icon-arrow-left" "me-1"}}Back to admin
{{end}}
diff --git a/internal/store/analytics.go b/internal/store/analytics.go
index d510aae..ccceefe 100644
--- a/internal/store/analytics.go
+++ b/internal/store/analytics.go
@@ -98,6 +98,61 @@ type PathStat struct {
Hits int64
}
+// HostStat is request volume for one request host over a window. Useful for
+// seeing which hosts fall into the "custom" surface (anything not matching the
+// configured app/auth/root hosts — e.g. the bare IP, a probe, or a crawler).
+type HostStat struct {
+ Host string
+ Requests int64
+}
+
+// VisitorStat is request volume for one (daily-rotating) visitor hash, plus the
+// surface and last path it was seen on, for a "traffic by user" view.
+type VisitorStat struct {
+ Visitor string
+ Surface string
+ LastSeen time.Time
+ LastPath string
+ Requests int64
+}
+
+// AnalyticsTopHosts returns the busiest request hosts over the last `days` days,
+// read live from the raw events so the breakdown reflects current traffic.
+func (s *Store) AnalyticsTopHosts(ctx context.Context, days, limit int) ([]HostStat, error) {
+ rows, err := s.pool.Query(ctx, `
+ SELECT host, count(*) FROM analytics_events
+ WHERE ts >= now() - ($1::int * interval '1 day')
+ GROUP BY host ORDER BY count(*) DESC LIMIT $2`, days, limit)
+ if err != nil {
+ return nil, fmt.Errorf("analytics top hosts: %w", err)
+ }
+ return collectRows(rows, func(r pgx.Row) (HostStat, error) {
+ var h HostStat
+ return h, r.Scan(&h.Host, &h.Requests)
+ })
+}
+
+// AnalyticsTopVisitors returns the busiest visitor hashes over the last `days`
+// days, with the surface and most-recent path each was seen on. Hashes rotate
+// daily, so a heavy daily user appears once per day, not once overall.
+func (s *Store) AnalyticsTopVisitors(ctx context.Context, days, limit int) ([]VisitorStat, error) {
+ rows, err := s.pool.Query(ctx, `
+ SELECT e.visitor, count(*) AS n,
+ (array_agg(e.surface ORDER BY e.ts DESC))[1] AS surface,
+ max(e.ts) AS last_seen,
+ (array_agg(e.path ORDER BY e.ts DESC))[1] AS last_path
+ FROM analytics_events e
+ WHERE e.ts >= now() - ($1::int * interval '1 day')
+ GROUP BY e.visitor ORDER BY n DESC LIMIT $2`, days, limit)
+ if err != nil {
+ return nil, fmt.Errorf("analytics top visitors: %w", err)
+ }
+ return collectRows(rows, func(r pgx.Row) (VisitorStat, error) {
+ var v VisitorStat
+ return v, r.Scan(&v.Visitor, &v.Requests, &v.Surface, &v.LastSeen, &v.LastPath)
+ })
+}
+
// AnalyticsDaily returns the last `days` days of totals, oldest first.
func (s *Store) AnalyticsDaily(ctx context.Context, days int) ([]DayStat, error) {
rows, err := s.pool.Query(ctx, `
diff --git a/internal/store/analytics_test.go b/internal/store/analytics_test.go
index 0a39de4..f6cae55 100644
--- a/internal/store/analytics_test.go
+++ b/internal/store/analytics_test.go
@@ -59,3 +59,37 @@ func TestAnalyticsRollup(t *testing.T) {
t.Fatalf("after re-rollup daily = %+v, want unchanged", daily)
}
}
+
+func TestAnalyticsHostsAndVisitors(t *testing.T) {
+ t.Parallel()
+ st, ctx := orgTestStore(t)
+ now := time.Now()
+
+ evs := []AnalyticsEvent{
+ {Ts: now.Add(-time.Minute), Surface: "custom", Host: "1.2.3.4", Path: "/", Method: "GET", Status: 200, Visitor: "bot"},
+ {Ts: now, Surface: "custom", Host: "1.2.3.4", Path: "/robots.txt", Method: "GET", Status: 200, Visitor: "bot"},
+ {Ts: now, Surface: "app", Host: "app.x", Path: "/dash", Method: "GET", Status: 200, Visitor: "alice"},
+ }
+ if err := st.InsertAnalyticsEvents(ctx, evs); err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+
+ hosts, err := st.AnalyticsTopHosts(ctx, 2, 10)
+ if err != nil {
+ t.Fatalf("hosts: %v", err)
+ }
+ if len(hosts) != 2 || hosts[0].Host != "1.2.3.4" || hosts[0].Requests != 2 {
+ t.Fatalf("top hosts = %+v, want 1.2.3.4=2 first", hosts)
+ }
+
+ vis, err := st.AnalyticsTopVisitors(ctx, 2, 10)
+ if err != nil {
+ t.Fatalf("visitors: %v", err)
+ }
+ if len(vis) != 2 || vis[0].Visitor != "bot" || vis[0].Requests != 2 {
+ t.Fatalf("top visitors = %+v, want bot=2 first", vis)
+ }
+ if vis[0].Surface != "custom" || vis[0].LastPath != "/robots.txt" {
+ t.Fatalf("bot visitor surface/last-path = %q/%q, want custom//robots.txt", vis[0].Surface, vis[0].LastPath)
+ }
+}