More analytics details

This commit is contained in:
Jonathon Leight committed 2026-06-25 08:28:49 -04:00
1 parent cbe9b08e45
commit f80015f650
4 files changed
+178

No files matched your search

+55
View File
@@ -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 {
+34
View File
@@ -88,6 +88,40 @@
</div>
</div>
</div>
<!-- Hosts + visitors -->
<div class="row row-cards mt-1">
<div class="col-lg-5">
<div class="card">
<div class="card-header"><h3 class="card-title">Traffic by host</h3></div>
<div class="card-body">
<p class="text-secondary small">“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.</p>
{{if .Hosts}}{{range .Hosts}}{{template "value-bar" .}}{{end}}
{{else}}<div class="text-secondary">No data.</div>{{end}}
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card">
<div class="card-header"><h3 class="card-title">Top visitors</h3></div>
<div class="card-body">
<p class="text-secondary small">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.</p>
{{if .Visitors}}
{{range .Visitors}}
<div class="mb-2">
<div class="d-flex justify-content-between small">
<span class="font-monospace text-truncate" style="max-width:60%" title="{{.Visitor}}">{{.Visitor}}</span>
<span class="text-secondary">{{.Requests}}</span>
</div>
<div class="progress" style="height:6px"><div class="progress-bar" style="width:{{.W}}%"></div></div>
<div class="text-secondary small text-truncate"><span class="badge bg-secondary-lt">{{.Surface}}</span> {{.LastPath}} · {{.LastSeen}}</div>
</div>
{{end}}
{{else}}<div class="text-secondary">No data.</div>{{end}}
</div>
</div>
</div>
</div>
{{end}}
<a class="back-link mt-3" href="/admin">{{template "icon-arrow-left" "me-1"}}Back to admin</a>
{{end}}
+55
View File
@@ -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, `
+34
View File
@@ -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)
}
}