use std::collections::{HashMap, HashSet};
use serde_json::json;
use crate::{ArtifactKind, BrowserEvent};
pub const DEFAULT_IDLE_GAP_MINUTES: i64 = 30;
const MAX_CHAIN_DEPTH: usize = 4096;
fn attr_i64(e: &BrowserEvent, key: &str) -> Option<i64> {
e.attrs.get(key).and_then(serde_json::Value::as_i64)
}
fn attr_str<'a>(e: &'a BrowserEvent, key: &str) -> Option<&'a str> {
e.attrs.get(key).and_then(serde_json::Value::as_str)
}
fn attr_bool(e: &BrowserEvent, key: &str) -> Option<bool> {
e.attrs.get(key).and_then(serde_json::Value::as_bool)
}
fn index_by_visit_id(events: &[BrowserEvent]) -> HashMap<i64, usize> {
let mut map = HashMap::new();
for (i, e) in events.iter().enumerate() {
if let Some(id) = attr_i64(e, "visit_id") {
map.entry(id).or_insert(i);
}
}
map
}
#[must_use]
pub fn human_transition_label(token: &str) -> &'static str {
match token {
"link" => "clicked link",
"typed" => "typed URL",
"auto_bookmark" | "bookmark" => "bookmark",
"auto_subframe" => "subframe (auto)",
"manual_subframe" => "subframe (manual)",
"generated" => "generated",
"auto_toplevel" | "start_page" => "start page",
"form_submit" => "form submit",
"reload" => "reload",
"keyword" | "keyword_generated" => "keyword search",
"embed" => "embedded object",
"redirect_permanent" => "redirect (permanent)",
"redirect_temporary" => "redirect (temporary)",
"download" => "download",
"framed_link" => "framed link",
_ => "unknown",
}
}
pub fn resolve_referrer_chains(events: &mut [BrowserEvent]) {
let url_of: HashMap<i64, String> = events
.iter()
.filter_map(|e| {
let id = attr_i64(e, "visit_id")?;
Some((id, attr_str(e, "url").unwrap_or_default().to_string()))
})
.collect();
let from_of: HashMap<i64, i64> = events
.iter()
.filter_map(|e| {
Some((
attr_i64(e, "visit_id")?,
attr_i64(e, "from_visit").unwrap_or(0),
))
})
.collect();
for e in events.iter_mut() {
let from = attr_i64(e, "from_visit").unwrap_or(0);
if from != 0 {
if let Some(u) = url_of.get(&from) {
e.attrs.insert("referrer_url".to_string(), json!(u));
}
}
let mut depth: i64 = 0;
let mut cur = from;
let mut seen: HashSet<i64> = HashSet::new();
while cur != 0 && (depth as usize) < MAX_CHAIN_DEPTH {
if !url_of.contains_key(&cur) || !seen.insert(cur) {
break; }
depth += 1;
cur = from_of.get(&cur).copied().unwrap_or(0);
}
e.attrs.insert("nav_depth".to_string(), json!(depth));
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedirectHop {
pub visit_id: i64,
pub url: String,
pub kind: Option<String>,
pub role: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedirectChain {
pub id: usize,
pub hops: Vec<RedirectHop>,
}
fn role_for(pos: usize, total: usize) -> &'static str {
if total <= 1 || pos == total - 1 {
"landing"
} else if pos == 0 {
"start"
} else {
"hop"
}
}
#[must_use]
pub fn redirect_chains(events: &[BrowserEvent]) -> Vec<RedirectChain> {
let id_to_idx = index_by_visit_id(events);
let is_red = |i: usize| attr_bool(&events[i], "is_redirect") == Some(true);
let mut redirect_children: HashMap<i64, Vec<usize>> = HashMap::new();
for (i, e) in events.iter().enumerate() {
if is_red(i) {
let from = attr_i64(e, "from_visit").unwrap_or(0);
redirect_children.entry(from).or_default().push(i);
}
}
let hop = |idx: usize, kind: Option<String>, role: &'static str| RedirectHop {
visit_id: attr_i64(&events[idx], "visit_id").unwrap_or(0),
url: attr_str(&events[idx], "url")
.unwrap_or_default()
.to_string(),
kind,
role,
};
let mut chains: Vec<RedirectChain> = Vec::new();
let mut assigned: HashSet<usize> = HashSet::new();
for (i, e) in events.iter().enumerate() {
if !is_red(i) || assigned.contains(&i) {
continue;
}
let from = attr_i64(e, "from_visit").unwrap_or(0);
let parent_idx = id_to_idx.get(&from).copied();
if matches!(parent_idx, Some(pi) if is_red(pi)) {
continue;
}
let mut run: Vec<usize> = Vec::new();
let mut cur = i;
let mut seen: HashSet<usize> = HashSet::new();
while run.len() < MAX_CHAIN_DEPTH && seen.insert(cur) {
run.push(cur);
assigned.insert(cur);
let cur_id = attr_i64(&events[cur], "visit_id").unwrap_or(0);
let next = redirect_children
.get(&cur_id)
.and_then(|kids| kids.iter().copied().find(|k| !seen.contains(k)));
match next {
Some(n) => cur = n,
None => break,
}
}
let origin = parent_idx.filter(|&pi| !is_red(pi));
let total = run.len() + usize::from(origin.is_some());
let mut hops: Vec<RedirectHop> = Vec::with_capacity(total);
let mut pos = 0;
if let Some(oi) = origin {
hops.push(hop(oi, None, role_for(pos, total)));
pos += 1;
}
for &ri in &run {
let kind = attr_str(&events[ri], "redirect_kind").map(str::to_string);
hops.push(hop(ri, kind, role_for(pos, total)));
pos += 1;
}
chains.push(RedirectChain {
id: chains.len(),
hops,
});
}
chains
}
pub fn tag_redirect_chains(events: &mut [BrowserEvent]) {
let chains = redirect_chains(events);
let mut tag: HashMap<i64, (usize, &'static str)> = HashMap::new();
for c in &chains {
for h in &c.hops {
tag.insert(h.visit_id, (c.id, h.role));
}
}
for e in events.iter_mut() {
if let Some(id) = attr_i64(e, "visit_id") {
if let Some((cid, role)) = tag.get(&id) {
e.attrs.insert("redirect_chain_id".to_string(), json!(cid));
e.attrs.insert("redirect_role".to_string(), json!(role));
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct SessionConfig {
pub idle_gap_ns: i64,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
idle_gap_ns: DEFAULT_IDLE_GAP_MINUTES * 60 * 1_000_000_000,
}
}
}
pub fn sessionize(events: &mut [BrowserEvent], cfg: SessionConfig) {
if events.is_empty() {
return;
}
let mut order: Vec<usize> = (0..events.len()).collect();
order.sort_by_key(|&i| events[i].timestamp_ns);
let mut session: i64 = 0;
let mut prev_ts: Option<i64> = None;
let mut prev_sess: Option<Option<i64>> = None;
for &i in &order {
let ts = events[i].timestamp_ns;
let recorded = attr_i64(&events[i], "session");
if let Some(pt) = prev_ts {
let gap = ts.saturating_sub(pt);
let sess_changed =
prev_sess.is_some_and(|ps| ps.is_some() && recorded.is_some() && ps != recorded);
if gap > cfg.idle_gap_ns || sess_changed {
session += 1;
}
}
events[i]
.attrs
.insert("session_id".to_string(), json!(session));
prev_ts = Some(ts);
prev_sess = Some(recorded);
}
}
#[must_use]
pub fn tabs_open_at(session_events: &[BrowserEvent], t_ns: i64) -> Vec<&BrowserEvent> {
session_events
.iter()
.filter(|e| {
e.artifact == ArtifactKind::Session && e.timestamp_ns > 0 && e.timestamp_ns <= t_ns
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BrowserEvent, BrowserFamily};
fn visit(id: i64, from: i64, ts_ns: i64, url: &str) -> BrowserEvent {
BrowserEvent::new(
ts_ns,
BrowserFamily::Chromium,
ArtifactKind::History,
"src",
url,
)
.with_attr("url", json!(url))
.with_attr("visit_id", json!(id))
.with_attr("from_visit", json!(from))
}
fn redirect_visit(id: i64, from: i64, ts_ns: i64, url: &str, kind: &str) -> BrowserEvent {
visit(id, from, ts_ns, url)
.with_attr("is_redirect", json!(true))
.with_attr("redirect_kind", json!(kind))
}
#[test]
fn transition_labels_are_human_readable() {
assert_eq!(human_transition_label("typed"), "typed URL");
assert_eq!(human_transition_label("link"), "clicked link");
assert_eq!(human_transition_label("form_submit"), "form submit");
assert_eq!(human_transition_label("reload"), "reload");
assert_eq!(
human_transition_label("redirect_permanent"),
"redirect (permanent)"
);
assert_eq!(human_transition_label("auto_bookmark"), "bookmark");
assert_eq!(human_transition_label("something_new"), "unknown");
}
#[test]
fn referrer_chain_sets_referrer_url_and_depth() {
let mut events = vec![
visit(1, 0, 1000, "https://a.example"),
visit(2, 1, 2000, "https://b.example"),
visit(3, 2, 3000, "https://c.example"),
];
resolve_referrer_chains(&mut events);
assert_eq!(events[0].attrs["nav_depth"], json!(0));
assert!(!events[0].attrs.contains_key("referrer_url"));
assert_eq!(events[1].attrs["referrer_url"], json!("https://a.example"));
assert_eq!(events[1].attrs["nav_depth"], json!(1));
assert_eq!(events[2].attrs["referrer_url"], json!("https://b.example"));
assert_eq!(events[2].attrs["nav_depth"], json!(2));
}
#[test]
fn dangling_from_visit_leaves_no_referrer() {
let mut events = vec![visit(1, 999, 1000, "https://a.example")];
resolve_referrer_chains(&mut events);
assert!(!events[0].attrs.contains_key("referrer_url"));
assert_eq!(events[0].attrs["nav_depth"], json!(0));
}
#[test]
fn cyclic_from_visit_is_bounded_not_infinite() {
let mut events = vec![
visit(1, 2, 1000, "https://a.example"),
visit(2, 1, 2000, "https://b.example"),
];
resolve_referrer_chains(&mut events);
assert_eq!(events[0].attrs["referrer_url"], json!("https://b.example"));
let d0 = events[0].attrs["nav_depth"].as_i64().unwrap();
let d1 = events[1].attrs["nav_depth"].as_i64().unwrap();
assert!(d0 <= MAX_CHAIN_DEPTH as i64);
assert!(d1 <= MAX_CHAIN_DEPTH as i64);
}
#[test]
fn redirect_chain_groups_origin_and_hops_with_roles() {
let mut events = vec![
visit(1, 0, 1000, "https://origin.example"),
redirect_visit(2, 1, 2000, "https://hop.example", "server"),
redirect_visit(3, 2, 3000, "https://landing.example", "client"),
];
let chains = redirect_chains(&events);
assert_eq!(chains.len(), 1);
let c = &chains[0];
assert_eq!(c.hops.len(), 3);
assert_eq!(c.hops[0].role, "start");
assert_eq!(c.hops[0].kind, None);
assert_eq!(c.hops[0].url, "https://origin.example");
assert_eq!(c.hops[1].role, "hop");
assert_eq!(c.hops[1].kind.as_deref(), Some("server"));
assert_eq!(c.hops[2].role, "landing");
assert_eq!(c.hops[2].kind.as_deref(), Some("client"));
tag_redirect_chains(&mut events);
assert_eq!(events[0].attrs["redirect_role"], json!("start"));
assert_eq!(events[1].attrs["redirect_role"], json!("hop"));
assert_eq!(events[2].attrs["redirect_role"], json!("landing"));
assert_eq!(
events[0].attrs["redirect_chain_id"],
events[2].attrs["redirect_chain_id"]
);
}
#[test]
fn no_redirects_yields_no_chains() {
let events = vec![
visit(1, 0, 1000, "https://a.example"),
visit(2, 0, 2000, "https://b.example"),
];
assert!(redirect_chains(&events).is_empty());
}
#[test]
fn redirect_chain_with_dangling_origin_starts_at_first_redirect() {
let events = vec![redirect_visit(2, 1, 2000, "https://only.example", "server")];
let chains = redirect_chains(&events);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].hops.len(), 1);
assert_eq!(chains[0].hops[0].role, "landing");
}
fn min_ns(m: i64) -> i64 {
m * 60 * 1_000_000_000
}
#[test]
fn sessionize_groups_by_idle_gap() {
let mut events = vec![
visit(1, 0, 0, "https://a.example"),
visit(2, 0, min_ns(5), "https://b.example"), visit(3, 0, min_ns(50), "https://c.example"), ];
sessionize(&mut events, SessionConfig::default());
assert_eq!(events[0].attrs["session_id"], json!(0));
assert_eq!(events[1].attrs["session_id"], json!(0));
assert_eq!(events[2].attrs["session_id"], json!(1));
}
#[test]
fn sessionize_respects_custom_idle_gap() {
let mut events = vec![
visit(1, 0, 0, "https://a.example"),
visit(2, 0, min_ns(5), "https://b.example"),
];
sessionize(
&mut events,
SessionConfig {
idle_gap_ns: min_ns(2),
},
);
assert_eq!(events[0].attrs["session_id"], json!(0));
assert_eq!(events[1].attrs["session_id"], json!(1));
}
#[test]
fn sessionize_splits_on_recorded_session_change() {
let mut events = vec![
visit(1, 0, 0, "https://a.example").with_attr("session", json!(7)),
visit(2, 0, min_ns(1), "https://b.example").with_attr("session", json!(8)),
];
sessionize(&mut events, SessionConfig::default());
assert_ne!(events[0].attrs["session_id"], events[1].attrs["session_id"]);
}
fn tab_event(ts_ns: i64, url: &str) -> BrowserEvent {
BrowserEvent::new(
ts_ns,
BrowserFamily::Chromium,
ArtifactKind::Session,
"src",
url,
)
.with_attr("url", json!(url))
}
#[test]
fn tabs_open_at_filters_by_time() {
let events = vec![
tab_event(1000, "https://early.example"),
tab_event(5000, "https://late.example"),
];
let open = tabs_open_at(&events, 2000);
assert_eq!(open.len(), 1);
assert_eq!(open[0].attrs["url"], json!("https://early.example"));
}
}