Skip to main content

browser_automation_cli/native/
perf_insight.rs

1//! Offline analysis of Chrome Tracing event dumps produced by `perf stop`.
2#![allow(missing_docs)]
3//!
4//! Input formats supported:
5//! - NDJSON lines, each an array of trace events
6//! - A single JSON array of events
7//! - Nested arrays from `Tracing.dataCollected` value payloads
8
9use std::collections::HashMap;
10use std::path::Path;
11
12use serde_json::{json, Value};
13
14#[derive(Default)]
15struct Acc {
16    event_count: u64,
17    by_name: HashMap<String, u64>,
18    by_cat: HashMap<String, u64>,
19    durations_ms: HashMap<String, f64>,
20    navigation_start: Option<f64>,
21    fcp_ms: Option<f64>,
22    lcp_ms: Option<f64>,
23    dcl_ms: Option<f64>,
24    load_ms: Option<f64>,
25    ttfb_ms: Option<f64>,
26    cls_score: Option<f64>,
27    long_tasks: u64,
28    layout_shifts: u64,
29}
30
31/// Analyze a trace file written by `perf stop` (NDJSON of event arrays).
32pub fn analyze_file(path: &Path, name_filter: Option<&str>) -> Result<Value, String> {
33    let raw = std::fs::read_to_string(path).map_err(|e| format!("trace read: {e}"))?;
34    analyze_text(&raw, name_filter, Some(path.to_string_lossy().as_ref()))
35}
36
37/// Analyze in-memory NDJSON / JSON chunks joined with newlines.
38pub fn analyze_text(
39    raw: &str,
40    name_filter: Option<&str>,
41    path: Option<&str>,
42) -> Result<Value, String> {
43    let mut acc = Acc::default();
44    let trimmed = raw.trim();
45    if trimmed.is_empty() {
46        return Ok(json!({
47            "perf": "insight",
48            "path": path,
49            "name": name_filter,
50            "event_count": 0,
51            "note": "empty trace; run perf start then stop with --path in the same run",
52            "offline": true,
53        }));
54    }
55
56    // Prefer whole-file JSON array first.
57    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
58        walk_value(&v, &mut acc);
59    } else {
60        for line in raw.lines() {
61            let line = line.trim();
62            if line.is_empty() {
63                continue;
64            }
65            if let Ok(v) = serde_json::from_str::<Value>(line) {
66                walk_value(&v, &mut acc);
67            }
68        }
69    }
70
71    if let Some(filter) = name_filter {
72        acc.by_name.retain(|k, _| k.contains(filter));
73    }
74
75    let mut top_events: Vec<(String, u64)> = acc.by_name.into_iter().collect();
76    top_events.sort_by_key(|b| std::cmp::Reverse(b.1));
77    top_events.truncate(25);
78
79    let mut top_cats: Vec<(String, u64)> = acc.by_cat.into_iter().collect();
80    top_cats.sort_by_key(|b| std::cmp::Reverse(b.1));
81    top_cats.truncate(15);
82
83    let mut slowest: Vec<(String, f64)> = acc.durations_ms.into_iter().collect();
84    slowest.sort_by(|a, b| match b.1.partial_cmp(&a.1) {
85        Some(o) => o,
86        None => std::cmp::Ordering::Equal,
87    });
88    slowest.truncate(15);
89
90    Ok(json!({
91        "perf": "insight",
92        "path": path,
93        "name": name_filter,
94        "event_count": acc.event_count,
95        "top_events": top_events.into_iter().map(|(n, c)| json!({"name": n, "count": c})).collect::<Vec<_>>(),
96        "top_categories": top_cats.into_iter().map(|(n, c)| json!({"category": n, "count": c})).collect::<Vec<_>>(),
97        "slowest_ms": slowest.into_iter().map(|(n, d)| json!({"name": n, "duration_ms": d})).collect::<Vec<_>>(),
98        "insights": {
99            "long_tasks": acc.long_tasks,
100            "layout_shifts": acc.layout_shifts,
101            "has_lcp": acc.lcp_ms.is_some(),
102            "has_fcp": acc.fcp_ms.is_some(),
103            "has_cls": acc.cls_score.is_some(),
104        },
105        "web_vitals": {
106            "fcp_ms": acc.fcp_ms,
107            "lcp_ms": acc.lcp_ms,
108            "cls": acc.cls_score,
109            "ttfb_ms": acc.ttfb_ms,
110            "dom_content_loaded_ms": acc.dcl_ms,
111            "load_ms": acc.load_ms,
112            "navigation_start": acc.navigation_start,
113        },
114        "offline": true,
115    }))
116}
117
118fn walk_value(v: &Value, acc: &mut Acc) {
119    match v {
120        Value::Array(items) => {
121            for item in items {
122                if item.is_array() {
123                    walk_value(item, acc);
124                } else if item.is_object() {
125                    ingest_event(item, acc);
126                }
127            }
128        }
129        Value::Object(_) => ingest_event(v, acc),
130        _ => {}
131    }
132}
133
134fn ingest_event(ev: &Value, acc: &mut Acc) {
135    acc.event_count += 1;
136    let name = ev
137        .get("name")
138        .and_then(|v| v.as_str())
139        .unwrap_or("unknown")
140        .to_string();
141    *acc.by_name.entry(name.clone()).or_insert(0) += 1;
142
143    if let Some(cat) = ev.get("cat").and_then(|v| v.as_str()) {
144        for part in cat.split(',') {
145            let p = part.trim();
146            if !p.is_empty() {
147                *acc.by_cat.entry(p.to_string()).or_insert(0) += 1;
148            }
149        }
150    }
151
152    let ph = ev.get("ph").and_then(|v| v.as_str()).unwrap_or("");
153    let ts = ev.get("ts").and_then(|v| v.as_f64());
154    let dur = ev.get("dur").and_then(|v| v.as_f64()).map(|d| d / 1000.0); // µs → ms in Chrome traces
155
156    if let Some(d) = dur {
157        let entry = acc.durations_ms.entry(name.clone()).or_insert(0.0);
158        if d > *entry {
159            *entry = d;
160        }
161    }
162
163    // Heuristics for common timeline marks.
164    match name.as_str() {
165        "navigationStart" | "NavigationStart" => {
166            if acc.navigation_start.is_none() {
167                acc.navigation_start = ts.map(|t| t / 1000.0);
168            }
169        }
170        "firstContentfulPaint" | "firstPaint" => {
171            if let (Some(nav), Some(t)) = (acc.navigation_start, ts) {
172                acc.fcp_ms = Some(t / 1000.0 - nav);
173            } else if let Some(d) = dur {
174                acc.fcp_ms = Some(d);
175            }
176        }
177        "largestContentfulPaint::Candidate" | "largestContentfulPaint" => {
178            if let (Some(nav), Some(t)) = (acc.navigation_start, ts) {
179                let v = t / 1000.0 - nav;
180                acc.lcp_ms = Some(acc.lcp_ms.map(|old| old.max(v)).unwrap_or(v));
181            }
182        }
183        "domContentLoadedEventEnd" => {
184            if let (Some(nav), Some(t)) = (acc.navigation_start, ts) {
185                acc.dcl_ms = Some(t / 1000.0 - nav);
186            }
187        }
188        "loadEventEnd" => {
189            if let (Some(nav), Some(t)) = (acc.navigation_start, ts) {
190                acc.load_ms = Some(t / 1000.0 - nav);
191            }
192        }
193        "responseStart" | "TimeToFirstByte" => {
194            if let (Some(nav), Some(t)) = (acc.navigation_start, ts) {
195                acc.ttfb_ms = Some(t / 1000.0 - nav);
196            } else if let Some(d) = dur {
197                acc.ttfb_ms = Some(d);
198            }
199        }
200        "LayoutShift" => {
201            acc.layout_shifts += 1;
202            if let Some(score) = ev
203                .get("args")
204                .and_then(|a| a.get("data"))
205                .and_then(|d| d.get("score").or_else(|| d.get("weighted_score_delta")))
206                .and_then(|v| v.as_f64())
207            {
208                acc.cls_score = Some(acc.cls_score.unwrap_or(0.0) + score);
209            }
210        }
211        "RunTask" | "RunMicrotasks" => {
212            if let Some(d) = dur {
213                if d >= 50.0 {
214                    acc.long_tasks += 1;
215                }
216            }
217        }
218        _ => {
219            if name.contains("LayoutShift") {
220                acc.layout_shifts += 1;
221            }
222            if ph == "R" || ph == "I" {
223                // keep counts only
224            }
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::io::Write;
233
234    #[test]
235    fn analyzes_ndjson_events() {
236        let dir = tempfile::tempdir().unwrap();
237        let path = dir.path().join("t.ndjson");
238        let body = r#"[{"name":"navigationStart","cat":"blink.user_timing","ph":"R","ts":1000000},{"name":"firstContentfulPaint","cat":"loading","ph":"R","ts":1250000},{"name":"EvaluateScript","cat":"devtools.timeline","ph":"X","ts":1100000,"dur":50000}]
239[{"name":"EvaluateScript","cat":"devtools.timeline","ph":"X","ts":1200000,"dur":10000},{"name":"LayoutShift","cat":"loading","ph":"I","ts":1300000,"args":{"data":{"score":0.05}}},{"name":"RunTask","cat":"devtools.timeline","ph":"X","ts":1400000,"dur":60000}]"#;
240        let mut f = std::fs::File::create(&path).unwrap();
241        f.write_all(body.as_bytes()).unwrap();
242        let v = analyze_file(&path, None).unwrap();
243        assert!(v["event_count"].as_u64().unwrap() >= 3);
244        assert_eq!(v["offline"], true);
245        assert!(!v["top_events"].as_array().unwrap().is_empty());
246        assert!(v["web_vitals"]["cls"].as_f64().unwrap() >= 0.05);
247        assert!(v["insights"]["long_tasks"].as_u64().unwrap() >= 1);
248        assert!(v["insights"]["layout_shifts"].as_u64().unwrap() >= 1);
249    }
250}