1use std::collections::{HashMap, HashSet};
25
26use serde_json::json;
27
28use crate::{ArtifactKind, BrowserEvent};
29
30pub const DEFAULT_IDLE_GAP_MINUTES: i64 = 30;
34
35const MAX_CHAIN_DEPTH: usize = 4096;
38
39fn attr_i64(e: &BrowserEvent, key: &str) -> Option<i64> {
44 e.attrs.get(key).and_then(serde_json::Value::as_i64)
45}
46
47fn attr_str<'a>(e: &'a BrowserEvent, key: &str) -> Option<&'a str> {
48 e.attrs.get(key).and_then(serde_json::Value::as_str)
49}
50
51fn attr_bool(e: &BrowserEvent, key: &str) -> Option<bool> {
52 e.attrs.get(key).and_then(serde_json::Value::as_bool)
53}
54
55fn index_by_visit_id(events: &[BrowserEvent]) -> HashMap<i64, usize> {
58 let mut map = HashMap::new();
59 for (i, e) in events.iter().enumerate() {
60 if let Some(id) = attr_i64(e, "visit_id") {
61 map.entry(id).or_insert(i);
62 }
63 }
64 map
65}
66
67#[must_use]
75pub fn human_transition_label(token: &str) -> &'static str {
76 match token {
77 "link" => "clicked link",
78 "typed" => "typed URL",
79 "auto_bookmark" | "bookmark" => "bookmark",
80 "auto_subframe" => "subframe (auto)",
81 "manual_subframe" => "subframe (manual)",
82 "generated" => "generated",
83 "auto_toplevel" | "start_page" => "start page",
84 "form_submit" => "form submit",
85 "reload" => "reload",
86 "keyword" | "keyword_generated" => "keyword search",
87 "embed" => "embedded object",
88 "redirect_permanent" => "redirect (permanent)",
89 "redirect_temporary" => "redirect (temporary)",
90 "download" => "download",
91 "framed_link" => "framed link",
92 _ => "unknown",
93 }
94}
95
96pub fn resolve_referrer_chains(events: &mut [BrowserEvent]) {
111 let url_of: HashMap<i64, String> = events
113 .iter()
114 .filter_map(|e| {
115 let id = attr_i64(e, "visit_id")?;
116 Some((id, attr_str(e, "url").unwrap_or_default().to_string()))
117 })
118 .collect();
119 let from_of: HashMap<i64, i64> = events
120 .iter()
121 .filter_map(|e| {
122 Some((
123 attr_i64(e, "visit_id")?,
124 attr_i64(e, "from_visit").unwrap_or(0),
125 ))
126 })
127 .collect();
128
129 for e in events.iter_mut() {
130 let from = attr_i64(e, "from_visit").unwrap_or(0);
131 if from != 0 {
132 if let Some(u) = url_of.get(&from) {
133 e.attrs.insert("referrer_url".to_string(), json!(u));
134 }
135 }
136 let mut depth: i64 = 0;
138 let mut cur = from;
139 let mut seen: HashSet<i64> = HashSet::new();
140 while cur != 0 && (depth as usize) < MAX_CHAIN_DEPTH {
141 if !url_of.contains_key(&cur) || !seen.insert(cur) {
142 break; }
144 depth += 1;
145 cur = from_of.get(&cur).copied().unwrap_or(0);
146 }
147 e.attrs.insert("nav_depth".to_string(), json!(depth));
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct RedirectHop {
158 pub visit_id: i64,
160 pub url: String,
162 pub kind: Option<String>,
165 pub role: &'static str,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct RedirectChain {
173 pub id: usize,
175 pub hops: Vec<RedirectHop>,
177}
178
179fn role_for(pos: usize, total: usize) -> &'static str {
188 if total <= 1 || pos == total - 1 {
189 "landing"
190 } else if pos == 0 {
191 "start"
192 } else {
193 "hop"
194 }
195}
196
197#[must_use]
198pub fn redirect_chains(events: &[BrowserEvent]) -> Vec<RedirectChain> {
199 let id_to_idx = index_by_visit_id(events);
200 let is_red = |i: usize| attr_bool(&events[i], "is_redirect") == Some(true);
201
202 let mut redirect_children: HashMap<i64, Vec<usize>> = HashMap::new();
204 for (i, e) in events.iter().enumerate() {
205 if is_red(i) {
206 let from = attr_i64(e, "from_visit").unwrap_or(0);
207 redirect_children.entry(from).or_default().push(i);
208 }
209 }
210
211 let hop = |idx: usize, kind: Option<String>, role: &'static str| RedirectHop {
212 visit_id: attr_i64(&events[idx], "visit_id").unwrap_or(0),
213 url: attr_str(&events[idx], "url")
214 .unwrap_or_default()
215 .to_string(),
216 kind,
217 role,
218 };
219
220 let mut chains: Vec<RedirectChain> = Vec::new();
221 let mut assigned: HashSet<usize> = HashSet::new();
222 for (i, e) in events.iter().enumerate() {
223 if !is_red(i) || assigned.contains(&i) {
224 continue;
225 }
226 let from = attr_i64(e, "from_visit").unwrap_or(0);
227 let parent_idx = id_to_idx.get(&from).copied();
228 if matches!(parent_idx, Some(pi) if is_red(pi)) {
231 continue;
232 }
233
234 let mut run: Vec<usize> = Vec::new();
236 let mut cur = i;
237 let mut seen: HashSet<usize> = HashSet::new();
238 while run.len() < MAX_CHAIN_DEPTH && seen.insert(cur) {
239 run.push(cur);
240 assigned.insert(cur);
241 let cur_id = attr_i64(&events[cur], "visit_id").unwrap_or(0);
242 let next = redirect_children
243 .get(&cur_id)
244 .and_then(|kids| kids.iter().copied().find(|k| !seen.contains(k)));
245 match next {
246 Some(n) => cur = n,
247 None => break,
248 }
249 }
250
251 let origin = parent_idx.filter(|&pi| !is_red(pi));
253 let total = run.len() + usize::from(origin.is_some());
254 let mut hops: Vec<RedirectHop> = Vec::with_capacity(total);
255 let mut pos = 0;
256 if let Some(oi) = origin {
257 hops.push(hop(oi, None, role_for(pos, total)));
258 pos += 1;
259 }
260 for &ri in &run {
261 let kind = attr_str(&events[ri], "redirect_kind").map(str::to_string);
262 hops.push(hop(ri, kind, role_for(pos, total)));
263 pos += 1;
264 }
265 chains.push(RedirectChain {
266 id: chains.len(),
267 hops,
268 });
269 }
270 chains
271}
272
273pub fn tag_redirect_chains(events: &mut [BrowserEvent]) {
277 let chains = redirect_chains(events);
278 let mut tag: HashMap<i64, (usize, &'static str)> = HashMap::new();
279 for c in &chains {
280 for h in &c.hops {
281 tag.insert(h.visit_id, (c.id, h.role));
282 }
283 }
284 for e in events.iter_mut() {
285 if let Some(id) = attr_i64(e, "visit_id") {
286 if let Some((cid, role)) = tag.get(&id) {
287 e.attrs.insert("redirect_chain_id".to_string(), json!(cid));
288 e.attrs.insert("redirect_role".to_string(), json!(role));
289 }
290 }
291 }
292}
293
294#[derive(Debug, Clone, Copy)]
300pub struct SessionConfig {
301 pub idle_gap_ns: i64,
303}
304
305impl Default for SessionConfig {
306 fn default() -> Self {
307 Self {
308 idle_gap_ns: DEFAULT_IDLE_GAP_MINUTES * 60 * 1_000_000_000,
309 }
310 }
311}
312
313pub fn sessionize(events: &mut [BrowserEvent], cfg: SessionConfig) {
323 if events.is_empty() {
324 return;
325 }
326 let mut order: Vec<usize> = (0..events.len()).collect();
327 order.sort_by_key(|&i| events[i].timestamp_ns);
328
329 let mut session: i64 = 0;
330 let mut prev_ts: Option<i64> = None;
331 let mut prev_sess: Option<Option<i64>> = None;
332 for &i in &order {
333 let ts = events[i].timestamp_ns;
334 let recorded = attr_i64(&events[i], "session");
335 if let Some(pt) = prev_ts {
336 let gap = ts.saturating_sub(pt);
337 let sess_changed =
339 prev_sess.is_some_and(|ps| ps.is_some() && recorded.is_some() && ps != recorded);
340 if gap > cfg.idle_gap_ns || sess_changed {
341 session += 1;
342 }
343 }
344 events[i]
345 .attrs
346 .insert("session_id".to_string(), json!(session));
347 prev_ts = Some(ts);
348 prev_sess = Some(recorded);
349 }
350}
351
352#[must_use]
363pub fn tabs_open_at(session_events: &[BrowserEvent], t_ns: i64) -> Vec<&BrowserEvent> {
364 session_events
365 .iter()
366 .filter(|e| {
367 e.artifact == ArtifactKind::Session && e.timestamp_ns > 0 && e.timestamp_ns <= t_ns
368 })
369 .collect()
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::{BrowserEvent, BrowserFamily};
376
377 fn visit(id: i64, from: i64, ts_ns: i64, url: &str) -> BrowserEvent {
378 BrowserEvent::new(
379 ts_ns,
380 BrowserFamily::Chromium,
381 ArtifactKind::History,
382 "src",
383 url,
384 )
385 .with_attr("url", json!(url))
386 .with_attr("visit_id", json!(id))
387 .with_attr("from_visit", json!(from))
388 }
389
390 fn redirect_visit(id: i64, from: i64, ts_ns: i64, url: &str, kind: &str) -> BrowserEvent {
391 visit(id, from, ts_ns, url)
392 .with_attr("is_redirect", json!(true))
393 .with_attr("redirect_kind", json!(kind))
394 }
395
396 #[test]
399 fn transition_labels_are_human_readable() {
400 assert_eq!(human_transition_label("typed"), "typed URL");
401 assert_eq!(human_transition_label("link"), "clicked link");
402 assert_eq!(human_transition_label("form_submit"), "form submit");
403 assert_eq!(human_transition_label("reload"), "reload");
404 assert_eq!(
405 human_transition_label("redirect_permanent"),
406 "redirect (permanent)"
407 );
408 assert_eq!(human_transition_label("auto_bookmark"), "bookmark");
409 assert_eq!(human_transition_label("something_new"), "unknown");
410 }
411
412 #[test]
415 fn referrer_chain_sets_referrer_url_and_depth() {
416 let mut events = vec![
418 visit(1, 0, 1000, "https://a.example"),
419 visit(2, 1, 2000, "https://b.example"),
420 visit(3, 2, 3000, "https://c.example"),
421 ];
422 resolve_referrer_chains(&mut events);
423 assert_eq!(events[0].attrs["nav_depth"], json!(0));
424 assert!(!events[0].attrs.contains_key("referrer_url"));
425 assert_eq!(events[1].attrs["referrer_url"], json!("https://a.example"));
426 assert_eq!(events[1].attrs["nav_depth"], json!(1));
427 assert_eq!(events[2].attrs["referrer_url"], json!("https://b.example"));
428 assert_eq!(events[2].attrs["nav_depth"], json!(2));
429 }
430
431 #[test]
432 fn dangling_from_visit_leaves_no_referrer() {
433 let mut events = vec![visit(1, 999, 1000, "https://a.example")];
435 resolve_referrer_chains(&mut events);
436 assert!(!events[0].attrs.contains_key("referrer_url"));
437 assert_eq!(events[0].attrs["nav_depth"], json!(0));
438 }
439
440 #[test]
441 fn cyclic_from_visit_is_bounded_not_infinite() {
442 let mut events = vec![
444 visit(1, 2, 1000, "https://a.example"),
445 visit(2, 1, 2000, "https://b.example"),
446 ];
447 resolve_referrer_chains(&mut events);
448 assert_eq!(events[0].attrs["referrer_url"], json!("https://b.example"));
450 let d0 = events[0].attrs["nav_depth"].as_i64().unwrap();
451 let d1 = events[1].attrs["nav_depth"].as_i64().unwrap();
452 assert!(d0 <= MAX_CHAIN_DEPTH as i64);
453 assert!(d1 <= MAX_CHAIN_DEPTH as i64);
454 }
455
456 #[test]
459 fn redirect_chain_groups_origin_and_hops_with_roles() {
460 let mut events = vec![
462 visit(1, 0, 1000, "https://origin.example"),
463 redirect_visit(2, 1, 2000, "https://hop.example", "server"),
464 redirect_visit(3, 2, 3000, "https://landing.example", "client"),
465 ];
466 let chains = redirect_chains(&events);
467 assert_eq!(chains.len(), 1);
468 let c = &chains[0];
469 assert_eq!(c.hops.len(), 3);
470 assert_eq!(c.hops[0].role, "start");
471 assert_eq!(c.hops[0].kind, None);
472 assert_eq!(c.hops[0].url, "https://origin.example");
473 assert_eq!(c.hops[1].role, "hop");
474 assert_eq!(c.hops[1].kind.as_deref(), Some("server"));
475 assert_eq!(c.hops[2].role, "landing");
476 assert_eq!(c.hops[2].kind.as_deref(), Some("client"));
477
478 tag_redirect_chains(&mut events);
480 assert_eq!(events[0].attrs["redirect_role"], json!("start"));
481 assert_eq!(events[1].attrs["redirect_role"], json!("hop"));
482 assert_eq!(events[2].attrs["redirect_role"], json!("landing"));
483 assert_eq!(
484 events[0].attrs["redirect_chain_id"],
485 events[2].attrs["redirect_chain_id"]
486 );
487 }
488
489 #[test]
490 fn no_redirects_yields_no_chains() {
491 let events = vec![
492 visit(1, 0, 1000, "https://a.example"),
493 visit(2, 0, 2000, "https://b.example"),
494 ];
495 assert!(redirect_chains(&events).is_empty());
496 }
497
498 #[test]
499 fn redirect_chain_with_dangling_origin_starts_at_first_redirect() {
500 let events = vec![redirect_visit(2, 1, 2000, "https://only.example", "server")];
502 let chains = redirect_chains(&events);
503 assert_eq!(chains.len(), 1);
504 assert_eq!(chains[0].hops.len(), 1);
505 assert_eq!(chains[0].hops[0].role, "landing");
506 }
507
508 fn min_ns(m: i64) -> i64 {
511 m * 60 * 1_000_000_000
512 }
513
514 #[test]
515 fn sessionize_groups_by_idle_gap() {
516 let mut events = vec![
517 visit(1, 0, 0, "https://a.example"),
518 visit(2, 0, min_ns(5), "https://b.example"), visit(3, 0, min_ns(50), "https://c.example"), ];
521 sessionize(&mut events, SessionConfig::default());
522 assert_eq!(events[0].attrs["session_id"], json!(0));
523 assert_eq!(events[1].attrs["session_id"], json!(0));
524 assert_eq!(events[2].attrs["session_id"], json!(1));
525 }
526
527 #[test]
528 fn sessionize_respects_custom_idle_gap() {
529 let mut events = vec![
530 visit(1, 0, 0, "https://a.example"),
531 visit(2, 0, min_ns(5), "https://b.example"),
532 ];
533 sessionize(
535 &mut events,
536 SessionConfig {
537 idle_gap_ns: min_ns(2),
538 },
539 );
540 assert_eq!(events[0].attrs["session_id"], json!(0));
541 assert_eq!(events[1].attrs["session_id"], json!(1));
542 }
543
544 #[test]
545 fn sessionize_splits_on_recorded_session_change() {
546 let mut events = vec![
547 visit(1, 0, 0, "https://a.example").with_attr("session", json!(7)),
548 visit(2, 0, min_ns(1), "https://b.example").with_attr("session", json!(8)),
550 ];
551 sessionize(&mut events, SessionConfig::default());
552 assert_ne!(events[0].attrs["session_id"], events[1].attrs["session_id"]);
553 }
554
555 fn tab_event(ts_ns: i64, url: &str) -> BrowserEvent {
558 BrowserEvent::new(
559 ts_ns,
560 BrowserFamily::Chromium,
561 ArtifactKind::Session,
562 "src",
563 url,
564 )
565 .with_attr("url", json!(url))
566 }
567
568 #[test]
569 fn tabs_open_at_filters_by_time() {
570 let events = vec![
571 tab_event(1000, "https://early.example"),
572 tab_event(5000, "https://late.example"),
573 ];
574 let open = tabs_open_at(&events, 2000);
575 assert_eq!(open.len(), 1);
576 assert_eq!(open[0].attrs["url"], json!("https://early.example"));
577 }
578}