Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1use axum::{
2 extract::{Path as AxumPath, Query, State},
3 http::StatusCode,
4 response::{IntoResponse, Redirect, Response},
5 routing::get,
6 Router,
7};
8use serde::Deserialize;
9use tower_cookies::Cookies;
10use uuid::Uuid;
11
12use crate::render::{render, render_to_string};
13use crate::routes::auth::is_authenticated;
14use crate::AppState;
15
16// Milliseconds per day. Used to convert between the date-range query
17// parameter (in days) and the millisecond timestamps stored in events.
18const DAY_MS: i64 = 24 * 60 * 60 * 1000;
19// Default look-back when the request omits ?date_range= and gives a custom
20// start/end. Matches what the dashboard's date selector picks by default.
21const DEFAULT_DATE_RANGE_DAYS: i64 = 28;
22
23pub fn router() -> Router<AppState> {
24 // The dashboard's UUID path segment is a catch-all; merging this module
25 // last in app::router keeps named routes (e.g. /login, /properties)
26 // winning the match (axum prefers literal segments over path parameters
27 // at the same depth).
28 Router::new().route("/{property_id}", get(property))
29}
30
31#[derive(Debug, Deserialize)]
32pub struct DashboardQuery {
33 pub date_start: Option<String>,
34 pub date_end: Option<String>,
35 pub date_range: Option<String>,
36 pub filter_url: Option<String>,
37 pub report: Option<String>,
38}
39
40/// ASCII-only filename so the Content-Disposition header value always parses;
41/// a property named "Café" must not panic the report download.
42fn ascii_filename(name: &str) -> String {
43 let cleaned: String = name
44 .chars()
45 .map(|c| if c.is_ascii_alphanumeric() || " .-_".contains(c) { c } else { '_' })
46 .collect();
47 let trimmed = cleaned.trim().trim_matches('.');
48 if trimmed.is_empty() { "report".to_string() } else { trimmed.to_string() }
49}
50
51pub async fn property(
52 State(state): State<AppState>,
53 AxumPath(property_id): AxumPath<Uuid>,
54 cookies: Cookies,
55 Query(q): Query<DashboardQuery>,
56) -> Response {
57 let row: Option<crate::models::PropertyRow> = sqlx::query_as(
58 "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \
59 FROM properties WHERE id = ?",
60 )
61 .bind(property_id.as_bytes().to_vec())
62 .fetch_optional(&state.pool)
63 .await
64 .unwrap_or(None);
65 let Some(row) = row else {
66 return Redirect::to("/properties").into_response();
67 };
68 let p = row.into_property();
69 let authed = is_authenticated(&cookies, &state);
70 if !p.is_public && !authed {
71 return Redirect::to("/login").into_response();
72 }
73
74 use chrono::{Duration, Local};
75
76 let today = Local::now().date_naive();
77 let default_start = today - Duration::days(DEFAULT_DATE_RANGE_DAYS);
78 let date_start = q
79 .date_start
80 .clone()
81 .unwrap_or_else(|| default_start.format("%Y-%m-%d").to_string());
82 let date_end = q
83 .date_end
84 .clone()
85 .unwrap_or_else(|| today.format("%Y-%m-%d").to_string());
86
87 let start_ms = match crate::queries::parse_date_to_ms(&date_start, false) {
88 Some(v) => v,
89 None => return (StatusCode::BAD_REQUEST, "bad date_start").into_response(),
90 };
91 let end_ms = match crate::queries::parse_date_to_ms(&date_end, true) {
92 Some(v) => v,
93 None => return (StatusCode::BAD_REQUEST, "bad date_end").into_response(),
94 };
95
96 let date_range: i64 = match q.date_range.as_deref() {
97 Some("custom") | None => {
98 // Days between start and end, inclusive of the end-of-day window.
99 let span = (end_ms - start_ms) / DAY_MS;
100 span.max(1)
101 }
102 Some(other) => other.parse::<i64>().unwrap_or(DEFAULT_DATE_RANGE_DAYS),
103 };
104
105 let prev_start_ms = start_ms - date_range * DAY_MS;
106 let prev_end_ms = end_ms - date_range * DAY_MS;
107 let filter_url = q.filter_url.as_deref().filter(|s| !s.is_empty());
108
109 let dash_value: serde_json::Value = {
110 let pool = &state.pool;
111 let pid = &p.id;
112
113 let event_cards =
114 crate::queries::standard_event_cards(pool, pid, start_ms, end_ms, prev_start_ms, prev_end_ms, filter_url).await;
115 let (custom_cards, custom_events) = crate::queries::custom_event_cards(
116 pool, pid, &p.custom_cards, start_ms, end_ms, prev_start_ms, prev_end_ms, filter_url,
117 )
118 .await;
119 let mut all_cards = event_cards;
120 all_cards.extend(custom_cards);
121
122 // Anchor the graph buckets to the requested end date, not today;
123 // otherwise historical ranges chart as all zeros.
124 let graph_end_date =
125 chrono::NaiveDate::parse_from_str(&date_end, "%Y-%m-%d").unwrap_or(today);
126 let total_events_graph = crate::queries::events_graph(
127 pool, pid, start_ms, end_ms, filter_url, graph_end_date, date_range,
128 )
129 .await;
130
131 let total_events_by_screen_size =
132 crate::queries::events_by_screen_size(pool, pid, start_ms, end_ms, filter_url, 7).await;
133 let total_events_by_device =
134 crate::queries::events_by_device(pool, pid, start_ms, end_ms, filter_url, 7).await;
135 let total_events_by_browser =
136 crate::queries::events_by_browser(pool, pid, start_ms, end_ms, filter_url, 7).await;
137 let total_events_by_platform =
138 crate::queries::events_by_platform(pool, pid, start_ms, end_ms, filter_url, 7).await;
139 let total_events_by_page_url =
140 crate::queries::events_by_page_url(pool, pid, start_ms, end_ms, filter_url, 10).await;
141 let total_page_views_by_page_url =
142 crate::queries::page_views_by_page_url(pool, pid, start_ms, end_ms, filter_url, 10).await;
143 let total_events_by_custom_event =
144 crate::queries::events_by_custom_event(pool, pid, start_ms, end_ms, filter_url, 10).await;
145 let total_session_starts_by_referrer =
146 crate::queries::session_starts_by_referrer(pool, pid, start_ms, end_ms, filter_url, 10).await;
147 let total_page_views_by_utm_medium =
148 crate::queries::page_views_by_utm(pool, pid, start_ms, end_ms, filter_url, "medium", 10).await;
149 let total_page_views_by_utm_source =
150 crate::queries::page_views_by_utm(pool, pid, start_ms, end_ms, filter_url, "source", 10).await;
151 let total_page_views_by_utm_campaign =
152 crate::queries::page_views_by_utm(pool, pid, start_ms, end_ms, filter_url, "campaign", 10).await;
153 let session_starts_by_country =
154 crate::queries::session_starts_by_country(pool, pid, start_ms, end_ms, filter_url).await;
155 let session_starts_by_country_region =
156 crate::queries::session_starts_by_country_region(pool, pid, start_ms, end_ms, filter_url).await;
157 let bot_traffic =
158 crate::queries::bot_traffic(pool, pid, start_ms, end_ms, 10).await;
159
160 serde_json::json!({
161 "event_cards": all_cards,
162 "custom_events": custom_events,
163 "total_events_graph": total_events_graph,
164 "total_events_by_screen_size": total_events_by_screen_size,
165 "total_events_by_device": total_events_by_device,
166 "total_events_by_browser": total_events_by_browser,
167 "total_events_by_platform": total_events_by_platform,
168 "total_events_by_page_url": total_events_by_page_url,
169 "total_page_views_by_page_url": total_page_views_by_page_url,
170 "total_events_by_custom_event": total_events_by_custom_event,
171 "total_session_starts_by_referrer": total_session_starts_by_referrer,
172 "total_page_views_by_utm_medium": total_page_views_by_utm_medium,
173 "total_page_views_by_utm_source": total_page_views_by_utm_source,
174 "total_page_views_by_utm_campaign": total_page_views_by_utm_campaign,
175 "session_starts_by_country": session_starts_by_country,
176 "session_starts_by_country_region": session_starts_by_country_region,
177 "bot_traffic": bot_traffic,
178 })
179 };
180
181 let total_live_users = crate::queries::total_live_users(&state.pool, &p.id).await;
182
183 // Build the small chart helpers + breakdown totals the print template needs.
184 let chart_polyline = build_chart_polyline(
185 dash_value
186 .get("total_events_graph")
187 .and_then(|v| v.as_array())
188 .map(|v| v.as_slice())
189 .unwrap_or(&[]),
190 );
191 let graph_arr = dash_value
192 .get("total_events_graph")
193 .and_then(|v| v.as_array())
194 .cloned()
195 .unwrap_or_default();
196 let chart_label_start = graph_arr
197 .first()
198 .and_then(|p| p.get("label"))
199 .and_then(|l| l.as_str())
200 .unwrap_or("")
201 .to_string();
202 let chart_label_end = graph_arr
203 .last()
204 .and_then(|p| p.get("label"))
205 .and_then(|l| l.as_str())
206 .unwrap_or("")
207 .to_string();
208 let (chart_peak_count, chart_peak_label) = graph_arr
209 .iter()
210 .max_by_key(|p| p.get("count").and_then(|c| c.as_i64()).unwrap_or(0))
211 .map(|p| {
212 (
213 p.get("count").and_then(|c| c.as_i64()).unwrap_or(0),
214 p.get("label").and_then(|l| l.as_str()).unwrap_or("").to_string(),
215 )
216 })
217 .unwrap_or((0, String::new()));
218
219 let breakdown_total = |key: &str| -> i64 {
220 dash_value
221 .get(key)
222 .and_then(|v| v.as_array())
223 .map(|arr| {
224 arr.iter()
225 .filter_map(|item| item.get("count").and_then(|c| c.as_i64()))
226 .sum::<i64>()
227 .max(1)
228 })
229 .unwrap_or(1)
230 };
231 let breakdown_totals = serde_json::json!({
232 "device": breakdown_total("total_events_by_device"),
233 "browser": breakdown_total("total_events_by_browser"),
234 "platform": breakdown_total("total_events_by_platform"),
235 "screen_size": breakdown_total("total_events_by_screen_size"),
236 });
237
238 let mut top_countries: Vec<serde_json::Value> = dash_value
239 .get("session_starts_by_country")
240 .and_then(|v| v.as_object())
241 .map(|m| {
242 m.iter()
243 .map(|(k, v)| {
244 serde_json::json!({
245 "label": k,
246 "count": v.as_i64().unwrap_or(0),
247 })
248 })
249 .collect()
250 })
251 .unwrap_or_default();
252 top_countries.sort_by_key(|v| -v.get("count").and_then(|c| c.as_i64()).unwrap_or(0));
253 top_countries.truncate(10);
254
255 let generated_at = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string();
256
257 let extra = minijinja::context! {
258 page => minijinja::context! {
259 title => &p.name,
260 description => format!("Analytics for {}", p.name),
261 },
262 property => minijinja::context! {
263 id => p.id.to_string(),
264 name => &p.name,
265 is_protected => p.is_protected,
266 is_public => p.is_public,
267 },
268 date_start => &date_start,
269 date_end => &date_end,
270 date_range => date_range,
271 filter_url => filter_url,
272 total_live_users => total_live_users,
273 event_cards => dash_value.get("event_cards").cloned().unwrap_or(serde_json::Value::Array(vec![])),
274 custom_events => dash_value.get("custom_events").cloned().unwrap_or(serde_json::Value::Array(vec![])),
275 total_events_graph => dash_value.get("total_events_graph").cloned().unwrap_or(serde_json::Value::Array(vec![])),
276 total_events_by_screen_size => dash_value.get("total_events_by_screen_size").cloned().unwrap_or(serde_json::Value::Array(vec![])),
277 total_events_by_device => dash_value.get("total_events_by_device").cloned().unwrap_or(serde_json::Value::Array(vec![])),
278 total_events_by_browser => dash_value.get("total_events_by_browser").cloned().unwrap_or(serde_json::Value::Array(vec![])),
279 total_events_by_platform => dash_value.get("total_events_by_platform").cloned().unwrap_or(serde_json::Value::Array(vec![])),
280 total_events_by_page_url => dash_value.get("total_events_by_page_url").cloned().unwrap_or(serde_json::Value::Array(vec![])),
281 total_page_views_by_page_url => dash_value.get("total_page_views_by_page_url").cloned().unwrap_or(serde_json::Value::Array(vec![])),
282 total_events_by_custom_event => dash_value.get("total_events_by_custom_event").cloned().unwrap_or(serde_json::Value::Array(vec![])),
283 total_session_starts_by_referrer => dash_value.get("total_session_starts_by_referrer").cloned().unwrap_or(serde_json::Value::Array(vec![])),
284 total_page_views_by_utm_medium => dash_value.get("total_page_views_by_utm_medium").cloned().unwrap_or(serde_json::Value::Array(vec![])),
285 total_page_views_by_utm_source => dash_value.get("total_page_views_by_utm_source").cloned().unwrap_or(serde_json::Value::Array(vec![])),
286 total_page_views_by_utm_campaign => dash_value.get("total_page_views_by_utm_campaign").cloned().unwrap_or(serde_json::Value::Array(vec![])),
287 session_starts_by_country => dash_value.get("session_starts_by_country").cloned().unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
288 session_starts_by_country_region => dash_value.get("session_starts_by_country_region").cloned().unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
289 bot_traffic => dash_value.get("bot_traffic").cloned().unwrap_or(serde_json::json!({"total": 0, "top_bots": [], "top_pages": []})),
290 chart_polyline => &chart_polyline,
291 chart_label_start => &chart_label_start,
292 chart_label_end => &chart_label_end,
293 chart_peak_count => chart_peak_count,
294 chart_peak_label => &chart_peak_label,
295 breakdown_totals => &breakdown_totals,
296 top_countries => &top_countries,
297 generated_at => &generated_at,
298 };
299
300 // Report exports.
301 if let Some(fmt) = q.report.as_deref() {
302 let fmt = if fmt.is_empty() { "pdf" } else { fmt };
303 let path = format!("/{property_id}");
304 if fmt == "md" {
305 let body = match render_to_string(
306 &state,
307 "properties/property_report.md",
308 &path,
309 authed,
310 extra,
311 ) {
312 Ok(b) => b,
313 Err(resp) => return resp,
314 };
315 let mut h = axum::http::HeaderMap::new();
316 h.insert(
317 axum::http::header::CONTENT_TYPE,
318 "text/markdown; charset=utf-8".parse().unwrap(),
319 );
320 h.insert(
321 axum::http::header::CONTENT_DISPOSITION,
322 format!("inline; filename=\"{}.md\"", ascii_filename(&p.name)).parse().unwrap(),
323 );
324 return (StatusCode::OK, h, body).into_response();
325 }
326 if fmt == "pdf" {
327 let typst_source = match render_to_string(
328 &state,
329 "properties/property_report.typ",
330 &path,
331 authed,
332 extra,
333 ) {
334 Ok(b) => b,
335 Err(resp) => return resp,
336 };
337 let renderer = state.pdf_renderer.clone();
338 let pdf_res =
339 tokio::task::spawn_blocking(move || renderer.render(typst_source)).await;
340 match pdf_res {
341 Ok(Ok(bytes)) => {
342 let mut h = axum::http::HeaderMap::new();
343 h.insert(axum::http::header::CONTENT_TYPE, "application/pdf".parse().unwrap());
344 h.insert(
345 axum::http::header::CONTENT_DISPOSITION,
346 format!("inline; filename=\"{}.pdf\"", ascii_filename(&p.name)).parse().unwrap(),
347 );
348 return (StatusCode::OK, h, bytes).into_response();
349 }
350 Ok(Err(e)) => {
351 tracing::error!("pdf render: {e}");
352 return (StatusCode::INTERNAL_SERVER_ERROR, "pdf error").into_response();
353 }
354 Err(e) => {
355 tracing::error!("pdf join: {e}");
356 return (StatusCode::INTERNAL_SERVER_ERROR, "pdf error").into_response();
357 }
358 }
359 }
360 }
361
362 render(
363 &state,
364 "properties/property.html",
365 &format!("/{property_id}"),
366 authed,
367 extra,
368 )
369}
370
371/// Toner-friendly SVG polyline points for the print template. `width`,
372/// `height`, and `padding` match the SVG viewBox in
373/// templates/properties/property_print.html — change one and change the other.
374fn build_chart_polyline(points: &[serde_json::Value]) -> String {
375 if points.is_empty() {
376 return String::new();
377 }
378 let counts: Vec<i64> = points
379 .iter()
380 .map(|p| p.get("count").and_then(|c| c.as_i64()).unwrap_or(0))
381 .collect();
382 let max = *counts.iter().max().unwrap_or(&1);
383 let max = if max == 0 { 1 } else { max };
384 let n = counts.len();
385 let width = 600.0_f64;
386 let height = 100.0_f64;
387 let padding = 4.0_f64;
388 let usable_h = height - 2.0 * padding;
389 if n == 1 {
390 let x = width / 2.0;
391 let y = height - padding - (counts[0] as f64 / max as f64) * usable_h;
392 return format!("{x:.1},{y:.1}");
393 }
394 counts
395 .iter()
396 .enumerate()
397 .map(|(i, c)| {
398 let x = (i as f64 / (n - 1) as f64) * width;
399 let y = height - padding - (*c as f64 / max as f64) * usable_h;
400 format!("{x:.1},{y:.1}")
401 })
402 .collect::<Vec<_>>()
403 .join(" ")
404}