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.2 KB · 405 lines · Rust Raw History
  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}