Skip to main content

memscope_rs/render_engine/dashboard/renderer/
event_dto.rs

1//! Lightweight event transfer objects for dashboard serialization.
2//!
3//! These DTOs avoid exposing the full internal `MemoryEvent` struct to
4//! the frontend while still providing enough detail for time-travel,
5//! filtering, and correlation views.
6
7use serde::{Deserialize, Serialize};
8
9/// A lightweight, serializable view of a `MemoryEvent` for dashboard use.
10///
11/// Contains only the fields needed by the frontend for timeline,
12/// allocation, and correlation views.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DashboardEventDTO {
15    /// Event timestamp (nanoseconds since epoch)
16    pub timestamp: u64,
17    /// Event type as a string: "Allocate", "Deallocate", "Reallocate", "Move", "Clone"
18    pub event_type: String,
19    /// Memory pointer address (formatted as hex string)
20    pub ptr: String,
21    /// Allocation size in bytes
22    pub size: usize,
23    /// Thread identifier
24    pub thread_id: u64,
25    /// Optional task identifier
26    pub task_id: Option<u64>,
27    /// Optional variable name
28    pub var_name: Option<String>,
29    /// Optional type name
30    pub type_name: Option<String>,
31    /// Optional source file path
32    pub source_file: Option<String>,
33    /// Optional source line number
34    pub source_line: Option<u32>,
35}
36
37impl From<&crate::event_store::event::MemoryEvent> for DashboardEventDTO {
38    fn from(e: &crate::event_store::event::MemoryEvent) -> Self {
39        Self {
40            timestamp: e.timestamp,
41            event_type: e.event_type.to_string(),
42            ptr: format!("0x{:x}", e.ptr),
43            size: e.size,
44            thread_id: e.thread_id,
45            task_id: e.task_id,
46            var_name: e.var_name.clone(),
47            type_name: e.type_name.clone(),
48            source_file: e.source_file.clone(),
49            source_line: e.source_line,
50        }
51    }
52}
53
54/// Frontend data index for cross-referencing allocations by various keys.
55///
56/// This index is built once during dashboard initialization and embedded
57/// in the JSON payload so the frontend can answer queries without
58/// linear scans.
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct DataIndex {
61    /// Maps pointer hex-string to list of allocation indices
62    pub ptr_to_allocation: std::collections::HashMap<String, Vec<usize>>,
63    /// Maps thread ID to list of allocation indices
64    pub thread_id_to_allocations: std::collections::HashMap<u64, Vec<usize>>,
65    /// Maps thread ID to list of event indices
66    pub thread_id_to_events: std::collections::HashMap<u64, Vec<usize>>,
67    /// Maps type name to list of allocation indices
68    pub type_name_to_allocations: std::collections::HashMap<String, Vec<usize>>,
69    /// Maps variable name to list of allocation indices
70    pub var_name_to_allocations: std::collections::HashMap<String, Vec<usize>>,
71    /// Maps allocation pointer to passport index
72    pub allocation_ptr_to_passport: std::collections::HashMap<String, Vec<usize>>,
73    /// Maps allocation pointer to unsafe report index
74    pub allocation_ptr_to_unsafe_reports: std::collections::HashMap<String, Vec<usize>>,
75    /// Maps source location "file:line" to list of allocation indices
76    pub source_location_to_allocations: std::collections::HashMap<String, Vec<usize>>,
77}
78
79/// Summary of event data included in the dashboard JSON.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct EventSummary {
82    /// Total number of raw events in the event store
83    pub total_event_count: usize,
84    /// Number of events included in the dashboard payload
85    pub exported_event_count: usize,
86    /// Whether a subset of events was exported
87    pub is_sampled: bool,
88    /// Description of the sampling strategy
89    pub sampling_strategy: String,
90    /// Total number of allocations (from event reconstruction)
91    pub total_allocations: usize,
92    /// Number of active allocations
93    pub active_allocations: usize,
94    /// Observations of an address that was already tracked with the same size.
95    ///
96    /// A pure diagnostic: these sightings are *not* counted as allocations or bytes
97    /// (plan A06). Non-zero is normal when the allocator hook and a `track!` observe
98    /// the same buffer.
99    #[serde(default)]
100    pub duplicate_observations: usize,
101    /// Releases that could only be matched by address (the address held several live
102    /// bindings). Non-zero means those closes are a policy choice, not identified
103    /// bindings, so counts derived from them are a lower bound of the evidence.
104    #[serde(default)]
105    pub degraded_releases: usize,
106}
107
108impl EventSummary {
109    pub fn new(
110        total_event_count: usize,
111        exported_event_count: usize,
112        total_allocations: usize,
113        active_allocations: usize,
114    ) -> Self {
115        let is_sampled = exported_event_count < total_event_count;
116        let strategy = if is_sampled {
117            format!("truncated to {} events", exported_event_count)
118        } else {
119            "complete".to_string()
120        };
121        Self {
122            total_event_count,
123            exported_event_count,
124            is_sampled,
125            sampling_strategy: strategy,
126            total_allocations,
127            active_allocations,
128            duplicate_observations: 0,
129            degraded_releases: 0,
130        }
131    }
132
133    /// Attach the degraded-release count reported by the capture layer.
134    #[must_use]
135    pub fn with_degraded_releases(mut self, count: usize) -> Self {
136        self.degraded_releases = count;
137        self
138    }
139
140    /// Attach the duplicate-observation count reported by the capture layer.
141    #[must_use]
142    pub fn with_duplicate_observations(mut self, count: usize) -> Self {
143        self.duplicate_observations = count;
144        self
145    }
146}
147
148/// Build a `DataIndex` from allocation info and events.
149///
150/// This is called once during dashboard initialization and the result
151/// is cached in the JSON payload so the frontend can use it immediately.
152pub fn build_data_index(
153    allocations: &[super::types::AllocationInfo],
154    events: &[DashboardEventDTO],
155    passport_details: &[super::types::PassportDetail],
156    unsafe_reports: &[super::types::UnsafeReport],
157) -> DataIndex {
158    use std::collections::HashMap;
159
160    let mut ptr_to_allocation: HashMap<String, Vec<usize>> = HashMap::new();
161    let mut thread_id_to_allocations: HashMap<u64, Vec<usize>> = HashMap::new();
162    let mut thread_id_to_events: HashMap<u64, Vec<usize>> = HashMap::new();
163    let mut type_name_to_allocations: HashMap<String, Vec<usize>> = HashMap::new();
164    let mut var_name_to_allocations: HashMap<String, Vec<usize>> = HashMap::new();
165    let mut allocation_ptr_to_passport: HashMap<String, Vec<usize>> = HashMap::new();
166    let mut allocation_ptr_to_unsafe_reports: HashMap<String, Vec<usize>> = HashMap::new();
167    let mut source_location_to_allocations: HashMap<String, Vec<usize>> = HashMap::new();
168
169    for (idx, alloc) in allocations.iter().enumerate() {
170        ptr_to_allocation
171            .entry(alloc.address.clone())
172            .or_default()
173            .push(idx);
174
175        // Parse thread_id from "Thread-N" format back to numeric
176        if let Some(tid) = parse_thread_id(&alloc.thread_id) {
177            thread_id_to_allocations.entry(tid).or_default().push(idx);
178        }
179
180        let tn = alloc.type_name.to_lowercase();
181        if !tn.is_empty() && tn != "unknown" {
182            type_name_to_allocations
183                .entry(alloc.type_name.clone())
184                .or_default()
185                .push(idx);
186        }
187
188        let vn = alloc.var_name.to_lowercase();
189        if !vn.is_empty() && vn != "unknown" {
190            var_name_to_allocations
191                .entry(alloc.var_name.clone())
192                .or_default()
193                .push(idx);
194        }
195
196        if let (Some(ref file), Some(line)) = (&alloc.source_file, alloc.source_line) {
197            let loc = format!("{}:{}", file, line);
198            source_location_to_allocations
199                .entry(loc)
200                .or_default()
201                .push(idx);
202        } else if let Some(ref file) = &alloc.source_file {
203            let loc = format!("{}:0", file);
204            source_location_to_allocations
205                .entry(loc)
206                .or_default()
207                .push(idx);
208        }
209    }
210
211    for (idx, event) in events.iter().enumerate() {
212        thread_id_to_events
213            .entry(event.thread_id)
214            .or_default()
215            .push(idx);
216    }
217
218    for (idx, passport) in passport_details.iter().enumerate() {
219        allocation_ptr_to_passport
220            .entry(passport.allocation_ptr.clone())
221            .or_default()
222            .push(idx);
223    }
224
225    for (idx, report) in unsafe_reports.iter().enumerate() {
226        allocation_ptr_to_unsafe_reports
227            .entry(report.allocation_ptr.clone())
228            .or_default()
229            .push(idx);
230    }
231
232    DataIndex {
233        ptr_to_allocation,
234        thread_id_to_allocations,
235        thread_id_to_events,
236        type_name_to_allocations,
237        var_name_to_allocations,
238        allocation_ptr_to_passport,
239        allocation_ptr_to_unsafe_reports,
240        source_location_to_allocations,
241    }
242}
243
244/// Parse a "Thread-N" format string back to a numeric thread ID.
245fn parse_thread_id(raw: &str) -> Option<u64> {
246    if let Some(stripped) = raw.strip_prefix("Thread-") {
247        stripped.parse::<u64>().ok()
248    } else {
249        raw.parse::<u64>().ok()
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    /// Objective: Verify that DashboardEventDTO is correctly constructed
258    /// from a MemoryEvent.
259    /// Invariants: All relevant fields are preserved in the DTO.
260    #[test]
261    fn test_dto_from_memory_event() {
262        use crate::event_store::event::MemoryEvent;
263        let mut event = MemoryEvent::allocate(0x1000, 64, 1);
264        event.var_name = Some("buf".to_string());
265        event.type_name = Some("[u8; 64]".to_string());
266        event.source_file = Some("src/main.rs".to_string());
267        event.source_line = Some(42);
268        event.task_id = Some(7);
269
270        let dto = DashboardEventDTO::from(&event);
271
272        assert_eq!(dto.ptr, "0x1000");
273        assert_eq!(dto.size, 64);
274        assert_eq!(dto.thread_id, 1);
275        assert_eq!(dto.var_name.as_deref(), Some("buf"));
276        assert_eq!(dto.event_type, "Allocate");
277        assert_eq!(dto.task_id, Some(7));
278        assert_eq!(dto.source_file.as_deref(), Some("src/main.rs"));
279        assert_eq!(dto.source_line, Some(42));
280    }
281
282    /// Objective: Verify that EventSummary correctly reports complete
283    /// vs sampled data.
284    /// Invariants: When all events are exported, is_sampled is false.
285    #[test]
286    fn test_event_summary_complete() {
287        let s = EventSummary::new(100, 100, 50, 25);
288        assert!(!s.is_sampled);
289        assert_eq!(s.total_event_count, 100);
290        assert_eq!(s.exported_event_count, 100);
291    }
292
293    /// Objective: Verify that EventSummary correctly reports truncated data.
294    /// Invariants: When fewer events are exported, is_sampled is true.
295    #[test]
296    fn test_event_summary_sampled() {
297        let s = EventSummary::new(1000, 100, 500, 200);
298        assert!(s.is_sampled);
299        assert_eq!(s.total_event_count, 1000);
300        assert_eq!(s.exported_event_count, 100);
301    }
302
303    /// Objective: Verify that build_data_index creates indexes correctly
304    /// from allocation info.
305    /// Invariants: Each allocation is indexed by its address, type, var name.
306    #[test]
307    fn test_build_data_index_with_allocations() {
308        use crate::render_engine::dashboard::renderer::types::AllocationInfo;
309
310        let alloc = AllocationInfo {
311            address: "0x1000".to_string(),
312            type_name: "Vec<u8>".to_string(),
313            size: 64,
314            var_name: "buffer".to_string(),
315            timestamp: "0".to_string(),
316            thread_id: "Thread-1".to_string(),
317            immutable_borrows: 0,
318            mutable_borrows: 0,
319            is_clone: false,
320            clone_count: 0,
321            timestamp_alloc: 1000,
322            timestamp_dealloc: 0,
323            lifetime_ms: None,
324            observed_age_ms: None,
325            is_leaked: false,
326            allocation_state: crate::snapshot::AllocationState::live(),
327            leak_status: crate::snapshot::LeakStatus::NotAssessed,
328            allocation_type: "heap".to_string(),
329            is_smart_pointer: false,
330            smart_pointer_type: String::new(),
331            source_file: Some("src/main.rs".to_string()),
332            source_line: Some(42),
333            module_path: None,
334            generation_id: 0,
335            provenance: String::new(),
336            evidence: Default::default(),
337            confidence: Default::default(),
338            layout_snapshot: None,
339        };
340
341        let index = build_data_index(&[alloc], &[], &[], &[]);
342
343        assert!(index.ptr_to_allocation.contains_key("0x1000"));
344        assert!(index.type_name_to_allocations.contains_key("Vec<u8>"));
345        assert!(index.var_name_to_allocations.contains_key("buffer"));
346        assert!(index
347            .source_location_to_allocations
348            .contains_key("src/main.rs:42"));
349    }
350
351    /// Objective: Verify that build_data_index handles empty inputs.
352    /// Invariants: No panic, all index maps are empty.
353    #[test]
354    fn test_build_data_index_empty() {
355        let index = build_data_index(&[], &[], &[], &[]);
356        assert!(index.ptr_to_allocation.is_empty());
357        assert!(index.thread_id_to_allocations.is_empty());
358        assert!(index.type_name_to_allocations.is_empty());
359    }
360
361    /// Objective: Verify that parse_thread_id handles various formats.
362    /// Invariants: "Thread-N" returns Some(N), plain numbers return Some(n),
363    /// arbitrary strings return None.
364    #[test]
365    fn test_parse_thread_id() {
366        assert_eq!(parse_thread_id("Thread-1"), Some(1));
367        assert_eq!(parse_thread_id("Thread-42"), Some(42));
368        assert_eq!(parse_thread_id("42"), Some(42));
369        assert_eq!(parse_thread_id("main"), None);
370    }
371
372    /// Objective: Verify that DTO serialization round-trips correctly.
373    /// Invariants: Serialize + deserialize preserves all fields.
374    #[test]
375    fn test_dto_serialization_roundtrip() {
376        let dto = DashboardEventDTO {
377            timestamp: 1234,
378            event_type: "Allocate".to_string(),
379            ptr: "0x1000".to_string(),
380            size: 64,
381            thread_id: 1,
382            task_id: Some(7),
383            var_name: Some("buf".to_string()),
384            type_name: Some("[u8]".to_string()),
385            source_file: Some("src/lib.rs".to_string()),
386            source_line: Some(10),
387        };
388
389        let json = serde_json::to_string(&dto).unwrap();
390        let deserialized: DashboardEventDTO = serde_json::from_str(&json).unwrap();
391
392        assert_eq!(deserialized.ptr, "0x1000");
393        assert_eq!(deserialized.event_type, "Allocate");
394        assert_eq!(deserialized.task_id, Some(7));
395        assert_eq!(deserialized.source_line, Some(10));
396    }
397}