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
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "html/template"
7 "log/slog"
8 "net/http"
9 "sort"
10 "strconv"
11 "strings"
12 "time"
13
14 "github.com/google/uuid"
15
16 "analytics.bythewood.me/web"
17)
18
19const (
20 dayMS = 24 * 60 * 60 * 1000
21 // Long enough to show a trend, short enough that the graph buckets daily.
22 defaultRangeDays = 28
23
24 maxRangeDays = 3650
25)
26
27// PageData is everything base.html and one page template need; the fields for
28// pages other than the one rendering are zero.
29type PageData struct {
30 Title string
31 Description string
32 Path string
33 Canonical string
34 Staging bool
35 Authenticated bool
36 Year int
37 BaseURL string
38 SourceURL string
39 SiteName string
40 AuthorName string
41
42 OGImage string
43 JSONLD template.JS
44
45 Script string
46 Styles []string
47 // The per-page Vite entry, empty on pages that only need the shared bundle.
48 PageScript string
49 PageStyles []string
50
51 // Empty CollectorID renders no self-tracking snippet.
52 CollectorID string
53 CollectorServer string
54
55 Error string
56 Next string
57
58 TotalProperties int64
59 TotalEvents int64
60 TotalUsers int
61 FirstEventAt string
62
63 Properties []PropertyRow
64 Totals PropertyTotals
65 Query string
66
67 Property *DashProperty
68 Dash *Dashboard
69 ReportedAt string
70}
71
72// DashProperty is the property identity the dashboard chrome renders.
73type DashProperty struct {
74 ID string
75 Name string
76 IsProtected bool
77 IsPublic bool
78}
79
80// Dashboard is one property's numbers over one date range.
81type Dashboard struct {
82 DateStart string
83 DateEnd string
84 DateRange int64
85 FilterURL string
86 LiveUsers int64
87
88 EventCards []EventCard
89 CustomEvents []CustomEventDescriptor
90
91 Graph []GraphPoint
92 ByScreenSize []LabelCount
93 ByDevice []LabelCount
94 ByBrowser []LabelCount
95 ByPlatform []LabelCount
96 ByPageURL []LabelCount
97 PageViewsByPageURL []LabelCount
98 ByCustomEvent []LabelCount
99 SessionsByReferrer []LabelCount
100 PageViewsByUTMMedium []LabelCount
101 PageViewsByUTMSource []LabelCount
102 PageViewsByUTMCampaig []LabelCount
103
104 SessionsByCountry map[string]int64
105 SessionsByCountryRegion map[string]map[string]int64
106
107 Bots BotTraffic
108
109 // Precomputed for the report templates, which have no charting engine and
110 // no JavaScript to compute this themselves.
111 ChartPolyline string
112 ChartLabelStart string
113 ChartLabelEnd string
114 ChartPeakCount int64
115 ChartPeakLabel string
116 BreakdownTotals BreakdownTotals
117 TopCountries []LabelCount
118}
119
120// BreakdownTotals holds each breakdown's sum, floored at one so a template can
121// divide by it unguarded.
122type BreakdownTotals struct {
123 Device int64
124 Browser int64
125 Platform int64
126 ScreenSize int64
127}
128
129// dashboard renders one property. Public properties are readable without a
130// session; everything else, including a missing property, redirects.
131func (s *site) dashboard(w http.ResponseWriter, r *http.Request, id uuid.UUID) {
132 ctx := r.Context()
133
134 p, err := lookupProperty(ctx, s.db, id)
135 if err != nil {
136 slog.Info(fmt.Sprintf("dashboard lookup: %v", err))
137 http.Error(w, "database error", http.StatusInternalServerError)
138 return
139 }
140 if p == nil {
141 http.Redirect(w, r, "/properties", http.StatusSeeOther)
142 return
143 }
144
145 authed := s.auth.Authenticated(r)
146 if !p.IsPublic && !authed {
147 http.Redirect(w, r, web.LoginURL(r), http.StatusSeeOther)
148 return
149 }
150
151 q := r.URL.Query()
152 today := time.Now()
153
154 dateStart := q.Get("date_start")
155 if dateStart == "" {
156 dateStart = today.AddDate(0, 0, -defaultRangeDays).Format("2006-01-02")
157 }
158 dateEnd := q.Get("date_end")
159 if dateEnd == "" {
160 dateEnd = today.Format("2006-01-02")
161 }
162
163 startMS, ok := parseDateToMS(dateStart, false)
164 if !ok {
165 http.Error(w, "bad date_start", http.StatusBadRequest)
166 return
167 }
168 endMS, ok := parseDateToMS(dateEnd, true)
169 if !ok {
170 http.Error(w, "bad date_end", http.StatusBadRequest)
171 return
172 }
173
174 // "custom" or absent means derive the range from the two dates.
175 var rangeDays int64
176 switch v := q.Get("date_range"); v {
177 case "", "custom":
178 rangeDays = max64((endMS-startMS)/dayMS, 1)
179 default:
180 n, err := strconv.ParseInt(v, 10, 64)
181 if err != nil || n < 1 {
182 n = defaultRangeDays
183 }
184 rangeDays = n
185 }
186
187 // Clamped outside the switch, so both arms are bounded. rangeDays sizes an
188 // allocating bucket slice that is then inlined into the page, and a public
189 // property makes this reachable with no cookie.
190 rangeDays = min64(max64(rangeDays, 1), maxRangeDays)
191
192 // The window too, so an ancient start date cannot widen the graph behind
193 // the clamp above.
194 if startMS < endMS-maxRangeDays*dayMS {
195 startMS = endMS - maxRangeDays*dayMS
196 }
197
198 prevStartMS := startMS - rangeDays*dayMS
199 prevEndMS := endMS - rangeDays*dayMS
200 filterURL := q.Get("filter_url")
201
202 // Anchor the graph to the requested end date; stepping back from today
203 // charts a historical range as zeros beside real metric cards.
204 graphEnd, err := time.ParseInLocation("2006-01-02", dateEnd, time.Local)
205 if err != nil {
206 graphEnd = today
207 }
208
209 d := &Dashboard{
210 DateStart: dateStart,
211 DateEnd: dateEnd,
212 DateRange: rangeDays,
213 FilterURL: filterURL,
214 LiveUsers: totalLiveUsers(ctx, s.db, p.ID),
215 }
216
217 d.EventCards = standardEventCards(ctx, s.db, p.ID, startMS, endMS, prevStartMS, prevEndMS, filterURL)
218 customCards, customEvents := customEventCards(ctx, s.db, p.ID, p.CustomCards, startMS, endMS, prevStartMS, prevEndMS, filterURL)
219 d.EventCards = append(d.EventCards, customCards...)
220 d.CustomEvents = customEvents
221
222 d.Graph = eventsGraph(ctx, s.db, p.ID, startMS, endMS, filterURL, graphEnd, rangeDays)
223 d.ByScreenSize = eventsByScreenSize(ctx, s.db, p.ID, startMS, endMS, filterURL, 7)
224 d.ByDevice = eventsByDevice(ctx, s.db, p.ID, startMS, endMS, filterURL, 7)
225 d.ByBrowser = eventsByBrowser(ctx, s.db, p.ID, startMS, endMS, filterURL, 7)
226 d.ByPlatform = eventsByPlatform(ctx, s.db, p.ID, startMS, endMS, filterURL, 7)
227 d.ByPageURL = eventsByPageURL(ctx, s.db, p.ID, startMS, endMS, filterURL, 10)
228 d.PageViewsByPageURL = pageViewsByPageURL(ctx, s.db, p.ID, startMS, endMS, filterURL, 10)
229 d.ByCustomEvent = eventsByCustomEvent(ctx, s.db, p.ID, startMS, endMS, filterURL, 10)
230 d.SessionsByReferrer = sessionStartsByReferrer(ctx, s.db, p.ID, startMS, endMS, filterURL, 10)
231 d.PageViewsByUTMMedium = pageViewsByUTM(ctx, s.db, p.ID, startMS, endMS, filterURL, "medium", 10)
232 d.PageViewsByUTMSource = pageViewsByUTM(ctx, s.db, p.ID, startMS, endMS, filterURL, "source", 10)
233 d.PageViewsByUTMCampaig = pageViewsByUTM(ctx, s.db, p.ID, startMS, endMS, filterURL, "campaign", 10)
234 d.SessionsByCountry = sessionStartsByCountry(ctx, s.db, p.ID, startMS, endMS, filterURL)
235 d.SessionsByCountryRegion = sessionStartsByCountryRegion(ctx, s.db, p.ID, startMS, endMS, filterURL)
236 d.Bots = botTraffic(ctx, s.db, p.ID, startMS, endMS, 10)
237
238 d.fillReportExtras()
239
240 data := s.page(r, p.Name, "Analytics for "+p.Name)
241 data.PageScript = s.propsScript
242 data.PageStyles = s.propsStyles
243 data.Property = &DashProperty{
244 ID: p.ID.String(),
245 Name: p.Name,
246 IsProtected: p.IsProtected,
247 IsPublic: p.IsPublic,
248 }
249 data.Dash = d
250 data.ReportedAt = time.Now().Format("2006-01-02 15:04")
251
252 // Report export is operator-only even for a public property: the PDF path
253 // spawns an unthrottled Typst compile.
254 if report, ok := reportFormat(q); ok {
255 if !authed {
256 http.Redirect(w, r, "/"+id.String(), http.StatusSeeOther)
257 return
258 }
259 s.renderReport(w, r, report, p.Name, data)
260 return
261 }
262
263 s.renderer.Render(w, http.StatusOK, "property.html", data)
264}
265
266// reportFormat reads ?report; a bare "?report" means pdf.
267func reportFormat(q map[string][]string) (string, bool) {
268 values, present := q["report"]
269 if !present {
270 return "", false
271 }
272 format := ""
273 if len(values) > 0 {
274 format = values[0]
275 }
276 if format == "" {
277 format = "pdf"
278 }
279 if format != "pdf" && format != "md" {
280 return "", false
281 }
282 return format, true
283}
284
285// fillReportExtras precomputes what the PDF and Markdown reports need. It runs
286// on every dashboard render, being arithmetic over data already in memory.
287func (d *Dashboard) fillReportExtras() {
288 d.ChartPolyline = chartPolyline(d.Graph)
289
290 if len(d.Graph) > 0 {
291 d.ChartLabelStart = d.Graph[0].Label
292 d.ChartLabelEnd = d.Graph[len(d.Graph)-1].Label
293 peak := d.Graph[0]
294 for _, p := range d.Graph[1:] {
295 if p.Count > peak.Count {
296 peak = p
297 }
298 }
299 d.ChartPeakCount = peak.Count
300 d.ChartPeakLabel = peak.Label
301 }
302
303 d.BreakdownTotals = BreakdownTotals{
304 Device: sumCounts(d.ByDevice),
305 Browser: sumCounts(d.ByBrowser),
306 Platform: sumCounts(d.ByPlatform),
307 ScreenSize: sumCounts(d.ByScreenSize),
308 }
309
310 // The report renders a table, which needs an order a Go map does not have.
311 countries := make([]LabelCount, 0, len(d.SessionsByCountry))
312 for code, count := range d.SessionsByCountry {
313 countries = append(countries, LabelCount{Label: code, Count: count})
314 }
315 sort.Slice(countries, func(i, j int) bool {
316 if countries[i].Count != countries[j].Count {
317 return countries[i].Count > countries[j].Count
318 }
319 // Ties break by name so the report is reproducible.
320 return countries[i].Label < countries[j].Label
321 })
322 if len(countries) > 10 {
323 countries = countries[:10]
324 }
325 d.TopCountries = countries
326}
327
328// sumCounts is a breakdown's denominator, floored at one so a template can
329// divide by it unguarded.
330func sumCounts(items []LabelCount) int64 {
331 var total int64
332 for _, i := range items {
333 total += i.Count
334 }
335 return max64(total, 1)
336}
337
338// chartPolyline renders the time series as SVG polyline points, for reports that
339// have no browser. The geometry matches the viewBox in the report templates.
340func chartPolyline(points []GraphPoint) string {
341 if len(points) == 0 {
342 return ""
343 }
344
345 const (
346 width = 600.0
347 height = 100.0
348 padding = 4.0
349 )
350 usableH := height - 2*padding
351
352 var maxCount int64 = 1
353 for _, p := range points {
354 if p.Count > maxCount {
355 maxCount = p.Count
356 }
357 }
358
359 // A single bucket has no horizontal span to divide by.
360 if len(points) == 1 {
361 y := height - padding - float64(points[0].Count)/float64(maxCount)*usableH
362 return fmt.Sprintf("%.1f,%.1f", width/2, y)
363 }
364
365 parts := make([]string, 0, len(points))
366 for i, p := range points {
367 x := float64(i) / float64(len(points)-1) * width
368 y := height - padding - float64(p.Count)/float64(maxCount)*usableH
369 parts = append(parts, fmt.Sprintf("%.1f,%.1f", x, y))
370 }
371 return strings.Join(parts, " ")
372}
373
374func max64(a, b int64) int64 {
375 if a > b {
376 return a
377 }
378 return b
379}
380
381func min64(a, b int64) int64 {
382 if a < b {
383 return a
384 }
385 return b
386}
387
388// jsonBlock marshals a value for an inline <script type="application/json">.
389// HTML escaping stays on so a value containing "</script>" cannot close the
390// block; html/template cannot work that out inside a non-JavaScript script type.
391func jsonBlock(v any) template.JS {
392 b, err := json.Marshal(v)
393 if err != nil {
394 slog.Info(fmt.Sprintf("json block: %v", err))
395 return template.JS("null")
396 }
397 return template.JS(b)
398}