repos
/ analytics-rust master

analytics-rust

mirror archived upstream

Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.

analyticsaxumdockerrustself-hostedsqliteviteweb-analytics

17.6 KB · 409 lines · Rust Raw History
  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}