orchard
mirrorEvery 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
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}