Skip to main content

profile_inspect/output/
json.rs

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
10/// JSON output formatter for CI/automation
11pub 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        // Build key takeaways based on call flow analysis
177        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        // Calculate what dependencies trigger
186        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            // Check what's triggering native work
209            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        // Build GC assessment
262        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        // Build recommendations
283        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}