1use std::any::Any;
13use std::collections::{HashSet, VecDeque};
14use std::rc::Rc;
15use std::time::Instant;
16
17use pixi_byte::value::jsvalue::JsValueKind;
18use pixi_byte::vm::VM;
19use pixi_byte::{JSEngine, JSResult, JSValue};
20
21use super::JsHost;
22
23const CAPACITY: usize = 512;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum JsTraceKind {
29 Script,
31 Event,
33 Timer,
35 Fetch,
37 Microtask,
39 Console,
41}
42
43impl JsTraceKind {
44 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Script => "script",
48 Self::Event => "event",
49 Self::Timer => "timer",
50 Self::Fetch => "fetch",
51 Self::Microtask => "microtask",
52 Self::Console => "console",
53 }
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct JsTraceEvent {
60 pub seq: u64,
62 pub at_ms: f64,
64 pub kind: JsTraceKind,
66 pub label: String,
68 pub detail: String,
70 pub duration_us: u64,
72 pub error: Option<String>,
74 pub stack: Vec<String>,
76 pub source: Option<String>,
78 pub source_is_fallback: bool,
81 pub variables: Vec<(String, String)>,
83 pub program_counter: Option<usize>,
85}
86
87#[derive(Debug, Clone, Default)]
89pub struct JsTraceBatch {
90 pub enabled: bool,
92 pub total: u64,
94 pub dropped: u64,
96 pub events: Vec<JsTraceEvent>,
98 pub active: Vec<JsActiveTrace>,
100}
101
102pub(crate) struct JsTraceSpan {
104 pub(crate) active_id: Option<usize>,
105 pub(crate) started: Instant,
106 pub(crate) kind: JsTraceKind,
107 pub(crate) label: String,
108 pub(crate) detail: String,
109 pub(crate) source: Option<String>,
110}
111
112#[derive(Debug, Clone)]
114pub struct JsActiveTrace {
115 pub kind: JsTraceKind,
116 pub label: String,
117 pub detail: String,
118 pub elapsed_ms: f64,
119 pub stack: Vec<String>,
120 pub source: Option<String>,
121}
122
123struct ActiveSpanEntry {
124 id: usize,
125 started: Instant,
126 kind: JsTraceKind,
127 label: String,
128 detail: String,
129 source: Option<String>,
130}
131
132pub(crate) struct JsTraceSession {
134 enabled: bool,
135 start: Instant,
136 events: VecDeque<JsTraceEvent>,
137 next_seq: u64,
138 next_active_id: usize,
139 total: u64,
140 dropped: u64,
141 latest_source: Option<String>,
142 active_spans: Vec<ActiveSpanEntry>,
143}
144
145impl JsTraceSession {
146 pub(crate) fn new() -> Self {
147 Self {
148 enabled: default_enabled(),
149 start: Instant::now(),
150 events: VecDeque::with_capacity(CAPACITY),
151 next_seq: 1,
152 next_active_id: 1,
153 total: 0,
154 dropped: 0,
155 latest_source: None,
156 active_spans: Vec::new(),
157 }
158 }
159
160 pub(crate) fn enabled(&self) -> bool {
161 self.enabled
162 }
163
164 fn set_enabled(&mut self, enabled: bool) {
165 self.enabled = enabled;
166 }
167
168 fn clear(&mut self) {
169 self.events.clear();
170 self.active_spans.clear();
171 self.start = Instant::now();
172 self.next_seq = 1;
173 self.total = 0;
174 self.dropped = 0;
175 self.latest_source = None;
176 }
177
178 fn begin_active(
179 &mut self,
180 kind: JsTraceKind,
181 label: String,
182 detail: String,
183 source: Option<String>,
184 ) -> Option<usize> {
185 if !self.enabled {
186 return None;
187 }
188 let id = self.next_active_id;
189 self.next_active_id += 1;
190 if let Some(source) = source.as_ref() {
191 self.latest_source = Some(source.clone());
192 }
193 self.active_spans.push(ActiveSpanEntry {
194 id,
195 started: Instant::now(),
196 kind,
197 label,
198 detail,
199 source,
200 });
201 Some(id)
202 }
203
204 fn end_active(&mut self, id: Option<usize>) {
205 let Some(id) = id else {
206 return;
207 };
208 if let Some(index) = self.active_spans.iter().rposition(|span| span.id == id) {
209 self.active_spans.remove(index);
210 }
211 }
212
213 fn snapshot_active(&self, vm: &VM) -> Vec<JsActiveTrace> {
214 if !self.enabled || self.active_spans.is_empty() {
215 return Vec::new();
216 }
217 let stack = stack_from_vm(vm);
218 self.active_spans
219 .iter()
220 .rev()
221 .map(|span| JsActiveTrace {
222 kind: span.kind,
223 label: span.label.clone(),
224 detail: span.detail.clone(),
225 elapsed_ms: span.started.elapsed().as_secs_f64() * 1000.0,
226 stack: stack.clone(),
227 source: span.source.clone().or_else(|| self.latest_source.clone()),
228 })
229 .collect()
230 }
231
232 #[allow(clippy::too_many_arguments)]
233 fn record(
234 &mut self,
235 kind: JsTraceKind,
236 label: String,
237 detail: String,
238 duration_us: u64,
239 error: Option<String>,
240 stack: Vec<String>,
241 source: Option<String>,
242 variables: Vec<(String, String)>,
243 program_counter: Option<usize>,
244 ) {
245 if !self.enabled {
246 return;
247 }
248 self.total += 1;
249 if let Some(source) = source.as_ref() {
250 self.latest_source = Some(source.clone());
251 }
252 let source_is_fallback =
255 source.is_none() && error.is_some() && self.latest_source.is_some();
256 let source = source.or_else(|| error.as_ref().and(self.latest_source.clone()));
257 if self.events.len() == CAPACITY {
258 self.events.pop_front();
259 self.dropped += 1;
260 }
261 let seq = self.next_seq;
262 self.next_seq += 1;
263 self.events.push_back(JsTraceEvent {
264 seq,
265 at_ms: self.start.elapsed().as_secs_f64() * 1000.0,
266 kind,
267 label,
268 detail,
269 duration_us,
270 error,
271 stack,
272 source,
273 source_is_fallback,
274 variables,
275 program_counter,
276 });
277 }
278
279 fn take_batch(&mut self, vm: &VM) -> JsTraceBatch {
280 JsTraceBatch {
281 enabled: self.enabled,
282 total: self.total,
283 dropped: self.dropped,
284 events: self.events.drain(..).collect(),
285 active: self.snapshot_active(vm),
286 }
287 }
288}
289
290pub fn default_enabled() -> bool {
295 true
296}
297
298pub(crate) fn install_caught_exception_hook(engine: &mut JSEngine) {
301 engine.global_mut().borrow_mut().set(
302 "__orinium_trace_caught_error".to_string(),
303 JSValue::from_native_function(trace_caught_exception),
304 );
305}
306
307fn trace_caught_exception(vm: &mut VM, args: Vec<JSValue>) -> JSResult<JSValue> {
308 let error = args
309 .get(1)
310 .cloned()
311 .unwrap_or_else(JSValue::undefined)
312 .to_console_string();
313 let catch_name = args
314 .get(2)
315 .map(JSValue::to_console_string)
316 .unwrap_or_else(|| "error".to_string());
317 let stack = stack_from_vm(vm);
318 record(
319 vm,
320 JsTraceKind::Event,
321 "caught async exception".to_string(),
322 error.clone(),
323 0,
324 Some(error.clone()),
325 stack,
326 None,
327 vec![(catch_name, error)],
328 None,
329 );
330 Ok(JSValue::undefined())
331}
332
333pub(crate) fn stack_from_vm(vm: &VM) -> Vec<String> {
335 vm.frames
336 .iter()
337 .rev()
338 .take(24)
339 .map(|frame| {
340 frame
341 .function_name
342 .as_ref()
343 .map(|name| name.as_str())
344 .unwrap_or_else(|| "<anonymous>".to_string())
345 })
346 .collect()
347}
348
349#[allow(clippy::too_many_arguments)]
351pub(crate) fn record(
352 vm: &VM,
353 kind: JsTraceKind,
354 label: String,
355 detail: String,
356 duration_us: u64,
357 error: Option<String>,
358 stack: Vec<String>,
359 source: Option<String>,
360 variables: Vec<(String, String)>,
361 program_counter: Option<usize>,
362) {
363 with_session_mut(vm, |session| {
364 session.record(
365 kind,
366 label,
367 detail,
368 duration_us,
369 error,
370 stack,
371 source,
372 variables,
373 program_counter,
374 )
375 });
376}
377
378pub(crate) fn record_console(vm: &VM, level: log::Level, message: &str) {
380 let label = match level {
381 log::Level::Error => "console.error",
382 log::Level::Warn => "console.warn",
383 log::Level::Info => "console.log",
384 log::Level::Debug => "console.debug",
385 log::Level::Trace => "console.trace",
386 };
387 let stack = stack_from_vm(vm);
388 record(
389 vm,
390 JsTraceKind::Console,
391 label.to_string(),
392 message.to_string(),
393 0,
394 None,
395 stack,
396 None,
397 Vec::new(),
398 None,
399 );
400}
401
402pub(crate) fn format_console_value(value: &JSValue) -> String {
404 format_console_value_inner(value, 0, &mut HashSet::new(), false)
405}
406
407fn format_console_value_inner(
408 value: &JSValue,
409 depth: usize,
410 seen: &mut HashSet<usize>,
411 quote_strings: bool,
412) -> String {
413 match value.kind() {
414 JsValueKind::String if quote_strings => {
415 serde_json::to_string(value.as_string().unwrap_or("")).unwrap_or_default()
416 }
417 JsValueKind::Object => {
418 let Some(object) = value.as_object() else {
419 return value.to_console_string();
420 };
421 let identity = Rc::as_ptr(&object) as usize;
422 if !seen.insert(identity) {
423 return "[Circular]".to_string();
424 }
425 if depth >= 4 {
426 seen.remove(&identity);
427 return "[Object]".to_string();
428 }
429 let (is_array, keys, length) = {
430 let object = object.borrow();
431 (
432 object.is_array(),
433 object.keys(),
434 object.get("length").to_number().max(0.0) as usize,
435 )
436 };
437 let rendered = if is_array {
438 let values = (0..length.min(40))
439 .map(|index| {
440 let item = object.borrow().get_index(index);
441 format_console_value_inner(&item, depth + 1, seen, true)
442 })
443 .collect::<Vec<_>>();
444 let mut values = values;
445 if length > 40 {
446 values.push(format!("… {} more", length - 40));
447 }
448 format!("[{}]", values.join(", "))
449 } else {
450 let mut properties = keys
451 .into_iter()
452 .take(40)
453 .map(|key| {
454 let item = object.borrow().get(&key);
455 format!(
456 "{}: {}",
457 serde_json::to_string(&key).unwrap_or_default(),
458 format_console_value_inner(&item, depth + 1, seen, true)
459 )
460 })
461 .collect::<Vec<_>>();
462 if object.borrow().keys().len() > 40 {
463 properties.push("… more properties".to_string());
464 }
465 format!("{{{}}}", properties.join(", "))
466 };
467 seen.remove(&identity);
468 rendered
469 }
470 _ => value.to_console_string(),
471 }
472}
473
474pub(crate) fn is_enabled(vm: &VM) -> bool {
476 let Some(host) = vm.host.as_ref() else {
477 return false;
478 };
479 let Ok(host_ref) = host.try_borrow() else {
480 return false;
481 };
482 let Some(js_host) = (&*host_ref as &dyn Any).downcast_ref::<JsHost>() else {
483 return false;
484 };
485 js_host.trace.enabled()
486}
487
488pub(crate) fn set_enabled(vm: &VM, enabled: bool) {
490 with_session_mut(vm, |session| session.set_enabled(enabled));
491}
492
493pub(crate) fn clear(vm: &VM) {
495 with_session_mut(vm, JsTraceSession::clear);
496}
497
498pub(crate) fn take_batch(vm: &VM) -> JsTraceBatch {
500 let Some(host) = vm.host.as_ref() else {
501 return JsTraceBatch::default();
502 };
503 let Ok(mut host_ref) = host.try_borrow_mut() else {
504 return JsTraceBatch::default();
505 };
506 let Some(js_host) = (&mut *host_ref as &mut dyn Any).downcast_mut::<JsHost>() else {
507 return JsTraceBatch::default();
508 };
509 js_host.trace.take_batch(vm)
510}
511
512pub(crate) fn begin_active(
513 vm: &VM,
514 kind: JsTraceKind,
515 label: String,
516 detail: String,
517 source: Option<String>,
518) -> Option<usize> {
519 let host = vm.host.as_ref()?;
520 let mut host_ref = host.try_borrow_mut().ok()?;
521 let js_host = (&mut *host_ref as &mut dyn Any).downcast_mut::<JsHost>()?;
522 js_host.trace.begin_active(kind, label, detail, source)
523}
524
525pub(crate) fn end_active(vm: &VM, id: Option<usize>) {
526 let Some(host) = vm.host.as_ref() else {
527 return;
528 };
529 let Ok(mut host_ref) = host.try_borrow_mut() else {
530 return;
531 };
532 if let Some(js_host) = (&mut *host_ref as &mut dyn Any).downcast_mut::<JsHost>() {
533 js_host.trace.end_active(id);
534 }
535}
536
537fn with_session_mut(vm: &VM, f: impl FnOnce(&mut JsTraceSession)) {
538 let Some(host) = vm.host.as_ref() else {
539 return;
540 };
541 let Ok(mut host_ref) = host.try_borrow_mut() else {
544 return;
545 };
546 if let Some(js_host) = (&mut *host_ref as &mut dyn Any).downcast_mut::<JsHost>() {
547 f(&mut js_host.trace);
548 }
549}
550
551pub(crate) fn source_summary(source: &str) -> String {
553 let line = source
554 .lines()
555 .map(str::trim)
556 .find(|line| !line.is_empty())
557 .unwrap_or("");
558 truncate_chars(line, 100)
559}
560
561pub(crate) fn source_preview(source: &str) -> String {
563 truncate_chars(source.trim(), 240)
564}
565
566fn truncate_chars(text: &str, limit: usize) -> String {
567 let mut out = String::new();
568 for (index, ch) in text.chars().enumerate() {
569 if index == limit {
570 out.push('…');
571 break;
572 }
573 out.push(ch);
574 }
575 out
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
583 fn session_keeps_only_the_newest_events() {
584 let mut session = JsTraceSession::new();
585 session.set_enabled(true);
586 for index in 0..(CAPACITY + 3) {
587 session.record(
588 JsTraceKind::Script,
589 format!("script-{index}"),
590 String::new(),
591 0,
592 None,
593 Vec::new(),
594 None,
595 Vec::new(),
596 None,
597 );
598 }
599 let batch = session.take_batch(&VM::new());
600 assert_eq!(batch.total as usize, CAPACITY + 3);
601 assert_eq!(batch.dropped, 3);
602 assert_eq!(batch.events.len(), CAPACITY);
603 assert_eq!(batch.events[0].label, "script-3");
604 }
605
606 #[test]
607 fn disabled_session_records_nothing_and_clear_resets_counters() {
608 let mut session = JsTraceSession::new();
609 session.set_enabled(false);
610 session.record(
611 JsTraceKind::Script,
612 "ignored".to_string(),
613 String::new(),
614 0,
615 None,
616 Vec::new(),
617 None,
618 Vec::new(),
619 None,
620 );
621 assert!(session.take_batch(&VM::new()).events.is_empty());
622
623 session.set_enabled(true);
624 session.record(
625 JsTraceKind::Event,
626 "click".to_string(),
627 String::new(),
628 0,
629 None,
630 Vec::new(),
631 None,
632 Vec::new(),
633 None,
634 );
635 session.clear();
636 let batch = session.take_batch(&VM::new());
637 assert_eq!(batch.total, 0);
638 assert!(batch.events.is_empty());
639 assert!(batch.active.is_empty());
640 }
641
642 #[test]
643 fn source_previews_are_bounded_and_char_safe() {
644 let summary = source_summary("\n\n const answer = 42; \n");
645 assert_eq!(summary, "const answer = 42;");
646 let preview = source_preview(&"あ".repeat(300));
647 assert_eq!(preview.chars().count(), 241);
648 }
649}