use crate::types::{Cookie, Error, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoogleAnalyticsCookie {
pub client_id: String,
pub session_id: Option<String>,
pub campaign_info: Option<CampaignInfo>,
pub referrer: Option<String>,
pub search_terms: Option<Vec<String>>,
pub page_views: Vec<PageView>,
pub first_visit: Option<DateTime<Utc>>,
pub last_visit: Option<DateTime<Utc>>,
pub session_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CampaignInfo {
pub source: String,
pub medium: String,
pub name: Option<String>,
pub term: Option<String>,
pub content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageView {
pub url: String,
pub timestamp: DateTime<Utc>,
pub time_on_page: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserJourney {
pub pages: Vec<PageView>,
pub entry_page: Option<String>,
pub exit_page: Option<String>,
pub total_duration: u32,
pub is_bounce: bool,
}
impl GoogleAnalyticsCookie {
pub fn decode(cookie: &Cookie) -> Result<Self> {
match cookie.name.as_str() {
"_ga" => Self::decode_ga_cookie(cookie),
"_gid" => Self::decode_gid_cookie(cookie),
"__utma" => Self::decode_utma_cookie(cookie),
"__utmz" => Self::decode_utmz_cookie(cookie),
_ => Err(Error::ForensicError(format!(
"Not a recognized GA cookie: {}",
cookie.name
))),
}
}
fn decode_ga_cookie(cookie: &Cookie) -> Result<Self> {
let parts: Vec<&str> = cookie.value.split('.').collect();
if parts.len() < 4 {
return Err(Error::ForensicError(
"Invalid _ga cookie format".to_string(),
));
}
let client_id = format!("{}.{}", parts[2], parts[3]);
let first_visit_ts = parts[3]
.parse::<i64>()
.ok()
.and_then(|ts| DateTime::from_timestamp(ts, 0));
Ok(Self {
client_id,
session_id: None,
campaign_info: None,
referrer: None,
search_terms: None,
page_views: Vec::new(),
first_visit: first_visit_ts,
last_visit: Some(cookie.created_at),
session_count: 1,
})
}
fn decode_gid_cookie(cookie: &Cookie) -> Result<Self> {
let parts: Vec<&str> = cookie.value.split('.').collect();
if parts.len() < 4 {
return Err(Error::ForensicError(
"Invalid _gid cookie format".to_string(),
));
}
let session_id = format!("{}.{}", parts[2], parts[3]);
Ok(Self {
client_id: String::new(),
session_id: Some(session_id),
campaign_info: None,
referrer: None,
search_terms: None,
page_views: Vec::new(),
first_visit: None,
last_visit: Some(cookie.created_at),
session_count: 1,
})
}
fn decode_utma_cookie(cookie: &Cookie) -> Result<Self> {
let parts: Vec<&str> = cookie.value.split('.').collect();
if parts.len() < 6 {
return Err(Error::ForensicError(
"Invalid __utma cookie format".to_string(),
));
}
let client_id = parts[1].to_string();
let session_count = parts[5].parse().unwrap_or(1);
let first_visit = parts[2]
.parse::<i64>()
.ok()
.and_then(|ts| DateTime::from_timestamp(ts, 0));
let last_visit = parts[4]
.parse::<i64>()
.ok()
.and_then(|ts| DateTime::from_timestamp(ts, 0));
Ok(Self {
client_id,
session_id: None,
campaign_info: None,
referrer: None,
search_terms: None,
page_views: Vec::new(),
first_visit,
last_visit,
session_count,
})
}
fn decode_utmz_cookie(cookie: &Cookie) -> Result<Self> {
let parts: Vec<&str> = cookie.value.split('.').collect();
if parts.len() < 4 {
return Err(Error::ForensicError(
"Invalid __utmz cookie format".to_string(),
));
}
let campaign_part = parts.get(4..).map(|p| p.join(".")).unwrap_or_default();
let campaign_info = Self::parse_campaign_params(&campaign_part);
Ok(Self {
client_id: String::new(),
session_id: None,
campaign_info,
referrer: None,
search_terms: None,
page_views: Vec::new(),
first_visit: None,
last_visit: Some(cookie.created_at),
session_count: parts[2].parse().unwrap_or(1),
})
}
fn parse_campaign_params(params: &str) -> Option<CampaignInfo> {
let mut map: HashMap<String, String> = HashMap::new();
for pair in params.split('|') {
if let Some((key, value)) = pair.split_once('=') {
map.insert(key.to_string(), value.to_string());
}
}
let source = map.get("utmcsr")?.clone();
let medium = map.get("utmcmd")?.clone();
Some(CampaignInfo {
source,
medium,
name: map.get("utmccn").cloned(),
term: map.get("utmctr").cloned(),
content: map.get("utmcct").cloned(),
})
}
#[must_use]
pub fn extract_search_terms(&self) -> Vec<String> {
let mut terms = Vec::new();
if let Some(ref campaign) = self.campaign_info {
if let Some(ref term) = campaign.term {
terms.push(term.clone());
}
}
if let Some(ref stored) = self.search_terms {
terms.extend(stored.clone());
}
terms
}
#[must_use]
pub fn reconstruct_user_journey(&self) -> UserJourney {
let pages = self.page_views.clone();
let entry_page = pages.first().map(|p| p.url.clone());
let exit_page = pages.last().map(|p| p.url.clone());
let total_duration = pages.iter().filter_map(|p| p.time_on_page).sum();
let is_bounce = pages.len() == 1;
UserJourney {
pages,
entry_page,
exit_page,
total_duration,
is_bounce,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decode_ga_cookie() {
let cookie = Cookie {
name: "_ga".to_string(),
value: "GA1.2.1234567890.1609459200".to_string(),
domain: Some("example.com".to_string()),
..Default::default()
};
let ga = GoogleAnalyticsCookie::decode(&cookie).unwrap();
assert_eq!(ga.client_id, "1234567890.1609459200");
}
#[test]
fn test_campaign_parsing() {
let params = "utmcsr=google|utmccn=cpc|utmcmd=organic|utmctr=cookies";
let campaign = GoogleAnalyticsCookie::parse_campaign_params(params).unwrap();
assert_eq!(campaign.source, "google");
assert_eq!(campaign.medium, "organic");
assert_eq!(campaign.term, Some("cookies".to_string()));
}
}