Skip to main content

rws_proxy/traffic/
summary.rs

1//! The Traffic page's data: totals for a period and the one before it, a
2//! series (hourly up to 48 hours, daily beyond), and the top lists.
3
4use std::collections::BTreeMap;
5
6use super::record::{snapshot, Day, Sessions, Top, BEHAVIOUR, DIMS};
7
8fn quote(s: &str) -> String {
9    let mut out = String::with_capacity(s.len() + 2);
10    out.push('"');
11    for c in s.chars() {
12        match c {
13            '"' => out.push_str("\\\""),
14            '\\' => out.push_str("\\\\"),
15            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
16            c => out.push(c),
17        }
18    }
19    out.push('"');
20    out
21}
22
23fn pairs(top: &Top, n: usize, host: Option<&str>) -> String {
24    let mut filtered = Top::default();
25    for (key, count) in &top.0 {
26        if host.is_none_or(|h| key.starts_with(h) || key == "(other)") {
27            filtered.0.insert(key.clone(), *count);
28        }
29    }
30    let rows: Vec<String> = filtered.sorted(n).iter().map(|(k, v)| format!("[{},{v}]", quote(k))).collect();
31    format!("[{}]", rows.join(","))
32}
33
34/// An audience/source list (`host\tvalue` keys) as `[[value, n], …]`: one
35/// host's, or all hosts' added up.
36fn dim(top: &Top, n: usize, host: Option<&str>) -> String {
37    let mut merged = Top::default();
38    for (key, count) in &top.0 {
39        let (h, value) = key.split_once('\t').unwrap_or(("", key.as_str()));
40        if host.is_none_or(|wanted| h.eq_ignore_ascii_case(wanted)) || key == "(other)" {
41            *merged.0.entry(value.to_string()).or_default() += count;
42        }
43    }
44    let rows: Vec<String> = merged.sorted(n).iter().map(|(k, v)| format!("[{},{v}]", quote(k))).collect();
45    format!("[{}]", rows.join(","))
46}
47
48/// The JSON for the last `hours` hours (1–720), optionally one host's.
49pub fn json(hours: u64, host: Option<&str>, now: u64) -> String {
50    let hours = hours.clamp(1, 24 * 90);
51    let (buckets, days) = snapshot(now);
52    let names = super::record::host_names();
53    let host_index = host.and_then(|h| names.iter().position(|n| n.eq_ignore_ascii_case(h))).map(|i| i as u8);
54    let wanted = |h: u8| host.is_none() || Some(h) == host_index;
55    let this_hour = now / 3600;
56    let daily = hours > 48;
57    // Daily views cover whole UTC days (today and the days before it), so
58    // the per-day counts — visitors, sessions, lists — line up with them.
59    let hours = if daily { hours.div_ceil(24) * 24 } else { hours };
60    let start = if daily { (this_hour / 24 + 1).saturating_sub(hours / 24) * 24 } else { this_hour + 1 - hours };
61    let previous_start = start.saturating_sub(hours);
62
63    let mut totals = [[0u64; 2]; 3];
64    let mut previous = [[0u64; 2]; 3];
65    // Per bucket: requests per group, then people's page views.
66    let mut series: BTreeMap<u64, [u64; 4]> = BTreeMap::new();
67    // The period before, by the same bucket offsets (for comparing).
68    let mut previous_series: BTreeMap<u64, [u64; 4]> = BTreeMap::new();
69    let mut first_hour = None;
70    for ((hour, h), counts) in &buckets {
71        if !wanted(*h) {
72            continue;
73        }
74        first_hour = Some(first_hour.map_or(*hour, |f: u64| f.min(*hour)));
75        let target = if *hour >= start && *hour <= this_hour {
76            Some(&mut totals)
77        } else if *hour >= previous_start && *hour < start {
78            Some(&mut previous)
79        } else {
80            None
81        };
82        if let Some(t) = target {
83            for g in 0..3 {
84                t[g][0] += counts[g].requests;
85                t[g][1] += counts[g].pages;
86            }
87        }
88        let into = if *hour >= start && *hour <= this_hour {
89            Some((&mut series, *hour))
90        } else if *hour >= previous_start && *hour < start {
91            Some((&mut previous_series, *hour + hours))
92        } else {
93            None
94        };
95        if let Some((map, at)) = into {
96            let key = if daily { at / 24 * 24 } else { at };
97            let point = map.entry(key).or_default();
98            for g in 0..3 {
99                point[g] += counts[g].requests;
100            }
101            point[3] += counts[0].pages;
102        }
103    }
104    // Every bucket of the period, empty ones included.
105    let step = if daily { 24 } else { 1 };
106    let mut t = if daily { start / 24 * 24 } else { start };
107    while t <= this_hour {
108        series.entry(t).or_default();
109        previous_series.entry(t).or_default();
110        t += step;
111    }
112    previous_series.retain(|k, _| series.contains_key(k));
113
114    let start_day = start / 24;
115    let previous_day = previous_start / 24;
116    let mut lists = Day::default();
117    let mut visitors = [0u64; 3];
118    let mut previous_visitors = [0u64; 3];
119    let mut sessions = Sessions::default();
120    let mut previous_sessions = Sessions::default();
121    let host_sessions = |d: &Day, into: &mut Sessions| {
122        for (h, s) in &d.sessions {
123            if host.is_none_or(|wanted| h.eq_ignore_ascii_case(wanted)) {
124                into.add(s);
125            }
126        }
127    };
128    // People's visitors and sessions per day (current and previous period,
129    // the previous keyed by the matching current day).
130    let mut per_day: BTreeMap<u64, [u64; 6]> = BTreeMap::new();
131    let period_days = hours.div_ceil(24).max(1);
132    for day in start_day..=this_hour / 24 {
133        per_day.entry(day).or_default();
134    }
135    for (day, d) in &days {
136        let mut s = Sessions::default();
137        host_sessions(d, &mut s);
138        let (key, offset) = if *day >= start_day { (*day, 0) } else { (*day + period_days, 3) };
139        if let Some(row) = per_day.get_mut(&key) {
140            row[offset] += d.visitors[0];
141            row[offset + 1] += s.sessions;
142            row[offset + 2] += s.bounces;
143        }
144        if *day >= start_day {
145            host_sessions(d, &mut sessions);
146        } else if *day >= previous_day {
147            host_sessions(d, &mut previous_sessions);
148        }
149        if *day >= start_day {
150            lists.merge(d);
151            for g in 0..3 {
152                visitors[g] += d.visitors[g];
153            }
154        } else if *day >= previous_day {
155            for g in 0..3 {
156                previous_visitors[g] += d.visitors[g];
157            }
158        }
159    }
160
161    let group = |t: &[[u64; 2]; 3], v: &[u64; 3], g: usize| format!(r#"{{"requests":{},"pages":{},"visitors":{}}}"#, t[g][0], t[g][1], v[g]);
162    let groups = |t: &[[u64; 2]; 3], v: &[u64; 3]| format!(r#"{{"people":{},"ai":{},"bots":{}}}"#, group(t, v, 0), group(t, v, 1), group(t, v, 2));
163    let point = |(t, p): (&u64, &[u64; 4])| format!(r#"{{"t":{},"people":{},"ai":{},"bots":{},"pages":{}}}"#, t * 3600, p[0], p[1], p[2], p[3]);
164    let points: Vec<String> = series.iter().map(point).collect();
165    let previous_points: Vec<String> = previous_series.iter().map(point).collect();
166    let day_points: Vec<String> = per_day
167        .iter()
168        .map(|(d, r)| format!(r#"{{"t":{},"visitors":{},"sessions":{},"bounces":{},"previous_visitors":{},"previous_sessions":{},"previous_bounces":{}}}"#, d * 86_400, r[0], r[1], r[2], r[3], r[4], r[5]))
169        .collect();
170    let agents: Vec<String> = lists
171        .ai_agents
172        .sorted(25)
173        .iter()
174        .map(|(key, n)| {
175            let mut parts = key.split('\t');
176            let (name, operator, kind, verified) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next() == Some("true"));
177            format!(r#"{{"name":{},"operator":{},"kind":{},"verified":{verified},"requests":{n}}}"#, quote(name), quote(operator), quote(kind))
178        })
179        .collect();
180    let bots: Vec<String> = lists
181        .bots
182        .sorted(25)
183        .iter()
184        .map(|(key, n)| {
185            let (kind, name) = key.split_once('\t').unwrap_or((key, ""));
186            format!(r#"{{"kind":{},"name":{},"requests":{n}}}"#, quote(kind), quote(name))
187        })
188        .collect();
189    let dims: Vec<String> = DIMS.iter().enumerate().map(|(i, name)| format!("{}:{}", quote(name), dim(&lists.dims[i], if i == 0 { 50 } else { 25 }, host))).collect();
190    // Time on page: seconds and reported views per page, as [page, avg, views].
191    let mut on_page: Vec<(String, u64, u64)> = lists.behaviour[1]
192        .0
193        .iter()
194        .filter(|(k, _)| host.is_none_or(|h| k.starts_with(h)) && k.as_str() != "(other)")
195        .map(|(k, views)| (k.clone(), lists.behaviour[0].0.get(k).copied().unwrap_or(0) / (*views).max(1), *views))
196        .collect();
197    on_page.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
198    on_page.truncate(25);
199    let on_page: Vec<String> = on_page.iter().map(|(k, avg, views)| format!("[{},{avg},{views}]", quote(k))).collect();
200    let behaviour: Vec<String> = BEHAVIOUR
201        .iter()
202        .enumerate()
203        .skip(2)
204        .map(|(i, name)| format!("{}:{}", quote(name), dim(&lists.behaviour[i], 25, host)))
205        .collect();
206    let session_json = |s: &Sessions| format!(r#"{{"sessions":{},"bounces":{},"duration":{},"pages":{}}}"#, s.sessions, s.bounces, s.duration, s.pages);
207    let geo = super::geo::table().map_or("null".to_string(), |t| quote(&t.source));
208    format!(
209        r#"{{"hours":{hours},"bucket":"{}","since":{},"hosts":[{}],"host":{},"totals":{},"previous":{},"series":[{}],"people_pages":{},"ai_pages":{},"ai_agents":[{}],"bots":[{}],"referrers":{},"not_found":{},{},"geo_source":{geo},"sessions":{},"previous_sessions":{},"entry_pages":{},"exit_pages":{},"previous_series":[{}],"days":[{}],"script":{},"store":{},"time_on_page":[{}],{}}}"#,
210        if daily { "day" } else { "hour" },
211        first_hour.map_or("null".to_string(), |h| (h * 3600).to_string()),
212        // Only sites with counted traffic (this machine's own calls, which
213        // aren't counted, still register their Host).
214        names
215            .iter()
216            .enumerate()
217            .filter(|(i, _)| buckets.iter().any(|((_, h), c)| *h as usize == *i && c.iter().any(|g| g.requests > 0)))
218            .map(|(_, n)| quote(n))
219            .collect::<Vec<_>>()
220            .join(","),
221        host.map_or("null".to_string(), quote),
222        groups(&totals, &visitors),
223        groups(&previous, &previous_visitors),
224        points.join(","),
225        pairs(&lists.people_pages, 25, host),
226        pairs(&lists.ai_pages, 25, host),
227        agents.join(","),
228        bots.join(","),
229        pairs(&lists.referrers, 25, None),
230        pairs(&lists.not_found, 25, host),
231        dims.join(","),
232        session_json(&sessions),
233        session_json(&previous_sessions),
234        pairs(&lists.entry_pages, 25, host),
235        pairs(&lists.exit_pages, 25, host),
236        previous_points.join(","),
237        day_points.join(","),
238        super::config::script(),
239        store_on(),
240        on_page.join(","),
241        behaviour.join(","),
242    )
243}
244
245/// The live view: people on the site in the last 5 minutes, page views per
246/// minute for the last 30, and what the active ones read and where they
247/// are; optionally one host's.
248pub fn live_json(host: Option<&str>, now: u64) -> String {
249    use std::collections::{HashMap, HashSet};
250    let prefix = host.map(|h| format!("{}/", h.to_ascii_lowercase()));
251    let views: Vec<_> = super::record::recent(now).into_iter().filter(|r| prefix.as_ref().is_none_or(|p| r.page.starts_with(p.as_str()))).collect();
252    let this_minute = now / 60;
253    let mut minutes = [0u64; 30];
254    let active_since = now.saturating_sub(5 * 60);
255    let mut active: HashSet<u64> = HashSet::new();
256    let mut pages = Top::default();
257    let mut countries: HashMap<u64, String> = HashMap::new();
258    for r in &views {
259        let ago = this_minute.saturating_sub(r.at / 60) as usize;
260        if ago < 30 {
261            minutes[29 - ago] += 1;
262        }
263        if r.at >= active_since {
264            active.insert(r.visitor);
265            *pages.0.entry(r.page.clone()).or_default() += 1;
266            let country = r.country.map_or("(unknown)".to_string(), |c| String::from_utf8_lossy(&c).into_owned());
267            countries.insert(r.visitor, country);
268        }
269    }
270    let mut by_country = Top::default();
271    for c in countries.values() {
272        *by_country.0.entry(c.clone()).or_default() += 1;
273    }
274    let series: Vec<String> = minutes.iter().enumerate().map(|(i, n)| format!(r#"{{"t":{},"views":{n}}}"#, (this_minute + 1 + i as u64 - 30) * 60)).collect();
275    format!(
276        r#"{{"now":{now},"host":{},"visitors":{},"views_5m":{},"series":[{}],"pages":{},"countries":{}}}"#,
277        host.map_or("null".to_string(), quote),
278        active.len(),
279        views.iter().filter(|r| r.at >= active_since).count(),
280        series.join(","),
281        pairs(&pages, 10, None),
282        pairs(&by_country, 10, None),
283    )
284}
285
286fn store_on() -> bool {
287    #[cfg(feature = "model-postgres")]
288    return super::store::enabled();
289    #[cfg(not(feature = "model-postgres"))]
290    false
291}
292
293/// Days since the epoch of `YYYY-MM-DD`.
294#[doc(hidden)]
295pub fn parse_day(text: &str) -> Option<u64> {
296    let mut parts = text.trim().splitn(3, '-');
297    let (y, m, d): (i64, i64, i64) = (parts.next()?.parse().ok()?, parts.next()?.parse().ok()?, parts.next()?.parse().ok()?);
298    if !(1970..=9999).contains(&y) || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
299        return None;
300    }
301    // Howard Hinnant's days_from_civil.
302    let y = if m <= 2 { y - 1 } else { y };
303    let era = y.div_euclid(400);
304    let yoe = y - era * 400;
305    let mp = (m + 9) % 12;
306    let doy = (153 * mp + 2) / 5 + d - 1;
307    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
308    let days = era * 146_097 + doe - 719_468;
309    (days >= 0).then_some(days as u64)
310}
311
312/// The `hours` and `now` for the summary of days `from`..=`to` (UTC, as
313/// `YYYY-MM-DD`): the period ends with `to`, or now if `to` is today.
314pub fn day_range(from: &str, to: &str, now: u64) -> Result<(u64, u64), String> {
315    let today = now / 86_400;
316    let to = if to.is_empty() { today } else { parse_day(to).ok_or(format!("to: expected YYYY-MM-DD, got {to:?}"))? };
317    let from = parse_day(from).ok_or(format!("from: expected YYYY-MM-DD, got {from:?}"))?;
318    if from > to {
319        return Err("from must be on or before to".to_string());
320    }
321    let end = if to >= today { now } else { (to + 1) * 86_400 - 1 };
322    let hours = (end / 3600 + 1).saturating_sub(from * 24).clamp(1, 24 * 90);
323    Ok((hours, end))
324}