Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1mod app;
2mod db;
3mod geoip;
4mod middleware;
5mod migrate;
6mod models;
7mod pdf;
8mod queries;
9mod render;
10mod routes;
11mod templates;
12mod ua;
13
14pub use app::{AppState, Config};
15
16use std::net::SocketAddr;
17use std::path::PathBuf;
18
19#[tokio::main]
20async fn main() -> anyhow::Result<()> {
21 dotenvy::dotenv().ok();
22 tracing_subscriber::fmt()
23 .with_env_filter(
24 tracing_subscriber::EnvFilter::try_from_default_env()
25 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
26 )
27 .init();
28
29 // Subcommand dispatch. Anything besides `migrate` falls through to the server.
30 let mut argv = std::env::args().skip(1);
31 if let Some(first) = argv.next() {
32 match first.as_str() {
33 "migrate" => return run_migrate(argv.collect()).await,
34 "--help" | "-h" => {
35 print_usage();
36 return Ok(());
37 }
38 other if !other.is_empty() => {
39 eprintln!("unknown subcommand: {other}");
40 print_usage();
41 std::process::exit(2);
42 }
43 _ => {}
44 }
45 }
46
47 let port: u16 = std::env::var("PORT")
48 .ok()
49 .and_then(|v| v.parse().ok())
50 .unwrap_or(8000);
51
52 let state = AppState::from_env().await?;
53 app::spawn_background_downloads(&state);
54 let router = app::router(state);
55
56 let addr = SocketAddr::from(([0, 0, 0, 0], port));
57 let listener = tokio::net::TcpListener::bind(addr).await?;
58 tracing::info!("analytics listening on http://{addr}");
59 axum::serve(listener, router).await?;
60 Ok(())
61}
62
63fn print_usage() {
64 eprintln!(
65 "analytics — single-binary axum analytics server\n\
66 \n\
67 Usage:\n \
68 analytics run the HTTP server\n \
69 analytics migrate <path> [--force] import a Django analytics SQLite\n"
70 );
71}
72
73async fn run_migrate(args: Vec<String>) -> anyhow::Result<()> {
74 let mut source: Option<PathBuf> = None;
75 let mut force = false;
76 for arg in args {
77 match arg.as_str() {
78 "--force" | "-f" => force = true,
79 "--help" | "-h" => {
80 eprintln!("Usage: analytics migrate <path-to-django-sqlite3> [--force]");
81 return Ok(());
82 }
83 v if v.starts_with("--") => anyhow::bail!("unknown migrate flag: {v}"),
84 v => {
85 if source.is_some() {
86 anyhow::bail!("migrate takes a single source path");
87 }
88 source = Some(PathBuf::from(v));
89 }
90 }
91 }
92 let source = source.ok_or_else(|| {
93 anyhow::anyhow!("usage: analytics migrate <path-to-django-sqlite3> [--force]")
94 })?;
95 migrate::run(source, force).await
96}