browser_automation_cli/native/
perf_insight.rs1#![allow(missing_docs)]
3use 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
31pub 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
37pub 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 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); 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 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 }
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}