1use std::io::Write;
2
3use serde::Serialize;
4
5use crate::analysis::{AllocationStats, CpuAnalysis, HeapAnalysis};
6use crate::ir::ProfileIR;
7
8use super::{Formatter, OutputError};
9
10pub struct JsonFormatter;
12
13#[derive(Serialize)]
14struct JsonOutput<'a> {
15 metadata: JsonMetadata<'a>,
16 executive_summary: JsonExecutiveSummary,
17 category_breakdown: JsonCategoryBreakdown,
18 functions_by_self_time: Vec<JsonFunction>,
19 functions_by_inclusive_time: Vec<JsonFunction>,
20 hot_paths: Vec<JsonHotPath>,
21 hot_function_details: Vec<JsonHotFunctionDetail>,
22 file_stats: Vec<JsonFileStats>,
23 package_stats: Vec<JsonPackageStats>,
24 signals: JsonSignals,
25 recommendations: JsonRecommendations,
26}
27
28#[derive(Serialize)]
29struct JsonMetadata<'a> {
30 source_file: Option<&'a str>,
31 total_time_us: u64,
32 total_time_ms: f64,
33 total_samples: usize,
34 sample_interval_ms: f64,
35 internals_filtered: bool,
36 focus_package: Option<&'a str>,
37}
38
39#[derive(Serialize)]
40struct JsonExecutiveSummary {
41 app_percent: f64,
42 deps_percent: f64,
43 node_internal_percent: f64,
44 v8_native_percent: f64,
45 key_takeaways: Vec<String>,
46}
47
48#[derive(Serialize)]
49struct JsonCategoryBreakdown {
50 app_us: u64,
51 app_ms: f64,
52 app_percent: f64,
53 deps_us: u64,
54 deps_ms: f64,
55 deps_percent: f64,
56 node_internal_us: u64,
57 node_internal_ms: f64,
58 node_internal_percent: f64,
59 v8_internal_us: u64,
60 v8_internal_ms: f64,
61 v8_internal_percent: f64,
62 native_us: u64,
63 native_ms: f64,
64 native_percent: f64,
65}
66
67#[derive(Serialize)]
68struct JsonFunction {
69 rank: usize,
70 name: String,
71 location: String,
72 category: String,
73 self_time_us: u64,
74 self_time_ms: f64,
75 self_percent: f64,
76 self_samples: u32,
77 inclusive_time_us: u64,
78 inclusive_time_ms: f64,
79 inclusive_percent: f64,
80 total_samples: u32,
81}
82
83#[derive(Serialize)]
84struct JsonHotPath {
85 rank: usize,
86 frames: Vec<JsonPathFrame>,
87 time_us: u64,
88 time_ms: f64,
89 percent: f64,
90 sample_count: u32,
91 explanation: Vec<String>,
92}
93
94#[derive(Serialize)]
95struct JsonPathFrame {
96 name: String,
97 location: String,
98 is_hotspot: bool,
99}
100
101#[derive(Serialize)]
102struct JsonHotFunctionDetail {
103 name: String,
104 location: String,
105 self_time_ms: f64,
106 self_percent: f64,
107 inclusive_time_ms: f64,
108 callers: Vec<JsonCallerCallee>,
109 callees: Vec<JsonCallerCallee>,
110 call_pattern_signal: Option<String>,
111}
112
113#[derive(Serialize)]
114struct JsonCallerCallee {
115 name: String,
116 location: String,
117 time_ms: f64,
118 call_count: u32,
119}
120
121#[derive(Serialize)]
122struct JsonFileStats {
123 file: String,
124 self_time_ms: f64,
125 total_time_ms: f64,
126 call_count: u32,
127 category: String,
128}
129
130#[derive(Serialize)]
131struct JsonPackageStats {
132 package: String,
133 time_ms: f64,
134 percent_of_deps: f64,
135 top_function: String,
136 top_function_location: String,
137}
138
139#[derive(Serialize)]
140struct JsonSignals {
141 gc_time_us: u64,
142 gc_time_ms: f64,
143 gc_percent: f64,
144 gc_assessment: String,
145 native_time_us: u64,
146 native_time_ms: f64,
147 native_percent: f64,
148}
149
150#[derive(Serialize)]
151struct JsonRecommendations {
152 critical: Vec<JsonRecommendation>,
153 high: Vec<JsonRecommendation>,
154}
155
156#[derive(Serialize)]
157struct JsonRecommendation {
158 function: String,
159 location: String,
160 self_percent: f64,
161 inclusive_percent: f64,
162 category: String,
163}
164
165impl Formatter for JsonFormatter {
166 #[expect(clippy::cast_precision_loss)]
167 fn write_cpu_analysis(
168 &self,
169 profile: &ProfileIR,
170 analysis: &CpuAnalysis,
171 writer: &mut dyn Write,
172 ) -> Result<(), OutputError> {
173 let breakdown = &analysis.category_breakdown;
174 let total = breakdown.total();
175
176 let mut key_takeaways = Vec::new();
178 let flow = &analysis.category_call_flow;
179
180 let app_pct = breakdown.percent(crate::ir::FrameCategory::App);
181 let deps_pct = breakdown.percent(crate::ir::FrameCategory::Deps);
182 let native_pct_total = breakdown.percent(crate::ir::FrameCategory::V8Internal)
183 + breakdown.percent(crate::ir::FrameCategory::Native);
184
185 let deps_triggers: u64 = flow
187 .callees_for(crate::ir::FrameCategory::Deps)
188 .iter()
189 .map(|(_, t)| *t)
190 .sum();
191
192 if app_pct > 50.0 {
193 key_takeaways.push(format!(
194 "App code dominates ({:.0}%) — focus optimization efforts on your code",
195 app_pct
196 ));
197 } else if deps_pct > 20.0 || (total > 0 && deps_triggers > total / 2) {
198 let deps_total_pct = if total > 0 {
199 ((breakdown.deps + deps_triggers) as f64 / total as f64) * 100.0
200 } else {
201 0.0
202 };
203 key_takeaways.push(format!(
204 "Dependencies drive {:.0}% of work — check which packages are expensive",
205 deps_total_pct.min(100.0)
206 ));
207 } else if native_pct_total > 70.0 {
208 let node_to_native: u64 = flow
210 .callees_for(crate::ir::FrameCategory::NodeInternal)
211 .iter()
212 .filter(|(cat, _)| {
213 *cat == crate::ir::FrameCategory::Native
214 || *cat == crate::ir::FrameCategory::V8Internal
215 })
216 .map(|(_, t)| *t)
217 .sum();
218 let app_to_native: u64 = flow
219 .callees_for(crate::ir::FrameCategory::App)
220 .iter()
221 .filter(|(cat, _)| {
222 *cat == crate::ir::FrameCategory::Native
223 || *cat == crate::ir::FrameCategory::V8Internal
224 })
225 .map(|(_, t)| *t)
226 .sum();
227
228 if node_to_native > app_to_native {
229 key_takeaways.push(format!(
230 "V8/Native dominates ({:.0}%) via Node.js — likely module loading/compilation",
231 native_pct_total
232 ));
233 } else {
234 key_takeaways.push(format!(
235 "V8/Native dominates ({:.0}%) — check for native addon work or compilation",
236 native_pct_total
237 ));
238 }
239 }
240
241 if let Some(top) = analysis.functions.first() {
242 let pct = top.self_percent(analysis.total_time);
243 if pct > 5.0 {
244 key_takeaways.push(format!(
245 "Top bottleneck: {} at {:.1}% self time",
246 top.name, pct
247 ));
248 }
249 }
250
251 if analysis.gc_time > 0 {
252 let gc_pct = (analysis.gc_time as f64 / analysis.total_time as f64) * 100.0;
253 if gc_pct > 5.0 {
254 key_takeaways.push(format!(
255 "GC overhead at {:.1}% — may indicate allocation pressure",
256 gc_pct
257 ));
258 }
259 }
260
261 let gc_pct = if analysis.total_time > 0 {
263 (analysis.gc_time as f64 / analysis.total_time as f64) * 100.0
264 } else {
265 0.0
266 };
267 let gc_assessment = if gc_pct > 10.0 {
268 "High GC pressure — investigate allocation patterns"
269 } else if gc_pct > 5.0 {
270 "Moderate GC activity — may warrant investigation"
271 } else {
272 "Normal GC overhead"
273 }
274 .to_string();
275
276 let native_pct = if analysis.total_time > 0 {
277 (analysis.native_time as f64 / analysis.total_time as f64) * 100.0
278 } else {
279 0.0
280 };
281
282 let critical: Vec<JsonRecommendation> = analysis
284 .functions
285 .iter()
286 .filter(|f| {
287 f.self_percent(analysis.total_time) >= 20.0
288 || f.total_percent(analysis.total_time) >= 35.0
289 })
290 .map(|f| JsonRecommendation {
291 function: f.name.clone(),
292 location: f.location.clone(),
293 self_percent: f.self_percent(analysis.total_time),
294 inclusive_percent: f.total_percent(analysis.total_time),
295 category: format!("{}", f.category),
296 })
297 .collect();
298
299 let high: Vec<JsonRecommendation> = analysis
300 .functions
301 .iter()
302 .filter(|f| {
303 let self_pct = f.self_percent(analysis.total_time);
304 let total_pct = f.total_percent(analysis.total_time);
305 (self_pct >= 10.0 && self_pct < 20.0) || (total_pct >= 20.0 && total_pct < 35.0)
306 })
307 .map(|f| JsonRecommendation {
308 function: f.name.clone(),
309 location: f.location.clone(),
310 self_percent: f.self_percent(analysis.total_time),
311 inclusive_percent: f.total_percent(analysis.total_time),
312 category: format!("{}", f.category),
313 })
314 .collect();
315
316 let output = JsonOutput {
317 metadata: JsonMetadata {
318 source_file: profile.source_file.as_deref(),
319 total_time_us: analysis.total_time,
320 total_time_ms: analysis.total_time as f64 / 1000.0,
321 total_samples: analysis.total_samples,
322 sample_interval_ms: analysis.metadata.sample_interval_ms,
323 internals_filtered: analysis.metadata.internals_filtered,
324 focus_package: analysis.metadata.focus_package.as_deref(),
325 },
326 executive_summary: JsonExecutiveSummary {
327 app_percent: app_pct,
328 deps_percent: deps_pct,
329 node_internal_percent: breakdown.percent(crate::ir::FrameCategory::NodeInternal),
330 v8_native_percent: breakdown.percent(crate::ir::FrameCategory::V8Internal)
331 + breakdown.percent(crate::ir::FrameCategory::Native),
332 key_takeaways,
333 },
334 category_breakdown: JsonCategoryBreakdown {
335 app_us: breakdown.app,
336 app_ms: breakdown.app as f64 / 1000.0,
337 app_percent: if total > 0 {
338 (breakdown.app as f64 / total as f64) * 100.0
339 } else {
340 0.0
341 },
342 deps_us: breakdown.deps,
343 deps_ms: breakdown.deps as f64 / 1000.0,
344 deps_percent: if total > 0 {
345 (breakdown.deps as f64 / total as f64) * 100.0
346 } else {
347 0.0
348 },
349 node_internal_us: breakdown.node_internal,
350 node_internal_ms: breakdown.node_internal as f64 / 1000.0,
351 node_internal_percent: if total > 0 {
352 (breakdown.node_internal as f64 / total as f64) * 100.0
353 } else {
354 0.0
355 },
356 v8_internal_us: breakdown.v8_internal,
357 v8_internal_ms: breakdown.v8_internal as f64 / 1000.0,
358 v8_internal_percent: if total > 0 {
359 (breakdown.v8_internal as f64 / total as f64) * 100.0
360 } else {
361 0.0
362 },
363 native_us: breakdown.native,
364 native_ms: breakdown.native as f64 / 1000.0,
365 native_percent: if total > 0 {
366 (breakdown.native as f64 / total as f64) * 100.0
367 } else {
368 0.0
369 },
370 },
371 functions_by_self_time: analysis
372 .functions
373 .iter()
374 .enumerate()
375 .map(|(i, f)| JsonFunction {
376 rank: i + 1,
377 name: f.name.clone(),
378 location: f.location.clone(),
379 category: format!("{}", f.category),
380 self_time_us: f.self_time,
381 self_time_ms: f.self_time as f64 / 1000.0,
382 self_percent: f.self_percent(analysis.total_time),
383 self_samples: f.self_samples,
384 inclusive_time_us: f.total_time,
385 inclusive_time_ms: f.total_time as f64 / 1000.0,
386 inclusive_percent: f.total_percent(analysis.total_time),
387 total_samples: f.total_samples,
388 })
389 .collect(),
390 functions_by_inclusive_time: analysis
391 .functions_by_total
392 .iter()
393 .enumerate()
394 .map(|(i, f)| JsonFunction {
395 rank: i + 1,
396 name: f.name.clone(),
397 location: f.location.clone(),
398 category: format!("{}", f.category),
399 self_time_us: f.self_time,
400 self_time_ms: f.self_time as f64 / 1000.0,
401 self_percent: f.self_percent(analysis.total_time),
402 self_samples: f.self_samples,
403 inclusive_time_us: f.total_time,
404 inclusive_time_ms: f.total_time as f64 / 1000.0,
405 inclusive_percent: f.total_percent(analysis.total_time),
406 total_samples: f.total_samples,
407 })
408 .collect(),
409 hot_paths: analysis
410 .hot_paths
411 .iter()
412 .enumerate()
413 .map(|(i, p)| {
414 let frames: Vec<JsonPathFrame> = p
415 .frames
416 .iter()
417 .enumerate()
418 .filter_map(|(idx, fid)| {
419 profile.get_frame(*fid).map(|f| JsonPathFrame {
420 name: f.display_name(),
421 location: f.location(),
422 is_hotspot: idx == p.frames.len() - 1,
423 })
424 })
425 .collect();
426
427 let mut explanation = Vec::new();
428 if let Some(&leaf_id) = p.frames.last() {
429 if let Some(func) =
430 analysis.functions.iter().find(|f| f.frame_id == leaf_id)
431 {
432 let self_pct = func.self_percent(analysis.total_time);
433 if self_pct > 1.0 {
434 explanation.push(format!(
435 "Leaf function has {:.1}% self time (self-heavy)",
436 self_pct
437 ));
438 }
439 }
440 }
441 if analysis.total_samples > 0 {
442 let path_pct =
443 (p.sample_count as f64 / analysis.total_samples as f64) * 100.0;
444 if path_pct > 1.0 {
445 explanation.push(format!(
446 "Appears in {:.1}% of samples (frequently executed)",
447 path_pct
448 ));
449 }
450 }
451
452 JsonHotPath {
453 rank: i + 1,
454 frames,
455 time_us: p.time,
456 time_ms: p.time as f64 / 1000.0,
457 percent: p.percent,
458 sample_count: p.sample_count,
459 explanation,
460 }
461 })
462 .collect(),
463 hot_function_details: analysis
464 .hot_function_details
465 .iter()
466 .map(|d| {
467 let call_pattern_signal =
468 if d.callers.len() == 1 && d.self_time > analysis.total_time / 100 {
469 Some(
470 "Single caller — if result is deterministic, consider memoization"
471 .to_string(),
472 )
473 } else if d.callers.len() > 3 {
474 Some(format!(
475 "Called from {} different sites — hot utility function",
476 d.callers.len()
477 ))
478 } else {
479 None
480 };
481
482 JsonHotFunctionDetail {
483 name: d.name.clone(),
484 location: d.location.clone(),
485 self_time_ms: d.self_time as f64 / 1000.0,
486 self_percent: if analysis.total_time > 0 {
487 (d.self_time as f64 / analysis.total_time as f64) * 100.0
488 } else {
489 0.0
490 },
491 inclusive_time_ms: d.total_time as f64 / 1000.0,
492 callers: d
493 .callers
494 .iter()
495 .map(|c| JsonCallerCallee {
496 name: c.name.clone(),
497 location: c.location.clone(),
498 time_ms: c.time as f64 / 1000.0,
499 call_count: c.call_count,
500 })
501 .collect(),
502 callees: d
503 .callees
504 .iter()
505 .map(|c| JsonCallerCallee {
506 name: c.name.clone(),
507 location: c.location.clone(),
508 time_ms: c.self_time as f64 / 1000.0,
509 call_count: c.call_count,
510 })
511 .collect(),
512 call_pattern_signal,
513 }
514 })
515 .collect(),
516 file_stats: analysis
517 .file_stats
518 .iter()
519 .map(|f| JsonFileStats {
520 file: f.file.clone(),
521 self_time_ms: f.self_time as f64 / 1000.0,
522 total_time_ms: f.total_time as f64 / 1000.0,
523 call_count: f.call_count,
524 category: format!("{}", f.category),
525 })
526 .collect(),
527 package_stats: analysis
528 .package_stats
529 .iter()
530 .map(|p| JsonPackageStats {
531 package: p.package.clone(),
532 time_ms: p.time as f64 / 1000.0,
533 percent_of_deps: p.percent_of_deps,
534 top_function: p.top_function.clone(),
535 top_function_location: p.top_function_location.clone(),
536 })
537 .collect(),
538 signals: JsonSignals {
539 gc_time_us: analysis.gc_time,
540 gc_time_ms: analysis.gc_time as f64 / 1000.0,
541 gc_percent: gc_pct,
542 gc_assessment,
543 native_time_us: analysis.native_time,
544 native_time_ms: analysis.native_time as f64 / 1000.0,
545 native_percent: native_pct,
546 },
547 recommendations: JsonRecommendations { critical, high },
548 };
549
550 serde_json::to_writer_pretty(writer, &output)?;
551 Ok(())
552 }
553
554 #[expect(clippy::cast_precision_loss)]
555 fn write_heap_analysis(
556 &self,
557 profile: &ProfileIR,
558 analysis: &HeapAnalysis,
559 writer: &mut dyn Write,
560 ) -> Result<(), OutputError> {
561 #[derive(Serialize)]
562 struct HeapOutput<'a> {
563 metadata: HeapMetadata<'a>,
564 category_breakdown: HeapCategoryBreakdown,
565 allocations: Vec<HeapAllocation>,
566 }
567
568 #[derive(Serialize)]
569 struct HeapMetadata<'a> {
570 source_file: Option<&'a str>,
571 total_size_bytes: u64,
572 total_size_formatted: String,
573 total_allocations: usize,
574 }
575
576 #[derive(Serialize)]
577 struct HeapCategoryBreakdown {
578 app_bytes: u64,
579 app_percent: f64,
580 deps_bytes: u64,
581 deps_percent: f64,
582 node_internal_bytes: u64,
583 node_internal_percent: f64,
584 v8_native_bytes: u64,
585 v8_native_percent: f64,
586 }
587
588 #[derive(Serialize)]
589 struct HeapAllocation {
590 name: String,
591 location: String,
592 category: String,
593 self_bytes: u64,
594 self_formatted: String,
595 self_percent: f64,
596 total_bytes: u64,
597 total_formatted: String,
598 allocation_count: u32,
599 }
600
601 let breakdown = &analysis.category_breakdown;
602 let total = breakdown.total();
603
604 let output = HeapOutput {
605 metadata: HeapMetadata {
606 source_file: profile.source_file.as_deref(),
607 total_size_bytes: analysis.total_size,
608 total_size_formatted: AllocationStats::format_size(analysis.total_size),
609 total_allocations: analysis.total_allocations,
610 },
611 category_breakdown: HeapCategoryBreakdown {
612 app_bytes: breakdown.app,
613 app_percent: if total > 0 {
614 (breakdown.app as f64 / total as f64) * 100.0
615 } else {
616 0.0
617 },
618 deps_bytes: breakdown.deps,
619 deps_percent: if total > 0 {
620 (breakdown.deps as f64 / total as f64) * 100.0
621 } else {
622 0.0
623 },
624 node_internal_bytes: breakdown.node_internal,
625 node_internal_percent: if total > 0 {
626 (breakdown.node_internal as f64 / total as f64) * 100.0
627 } else {
628 0.0
629 },
630 v8_native_bytes: breakdown.v8_internal + breakdown.native,
631 v8_native_percent: if total > 0 {
632 ((breakdown.v8_internal + breakdown.native) as f64 / total as f64) * 100.0
633 } else {
634 0.0
635 },
636 },
637 allocations: analysis
638 .functions
639 .iter()
640 .map(|f| HeapAllocation {
641 name: f.name.clone(),
642 location: f.location.clone(),
643 category: format!("{:?}", f.category),
644 self_bytes: f.self_size,
645 self_formatted: AllocationStats::format_size(f.self_size),
646 self_percent: f.self_percent(analysis.total_size),
647 total_bytes: f.total_size,
648 total_formatted: AllocationStats::format_size(f.total_size),
649 allocation_count: f.allocation_count,
650 })
651 .collect(),
652 };
653
654 serde_json::to_writer_pretty(writer, &output)?;
655 Ok(())
656 }
657}