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

11.8 KB · 352 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"encoding/json"
  7	"fmt"
  8	"log/slog"
  9	"math"
 10	"math/rand/v2"
 11	"time"
 12
 13	"github.com/google/uuid"
 14)
 15
 16// Dev tooling: fills a "Seed Test" property with fake traffic so the dashboard
 17// has something to render. It is a flag on the site binary rather than its own
 18// cmd/, since it needs the schema, connection settings and column list.
 19
 20const seedPropertyName = "Seed Test"
 21
 22type weightedURL struct {
 23	Path   string
 24	Title  string
 25	Weight int
 26}
 27
 28var seedURLs = []weightedURL{
 29	{"/", "Home", 40},
 30	{"/about", "About", 10},
 31	{"/pricing", "Pricing", 8},
 32	{"/docs", "Documentation", 8},
 33	{"/blog", "Blog", 5},
 34	{"/blog/getting-started", "Getting Started", 5},
 35	{"/blog/whats-new-in-v2", "What's New in v2", 4},
 36	{"/blog/case-studies", "Case Studies", 3},
 37	{"/contact", "Contact", 4},
 38	{"/login", "Sign In", 4},
 39	{"/signup", "Sign Up", 4},
 40	{"/dashboard", "Dashboard", 5},
 41}
 42
 43type weightedString struct {
 44	Value  string
 45	Weight int
 46}
 47
 48// The empty referrer is the heaviest entry; most real traffic is direct or has
 49// its referrer stripped.
 50var seedReferrers = []weightedString{
 51	{"", 50}, {"google.com", 20}, {"twitter.com", 5}, {"news.ycombinator.com", 3},
 52	{"github.com", 3}, {"reddit.com", 4}, {"duckduckgo.com", 3}, {"bing.com", 3},
 53	{"linkedin.com", 2}, {"producthunt.com", 2}, {"dev.to", 2}, {"medium.com", 1},
 54}
 55
 56type seedAgent struct {
 57	UA       string
 58	Platform string
 59	Browser  string
 60	Device   string
 61	IsBot    bool
 62	BotName  string
 63	Weight   int
 64}
 65
 66var seedAgents = []seedAgent{
 67	{UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
 68		Platform: "Windows", Browser: "Chrome", Device: "Desktop", Weight: 25},
 69	{UA: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
 70		Platform: "Mac OS X", Browser: "Chrome", Device: "Desktop", Weight: 15},
 71	{UA: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36",
 72		Platform: "Android", Browser: "Chrome Mobile", Device: "Mobile", Weight: 15},
 73	{UA: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
 74		Platform: "Mac OS X", Browser: "Safari", Device: "Desktop", Weight: 10},
 75	{UA: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
 76		Platform: "iOS", Browser: "Mobile Safari", Device: "Mobile", Weight: 15},
 77	{UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
 78		Platform: "Windows", Browser: "Firefox", Device: "Desktop", Weight: 5},
 79	{UA: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
 80		Platform: "Ubuntu", Browser: "Firefox", Device: "Desktop", Weight: 3},
 81	{UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",
 82		Platform: "Windows", Browser: "Edge", Device: "Desktop", Weight: 8},
 83	{UA: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
 84		IsBot: true, BotName: "Googlebot", Weight: 1},
 85	{UA: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
 86		IsBot: true, BotName: "bingbot", Weight: 1},
 87	{UA: "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
 88		IsBot: true, BotName: "facebookexternalhit", Weight: 1},
 89}
 90
 91type seedGeo struct {
 92	Country string
 93	Region  string
 94	City    string
 95	Lat     float64
 96	Lon     float64
 97	Weight  int
 98}
 99
