repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

3.7 KB · 123 lines · Go Raw History
  1// The read only JSON view of traffic, for chat.bythewood.me's tools.
  2//
  3// It answers the shape of question a person actually asks about their own
  4// analytics, which is how many people, from where, reading what, over some
  5// number of days. The dashboard's own cards are richer and the report PDF is
  6// richer still, and neither is worth a model reading past to find three
  7// numbers.
  8package main
  9
 10import (
 11	"context"
 12	"database/sql"
 13	"encoding/json"
 14	"net/http"
 15	"sort"
 16	"strconv"
 17	"strings"
 18	"time"
 19
 20	"github.com/google/uuid"
 21)
 22
 23type apiPropertySummary struct {
 24	ID           string       `json:"id"`
 25	Name         string       `json:"name"`
 26	Public       bool         `json:"public"`
 27	Sessions     int64        `json:"sessions"`
 28	PageViews    int64        `json:"page_views"`
 29	LiveUsers    int64        `json:"live_users"`
 30	TopPages     []LabelCount `json:"top_pages"`
 31	TopReferrers []LabelCount `json:"top_referrers"`
 32	TopCountries []LabelCount `json:"top_countries"`
 33	TopBrowsers  []LabelCount `json:"top_browsers"`
 34	TopDevices   []LabelCount `json:"top_devices"`
 35}
 36
 37func (s *site) apiSummary(w http.ResponseWriter, r *http.Request) {
 38	ctx := r.Context()
 39
 40	days := int64(7)
 41	if n, err := strconv.ParseInt(r.URL.Query().Get("days"), 10, 64); err == nil && n > 0 && n <= 365 {
 42		days = n
 43	}
 44	end := time.Now()
 45	endMS := end.UnixMilli()
 46	startMS := end.AddDate(0, 0, -int(days)).UnixMilli()
 47
 48	props, err := s.apiProperties(ctx)
 49	if err != nil {
 50		http.Error(w, "database error", http.StatusInternalServerError)
 51		return
 52	}
 53
 54	// One property can be asked for by name, since a model given every
 55	// property spends most of the answer saying which one it is talking about.
 56	want := r.URL.Query().Get("property")
 57
 58	out := make([]apiPropertySummary, 0, len(props))
 59	for _, p := range props {
 60		if want != "" && !strings.EqualFold(p.Name, want) {
 61			continue
 62		}
 63		counts := eventCounts(ctx, s.db, p.ID, startMS, endMS, "")
 64		sum := apiPropertySummary{
 65			ID: p.ID.String(), Name: p.Name, Public: p.IsPublic,
 66			Sessions:     counts.SessionStart,
 67			PageViews:    counts.PageView,
 68			LiveUsers:    totalLiveUsers(ctx, s.db, p.ID),
 69			TopPages:     pageViewsByPageURL(ctx, s.db, p.ID, startMS, endMS, "", 10),
 70			TopReferrers: sessionStartsByReferrer(ctx, s.db, p.ID, startMS, endMS, "", 10),
 71			TopBrowsers:  eventsByBrowser(ctx, s.db, p.ID, startMS, endMS, "", 6),
 72			TopDevices:   eventsByDevice(ctx, s.db, p.ID, startMS, endMS, "", 6),
 73			TopCountries: topCountries(ctx, s.db, p.ID, startMS, endMS, 10),
 74		}
 75		out = append(out, sum)
 76	}
 77
 78	w.Header().Set("Content-Type", "application/json; charset=utf-8")
 79	w.Header().Set("Cache-Control", "no-store")
 80	_ = json.NewEncoder(w).Encode(map[string]any{
 81		"days":       days,
 82		"properties": out,
 83	})
 84}
 85
 86func (s *site) apiProperties(ctx context.Context) ([]*Property, error) {
 87	rows, err := s.db.QueryContext(ctx,
 88		"SELECT "+propertyColumns+" FROM properties ORDER BY is_protected DESC, created_at ASC")
 89	if err != nil {
 90		return nil, err
 91	}
 92	defer rows.Close()
 93	var out []*Property
 94	for rows.Next() {
 95		p, err := scanProperty(rows.Scan)
 96		if err != nil {
 97			return nil, err
 98		}
 99		out = append(out, p)
100	}
101	return out, rows.Err()
102}
103
104// topCountries flattens the map the dashboard uses into the ordered list every
105// other breakdown here already returns.
106func topCountries(ctx context.Context, db *sql.DB, id uuid.UUID, startMS, endMS int64, limit int) []LabelCount {
107	counts := sessionStartsByCountry(ctx, db, id, startMS, endMS, "")
108	out := make([]LabelCount, 0, len(counts))
109	for label, n := range counts {
110		out = append(out, LabelCount{Label: label, Count: n})
111	}
112	sort.Slice(out, func(i, j int) bool {
113		if out[i].Count != out[j].Count {
114			return out[i].Count > out[j].Count
115		}
116		return out[i].Label < out[j].Label
117	})
118	if len(out) > limit {
119		out = out[:limit]
120	}
121	return out
122}