use chromiumoxide::cdp::browser_protocol::network::SetBlockedUrLsParams;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use url::Url;
use crate::browser::BrowserManager;
use crate::error::{AuditError, Result};
use crate::util::truncate_ellipsis as truncate;
use super::vitals::{extract_web_vitals, prepare_vitals_collection};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThirdPartyOrigin {
pub origin: String,
pub transfer_bytes: u64,
pub request_count: u32,
pub resource_kinds: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub largest_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
pub largest_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThirdPartyAttribution {
pub origins: Vec<ThirdPartyOrigin>,
pub total_origins: u32,
pub total_bytes: u64,
pub total_requests: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub isolated_impact: Vec<ThirdPartyImpact>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThirdPartyImpact {
pub origin: String,
pub blocked_url_pattern: String,
pub baseline_tbt_ms: f64,
pub without_script_tbt_ms: f64,
pub estimated_impact_ms: f64,
}
impl ThirdPartyAttribution {
pub fn is_significant(&self, page_total_bytes: u64) -> bool {
page_total_bytes > 0 && self.total_bytes * 100 / page_total_bytes >= 20
}
}
pub async fn analyze_third_party_attribution(
page: &Page,
page_url: &str,
) -> Result<ThirdPartyAttribution> {
info!("Analyzing third-party attribution...");
let js = r#"
(() => {
var resources = performance.getEntriesByType('resource');
return JSON.stringify(resources.map(function(r) {
return {
url: r.name,
transferSize: r.transferSize || 0,
initiatorType: r.initiatorType || 'other'
};
}));
})()
"#;
let result = page
.evaluate(js)
.await
.map_err(|e| AuditError::CdpError(format!("Third-party attribution JS failed: {e}")))?;
let json_str = result.value().and_then(|v| v.as_str()).unwrap_or("[]");
let entries: Vec<ResourceEntry> = serde_json::from_str(json_str).unwrap_or_default();
let page_host = Url::parse(page_url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default();
let mut origin_map: std::collections::HashMap<String, OriginAccum> =
std::collections::HashMap::new();
for entry in &entries {
let host = match Url::parse(&entry.url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
{
Some(h) => h,
None => continue,
};
if host == page_host {
continue; }
let kind = classify_kind(&entry.url, &entry.initiator_type);
let accum = origin_map
.entry(host.clone())
.or_insert_with(OriginAccum::new);
accum.transfer_bytes += entry.transfer_size;
accum.request_count += 1;
if !accum.kinds.contains(&kind) {
accum.kinds.push(kind);
}
if entry.transfer_size > accum.largest_bytes {
accum.largest_bytes = entry.transfer_size;
accum.largest_url = Some(truncate(&entry.url, 120));
}
}
let total_bytes: u64 = origin_map.values().map(|a| a.transfer_bytes).sum();
let total_requests: u32 = origin_map.values().map(|a| a.request_count).sum();
let total_origins = origin_map.len() as u32;
let mut origins: Vec<ThirdPartyOrigin> = origin_map
.into_iter()
.map(|(host, a)| {
let (provider, category) = classify_origin(&host);
ThirdPartyOrigin {
origin: host,
provider,
category,
transfer_bytes: a.transfer_bytes,
request_count: a.request_count,
resource_kinds: a.kinds,
largest_url: a.largest_url,
largest_bytes: a.largest_bytes,
}
})
.collect();
origins.sort_by_key(|o| std::cmp::Reverse(o.transfer_bytes));
info!(
"Third-party attribution: {} origins, {} requests, {:.1} KB",
total_origins,
total_requests,
total_bytes as f64 / 1024.0
);
Ok(ThirdPartyAttribution {
origins,
total_origins,
total_bytes,
total_requests,
isolated_impact: Vec::new(),
})
}
pub const MAX_ISOLATED_ORIGINS: usize = 5;
pub fn select_origins_to_isolate(
origins: &[ThirdPartyOrigin],
cap: usize,
) -> Vec<&ThirdPartyOrigin> {
origins.iter().take(cap).collect()
}
pub fn compute_isolated_impact(
origin: &str,
blocked_url_pattern: &str,
baseline_tbt_ms: f64,
without_script_tbt_ms: f64,
) -> ThirdPartyImpact {
ThirdPartyImpact {
origin: origin.to_string(),
blocked_url_pattern: blocked_url_pattern.to_string(),
baseline_tbt_ms,
without_script_tbt_ms,
estimated_impact_ms: (baseline_tbt_ms - without_script_tbt_ms).max(0.0),
}
}
async fn set_blocked_urls(page: &Page, patterns: &[String]) -> Result<()> {
page.execute(SetBlockedUrLsParams::new(patterns.to_vec()))
.await
.map_err(|e| AuditError::CdpError(format!("Network.setBlockedURLs failed: {e}")))?;
Ok(())
}
pub async fn isolate_third_party_impact(
page: &Page,
browser: &BrowserManager,
url: &str,
origins: &[ThirdPartyOrigin],
baseline_tbt_ms: f64,
) -> Vec<ThirdPartyImpact> {
let mut results = Vec::new();
for origin in select_origins_to_isolate(origins, MAX_ISOLATED_ORIGINS) {
let pattern = format!("*{}*", origin.origin);
info!("Isolating third-party impact for {}", origin.origin);
if let Err(e) = set_blocked_urls(page, std::slice::from_ref(&pattern)).await {
warn!("Failed to block {} for isolation: {}", origin.origin, e);
continue;
}
if let Err(e) = prepare_vitals_collection(page).await {
warn!(
"Vitals injection failed while isolating {}: {}",
origin.origin, e
);
let _ = set_blocked_urls(page, &[]).await;
continue;
}
if let Err(e) = browser.navigate(page, url).await {
warn!("Navigation failed while isolating {}: {}", origin.origin, e);
let _ = set_blocked_urls(page, &[]).await;
continue;
}
let without_tbt = match extract_web_vitals(page).await {
Ok(vitals) => vitals.tbt.map(|m| m.value),
Err(e) => {
warn!(
"Vitals extraction failed while isolating {}: {}",
origin.origin, e
);
None
}
};
let _ = set_blocked_urls(page, &[]).await;
if let Some(without_tbt) = without_tbt {
results.push(compute_isolated_impact(
&origin.origin,
&pattern,
baseline_tbt_ms,
without_tbt,
));
}
}
results
}
struct OriginAccum {
transfer_bytes: u64,
request_count: u32,
kinds: Vec<String>,
largest_bytes: u64,
largest_url: Option<String>,
}
impl OriginAccum {
fn new() -> Self {
Self {
transfer_bytes: 0,
request_count: 0,
kinds: vec![],
largest_bytes: 0,
largest_url: None,
}
}
}
#[derive(Debug, Deserialize)]
struct ResourceEntry {
url: String,
#[serde(rename = "transferSize")]
transfer_size: u64,
#[serde(rename = "initiatorType")]
initiator_type: String,
}
fn classify_kind(url: &str, initiator_type: &str) -> String {
let url_lower = url.to_lowercase();
if initiator_type == "script" || url_lower.ends_with(".js") {
"script"
} else if initiator_type == "css" || url_lower.ends_with(".css") {
"css"
} else if url_lower.contains(".woff")
|| url_lower.contains(".ttf")
|| url_lower.contains(".otf")
|| url_lower.contains(".eot")
{
"font"
} else if initiator_type == "img"
|| url_lower.ends_with(".png")
|| url_lower.ends_with(".jpg")
|| url_lower.ends_with(".jpeg")
|| url_lower.ends_with(".webp")
|| url_lower.ends_with(".svg")
|| url_lower.ends_with(".gif")
{
"img"
} else if url_lower.ends_with(".mp4")
|| url_lower.ends_with(".webm")
|| url_lower.ends_with(".mp3")
{
"media"
} else {
"other"
}
.to_string()
}
fn classify_origin(host: &str) -> (Option<String>, Option<String>) {
let lower = host.to_ascii_lowercase();
let pair = if lower.contains("googletagmanager.com")
|| lower.contains("google-analytics.com")
|| lower.contains("analytics.google.com")
{
("Google", "analytics")
} else if lower.contains("doubleclick.net") || lower.contains("googleadservices.com") {
("Google Ads", "ads")
} else if lower.contains("facebook.com") || lower.contains("connect.facebook.net") {
("Meta", "social")
} else if lower.contains("hotjar.com") {
("Hotjar", "analytics")
} else if lower.contains("hubspot.com") || lower.contains("hs-analytics.net") {
("HubSpot", "marketing")
} else if lower.contains("matomo") {
("Matomo", "analytics")
} else {
return (None, None);
};
(Some(pair.0.to_string()), Some(pair.1.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_kind_script() {
assert_eq!(
classify_kind("https://cdn.example.com/app.js", "script"),
"script"
);
assert_eq!(
classify_kind("https://cdn.example.com/app.js", "other"),
"script"
);
}
#[test]
fn test_classify_kind_font() {
assert_eq!(
classify_kind("https://fonts.gstatic.com/s/font.woff2", "other"),
"font"
);
}
#[test]
fn test_is_significant() {
let attr = ThirdPartyAttribution {
origins: vec![],
total_origins: 0,
total_bytes: 200_000,
total_requests: 5,
isolated_impact: vec![],
};
assert!(attr.is_significant(500_000)); assert!(!attr.is_significant(2_000_000)); }
fn test_origin(host: &str, bytes: u64) -> ThirdPartyOrigin {
ThirdPartyOrigin {
origin: host.to_string(),
transfer_bytes: bytes,
request_count: 1,
resource_kinds: vec!["script".to_string()],
largest_url: None,
provider: None,
category: None,
largest_bytes: bytes,
}
}
#[test]
fn select_origins_to_isolate_caps_at_the_given_number() {
let origins: Vec<ThirdPartyOrigin> = (0..8)
.map(|i| test_origin(&format!("host{i}.example.com"), (8 - i) * 1000))
.collect();
let selected = select_origins_to_isolate(&origins, MAX_ISOLATED_ORIGINS);
assert_eq!(selected.len(), MAX_ISOLATED_ORIGINS);
assert_eq!(selected[0].origin, "host0.example.com");
assert_eq!(selected[4].origin, "host4.example.com");
}
#[test]
fn select_origins_to_isolate_returns_all_when_under_the_cap() {
let origins = vec![
test_origin("a.example.com", 100),
test_origin("b.example.com", 50),
];
let selected = select_origins_to_isolate(&origins, MAX_ISOLATED_ORIGINS);
assert_eq!(selected.len(), 2);
}
#[test]
fn compute_isolated_impact_is_the_positive_baseline_minus_without_delta() {
let impact = compute_isolated_impact("tag.example.com", "*tag.example.com*", 450.0, 120.0);
assert_eq!(impact.origin, "tag.example.com");
assert_eq!(impact.blocked_url_pattern, "*tag.example.com*");
assert_eq!(impact.baseline_tbt_ms, 450.0);
assert_eq!(impact.without_script_tbt_ms, 120.0);
assert_eq!(impact.estimated_impact_ms, 330.0);
}
#[test]
fn compute_isolated_impact_clamps_negative_delta_to_zero() {
let impact =
compute_isolated_impact("noise.example.com", "*noise.example.com*", 100.0, 140.0);
assert_eq!(impact.estimated_impact_ms, 0.0);
}
}