Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1// Seeds a "Seed Test" property with realistic-looking fake events.
2//
3// Usage:
4// cargo run --bin seed # 6000 sessions, last 90 days
5// cargo run --bin seed -- 12000 180 # 12000 sessions, last 180 days
6//
7// Sessions are timestamped with a gentle bias toward recent days so the
8// dashboard's default 28-day window shows a small positive delta vs. the
9// previous 28 days (rather than +1000% from comparing a full window to a
10// barely-populated one). At default 6000/90 the visible 28-day window
11// holds ~25k events and most metric-card deltas land in the ±20% range.
12//
13// Re-runs reuse the property and wipe its existing events first so the
14// dashboard URL stays stable. The property's `custom_cards` are also
15// rewritten on every run so a new seed always shows the demo cards.
16
17#[path = "../db.rs"]
18#[allow(dead_code)]
19mod db;
20
21use anyhow::Result;
22use chrono::Utc;
23use rand::prelude::*;
24use sqlx::{SqliteConnection, SqlitePool};
25use std::path::PathBuf;
26use uuid::Uuid;
27
28const PROPERTY_NAME: &str = "Seed Test";
29
30const URLS: &[(&str, &str, u32)] = &[
31 ("/", "Home", 40),
32 ("/about", "About", 10),
33 ("/pricing", "Pricing", 8),
34 ("/docs", "Documentation", 8),
35 ("/blog", "Blog", 5),
36 ("/blog/getting-started", "Getting Started", 5),
37 ("/blog/whats-new-in-v2", "What's New in v2", 4),
38 ("/blog/case-studies", "Case Studies", 3),
39 ("/contact", "Contact", 4),
40 ("/login", "Log In", 4),
41 ("/signup", "Sign Up", 4),
42 ("/dashboard", "Dashboard", 5),
43];
44
45const REFERRERS: &[(&str, u32)] = &[
46 ("", 50),
47 ("google.com", 20),
48 ("twitter.com", 5),
49 ("news.ycombinator.com", 3),
50 ("github.com", 3),
51 ("reddit.com", 4),
52 ("duckduckgo.com", 3),
53 ("bing.com", 3),
54 ("linkedin.com", 2),
55 ("producthunt.com", 2),
56 ("dev.to", 2),
57 ("medium.com", 1),
58];
59
60struct Agent {
61 ua: &'static str,
62 platform: &'static str,
63 browser: &'static str,
64 device: &'static str,
65 is_bot: bool,
66 bot_name: Option<&'static str>,
67 weight: u32,
68}
69
70const AGENTS: &[Agent] = &[
71 Agent { ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
72 platform: "Windows", browser: "Chrome", device: "Desktop", is_bot: false, bot_name: None, weight: 25 },
73 Agent { 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",
74 platform: "Mac OS X", browser: "Chrome", device: "Desktop", is_bot: false, bot_name: None, weight: 15 },
75 Agent { ua: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36",
76 platform: "Android", browser: "Chrome Mobile", device: "Mobile", is_bot: false, bot_name: None, weight: 15 },
77 Agent { 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",
78 platform: "Mac OS X", browser: "Safari", device: "Desktop", is_bot: false, bot_name: None, weight: 10 },
79 Agent { 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",
80 platform: "iOS", browser: "Mobile Safari", device: "Mobile", is_bot: false, bot_name: None, weight: 15 },
81 Agent { ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
82 platform: "Windows", browser: "Firefox", device: "Desktop", is_bot: false, bot_name: None, weight: 5 },
83 Agent { ua: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
84 platform: "Ubuntu", browser: "Firefox", device: "Desktop", is_bot: false, bot_name: None, weight: 3 },
85 Agent { 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",
86 platform: "Windows", browser: "Edge", device: "Desktop", is_bot: false, bot_name: None, weight: 8 },
87 Agent { ua: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
88 platform: "", browser: "", device: "", is_bot: true, bot_name: Some("Googlebot"), weight: 1 },
89 Agent { ua: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
90 platform: "", browser: "", device: "", is_bot: true, bot_name: Some("bingbot"), weight: 1 },
91 Agent { ua: "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
92 platform: "", browser: "", device: "", is_bot: true, bot_name: Some("facebookexternalhit"), weight: 1 },
93];
94
95struct GeoRow {
96 country: &'static str,
97 region: &'static str,
98 city: &'static str,
99 lat: f64,
100 lon: f64,
101 weight: u32,
102}
103
104const GEO: &[GeoRow] = &[
105 GeoRow { country: "US", region: "New York", city: "New York", lat: 40.7128, lon: -74.0060, weight: 15 },
106 GeoRow { country: "US", region: "California", city: "Los Angeles", lat: 34.0522, lon: -118.2437, weight: 10 },
107 GeoRow { country: "US", region: "California", city: "San Francisco", lat: 37.7749, lon: -122.4194, weight: 8 },
108 GeoRow { country: "US", region: "Illinois", city: "Chicago", lat: 41.8781, lon: -87.6298, weight: 5 },
109 GeoRow { country: "US", region: "Texas", city: "Austin", lat: 30.2672, lon: -97.7431, weight: 5 },
110 GeoRow { country: "GB", region: "England", city: "London", lat: 51.5074, lon: -0.1278, weight: 8 },
111 GeoRow { country: "GB", region: "England", city: "Manchester", lat: 53.4808, lon: -2.2426, weight: 2 },
112 GeoRow { country: "DE", region: "Berlin", city: "Berlin", lat: 52.5200, lon: 13.4050, weight: 5 },
113 GeoRow { country: "DE", region: "Bavaria", city: "Munich", lat: 48.1351, lon: 11.5820, weight: 3 },
114 GeoRow { country: "FR", region: "Île-de-France", city: "Paris", lat: 48.8566, lon: 2.3522, weight: 5 },
115 GeoRow { country: "CA", region: "Ontario", city: "Toronto", lat: 43.6532, lon: -79.3832, weight: 4 },
116 GeoRow { country: "CA", region: "British Columbia", city: "Vancouver", lat: 49.2827, lon: -123.1207, weight: 2 },
117 GeoRow { country: "AU", region: "New South Wales", city: "Sydney", lat: -33.8688, lon: 151.2093, weight: 3 },
118 GeoRow { country: "AU", region: "Victoria", city: "Melbourne", lat: -37.8136, lon: 144.9631, weight: 2 },
119 GeoRow { country: "JP", region: "Tokyo", city: "Tokyo", lat: 35.6762, lon: 139.6503, weight: 4 },
120 GeoRow { country: "BR", region: "São Paulo", city: "São Paulo", lat: -23.5505, lon: -46.6333, weight: 3 },
121 GeoRow { country: "IN", region: "Maharashtra", city: "Mumbai", lat: 19.0760, lon: 72.8777, weight: 3 },
122 GeoRow { country: "IN", region: "Karnataka", city: "Bangalore", lat: 12.9716, lon: 77.5946, weight: 3 },
123 GeoRow { country: "NL", region: "North Holland", city: "Amsterdam", lat: 52.3676, lon: 4.9041, weight: 3 },
124 GeoRow { country: "ES", region: "Madrid", city: "Madrid", lat: 40.4168, lon: -3.7038, weight: 2 },
125 GeoRow { country: "IT", region: "Lazio", city: "Rome", lat: 41.9028, lon: 12.4964, weight: 2 },
126 GeoRow { country: "MX", region: "Mexico City", city: "Mexico City", lat: 19.4326, lon: -99.1332, weight: 2 },
127 GeoRow { country: "KR", region: "Seoul", city: "Seoul", lat: 37.5665, lon: 126.9780, weight: 2 },
128 GeoRow { country: "SE", region: "Stockholm", city: "Stockholm", lat: 59.3293, lon: 18.0686, weight: 2 },
129 GeoRow { country: "PL", region: "Mazovia", city: "Warsaw", lat: 52.2297, lon: 21.0122, weight: 2 },
130 GeoRow { country: "TR", region: "Istanbul", city: "Istanbul", lat: 41.0082, lon: 28.9784, weight: 2 },
131 GeoRow { country: "ZA", region: "Gauteng", city: "Johannesburg", lat: -26.2041, lon: 28.0473, weight: 1 },
132];
133
134const SCREENS_DESKTOP: &[(i64, i64)] = &[
135 (1920, 1080), (1366, 768), (1440, 900), (1536, 864), (1680, 1050), (2560, 1440),
136];
137
138const SCREENS_MOBILE: &[(i64, i64)] = &[
139 (390, 844), (414, 896), (375, 667), (360, 800), (412, 915), (393, 851),
140];
141
142const UTM_SOURCES: &[&str] = &["google", "twitter", "hn", "newsletter", "github", "producthunt"];
143const UTM_MEDIUMS: &[&str] = &["cpc", "social", "email", "referral", "organic"];
144const UTM_CAMPAIGNS: &[&str] = &["launch-2026", "spring-promo", "blog-feature", "rebrand", "retarget"];
145
146// Demo custom events emitted alongside page-views. Probabilities are
147// per-session — at the default 2000 sessions this yields ~100 signups,
148// ~40 checkouts, and ~160 CTA clicks, plenty to populate the cards.
149const CUSTOM_EVENTS: &[(&str, f64)] = &[
150 ("signup", 0.05),
151 ("checkout_success", 0.02),
152 ("signup_cta_click", 0.08),
153];
154
155fn weighted<'a, T>(rng: &mut impl Rng, items: &'a [T], weight: impl Fn(&T) -> u32) -> &'a T {
156 let total: u32 = items.iter().map(&weight).sum();
157 let mut pick = rng.gen_range(0..total);
158 for it in items {
159 let w = weight(it);
160 if pick < w {
161 return it;
162 }
163 pick -= w;
164 }
165 items.last().unwrap()
166}
167
168#[tokio::main]
169async fn main() -> Result<()> {
170 let _ = dotenvy::dotenv();
171
172 let args: Vec<String> = std::env::args().collect();
173 // 6000 sessions over 90 days, biased toward recent. The default 28-day
174 // dashboard window catches ~34% of sessions (~2000 × ~12 events ≈ 25k).
175 // Override with `cargo run --bin seed -- <sessions> <days>`.
176 let sessions: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(6000);
177 let days: i64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(90);
178
179 let data_dir = std::env::var("ANALYTICS_DATA_DIR")
180 .map(PathBuf::from)
181 .unwrap_or_else(|_| PathBuf::from("./data"));
182 std::fs::create_dir_all(&data_dir)?;
183
184 let pool = db::init(&data_dir).await?;
185
186 let property_id = ensure_property(&pool, PROPERTY_NAME).await?;
187 let pid_bytes = property_id.as_bytes().to_vec();
188
189 sqlx::query("DELETE FROM events WHERE property_id = ?")
190 .bind(&pid_bytes)
191 .execute(&pool)
192 .await?;
193 sqlx::query("DELETE FROM bot_events WHERE property_id = ?")
194 .bind(&pid_bytes)
195 .execute(&pool)
196 .await?;
197
198 // Always rewrite custom_cards so re-seeding wipes prior state. The shape
199 // matches CustomCard in models.rs ([{event, value: bool}]).
200 let cards_json = serde_json::Value::Array(
201 CUSTOM_EVENTS
202 .iter()
203 .map(|(name, _)| serde_json::json!({ "event": name, "value": true }))
204 .collect(),
205 )
206 .to_string();
207 sqlx::query("UPDATE properties SET custom_cards = ?, updated_at = ? WHERE id = ?")
208 .bind(&cards_json)
209 .bind(Utc::now().timestamp_millis())
210 .bind(&pid_bytes)
211 .execute(&pool)
212 .await?;
213
214 let total = generate(&pool, &pid_bytes, sessions, days).await?;
215
216 println!("Seeded {} sessions ({} events) into property '{}' ({})", sessions, total, PROPERTY_NAME, property_id);
217 println!("Dashboard: http://localhost:8000/{}", property_id);
218
219 Ok(())
220}
221
222async fn ensure_property(pool: &SqlitePool, name: &str) -> Result<Uuid> {
223 let existing: Option<(Vec<u8>,)> = sqlx::query_as("SELECT id FROM properties WHERE name = ?")
224 .bind(name)
225 .fetch_optional(pool)
226 .await?;
227 if let Some((bytes,)) = existing {
228 return Ok(Uuid::from_slice(&bytes)?);
229 }
230 let id = Uuid::new_v4();
231 let now = Utc::now().timestamp_millis();
232 sqlx::query(
233 "INSERT INTO properties (id, name, custom_cards, is_protected, is_public, created_at, updated_at) \
234 VALUES (?, ?, '[]', 0, 0, ?, ?)",
235 )
236 .bind(id.as_bytes().to_vec())
237 .bind(name)
238 .bind(now)
239 .bind(now)
240 .execute(pool)
241 .await?;
242 Ok(id)
243}
244
245async fn generate(pool: &SqlitePool, pid: &[u8], sessions: usize, days: i64) -> Result<u64> {
246 let mut rng = thread_rng();
247 let now = Utc::now().timestamp_millis();
248 let window_ms: i64 = days * 24 * 60 * 60 * 1000;
249 let mut total = 0u64;
250
251 let mut tx = pool.begin().await?;
252
253 for _ in 0..sessions {
254 let agent = weighted(&mut rng, AGENTS, |a| a.weight);
255 let geo = weighted(&mut rng, GEO, |g| g.weight);
256 let referrer_str = weighted(&mut rng, REFERRERS, |r| r.1).0;
257 let referrer = if referrer_str.is_empty() { None } else { Some(referrer_str) };
258
259 let user_id = format!("{}", rng.gen_range(100_000_000u64..999_999_999u64));
260
261 // r.powf(1.15) gently biases toward 0, putting more sessions in the
262 // recent end of the window. Empirically yields ~10–15% growth comparing
263 // the most-recent 28 days to the previous 28 days, which matches what
264 // a real site looks like — instead of the +1000% you'd get from a
265 // uniform 30-day seed where the prev window is mostly empty.
266 let r: f64 = rng.gen();
267 let offset_ms = (r.powf(1.15) * window_ms as f64) as i64;
268 let session_start = now - offset_ms;
269
270 let (sw, sh) = if agent.device == "Mobile" {
271 *SCREENS_MOBILE.choose(&mut rng).unwrap()
272 } else {
273 *SCREENS_DESKTOP.choose(&mut rng).unwrap()
274 };
275
276 let (utm_source, utm_medium, utm_campaign) = if rng.gen_bool(0.3) {
277 (
278 Some(*UTM_SOURCES.choose(&mut rng).unwrap()),
279 Some(*UTM_MEDIUMS.choose(&mut rng).unwrap()),
280 Some(*UTM_CAMPAIGNS.choose(&mut rng).unwrap()),
281 )
282 } else {
283 (None, None, None)
284 };
285
286 if agent.is_bot {
287 let url_pick = weighted(&mut rng, URLS, |u| u.2);
288 sqlx::query(
289 "INSERT INTO bot_events (property_id, event, created_at, bot_name, url, user_agent, country, extra) \
290 VALUES (?,?,?,?,?,?,?,'{}')",
291 )
292 .bind(pid)
293 .bind("page_view")
294 .bind(session_start)
295 .bind(agent.bot_name)
296 .bind(url_pick.0)
297 .bind(agent.ua)
298 .bind(geo.country)
299 .execute(&mut *tx)
300 .await?;
301 total += 1;
302 continue;
303 }
304
305 let page_count = rng.gen_range(1..=8usize);
306 let mut t = session_start;
307 let mut url_pick = weighted(&mut rng, URLS, |u| u.2);
308
309 insert_human(&mut tx, pid, "session_start", t, &user_id, url_pick.0, url_pick.1,
310 referrer, agent, sw, sh, geo, utm_source, utm_medium, utm_campaign, None).await?;
311 total += 1;
312
313 // Emit demo custom events at their per-session probability. Bucketed
314 // a few seconds after the session start so they fall inside the
315 // active window and show up on the dashboard's custom-event cards.
316 for (name, prob) in CUSTOM_EVENTS {
317 if rng.gen_bool(*prob) {
318 let offset = rng.gen_range(1_000i64..30_000);
319 insert_human(&mut tx, pid, name, t + offset, &user_id, url_pick.0, url_pick.1,
320 None, agent, sw, sh, geo, None, None, None, None).await?;
321 total += 1;
322 }
323 }
324
325 for i in 0..page_count {
326 let time_on_page = rng.gen_range(2_000i64..120_000i64);
327 let pv_referrer = if i == 0 { referrer } else { None };
328
329 insert_human(&mut tx, pid, "page_view", t, &user_id, url_pick.0, url_pick.1,
330 pv_referrer, agent, sw, sh, geo, utm_source, utm_medium, utm_campaign, None).await?;
331 total += 1;
332
333 if rng.gen_bool(0.4) {
334 let click_offset = rng.gen_range(500..time_on_page.max(1001));
335 insert_human(&mut tx, pid, "click", t + click_offset, &user_id, url_pick.0, url_pick.1,
336 None, agent, sw, sh, geo, None, None, None, None).await?;
337 total += 1;
338 }
339
340 insert_human(&mut tx, pid, "page_leave", t + time_on_page, &user_id, url_pick.0, url_pick.1,
341 None, agent, sw, sh, geo, None, None, None, Some(time_on_page)).await?;
342 total += 1;
343
344 t += time_on_page + rng.gen_range(500..3000);
345
346 if i + 1 < page_count {
347 url_pick = weighted(&mut rng, URLS, |u| u.2);
348 }
349 }
350 }
351
352 tx.commit().await?;
353 Ok(total)
354}
355
356#[allow(clippy::too_many_arguments)]
357async fn insert_human(
358 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
359 pid: &[u8],
360 event: &str,
361 created_at: i64,
362 user_id: &str,
363 url: &str,
364 title: &str,
365 referrer: Option<&str>,
366 agent: &Agent,
367 screen_w: i64,
368 screen_h: i64,
369 geo: &GeoRow,
370 utm_source: Option<&str>,
371 utm_medium: Option<&str>,
372 utm_campaign: Option<&str>,
373 time_on_page_ms: Option<i64>,
374) -> Result<()> {
375 let conn: &mut SqliteConnection = &mut *tx;
376 sqlx::query(
377 "INSERT INTO events (\
378 property_id, event, created_at, user_id, url, title, referrer, user_agent, \
379 platform, browser, device, screen_width, screen_height, country, region, city, \
380 lat, lon, utm_source, utm_medium, utm_campaign, time_on_page_ms, extra\
381 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'{}')",
382 )
383 .bind(pid)
384 .bind(event)
385 .bind(created_at)
386 .bind(user_id)
387 .bind(url)
388 .bind(title)
389 .bind(referrer)
390 .bind(agent.ua)
391 .bind(agent.platform)
392 .bind(agent.browser)
393 .bind(agent.device)
394 .bind(screen_w)
395 .bind(screen_h)
396 .bind(geo.country)
397 .bind(geo.region)
398 .bind(geo.city)
399 .bind(geo.lat)
400 .bind(geo.lon)
401 .bind(utm_source)
402 .bind(utm_medium)
403 .bind(utm_campaign)
404 .bind(time_on_page_ms)
405 .execute(conn)
406 .await?;
407 Ok(())
408}