mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-23 21:43:06 +00:00
More analytics details
This commit is contained in:
1 parent
cbe9b08e45
commit
f80015f650
4 files changed
+178
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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, `
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user