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

21.9 KB · 658 lines · Go Raw History
  1package main
  2
  3// Every aggregation here logs its database error and returns a zero value, so
  4// one failing panel leaves a blank panel rather than a 500 page. Time arithmetic
  5// is unix milliseconds throughout, matching events.created_at.
  6
  7import (
  8	"context"
  9	"database/sql"
 10	"fmt"
 11	"log/slog"
 12	"math"
 13	"sort"
 14	"strconv"
 15	"strings"
 16	"time"
 17
 18	"github.com/google/uuid"
 19)
 20
 21// The events the collector emits on its own; everything else is a custom event.
 22var builtInEvents = []string{"session_start", "page_view", "page_leave", "click", "scroll"}
 23
 24// Page-leave timings outside this band are a double fire or a tab left open.
 25const (
 26	timeOnPageMinS = 1.0
 27	timeOnPageMaxS = 30.0 * 60.0
 28)
 29
 30// LabelCount is one row of any "top N by something" breakdown.
 31type LabelCount struct {
 32	Label string `json:"label"`
 33	Count int64  `json:"count"`
 34}
 35
 36// GraphPoint is one bucket of the time series. Label is preformatted because
 37// only the query knows which bucket width it produced.
 38type GraphPoint struct {
 39	Label string `json:"label"`
 40	Count int64  `json:"count"`
 41}
 42
 43// EventCard is one metric tile. Value is a string because two tiles carry their
 44// units in it.
 45type EventCard struct {
 46	Name          string `json:"name"`
 47	Value         string `json:"value"`
 48	PercentChange int64  `json:"percent_change"`
 49	HelpText      string `json:"help_text,omitempty"`
 50}
 51
 52// CustomEventDescriptor is one row of the "which custom events exist, and
 53// which are pinned" list behind the dashboard's card picker.
 54type CustomEventDescriptor struct {
 55	Event  string `json:"event"`
 56	Active bool   `json:"active"`
 57}
 58
 59// BotTraffic is the bot panel, read from the separate bot_events table.
 60type BotTraffic struct {
 61	Total    int64        `json:"total"`
 62	TopBots  []LabelCount `json:"top_bots"`
 63	TopPages []LabelCount `json:"top_pages"`
 64}
 65
 66// EventCounts is the five headline counts, gathered in one pass.
 67type EventCounts struct {
 68	SessionStart int64
 69	PageView     int64
 70	Click        int64
 71	Scroll       int64
 72	Total        int64
 73}
 74
 75// pctChange is the period-over-period delta on each card. A zero previous
 76// returns zero, not infinity, which would put "+100%" on every first-week card.
 77func pctChange(current, previous float64) int64 {
 78	if previous == 0 {
 79		return 0
 80	}
 81	return int64(math.Round((current - previous) / previous * 100))
 82}
 83
 84// filterClause returns SQL to append and the argument to bind with it, so a
 85// visitor-supplied URL is never interpolated.
 86func filterClause(filterURL string) (string, []any) {
 87	if filterURL == "" {
 88		return "", nil
 89	}
 90	return " AND url = ?", []any{filterURL}
 91}
 92
 93// placeholders renders "?,?,?" for an IN clause of n items.
 94func placeholders(n int) string {
 95	return strings.TrimSuffix(strings.Repeat("?,", n), ",")
 96}
 97
 98func logQuery(what string, err error) {
 99	if err != nil && err != sql.ErrNoRows {
100		slog.Info(fmt.Sprintf("query %s: %v", what, err))
101	}
102}
103
104// totalLiveUsers counts distinct visitors seen in the last thirty minutes. It
105// ignores the dashboard's date range, since "live" means now.
106func totalLiveUsers(ctx context.Context, db *sql.DB, propertyID uuid.UUID) int64 {
107	cutoff := time.Now().Add(-30 * time.Minute).UnixMilli()
108	var n int64
109	err := db.QueryRowContext(ctx,
110		`SELECT COUNT(DISTINCT user_id) FROM events
111		 WHERE property_id = ? AND created_at >= ? AND user_id IS NOT NULL`,
112		propertyID[:], cutoff).Scan(&n)
113	logQuery("total_live_users", err)
114	return n
115}
116
117func eventCounts(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS int64, filterURL string) EventCounts {
118	extraSQL, extraArgs := filterClause(filterURL)
119	query := `SELECT
120	    SUM(CASE WHEN event = 'session_start' THEN 1 ELSE 0 END),
121	    SUM(CASE WHEN event = 'page_view'     THEN 1 ELSE 0 END),
122	    SUM(CASE WHEN event = 'click'         THEN 1 ELSE 0 END),
123	    SUM(CASE WHEN event = 'scroll'        THEN 1 ELSE 0 END),
124	    COUNT(*)
125	  FROM events
126	  WHERE property_id = ? AND created_at >= ? AND created_at <= ?` + extraSQL
127
128	args := append([]any{propertyID[:], startMS, endMS}, extraArgs...)
129
130	// SUM over no rows is NULL, not 0, so these scan through nullables.
131	var ss, pv, cl, sc sql.NullInt64
132	var total int64
133	err := db.QueryRowContext(ctx, query, args...).Scan(&ss, &pv, &cl, &sc, &total)
134	logQuery("event_counts", err)
135	return EventCounts{
136		SessionStart: ss.Int64,
137		PageView:     pv.Int64,
138		Click:        cl.Int64,
139		Scroll:       sc.Int64,
140		Total:        total,
141	}
142}
143
144// engagedUsers is the share of visitors with more than ten events, as a
145// percentage of session starts.
146func engagedUsers(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS int64, filterURL string, sessionStarts int64) float64 {
147	if sessionStarts == 0 {
148		return 0
149	}
150	extraSQL, extraArgs := filterClause(filterURL)
151	query := `SELECT COUNT(*) FROM (
152	    SELECT user_id FROM events
153	    WHERE property_id = ? AND created_at >= ? AND created_at <= ?
154	          AND user_id IS NOT NULL` + extraSQL + `
155	    GROUP BY user_id HAVING COUNT(*) >= 10
156	  )`
157	args := append([]any{propertyID[:], startMS, endMS}, extraArgs...)
158
159	var engaged int64
160	err := db.QueryRowContext(ctx, query, args...).Scan(&engaged)
161	logQuery("engaged_users", err)
162	return math.Round(float64(engaged)/float64(sessionStarts)*100*100) / 100
163}
164
165func avgTimeOnPage(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS int64, filterURL string) float64 {
166	extraSQL, extraArgs := filterClause(filterURL)
167	query := `SELECT AVG(time_on_page_ms / 1000.0) FROM events
168	  WHERE property_id = ? AND created_at >= ? AND created_at <= ?
169	        AND event = 'page_leave'
170	        AND time_on_page_ms IS NOT NULL
171	        AND time_on_page_ms / 1000.0 BETWEEN ? AND ?` + extraSQL
172	args := append([]any{propertyID[:], startMS, endMS, timeOnPageMinS, timeOnPageMaxS}, extraArgs...)
173
174	var avg sql.NullFloat64
175	err := db.QueryRowContext(ctx, query, args...).Scan(&avg)
176	logQuery("avg_time_on_page", err)
177	return math.Round(avg.Float64*100) / 100
178}
179
180// standardEventCards builds the seven tiles every property gets, each with its
181// change against the immediately preceding period of the same length.
182func standardEventCards(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS, prevStartMS, prevEndMS int64, filterURL string) []EventCard {
183	cur := eventCounts(ctx, db, propertyID, startMS, endMS, filterURL)
184	prev := eventCounts(ctx, db, propertyID, prevStartMS, prevEndMS, filterURL)
185
186	cards := []EventCard{
187		{
188			Name:          "Total session starts",
189			Value:         fmt.Sprintf("%d", cur.SessionStart),
190			PercentChange: pctChange(float64(cur.SessionStart), float64(prev.SessionStart)),
191			HelpText:      "Unique users visiting your site for your selected date range.",
192		},
193		{
194			Name:          "Total page views",
195			Value:         fmt.Sprintf("%d", cur.PageView),
196			PercentChange: pctChange(float64(cur.PageView), float64(prev.PageView)),
197			HelpText:      "Total pages viewed for your selected date range.",
198		},
199		{
200			Name:          "Total clicks",
201			Value:         fmt.Sprintf("%d", cur.Click),
202			PercentChange: pctChange(float64(cur.Click), float64(prev.Click)),
203			HelpText:      "Total clicks users made on all your pages for your selected date range.",
204		},
205		{
206			Name:          "Total scrolls",
207			Value:         fmt.Sprintf("%d", cur.Scroll),
208			PercentChange: pctChange(float64(cur.Scroll), float64(prev.Scroll)),
209			HelpText:      "Total scrolls users made on all your pages for your selected date range.",
210		},
211		{
212			Name:          "Total events",
213			Value:         fmt.Sprintf("%d", cur.Total),
214			PercentChange: pctChange(float64(cur.Total), float64(prev.Total)),
215			HelpText:      "All events for your selected date range, including custom events.",
216		},
217	}
218
219	engCur := engagedUsers(ctx, db, propertyID, startMS, endMS, filterURL, cur.SessionStart)
220	engPrev := engagedUsers(ctx, db, propertyID, prevStartMS, prevEndMS, filterURL, prev.SessionStart)
221	cards = append(cards, EventCard{
222		Name:          "Total user engagement",
223		Value:         trimFloat(engCur) + "%",
224		PercentChange: pctChange(engCur, engPrev),
225		HelpText:      "An engaged user is a user with more than 10 events collected for your selected date range.",
226	})
227
228	tCur := avgTimeOnPage(ctx, db, propertyID, startMS, endMS, filterURL)
229	tPrev := avgTimeOnPage(ctx, db, propertyID, prevStartMS, prevEndMS, filterURL)
230	cards = append(cards, EventCard{
231		Name:          "Avg. time on page",
232		Value:         trimFloat(tCur) + "s",
233		PercentChange: pctChange(tCur, tPrev),
234		HelpText:      "Average time a user spends on each page. Sessions over 30 minutes are excluded as idle.",
235	})
236
237	return cards
238}
239
240// trimFloat uses 'f' with precision -1 rather than %v, which is %g underneath
241// and flips to scientific notation at the top of the range.
242func trimFloat(v float64) string {
243	return strconv.FormatFloat(v, 'f', -1, 64)
244}
245
246// customEventCards returns the pinned custom-event tiles and, separately,
247// every custom event this property has ever recorded so the picker can list
248// them.
249func customEventCards(ctx context.Context, db *sql.DB, propertyID uuid.UUID, cards []CustomCard, startMS, endMS, prevStartMS, prevEndMS int64, filterURL string) ([]EventCard, []CustomEventDescriptor) {
250	// Unbounded by the date range, so the picker still lists a stale event.
251	query := `SELECT DISTINCT event FROM events
252	  WHERE property_id = ? AND event NOT IN (` + placeholders(len(builtInEvents)) + `)
253	  ORDER BY event`
254	args := []any{propertyID[:]}
255	for _, b := range builtInEvents {
256		args = append(args, b)
257	}
258
259	var names []string
260	rows, err := db.QueryContext(ctx, query, args...)
261	logQuery("custom_event_names", err)
262	if err == nil {
263		for rows.Next() {
264			var n string
265			if rows.Scan(&n) == nil {
266				names = append(names, n)
267			}
268		}
269		rows.Close()
270	}
271
272	active := make(map[string]bool, len(cards))
273	for _, c := range cards {
274		if c.Value {
275			active[c.Event] = true
276		}
277	}
278
279	descriptors := make([]CustomEventDescriptor, 0, len(names))
280	for _, n := range names {
281		descriptors = append(descriptors, CustomEventDescriptor{Event: n, Active: active[n]})
282	}
283
284	if len(active) == 0 {
285		return nil, descriptors
286	}
287
288	// Iterate names, not the map, so the tile order is the picker's rather
289	// than Go's randomised one.
290	activeNames := make([]string, 0, len(active))
291	for _, n := range names {
292		if active[n] {
293			activeNames = append(activeNames, n)
294		}
295	}
296	if len(activeNames) == 0 {
297		return nil, descriptors
298	}
299
300	countFor := func(periodStart, periodEnd int64) map[string]int64 {
301		extraSQL, extraArgs := filterClause(filterURL)
302		q := `SELECT event, COUNT(*) FROM events
303		  WHERE property_id = ? AND created_at >= ? AND created_at <= ?
304		        AND event IN (` + placeholders(len(activeNames)) + `)` + extraSQL + `
305		  GROUP BY event`
306		a := []any{propertyID[:], periodStart, periodEnd}
307		for _, n := range activeNames {
308			a = append(a, n)
309		}
310		a = append(a, extraArgs...)
311
312		out := make(map[string]int64, len(activeNames))
313		r, err := db.QueryContext(ctx, q, a...)
314		logQuery("custom_event_counts", err)
315		if err != nil {
316			return out
317		}
318		defer r.Close()
319		for r.Next() {
320			var name string
321			var c int64
322			if r.Scan(&name, &c) == nil {
323				out[name] = c
324			}
325		}
326		return out
327	}
328
329	curMap := countFor(startMS, endMS)
330	prevMap := countFor(prevStartMS, prevEndMS)
331
332	out := make([]EventCard, 0, len(activeNames))
333	for _, name := range activeNames {
334		v := curMap[name]
335		p := prevMap[name]
336		out = append(out, EventCard{
337			Name:          name,
338			Value:         fmt.Sprintf("%d", v),
339			PercentChange: pctChange(float64(v), float64(p)),
340		})
341	}
342	return out, descriptors
343}
344
345// eventsGraph is the time series, bucketed by day, week or month and stepping
346// back from endDate. Stepping back from today instead would chart a historical
347// range as a row of zeros beside real metric cards.
348func eventsGraph(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS int64, filterURL string, endDate time.Time, rangeDays int64) []GraphPoint {
349	extraSQL, extraArgs := filterClause(filterURL)
350	query := `SELECT date(created_at / 1000, 'unixepoch') AS day, COUNT(*)
351	  FROM events
352	  WHERE property_id = ? AND created_at >= ? AND created_at <= ?` + extraSQL + `
353	  GROUP BY day`
354	args := append([]any{propertyID[:], startMS, endMS}, extraArgs...)
355
356	byDay := map[string]int64{}
357	rows, err := db.QueryContext(ctx, query, args...)
358	logQuery("events_graph", err)
359	if err == nil {
360		for rows.Next() {
361			var day string
362			var c int64
363			if rows.Scan(&day, &c) == nil {
364				byDay[day] = c
365			}
366		}
367		rows.Close()
368	}
369
370	key := func(t time.Time) string { return t.Format("2006-01-02") }
371	bucketSum := func(start time.Time, days int) int64 {
372		var sum int64
373		for j := 0; j < days; j++ {
374			sum += byDay[key(start.AddDate(0, 0, j))]
375		}
376		return sum
377	}
378
379	type point struct {
380		date  time.Time
381		count int64
382	}
383	var points []point
384
385	switch {
386	case rangeDays <= 28:
387		for i := int64(0); i < rangeDays; i++ {
388			d := endDate.AddDate(0, 0, -int(i))
389			points = append(points, point{d, byDay[key(d)]})
390		}
391	case rangeDays <= 6*28:
392		weeks := rangeDays / 7
393		for w := int64(0); w < weeks; w++ {
394			d := endDate.AddDate(0, 0, -int(7*w))
395			points = append(points, point{d, bucketSum(d, 7)})
396		}
397	default:
398		months := rangeDays / 28
399		for m := int64(0); m < months; m++ {
400			d := endDate.AddDate(0, 0, -int(28*m))
401			points = append(points, point{d, bucketSum(d, 28)})
402		}
403	}
404
405	sort.Slice(points, func(i, j int) bool { return points[i].date.Before(points[j].date) })
406
407	out := make([]GraphPoint, 0, len(points))
408	for _, p := range points {
409		out = append(out, GraphPoint{Label: formatGraphLabel(p.date), Count: p.count})
410	}
411	return out
412}
413
414// formatGraphLabel renders "Jan 5"; Go has no unpadded day verb, so the padded
415// form is trimmed.
416func formatGraphLabel(t time.Time) string {
417	return t.Format("Jan") + " " + strings.TrimPrefix(t.Format("02"), "0")
418}
419
420// topByColumn groups by one column and takes the top N. column and countExpr are
421// interpolated into the SQL, which is safe only while every caller passes a
422// literal; never pass one from a request.
423func topByColumn(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS int64, filterURL, column, event string, limit int64, distinctUsers bool) []LabelCount {
424	countExpr := "COUNT(*)"
425	if distinctUsers {
426		countExpr = "COUNT(DISTINCT user_id)"
427	}
428
429	var sb strings.Builder
430	fmt.Fprintf(&sb, `SELECT %s, %s FROM events
431	  WHERE property_id = ? AND created_at >= ? AND created_at <= ?
432	        AND %s IS NOT NULL AND %s != ''`, column, countExpr, column, column)
433
434	args := []any{propertyID[:], startMS, endMS}
435	if distinctUsers {
436		sb.WriteString(" AND user_id IS NOT NULL")
437	}
438	if event != "" {
439		sb.WriteString(" AND event = ?")
440		args = append(args, event)
441	}
442	extraSQL, extraArgs := filterClause(filterURL)
443	sb.WriteString(extraSQL)
444	args = append(args, extraArgs...)
445	fmt.Fprintf(&sb, " GROUP BY %s ORDER BY %s DESC LIMIT ?", column, countExpr)
446	args = append(args, limit)
447
448	return scanLabelCounts(ctx, db, "top_by_"+column, sb.String(), args...)
449}
450
451func scanLabelCounts(ctx context.Context, db *sql.DB, what, query string, args ...any) []LabelCount {
452	rows, err := db.QueryContext(ctx, query, args...)
453	logQuery(what, err)
454	if err != nil {
455		return nil
456	}
457	defer rows.Close()
458
459	var out []LabelCount
460	for rows.Next() {
461		var lc LabelCount
462		if rows.Scan(&lc.Label, &lc.Count) == nil {
463			out = append(out, lc)
464		}
465	}
466	return out
467}
468
469// eventsByScreenSize counts distinct visitors per screen size. It filters on
470// page_view, like the three breakdowns below it, because the collector's user-id
471// cookie suppresses session_start after a visitor's first visit.
472func eventsByScreenSize(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS int64, filterURL string, limit int64) []LabelCount {
473	extraSQL, extraArgs := filterClause(filterURL)
474	query := `SELECT screen_width, screen_height, COUNT(DISTINCT user_id) FROM events
475	  WHERE property_id = ? AND created_at >= ? AND created_at <= ?
476	        AND event = 'page_view'
477	        AND screen_width IS NOT NULL
478	        AND user_id IS NOT NULL` + extraSQL + `
479	  GROUP BY screen_width, screen_height
480	  ORDER BY COUNT(DISTINCT user_id) DESC LIMIT ?`
481	args := append([]any{propertyID[:], startMS, endMS}, extraArgs...)
482	args = append(args, limit)
483
484	rows, err := db.QueryContext(ctx, query, args...)
485	logQuery("events_by_screen_size", err)
486	if err != nil {
487		return nil
488	}
489	defer rows.Close()
490
491	var out []LabelCount
492	for rows.Next() {
493		var w, h sql.NullInt64
494		var c int64
495		if rows.Scan(&w, &h, &c) == nil {
496			out = append(out, LabelCount{
497				Label: fmt.Sprintf("%dx%d", w.Int64, h.Int64),
498				Count: c,
499			})
500		}
501	}
502	return out
503}
504
505func eventsByDevice(ctx context.Context, db *sql.DB, id uuid.UUID, s, e int64, f string, limit int64) []LabelCount {
506	return topByColumn(ctx, db, id, s, e, f, "device", "page_view", limit, true)
507}
508
509func eventsByBrowser(ctx context.Context, db *sql.DB, id uuid.UUID, s, e int64, f string, limit int64) []LabelCount {
510	return topByColumn(ctx, db, id, s, e, f, "browser", "page_view", limit, true)
511}
512
513func eventsByPlatform(ctx context.Context, db *sql.DB, id uuid.UUID, s, e int64, f string, limit int64) []LabelCount {
514	return topByColumn(ctx, db, id, s, e, f, "platform", "page_view", limit, true)
515}
516
517func eventsByPageURL(ctx context.Context, db *sql.DB, id uuid.UUID, s, e int64, f string, limit int64) []LabelCount {
518	return topByColumn(ctx, db, id, s, e, f, "url", "", limit, false)
519}
520
521func pageViewsByPageURL(ctx context.Context, db *sql.DB, id uuid.UUID, s, e int64, f string, limit int64) []LabelCount {
522	return topByColumn(ctx, db, id, s, e, f, "url", "page_view", limit, false)
523}
524
525func sessionStartsByReferrer(ctx context.Context, db *sql.DB, id uuid.UUID, s, e int64, f string, limit int64) []LabelCount {
526	return topByColumn(ctx, db, id, s, e, f, "referrer", "session_start", limit, false)
527}
528
529// pageViewsByUTM maps a campaign field to its column, which is what keeps a
530// request-supplied field out of topByColumn's interpolation.
531func pageViewsByUTM(ctx context.Context, db *sql.DB, id uuid.UUID, s, e int64, f, field string, limit int64) []LabelCount {
532	column, ok := map[string]string{
533		"source":   "utm_source",
534		"medium":   "utm_medium",
535		"campaign": "utm_campaign",
536		"term":     "utm_term",
537		"content":  "utm_content",
538	}[field]
539	if !ok {
540		return nil
541	}
542	return topByColumn(ctx, db, id, s, e, f, column, "page_view", limit, false)
543}
544
545func eventsByCustomEvent(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS int64, filterURL string, limit int64) []LabelCount {
546	extraSQL, extraArgs := filterClause(filterURL)
547	query := `SELECT event, COUNT(*) FROM events
548	  WHERE property_id = ? AND created_at >= ? AND created_at <= ?
549	        AND event NOT IN (` + placeholders(len(builtInEvents)) + `)` + extraSQL + `
550	  GROUP BY event ORDER BY COUNT(*) DESC LIMIT ?`
551	args := []any{propertyID[:], startMS, endMS}
552	for _, b := range builtInEvents {
553		args = append(args, b)
554	}
555	args = append(args, extraArgs...)
556	args = append(args, limit)
557
558	return scanLabelCounts(ctx, db, "events_by_custom_event", query, args...)
559}
560
561// sessionStartsByCountry feeds the world map, keyed by ISO country code.
562func sessionStartsByCountry(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS int64, filterURL string) map[string]int64 {
563	extraSQL, extraArgs := filterClause(filterURL)
564	query := `SELECT country, COUNT(*) FROM events
565	  WHERE property_id = ? AND created_at >= ? AND created_at <= ?
566	        AND event = 'session_start' AND country IS NOT NULL` + extraSQL + `
567	  GROUP BY country`
568	args := append([]any{propertyID[:], startMS, endMS}, extraArgs...)
569
570	out := map[string]int64{}
571	rows, err := db.QueryContext(ctx, query, args...)
572	logQuery("session_starts_by_country", err)
573	if err != nil {
574		return out
575	}
576	defer rows.Close()
577	for rows.Next() {
578		var country string
579		var c int64
580		if rows.Scan(&country, &c) == nil {
581			out[country] = c
582		}
583	}
584	return out
585}
586
587// sessionStartsByCountryRegion feeds the map's admin-1 drill-down.
588func sessionStartsByCountryRegion(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS int64, filterURL string) map[string]map[string]int64 {
589	extraSQL, extraArgs := filterClause(filterURL)
590	query := `SELECT country, region, COUNT(*) FROM events
591	  WHERE property_id = ? AND created_at >= ? AND created_at <= ?
592	        AND event = 'session_start'
593	        AND country IS NOT NULL AND region IS NOT NULL` + extraSQL + `
594	  GROUP BY country, region`
595	args := append([]any{propertyID[:], startMS, endMS}, extraArgs...)
596
597	out := map[string]map[string]int64{}
598	rows, err := db.QueryContext(ctx, query, args...)
599	logQuery("session_starts_by_country_region", err)
600	if err != nil {
601		return out
602	}
603	defer rows.Close()
604	for rows.Next() {
605		var country, region string
606		var c int64
607		if rows.Scan(&country, &region, &c) != nil {
608			continue
609		}
610		if out[country] == nil {
611			out[country] = map[string]int64{}
612		}
613		out[country][region] = c
614	}
615	return out
616}
617
618func botTraffic(ctx context.Context, db *sql.DB, propertyID uuid.UUID, startMS, endMS, limit int64) BotTraffic {
619	var total int64
620	err := db.QueryRowContext(ctx,
621		`SELECT COUNT(*) FROM bot_events
622		 WHERE property_id = ? AND created_at >= ? AND created_at <= ?`,
623		propertyID[:], startMS, endMS).Scan(&total)
624	logQuery("bot_traffic_total", err)
625	if total == 0 {
626		return BotTraffic{}
627	}
628
629	return BotTraffic{
630		Total: total,
631		TopBots: scanLabelCounts(ctx, db, "bot_traffic_bots",
632			`SELECT bot_name, COUNT(*) FROM bot_events
633			 WHERE property_id = ? AND created_at >= ? AND created_at <= ?
634			       AND bot_name IS NOT NULL AND bot_name != ''
635			 GROUP BY bot_name ORDER BY COUNT(*) DESC LIMIT ?`,
636			propertyID[:], startMS, endMS, limit),
637		TopPages: scanLabelCounts(ctx, db, "bot_traffic_pages",
638			`SELECT url, COUNT(*) FROM bot_events
639			 WHERE property_id = ? AND created_at >= ? AND created_at <= ?
640			       AND url IS NOT NULL AND url != ''
641			 GROUP BY url ORDER BY COUNT(*) DESC LIMIT ?`,
642			propertyID[:], startMS, endMS, limit),
643	}
644}
645
646// parseDateToMS turns a "YYYY-MM-DD" query parameter into a unix-ms bound. The
647// boundary resolves in local time, since the operator means their own days.
648func parseDateToMS(date string, endOfDay bool) (int64, bool) {
649	d, err := time.ParseInLocation("2006-01-02", date, time.Local)
650	if err != nil {
651		return 0, false
652	}
653	if endOfDay {
654		d = d.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
655	}
656	return d.UnixMilli(), true
657}