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//!
9//! # Workload
10//!
11//! **CPU-light → CPU-bound when large:** typical agent traces stay sequential
12//! (PAR-69, cost ≪ Rayon). Top-level event arrays with length ≥
13//! [`crate::concurrency::CPU_MAP_THRESHOLD`] fold via [`crate::concurrency::map_cpu`]
14//! into partial [`Acc`] shards then merge (PAR-86). Nested walk remains sequential
15//! (shared counters + rare nesting).
16
17use std::collections::HashMap;
18use std::path::Path;
19
20use serde_json::{json, Value};
21
22#[derive(Default, Clone)]
23struct Acc {
24    event_count: u64,
25    by_name: HashMap<String, u64>,
26    by_cat: HashMap<String, u64>,
27    durations_ms: HashMap<String, f64>,
28    navigation_start: Option<f64>,
29    fcp_ms: Option<f64>,
30    lcp_ms: Option<f64>,
31    dcl_ms: Option<f64>,
32    load_ms: Option<f64>,
33    ttfb_ms: Option<f64>,
34    cls_score: Option<f64>,
35    long_tasks: u64,
36    layout_shifts: u64,
37}
38
39impl Acc {
40    fn merge(&mut self, other: Acc) {
41        self.event_count += other.event_count;
42        for (k, v) in other.by_name {
43            *self.by_name.entry(k).or_insert(0) += v;
44        }
45        for (k, v) in other.by_cat {
46            *self.by_cat.entry(k).or_insert(0) += v;
47        }
48        for (k, v) in other.durations_ms {
49            let e = self.durations_ms.entry(k).or_insert(0.0);
50            if v > *e {
51                *e = v;
52            }
53        }
54        if self.navigation_start.is_none() {
55            self.navigation_start = other.navigation_start;
56        }
57        if self.fcp_ms.is_none() {
58            self.fcp_ms = other.fcp_ms;
59        }
60        if self.lcp_ms.is_none() {
61            self.lcp_ms = other.lcp_ms;
62        }
63        if self.dcl_ms.is_none() {
64            self.dcl_ms = other.dcl_ms;
65        }
66        if self.load_ms.is_none() {
67            self.load_ms = other.load_ms;
68        }
69        if self.ttfb_ms.is_none() {
70            self.ttfb_ms = other.ttfb_ms;
71        }
72        if self.cls_score.is_none() {
73            self.cls_score = other.cls_score;
74        }
75        self.long_tasks += other.long_tasks;
76        self.layout_shifts += other.layout_shifts;
77    }
78}
79
80/// Analyze a trace file written by `perf stop` (NDJSON of event arrays).
81pub fn analyze_file(path: &Path, name_filter: Option<&str>) -> Result<Value, String> {
82    let raw = std::fs::read_to_string(path).map_err(|e| format!("trace read: {e}"))?;
83    analyze_text(&raw, name_filter, Some(path.to_string_lossy().as_ref()))
84}
85
86/// Analyze in-memory NDJSON / JSON chunks joined with newlines.
87pub fn analyze_text(
88    raw: &str,
89    name_filter: Option<&str>,
90    path: Option<&str>,
91) -> Result<Value, String> {
92    let mut acc = Acc::default();
93    let trimmed = raw.trim();
94    if trimmed.is_empty() {
95        return Ok(json!({
96            "perf": "insight",
97            "path": path,
98            "name": name_filter,
99            "event_count": 0,
100            "note": "empty trace; run perf start then stop with --path in the same run",
101            "offline": true,
102        }));
103    }
104
105    // Prefer whole-file JSON array first.
106    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
107        walk_value(&v, &mut acc);
108    } else {
109        for line in raw.lines() {
110            let line = line.trim();
111            if line.is_empty() {
112                continue;
113            }
114            if let Ok(v) = serde_json::from_str::<Value>(line) {
115                walk_value(&v, &mut acc);
116            }
117        }
118    }
119
120    if let Some(filter) = name_filter {
121        acc.by_name.retain(|k, _| k.contains(filter));
122    }
123
124    // PAR-106: top-N sorts use sort_*_cpu when the histogram is large.
125    let mut top_events: Vec<(String, u64)> = acc.by_name.into_iter().collect();
126    crate::concurrency::sort_by_key_cpu(&mut top_events, |b| std::cmp::Reverse(b.1));
127    top_events.truncate(25);
128
129    let mut top_cats: Vec<(String, u64)> = acc.by_cat.into_iter().collect();
130    crate::concurrency::sort_by_key_cpu(&mut top_cats, |b| std::cmp::Reverse(b.1));
131    top_cats.truncate(15);
132
133    let mut slowest: Vec<(String, f64)> = acc.durations_ms.into_iter().collect();
134    crate::concurrency::sort_by_cpu(&mut slowest, |a, b| match b.1.partial_cmp(&a.1) {
135        Some(o) => o,
136        None => std::cmp::Ordering::Equal,
137    });
138    slowest.truncate(15);
139
140    Ok(json!({
141        "perf": "insight",
142        "path": path,
143        "name": name_filter,
144        "event_count": acc.event_count,
145        "top_events": top_events.into_iter().map(|(n, c)| json!({"name": n, "count": c})).collect::<Vec<_>>(),
146        "top_categories": top_cats.into_iter().map(|(n, c)| json!({"category": n, "count": c})).collect::<Vec<_>>(),
147        "slowest_ms": slowest.into_iter().map(|(n, d)| json!({"name": n, "duration_ms": d})).collect::<Vec<_>>(),
148        "insights": {
149            "long_tasks": acc.long_tasks,
150            "layout_shifts": acc.layout_shifts,
151            "has_lcp": acc.lcp_ms.is_some(),
152            "has_fcp": acc.fcp_ms.is_some(),
153            "has_cls": acc.cls_score.is_some(),
154        },
155        "web_vitals": {
156            "fcp_ms": acc.fcp_ms,
157            "lcp_ms": acc.lcp_ms,
158            "cls": acc.cls_score,
159            "ttfb_ms": acc.ttfb_ms,
160            "dom_content_loaded_ms": acc.dcl_ms,
161            "load_ms": acc.load_ms,
162            "navigation_start": acc.navigation_start,
163        },
164        "offline": true,
165    }))
166}
167
168fn walk_value(v: &Value, acc: &mut Acc) {
169    match v {
170        Value::Array(items) => {
171            // PAR-86: flat object arrays ≥ threshold → map_cpu shard + merge.
172            let all_objects = !items.is_empty() && items.iter().all(|i| i.is_object());
173            if all_objects && items.len() >= crate::concurrency::CPU_MAP_THRESHOLD {
174                let partials = crate::concurrency::map_cpu(items, |item| {
175                    let mut local = Acc::default();
176                    ingest_event(item, &mut local);
177                    local
178                });
179                for p in partials {
180                    acc.merge(p);
181                }
182                return;
183            }
184            for item in items {
185                if item.is_array() {
186                    walk_value(item, acc);
187                } else if item.is_object() {
188                    ingest_event(item, acc);
189                }
190            }
191        }
192        Value::Object(_) => ingest_event(v, acc),
193        _ => {}
194    }
195}
196
197fn ingest_event(ev: &Value, acc: &mut Acc) {
198    acc.event_count += 1;
199    let name = ev
200        .get("name")
201        .and_then(|v| v.as_str())
202        .unwrap_or("unknown")
203        .to_string();
204    *acc.by_name.entry(name.clone()).or_insert(0) += 1;
205
206    if let Some(cat) = ev.get("cat").and_then(|v| v.as_str()) {
207        for part in cat.split(',') {
208            let p = part.trim();
209            if !p.is_empty() {
210                *acc.by_cat.entry(p.to_string()).or_insert(0) += 1;
211            }
212        }
213    }
214
215    let ph = ev.get("ph").and_then(|v| v.as_str()).unwrap_or("");
216    let ts = ev.get("ts").and_then(|v| v.as_f64());
217    let dur = ev.get("dur").and_then(|v| v.as_f64()).map(|d| d / 1000.0); // µs → ms in Chrome traces
218
219    if let Some(d) = dur {
220        let entry = acc.durations_ms.entry(name.clone()).or_insert(0.0);
221        if d > *entry {
222            *entry = d;
223        }
224    }
225
226    // Heuristics for common timeline marks.
227    match name.as_str() {
228        "navigationStart" | "NavigationStart" => {
229            if acc.navigation_start.is_none() {
230                acc.navigation_start = ts.map(|t| t / 1000.0);
231            }
232        }
233        "firstContentfulPaint" | "firstPaint" => {
234            if let (Some(nav), Some(t)) = (acc.navigation_start, ts) {
235                acc.fcp_ms = Some(t / 1000.0 - nav);
236            } else if let Some(d) = dur {
237                acc.fcp_ms = Some(d);
238            }
239        }
240        "largestContentfulPaint::Candidate" | "largestContentfulPaint" => {
241            if let (Some(nav), Some(t)) = (acc.navigation_start, ts) {
242                let v = t / 1000.0 - nav;
243                acc.lcp_ms = Some(acc.lcp_ms.map(|old| old.max(v)).unwrap_or(v));
244            }
245        }
246        "domContentLoadedEventEnd" => {
247            if let (Some(nav), Some(t)) = (acc.navigation_start, ts) {
248                acc.dcl_ms = Some(t / 1000.0 - nav);
249            }
250        }
251        "loadEventEnd" => {
252            if let (Some(nav), Some(t)) = (acc.navigation_start, ts) {
253                acc.load_ms = Some(t / 1000.0 - nav);
254            }
255        }
256        "responseStart" | "TimeToFirstByte" => {
257            if let (Some(nav), Some(t)) = (acc.navigation_start, ts) {
258                acc.ttfb_ms = Some(t / 1000.0 - nav);
259            } else if let Some(d) = dur {
260                acc.ttfb_ms = Some(d);
261            }
262        }
263        "LayoutShift" => {
264            acc.layout_shifts += 1;
265            if let Some(score) = ev
266                .get("args")
267                .and_then(|a| a.get("data"))
268                .and_then(|d| d.get("score").or_else(|| d.get("weighted_score_delta")))
269                .and_then(|v| v.as_f64())
270            {
271                acc.cls_score = Some(acc.cls_score.unwrap_or(0.0) + score);
272            }
273        }
274        "RunTask" | "RunMicrotasks" => {
275            if let Some(d) = dur {
276                if d >= 50.0 {
277                    acc.long_tasks += 1;
278                }
279            }
280        }
281        _ => {
282            if name.contains("LayoutShift") {
283                acc.layout_shifts += 1;
284            }
285            if ph == "R" || ph == "I" {
286                // keep counts only
287            }
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use std::io::Write;
296
297    #[test]
298    fn analyzes_ndjson_events() {
299        let dir = tempfile::tempdir().unwrap();
300        let path = dir.path().join("t.ndjson");
301        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}]
302[{"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}]"#;
303        let mut f = std::fs::File::create(&path).unwrap();
304        f.write_all(body.as_bytes()).unwrap();
305        let v = analyze_file(&path, None).unwrap();
306        assert!(v["event_count"].as_u64().unwrap() >= 3);
307        assert_eq!(v["offline"], true);
308        assert!(!v["top_events"].as_array().unwrap().is_empty());
309        assert!(v["web_vitals"]["cls"].as_f64().unwrap() >= 0.05);
310        assert!(v["insights"]["long_tasks"].as_u64().unwrap() >= 1);
311        assert!(v["insights"]["layout_shifts"].as_u64().unwrap() >= 1);
312    }
313}