Skip to main content

memscope_rs/render_engine/export/
snapshot_json.rs

1//! JSON projections of memory snapshots and their export options.
2
3use crate::snapshot::{ActiveAllocation, MemorySnapshot, ThreadMemoryStats};
4use rayon::prelude::*;
5use serde_json::json;
6use std::{
7    collections::HashMap,
8    fs::File,
9    io::{BufWriter, Write},
10    path::Path,
11};
12
13/// Optimization level for JSON export
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum OptimizationLevel {
16    /// Low optimization - maximum compatibility
17    Low,
18    /// Medium optimization - balanced
19    #[default]
20    Medium,
21    /// High optimization - maximum performance
22    High,
23    /// Maximum optimization - aggressive optimization
24    Maximum,
25}
26
27/// Schema validator for JSON export
28#[derive(Debug, Clone, Default)]
29pub struct SchemaValidator {
30    strict_mode: bool,
31}
32
33impl SchemaValidator {
34    pub fn new() -> Self {
35        Self { strict_mode: false }
36    }
37    pub fn with_strict_mode(mut self, strict: bool) -> Self {
38        self.strict_mode = strict;
39        self
40    }
41    pub fn validate(&self, data: &serde_json::Value) -> Result<(), String> {
42        if !data.is_object() {
43            return Err("Export data must be a JSON object".to_string());
44        }
45        let obj = data.as_object().ok_or("Invalid JSON object")?;
46        if self.strict_mode {
47            let required_fields = ["timestamp", "allocations", "stats"];
48            for field in &required_fields {
49                if !obj.contains_key(*field) {
50                    return Err(format!("Missing required field: {}", field));
51                }
52            }
53        }
54        Ok(())
55    }
56}
57
58#[derive(Debug, Clone)]
59pub struct ExportJsonOptions {
60    pub parallel_processing: bool,
61    pub buffer_size: usize,
62    pub use_compact_format: Option<bool>,
63    pub enable_type_cache: bool,
64    pub batch_size: usize,
65    pub streaming_writer: bool,
66    pub schema_validation: bool,
67    pub adaptive_optimization: bool,
68    pub max_cache_size: usize,
69    pub security_analysis: bool,
70    pub include_low_severity: bool,
71    pub integrity_hashes: bool,
72    pub fast_export_mode: bool,
73    pub auto_fast_export_threshold: Option<usize>,
74    pub thread_count: Option<usize>,
75}
76
77impl Default for ExportJsonOptions {
78    fn default() -> Self {
79        Self {
80            parallel_processing: true,
81            buffer_size: 256 * 1024,
82            use_compact_format: None,
83            enable_type_cache: true,
84            batch_size: 1000,
85            streaming_writer: true,
86            schema_validation: false,
87            adaptive_optimization: true,
88            max_cache_size: 10_000,
89            security_analysis: false,
90            include_low_severity: false,
91            integrity_hashes: false,
92            fast_export_mode: false,
93            auto_fast_export_threshold: Some(10_000),
94            thread_count: None,
95        }
96    }
97}
98
99impl ExportJsonOptions {
100    pub fn fast_export_mode(mut self, enabled: bool) -> Self {
101        self.fast_export_mode = enabled;
102        self
103    }
104    pub fn security_analysis(mut self, enabled: bool) -> Self {
105        self.security_analysis = enabled;
106        self
107    }
108    pub fn streaming_writer(mut self, enabled: bool) -> Self {
109        self.streaming_writer = enabled;
110        self
111    }
112    pub fn schema_validation(mut self, enabled: bool) -> Self {
113        self.schema_validation = enabled;
114        self
115    }
116    pub fn integrity_hashes(mut self, enabled: bool) -> Self {
117        self.integrity_hashes = enabled;
118        self
119    }
120    pub fn batch_size(mut self, size: usize) -> Self {
121        self.batch_size = size;
122        self
123    }
124    pub fn adaptive_optimization(mut self, enabled: bool) -> Self {
125        self.adaptive_optimization = enabled;
126        self
127    }
128    pub fn max_cache_size(mut self, size: usize) -> Self {
129        self.max_cache_size = size;
130        self
131    }
132    pub fn include_low_severity(mut self, include: bool) -> Self {
133        self.include_low_severity = include;
134        self
135    }
136    pub fn thread_count(mut self, count: Option<usize>) -> Self {
137        self.thread_count = count;
138        self
139    }
140}
141
142pub fn export_snapshot_to_json(
143    snapshot: &MemorySnapshot,
144    output_path: &Path,
145    options: &ExportJsonOptions,
146) -> Result<(), Box<dyn std::error::Error>> {
147    export_snapshot_with_containers(snapshot, &[], output_path, options)
148}
149
150/// Export a snapshot plus the container/stack metadata observed in the same capture.
151///
152/// Containers carry no heap bytes and are therefore not part of the counters; they are
153/// written to their own array so their metadata survives without inflating the live
154/// numbers (plan A04 follow-up).
155pub fn export_snapshot_with_containers(
156    snapshot: &MemorySnapshot,
157    containers: &[crate::capture::types::AllocationInfo],
158    output_path: &Path,
159    options: &ExportJsonOptions,
160) -> Result<(), Box<dyn std::error::Error>> {
161    // Create parent directory if it doesn't exist
162    if let Some(parent) = output_path.parent() {
163        if !parent.as_os_str().is_empty() {
164            std::fs::create_dir_all(parent)?;
165        }
166    }
167    // Every live identity, not just the newest binding per address: an overlapping
168    // generation (a missed release) must appear in the file it is counted in.
169    let allocations: Vec<&ActiveAllocation> = snapshot.allocations().collect();
170    let mut processed = process_allocations(&allocations, snapshot.timestamp, options)?;
171    // `process_allocations` preserves order, so the identity of each row can be taken
172    // from the snapshot's binding list when it carries one.
173    if let Some(bindings) = snapshot
174        .active_bindings
175        .as_ref()
176        .filter(|bindings| bindings.len() == processed.len())
177    {
178        for (entry, binding) in processed.iter_mut().zip(bindings.iter()) {
179            entry["generation_id"] = serde_json::json!(binding.generation_id);
180            entry["binding_serial"] = serde_json::json!(binding.binding_serial);
181        }
182    }
183    // Use output_path as the base directory for generated files
184    let output_dir = if output_path.extension().is_some() {
185        // If output_path has an extension, treat it as a file and use its parent
186        output_path.parent().unwrap_or(Path::new("."))
187    } else {
188        output_path
189    };
190    generate_memory_analysis_json(
191        output_dir,
192        &processed,
193        containers,
194        &snapshot.stats,
195        snapshot.active_bindings.is_some(),
196        options,
197        snapshot.timestamp,
198    )?;
199    generate_lifetime_json(output_dir, &processed, options, snapshot.timestamp)?;
200    generate_thread_analysis_json(
201        output_dir,
202        &snapshot.thread_stats,
203        options,
204        snapshot.timestamp,
205    )?;
206    Ok(())
207}
208
209fn process_allocations(
210    allocations: &[&ActiveAllocation],
211    captured_at: u64,
212    options: &ExportJsonOptions,
213) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
214    if options.parallel_processing && allocations.len() > options.batch_size {
215        let chunk_size = (allocations.len() / num_cpus::get()).max(1);
216        Ok(allocations
217            .par_chunks(chunk_size)
218            .flat_map(|batch| process_allocation_batch(batch, captured_at))
219            .collect())
220    } else {
221        Ok(process_allocation_batch(allocations, captured_at))
222    }
223}
224
225fn process_allocation_batch(
226    allocations: &[&ActiveAllocation],
227    captured_at: u64,
228) -> Vec<serde_json::Value> {
229    allocations
230        .iter()
231        .map(|alloc| {
232            let type_info = get_or_compute_type_info(
233                alloc.type_name.as_deref().unwrap_or("unknown"),
234                alloc.size,
235            );
236            // `lifetime_ms` describes a *complete* lifetime and is only filled when the
237            // release time is known. A live object reports `observed_age_ms` instead:
238            // "how long it had been alive when the snapshot was taken", never an invented
239            // total lifetime (plan §4.5).
240            let observed_age_ms = if alloc.allocated_at > 0 {
241                (captured_at.saturating_sub(alloc.allocated_at)) / 1_000_000
242            } else {
243                0
244            };
245            // This projection only contains allocations that are still live, so a
246            // complete lifetime is never known here: `lifetime_ms` stays null and the
247            // consumer is pointed at `observed_age_ms` instead of mistaking an open-ended
248            // age for the object's total lifetime (plan §4.5).
249            let lifetime_ms: Option<u64> = None;
250            let address = match alloc.ptr {
251                Some(ptr) => format!("0x{:x}", ptr),
252                None => "N/A".to_string(),
253            };
254            let mut entry = json!({
255                "address": address, "size": alloc.size, "type": type_info,
256                "timestamp": alloc.allocated_at, "thread_id": alloc.thread_id,
257                "lifetime_ms": lifetime_ms, "observed_age_ms": observed_age_ms,
258                "lifetime_complete": lifetime_ms.is_some(),
259            });
260            if let Some(ref var_name) = alloc.var_name {
261                entry["var_name"] = serde_json::json!(var_name);
262            }
263            if let Some(ref type_name) = alloc.type_name {
264                entry["type_name"] = serde_json::json!(type_name);
265            }
266            if let Some(ref module_path) = alloc.module_path {
267                // Filter out library paths, only show user project paths
268                if !is_library_module_path(module_path) {
269                    entry["module_path"] = serde_json::json!(module_path);
270                }
271            }
272            entry
273        })
274        .collect()
275}
276
277/// Check if a module path belongs to a library (std, core, alloc, or external crates)
278fn is_library_module_path(module_path: &str) -> bool {
279    // Filter out standard library paths
280    if module_path.starts_with("std::")
281        || module_path.starts_with("core::")
282        || module_path.starts_with("alloc::")
283    {
284        return true;
285    }
286    // Filter out memscope_rs library paths
287    if module_path.starts_with("memscope_rs::") {
288        return true;
289    }
290    // Filter out other common library prefixes
291    let library_prefixes = [
292        "tokio::",
293        "serde::",
294        "async_trait::",
295        "futures::",
296        "log::",
297        "tracing::",
298        "chrono::",
299        "indexmap::",
300        "rustc_hash::",
301        "parking_lot::",
302        "crossbeam::",
303        "rayon::",
304        "dashmap::",
305        "ahash::",
306        "hashbrown::",
307    ];
308    for prefix in library_prefixes.iter() {
309        if module_path.starts_with(prefix) {
310            return true;
311        }
312    }
313    false
314}
315
316pub(super) fn get_or_compute_type_info(type_name: &str, size: usize) -> String {
317    // Check for Vec but not VecDeque
318    if (type_name.contains("Vec<") || type_name.contains("vec::Vec<"))
319        && !type_name.contains("VecDeque")
320    {
321        "dynamic_array".to_string()
322    } else if type_name == "str"
323        || type_name == "String"
324        || type_name.contains("&str")
325        || type_name.contains("alloc::string::String")
326    {
327        "string".to_string()
328    } else if type_name.contains("Box") || type_name.contains("Rc") || type_name.contains("Arc") {
329        "smart_pointer".to_string()
330    } else if type_name.contains("[") && type_name.contains("u8") {
331        "byte_array".to_string()
332    } else if size > 1024 * 1024 {
333        "large_buffer".to_string()
334    } else {
335        "custom".to_string()
336    }
337}
338
339fn generate_memory_analysis_json<P: AsRef<Path>>(
340    output_path: P,
341    allocations: &[serde_json::Value],
342    containers: &[crate::capture::types::AllocationInfo],
343    stats: &crate::snapshot::types::MemoryStats,
344    has_transition_history: bool,
345    options: &ExportJsonOptions,
346    captured_at: u64,
347) -> Result<(), Box<dyn std::error::Error>> {
348    let total_size: usize = allocations
349        .iter()
350        .filter_map(|a| a.get("size").and_then(|s| s.as_u64()))
351        .map(|s| s as usize)
352        .sum();
353    let type_distribution: HashMap<String, usize> = {
354        let mut dist = HashMap::new();
355        for alloc in allocations {
356            if let Some(t) = alloc.get("type").and_then(|t| t.as_str()) {
357                *dist.entry(t.to_string()).or_insert(0) += 1;
358            }
359        }
360        dist
361    };
362    // Two different questions are answered here and must not be confused:
363    //   * `live_*`        – what is still allocated at this cut (sum over the rows);
364    //   * `cumulative_*`  – what the capture observed over the whole run, including
365    //                       blocks that were released before the cut.
366    // `total_size_bytes` / `total_allocations` stay as the live values for backwards
367    // compatibility; `counters_scope` says which is which.
368    // Without a transition history the snapshot only knows what is live, so the
369    // cumulative counters must stay null instead of echoing the live values.
370    let (cumulative_allocated, cumulative_deallocated, peak) = if has_transition_history {
371        (
372            serde_json::json!(stats.total_allocated),
373            serde_json::json!(stats.total_deallocated),
374            serde_json::json!(stats.peak_memory),
375        )
376    } else {
377        (
378            serde_json::Value::Null,
379            serde_json::Value::Null,
380            serde_json::Value::Null,
381        )
382    };
383    let counters_scope = if has_transition_history {
384        "live counters describe this cut, cumulative counters describe the whole capture"
385    } else {
386        "live only: this snapshot has no transition history, cumulative counters are unknown"
387    };
388    let data = json!({
389        "metadata": {
390            "export_version": "2.0", "export_timestamp": snapshot_timestamp(captured_at),
391            "snapshot_timestamp_ns": captured_at,
392            "specification": "memscope-rs memory analysis", "counters_scope": counters_scope,
393            "total_allocations": allocations.len(), "total_size_bytes": total_size,
394            "live_size_bytes": total_size, "degraded_releases": stats.degraded_releases,
395            "cumulative_allocated_bytes": cumulative_allocated, "cumulative_deallocated_bytes": cumulative_deallocated, "peak_bytes": peak
396        },
397        "allocations": allocations,
398        // Stack-side container metadata: listed for context, never counted as heap bytes.
399        "containers": containers.iter().map(|container| json!({
400            "address": if container.ptr == 0 { "N/A".to_string() } else { format!("0x{:x}", container.ptr) },
401            "size": container.size,
402            "type_name": container.type_name.clone().unwrap_or_else(|| "unknown".to_string()),
403            "var_name": container.var_name.clone(), "thread_id": container.thread_id_u64, "counted_in_statistics": false,
404        })).collect::<Vec<_>>(),
405        "statistics": {
406            "total_allocations": allocations.len(), "total_size_bytes": total_size,
407            "average_size_bytes": if allocations.is_empty() { 0 } else { total_size / allocations.len() },
408            "live_size_bytes": total_size, "allocation_count": stats.total_allocations,
409            "deallocation_count": stats.total_deallocations, "degraded_releases": stats.degraded_releases,
410            "cumulative_allocated_bytes": cumulative_allocated, "cumulative_deallocated_bytes": cumulative_deallocated,
411            "peak_bytes": peak, "active_bindings": stats.active_allocations
412        },
413        "type_distribution": type_distribution
414    });
415    let path = output_path.as_ref().join("memory_analysis.json");
416    write_json_optimized(path, &data, options)?;
417    Ok(())
418}
419
420fn generate_lifetime_json<P: AsRef<Path>>(
421    output_path: P,
422    allocations: &[serde_json::Value],
423    options: &ExportJsonOptions,
424    captured_at: u64,
425) -> Result<(), Box<dyn std::error::Error>> {
426    let ownership_histories: Vec<serde_json::Value> = allocations.iter().map(|alloc| {
427        json!({
428            "address": alloc.get("address"), "var_name": alloc.get("var_name"),
429            "type_name": alloc.get("type_name"), "size": alloc.get("size"),
430            "timestamp_alloc": alloc.get("timestamp"), "timestamp_dealloc": null,
431            // The projection only sees live allocations, so a complete lifetime is
432            // unknown: report the observed age and say so explicitly (plan §4.5).
433            "lifetime_ms": null, "observed_age_ms": alloc.get("observed_age_ms"), "lifetime_complete": false,
434            "events": [{ "event_type": "Created", "timestamp": alloc.get("timestamp"), "context": "initial_allocation" }]
435        })
436    }).collect();
437    let lifetime_data = json!({
438        "metadata": {
439            "export_version": "2.0", "export_timestamp": snapshot_timestamp(captured_at),
440            "snapshot_timestamp_ns": captured_at,
441            "specification": "memscope-rs lifetime tracking", "total_tracked_allocations": ownership_histories.len()
442        },
443        "ownership_histories": ownership_histories
444    });
445    let lifetime_path = output_path.as_ref().join("lifetime.json");
446    write_json_optimized(lifetime_path, &lifetime_data, options)?;
447    Ok(())
448}
449
450fn generate_thread_analysis_json<P: AsRef<Path>>(
451    output_path: P,
452    thread_stats: &HashMap<u64, ThreadMemoryStats>,
453    options: &ExportJsonOptions,
454    captured_at: u64,
455) -> Result<(), Box<dyn std::error::Error>> {
456    let mut thread_analysis: Vec<serde_json::Value> = thread_stats.values().map(|stats| {
457        json!({
458            "thread_id": stats.thread_id, "allocation_count": stats.allocation_count,
459            "total_allocated": stats.total_allocated, "current_memory": stats.current_memory, "peak_memory": stats.peak_memory,
460        })
461    }).collect();
462    thread_analysis.sort_by_key(|row| row["thread_id"].as_u64());
463    let data = json!({
464        "metadata": {
465            "export_version": "2.0", "export_timestamp": snapshot_timestamp(captured_at),
466            "snapshot_timestamp_ns": captured_at,
467            "specification": "thread analysis", "total_threads": thread_analysis.len()
468        },
469        "thread_analysis": thread_analysis
470    });
471    let path = output_path.as_ref().join("thread_analysis.json");
472    write_json_optimized(path, &data, options)?;
473    Ok(())
474}
475
476/// Keep the legacy timestamp field stable when one snapshot is exported again.
477fn snapshot_timestamp(captured_at: u64) -> String {
478    chrono::DateTime::from_timestamp(
479        (captured_at / 1_000_000_000) as i64,
480        (captured_at % 1_000_000_000) as u32,
481    )
482    .expect("a u64 nanosecond timestamp is within chrono's supported range")
483    .to_rfc3339()
484}
485
486fn write_json_optimized<P: AsRef<Path>>(
487    path: P,
488    data: &serde_json::Value,
489    options: &ExportJsonOptions,
490) -> Result<(), Box<dyn std::error::Error>> {
491    let path = path.as_ref();
492    let estimated_size = estimate_json_size(data);
493    let use_compact = options
494        .use_compact_format
495        .unwrap_or(estimated_size > 1_000_000);
496    if options.streaming_writer && estimated_size > 500_000 {
497        let file = File::create(path)?;
498        let mut writer = BufWriter::with_capacity(options.buffer_size, file);
499        if use_compact {
500            serde_json::to_writer(&mut writer, data)?;
501        } else {
502            serde_json::to_writer_pretty(&mut writer, data)?;
503        }
504        writer.flush()?;
505    } else {
506        let json_string = if use_compact {
507            serde_json::to_string(data)?
508        } else {
509            serde_json::to_string_pretty(data)?
510        };
511        std::fs::write(path, json_string)?;
512    }
513    Ok(())
514}
515
516pub(super) fn estimate_json_size(data: &serde_json::Value) -> usize {
517    match data {
518        serde_json::Value::Object(map) => {
519            map.values().map(estimate_json_size).sum::<usize>() + map.len() * 20
520        }
521        serde_json::Value::Array(arr) => {
522            arr.iter().map(estimate_json_size).sum::<usize>() + arr.len() * 10
523        }
524        serde_json::Value::String(s) => s.len(),
525        serde_json::Value::Number(n) => n.to_string().len(),
526        _ => 10,
527    }
528}
529
530#[derive(Debug, thiserror::Error)]
531pub enum ExportError {
532    #[error("IO error: {0}")]
533    Io(#[from] std::io::Error),
534    #[error("JSON error: {0}")]
535    Json(#[from] serde_json::Error),
536    #[error("Export failed: {0}")]
537    ExportFailed(String),
538}