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