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 @@ + + +
+
+
+

Traffic by host

+
+

“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}} +
+
+
+
+
+

Top visitors

+
+

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) + } +}