100// Region names are the English forms the admin-1 topojson joins on.
101var seedGeos = []seedGeo{
102	{"US", "New York", "New York", 40.7128, -74.0060, 15},
103	{"US", "California", "Los Angeles", 34.0522, -118.2437, 10},
104	{"US", "California", "San Francisco", 37.7749, -122.4194, 8},
105	{"US", "Illinois", "Chicago", 41.8781, -87.6298, 5},
106	{"US", "Texas", "Austin", 30.2672, -97.7431, 5},
107	{"GB", "England", "London", 51.5074, -0.1278, 8},
108	{"GB", "England", "Manchester", 53.4808, -2.2426, 2},
109	{"DE", "Berlin", "Berlin", 52.5200, 13.4050, 5},
110	{"DE", "Bavaria", "Munich", 48.1351, 11.5820, 3},
111	{"FR", "Île-de-France", "Paris", 48.8566, 2.3522, 5},
112	{"CA", "Ontario", "Toronto", 43.6532, -79.3832, 4},
113	{"CA", "British Columbia", "Vancouver", 49.2827, -123.1207, 2},
114	{"AU", "New South Wales", "Sydney", -33.8688, 151.2093, 3},
115	{"AU", "Victoria", "Melbourne", -37.8136, 144.9631, 2},
116	{"JP", "Tokyo", "Tokyo", 35.6762, 139.6503, 4},
117	{"BR", "São Paulo", "São Paulo", -23.5505, -46.6333, 3},
118	{"IN", "Maharashtra", "Mumbai", 19.0760, 72.8777, 3},
119	{"IN", "Karnataka", "Bangalore", 12.9716, 77.5946, 3},
120	{"NL", "North Holland", "Amsterdam", 52.3676, 4.9041, 3},
121	{"ES", "Madrid", "Madrid", 40.4168, -3.7038, 2},
122	{"IT", "Lazio", "Rome", 41.9028, 12.4964, 2},
123	{"MX", "Mexico City", "Mexico City", 19.4326, -99.1332, 2},
124	{"KR", "Seoul", "Seoul", 37.5665, 126.9780, 2},
125	{"SE", "Stockholm", "Stockholm", 59.3293, 18.0686, 2},
126	{"PL", "Mazovia", "Warsaw", 52.2297, 21.0122, 2},
127	{"TR", "Istanbul", "Istanbul", 41.0082, 28.9784, 2},
128	{"ZA", "Gauteng", "Johannesburg", -26.2041, 28.0473, 1},
129}
130
131var (
132	seedDesktopScreens = [][2]int64{{1920, 1080}, {1366, 768}, {1440, 900}, {1536, 864}, {1680, 1050}, {2560, 1440}}
133	seedMobileScreens  = [][2]int64{{390, 844}, {414, 896}, {375, 667}, {360, 800}, {412, 915}, {393, 851}}
134	seedUTMSources     = []string{"google", "twitter", "hn", "newsletter", "github", "producthunt"}
135	seedUTMMediums     = []string{"cpc", "social", "email", "referral", "organic"}
136	seedUTMCampaigns   = []string{"launch-2026", "spring-promo", "blog-feature", "rebrand", "retarget"}
137)
138
139// Demo custom events, each with a per-session probability.
140var seedCustomEvents = []struct {
141	Name        string
142	Probability float64
143}{
144	{"signup", 0.05},
145	{"checkout_success", 0.02},
146	{"signup_cta_click", 0.08},
147}
148
149func weightedPick[T any](items []T, weight func(T) int) T {
150	total := 0
151	for _, it := range items {
152		total += weight(it)
153	}
154	pick := rand.IntN(total)
155	for _, it := range items {
156		w := weight(it)
157		if pick < w {
158			return it
159		}
160		pick -= w
161	}
162	return items[len(items)-1]
163}
164
165// runSeed wipes and refills the Seed Test property, reusing it so the dashboard
166// URL stays stable across seeds.
167func runSeed(ctx context.Context, db *sql.DB, sessions, days int) error {
168	id, err := ensureSeedProperty(ctx, db)
169	if err != nil {
170		return err
171	}
172
173	for _, table := range []string{"events", "bot_events"} {
174		if _, err := db.ExecContext(ctx,
175			"DELETE FROM "+table+" WHERE property_id = ?", id[:]); err != nil {
176			return fmt.Errorf("clear %s: %w", table, err)
177		}
178	}
179
180	cards := make([]CustomCard, 0, len(seedCustomEvents))
181	for _, ce := range seedCustomEvents {
182		cards = append(cards, CustomCard{Event: ce.Name, Value: true})
183	}
184	encoded, _ := json.Marshal(cards)
185	if _, err := db.ExecContext(ctx,
186		"UPDATE properties SET custom_cards = ?, updated_at = ? WHERE id = ?",
187		string(encoded), time.Now().UnixMilli(), id[:]); err != nil {
188		return fmt.Errorf("set custom cards: %w", err)
189	}
190
191	total, err := generateSeed(ctx, db, id, sessions, days)
192	if err != nil {
193		return err
194	}
195
196	slog.Info(fmt.Sprintf("seeded %d sessions (%d events) into %q (%s)", sessions, total, seedPropertyName, id))
197	slog.Info(fmt.Sprintf("dashboard: http://localhost:8000/%s", id))
198	return nil
199}
200
201func ensureSeedProperty(ctx context.Context, db *sql.DB) (uuid.UUID, error) {
202	var raw []byte
203	err := db.QueryRowContext(ctx,
204		"SELECT id FROM properties WHERE name = ?", seedPropertyName).Scan(&raw)
205	if err == nil {
206		return uuid.FromBytes(raw)
207	}
208	if err != sql.ErrNoRows {
209		return uuid.Nil, err
210	}
211
212	id := uuid.New()
213	now := time.Now().UnixMilli()
214	_, err = db.ExecContext(ctx,
215		`INSERT INTO properties (id, name, custom_cards, is_protected, is_public, created_at, updated_at)
216		 VALUES (?, ?, '[]', 0, 0, ?, ?)`,
217		id[:], seedPropertyName, now, now)
218	return id, err
219}
220
221func generateSeed(ctx context.Context, db *sql.DB, id uuid.UUID, sessions, days int) (int64, error) {
222	// One transaction for the whole run; otherwise every insert is its own
223	// commit and its own fsync.
224	tx, err := db.BeginTx(ctx, nil)
225	if err != nil {
226		return 0, err
227	}
228	defer tx.Rollback()
229
230	human, err := tx.PrepareContext(ctx, `INSERT INTO events (
231	    property_id, event, created_at, user_id, url, title, referrer, user_agent,
232	    platform, browser, device, screen_width, screen_height, country, region, city,
233	    lat, lon, utm_source, utm_medium, utm_campaign, time_on_page_ms, extra
234	  ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'{}')`)
235	if err != nil {
236		return 0, err
237	}
238	defer human.Close()
239
240	bot, err := tx.PrepareContext(ctx, `INSERT INTO bot_events (
241	    property_id, event, created_at, bot_name, url, user_agent, country, extra
242	  ) VALUES (?,?,?,?,?,?,?,'{}')`)
243	if err != nil {
244		return 0, err
245	}
246	defer bot.Close()
247
248	now := time.Now().UnixMilli()
249	windowMS := int64(days) * 24 * 60 * 60 * 1000
250	var total int64
251
252	for i := 0; i < sessions; i++ {
253		agent := weightedPick(seedAgents, func(a seedAgent) int { return a.Weight })
254		geo := weightedPick(seedGeos, func(g seedGeo) int { return g.Weight })
255		referrer := weightedPick(seedReferrers, func(r weightedString) int { return r.Weight }).Value
256
257		userID := fmt.Sprintf("%d", 100_000_000+rand.Int64N(899_999_999))
258
259		// Bias toward recent days, or the default window compares a full
260		// period against a nearly empty one and shows +1000% everywhere.
261		offset := int64(math.Pow(rand.Float64(), 1.15) * float64(windowMS))
262		sessionStart := now - offset
263
264		url := weightedPick(seedURLs, func(u weightedURL) int { return u.Weight })
265
266		if agent.IsBot {
267			if _, err := bot.ExecContext(ctx, id[:], "page_view", sessionStart,
268				agent.BotName, url.Path, agent.UA, geo.Country); err != nil {
269				return total, err
270			}
271			total++
272			continue
273		}
274
275		screens := seedDesktopScreens
276		if agent.Device == "Mobile" {
277			screens = seedMobileScreens
278		}
279		screen := screens[rand.IntN(len(screens))]
280
281		var utmSource, utmMedium, utmCampaign any
282		if rand.Float64() < 0.3 {
283			utmSource = seedUTMSources[rand.IntN(len(seedUTMSources))]
284			utmMedium = seedUTMMediums[rand.IntN(len(seedUTMMediums))]
285			utmCampaign = seedUTMCampaigns[rand.IntN(len(seedUTMCampaigns))]
286		}
287
288		insert := func(event string, at int64, url weightedURL, ref any, utmS, utmM, utmC any, timeOnPage any) error {
289			_, err := human.ExecContext(ctx, id[:], event, at, userID, url.Path, url.Title,
290				ref, agent.UA, agent.Platform, agent.Browser, agent.Device,
291				screen[0], screen[1], geo.Country, geo.Region, geo.City, geo.Lat, geo.Lon,
292				utmS, utmM, utmC, timeOnPage)
293			return err
294		}
295
296		var sessionRef any
297		if referrer != "" {
298			sessionRef = referrer
299		}
300
301		if err := insert("session_start", sessionStart, url, sessionRef, utmSource, utmMedium, utmCampaign, nil); err != nil {
302			return total, err
303		}
304		total++
305
306		for _, ce := range seedCustomEvents {
307			if rand.Float64() < ce.Probability {
308				at := sessionStart + 1_000 + rand.Int64N(29_000)
309				if err := insert(ce.Name, at, url, nil, nil, nil, nil, nil); err != nil {
310					return total, err
311				}
312				total++
313			}
314		}
315
316		t := sessionStart
317		pageCount := 1 + rand.IntN(8)
318		for page := 0; page < pageCount; page++ {
319			timeOnPage := 2_000 + rand.Int64N(118_000)
320
321			// Only the first page view of a session carries the referrer.
322			var pvRef any
323			if page == 0 {
324				pvRef = sessionRef
325			}
326			if err := insert("page_view", t, url, pvRef, utmSource, utmMedium, utmCampaign, nil); err != nil {
327				return total, err
328			}
329			total++
330
331			if rand.Float64() < 0.4 {
332				if err := insert("click", t+500+rand.Int64N(timeOnPage), url, nil, nil, nil, nil, nil); err != nil {
333					return total, err
334				}
335				total++
336			}
337
338			if err := insert("page_leave", t+timeOnPage, url, nil, nil, nil, nil, timeOnPage); err != nil {
339				return total, err
340			}
341			total++
342
343			t += timeOnPage + 500 + rand.Int64N(2_500)
344			if page+1 < pageCount {
345				url = weightedPick(seedURLs, func(u weightedURL) int { return u.Weight })
346			}
347		}
348	}
349
350	return total, tx.Commit()
351}