1use std::{
2 fmt,
3 panic::{AssertUnwindSafe, catch_unwind},
4 time::{Duration, Instant},
5};
6
7use candid::Principal;
8use pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc, RejectResponse};
9
10use super::transport;
11
12pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
14
15pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct CanisterLogRenderLimits {
21 record_limit: usize,
22 byte_limit: usize,
23}
24
25impl CanisterLogRenderLimits {
26 #[must_use]
30 pub const fn new(record_limit: usize, byte_limit: usize) -> Self {
31 Self {
32 record_limit,
33 byte_limit,
34 }
35 }
36
37 #[must_use]
39 pub const fn record_limit(self) -> usize {
40 self.record_limit
41 }
42
43 #[must_use]
45 pub const fn byte_limit(self) -> usize {
46 self.byte_limit
47 }
48}
49
50impl Default for CanisterLogRenderLimits {
51 fn default() -> Self {
52 Self::new(
53 DEFAULT_CANISTER_LOG_RECORD_LIMIT,
54 DEFAULT_CANISTER_LOG_BYTE_LIMIT,
55 )
56 }
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct CanisterDiagnosticsRequest {
62 canister_id: Principal,
63 status_sender: Principal,
64 log_sender: Principal,
65 log_limits: CanisterLogRenderLimits,
66}
67
68impl CanisterDiagnosticsRequest {
69 #[must_use]
74 pub fn new(canister_id: Principal, status_sender: Principal, log_sender: Principal) -> Self {
75 Self {
76 canister_id,
77 status_sender,
78 log_sender,
79 log_limits: CanisterLogRenderLimits::default(),
80 }
81 }
82
83 #[must_use]
85 pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
86 self.log_limits = limits;
87 self
88 }
89
90 #[must_use]
92 pub const fn canister_id(self) -> Principal {
93 self.canister_id
94 }
95
96 #[must_use]
98 pub const fn status_sender(self) -> Principal {
99 self.status_sender
100 }
101
102 #[must_use]
104 pub const fn log_sender(self) -> Principal {
105 self.log_sender
106 }
107
108 #[must_use]
110 pub const fn log_limits(self) -> CanisterLogRenderLimits {
111 self.log_limits
112 }
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct LabeledCanisterDiagnosticsRequest {
118 label: String,
119 request: CanisterDiagnosticsRequest,
120}
121
122impl LabeledCanisterDiagnosticsRequest {
123 #[must_use]
125 pub fn new(label: impl Into<String>, request: CanisterDiagnosticsRequest) -> Self {
126 Self {
127 label: label.into(),
128 request,
129 }
130 }
131
132 #[must_use]
134 pub fn label(&self) -> &str {
135 &self.label
136 }
137
138 #[must_use]
140 pub const fn request(&self) -> CanisterDiagnosticsRequest {
141 self.request
142 }
143
144 #[must_use]
146 pub fn into_parts(self) -> (String, CanisterDiagnosticsRequest) {
147 (self.label, self.request)
148 }
149}
150
151#[non_exhaustive]
153#[derive(Debug)]
154pub enum CanisterDiagnosticFailure {
155 Rejected(RejectResponse),
157 InstanceUnavailable {
159 message: String,
161 },
162 Panicked {
164 message: String,
166 },
167}
168
169impl fmt::Display for CanisterDiagnosticFailure {
170 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
171 match self {
172 Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
173 Self::InstanceUnavailable { message } => {
174 write!(formatter, "PocketIC instance unavailable: {message}")
175 }
176 Self::Panicked { message } => write!(formatter, "panicked: {message}"),
177 }
178 }
179}
180
181impl std::error::Error for CanisterDiagnosticFailure {}
182
183#[derive(Clone, Debug, Eq, PartialEq)]
185pub struct CanisterDiagnosticLogRecord {
186 index: u64,
187 timestamp_nanos: u64,
188 content: String,
189 original_content_bytes: usize,
190 omitted_content_bytes: usize,
191}
192
193impl CanisterDiagnosticLogRecord {
194 #[must_use]
196 pub const fn index(&self) -> u64 {
197 self.index
198 }
199
200 #[must_use]
202 pub const fn timestamp_nanos(&self) -> u64 {
203 self.timestamp_nanos
204 }
205
206 #[must_use]
208 pub fn content(&self) -> &str {
209 &self.content
210 }
211
212 #[must_use]
214 pub const fn original_content_bytes(&self) -> usize {
215 self.original_content_bytes
216 }
217
218 #[must_use]
220 pub const fn omitted_content_bytes(&self) -> usize {
221 self.omitted_content_bytes
222 }
223
224 #[must_use]
226 pub const fn was_truncated(&self) -> bool {
227 self.omitted_content_bytes != 0
228 }
229}
230
231#[derive(Clone, Debug, Eq, PartialEq)]
233pub struct CanisterDiagnosticLogs {
234 records: Vec<CanisterDiagnosticLogRecord>,
235 total_records: usize,
236 total_content_bytes: usize,
237 omitted_records: usize,
238 omitted_content_bytes: usize,
239}
240
241impl CanisterDiagnosticLogs {
242 #[must_use]
244 pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
245 &self.records
246 }
247
248 #[must_use]
250 pub const fn total_records(&self) -> usize {
251 self.total_records
252 }
253
254 #[must_use]
256 pub const fn total_content_bytes(&self) -> usize {
257 self.total_content_bytes
258 }
259
260 #[must_use]
262 pub const fn omitted_records(&self) -> usize {
263 self.omitted_records
264 }
265
266 #[must_use]
268 pub const fn omitted_content_bytes(&self) -> usize {
269 self.omitted_content_bytes
270 }
271
272 #[must_use]
274 pub const fn was_truncated(&self) -> bool {
275 self.omitted_records != 0 || self.omitted_content_bytes != 0
276 }
277}
278
279impl fmt::Display for CanisterDiagnosticLogs {
280 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
281 if self.records.is_empty() {
282 if self.total_records == 0 {
283 formatter.write_str("<empty>")?;
284 } else {
285 formatter.write_str("<no retained records>")?;
286 }
287 } else {
288 for (position, record) in self.records.iter().enumerate() {
289 if position != 0 {
290 formatter.write_str(", ")?;
291 }
292 write!(
293 formatter,
294 "[{}@{}]={:?}",
295 record.index, record.timestamp_nanos, record.content
296 )?;
297 if record.was_truncated() {
298 write!(
299 formatter,
300 " (truncated {} bytes)",
301 record.omitted_content_bytes
302 )?;
303 }
304 }
305 }
306 if self.was_truncated() {
307 write!(
308 formatter,
309 "; truncated omitted_records={} omitted_content_bytes={}",
310 self.omitted_records, self.omitted_content_bytes
311 )?;
312 }
313 Ok(())
314 }
315}
316
317#[derive(Debug)]
319pub struct CanisterDiagnosticsReport {
320 request: CanisterDiagnosticsRequest,
321 status: Result<CanisterStatusResult, CanisterDiagnosticFailure>,
322 logs: Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
323}
324
325impl CanisterDiagnosticsReport {
326 #[must_use]
328 pub const fn request(&self) -> CanisterDiagnosticsRequest {
329 self.request
330 }
331
332 pub const fn status(&self) -> Result<&CanisterStatusResult, &CanisterDiagnosticFailure> {
334 self.status.as_ref()
335 }
336
337 pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &CanisterDiagnosticFailure> {
339 self.logs.as_ref()
340 }
341
342 #[must_use]
344 pub const fn is_success(&self) -> bool {
345 self.status.is_ok() && self.logs.is_ok()
346 }
347
348 pub fn into_parts(
350 self,
351 ) -> (
352 CanisterDiagnosticsRequest,
353 Result<CanisterStatusResult, CanisterDiagnosticFailure>,
354 Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
355 ) {
356 (self.request, self.status, self.logs)
357 }
358
359 #[must_use]
361 pub fn render_compact(&self) -> String {
362 self.to_string()
363 }
364}
365
366#[derive(Debug)]
368pub struct CanisterDiagnosticsBatchEntry {
369 label: String,
370 report: CanisterDiagnosticsReport,
371 entry_elapsed: Duration,
372}
373
374impl CanisterDiagnosticsBatchEntry {
375 #[must_use]
377 pub fn label(&self) -> &str {
378 &self.label
379 }
380
381 #[must_use]
383 pub const fn report(&self) -> &CanisterDiagnosticsReport {
384 &self.report
385 }
386
387 #[must_use]
389 pub const fn is_success(&self) -> bool {
390 self.report.is_success()
391 }
392
393 #[must_use]
395 pub const fn entry_elapsed(&self) -> Duration {
396 self.entry_elapsed
397 }
398
399 #[must_use]
401 pub fn into_parts(self) -> (String, CanisterDiagnosticsReport, Duration) {
402 (self.label, self.report, self.entry_elapsed)
403 }
404}
405
406#[derive(Debug, Default)]
408pub struct CanisterDiagnosticsBatchReport {
409 entries: Vec<CanisterDiagnosticsBatchEntry>,
410 total: Duration,
411}
412
413impl CanisterDiagnosticsBatchReport {
414 #[must_use]
416 pub fn entries(&self) -> &[CanisterDiagnosticsBatchEntry] {
417 &self.entries
418 }
419
420 pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
422 self.entries.iter().filter(|entry| !entry.is_success())
423 }
424
425 #[must_use]
427 pub fn is_success(&self) -> bool {
428 self.entries
429 .iter()
430 .all(CanisterDiagnosticsBatchEntry::is_success)
431 }
432
433 #[must_use]
435 pub const fn total(&self) -> Duration {
436 self.total
437 }
438
439 #[must_use]
441 pub fn into_entries(self) -> Vec<CanisterDiagnosticsBatchEntry> {
442 self.entries
443 }
444
445 #[must_use]
447 pub fn render_compact(&self) -> String {
448 self.to_string()
449 }
450}
451
452impl fmt::Display for CanisterDiagnosticsBatchReport {
453 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
454 write!(
455 formatter,
456 "diagnostics={} total={:?}",
457 self.entries.len(),
458 self.total
459 )?;
460 for entry in &self.entries {
461 write!(
462 formatter,
463 "; label={:?} elapsed={:?} {}",
464 entry.label, entry.entry_elapsed, entry.report
465 )?;
466 }
467 Ok(())
468 }
469}
470
471impl fmt::Display for CanisterDiagnosticsReport {
472 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
473 write!(
474 formatter,
475 "canister={} status_sender={} status=",
476 self.request.canister_id, self.request.status_sender
477 )?;
478 match &self.status {
479 Ok(status) => write!(
480 formatter,
481 "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
482 status.status,
483 status.version,
484 status.settings.controllers.len(),
485 status.module_hash.as_ref().map_or(0, Vec::len),
486 status.memory_size,
487 status.cycles,
488 ),
489 Err(failure) => write!(formatter, "<{failure}>"),
490 }?;
491 write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
492 match &self.logs {
493 Err(failure) => write!(formatter, "<{failure}>")?,
494 Ok(logs) => write!(formatter, "{logs}")?,
495 }
496 Ok(())
497 }
498}
499
500pub trait PocketIcDiagnosticsExt {
502 fn collect_canister_diagnostics(
507 &self,
508 request: CanisterDiagnosticsRequest,
509 ) -> CanisterDiagnosticsReport;
510
511 fn collect_canister_diagnostics_batch(
517 &self,
518 requests: &[LabeledCanisterDiagnosticsRequest],
519 ) -> CanisterDiagnosticsBatchReport {
520 let started = Instant::now();
521 let entries = requests
522 .iter()
523 .map(|labeled| {
524 let entry_started = Instant::now();
525 let request = labeled.request;
526 let report = catch_unwind(AssertUnwindSafe(|| {
527 self.collect_canister_diagnostics(request)
528 }))
529 .unwrap_or_else(|payload| {
530 let message = transport::panic_payload_to_string(payload.as_ref());
531 CanisterDiagnosticsReport {
532 request,
533 status: Err(diagnostic_panic_failure(message.clone())),
534 logs: Err(diagnostic_panic_failure(message)),
535 }
536 });
537 CanisterDiagnosticsBatchEntry {
538 label: labeled.label.clone(),
539 report,
540 entry_elapsed: entry_started.elapsed(),
541 }
542 })
543 .collect();
544 CanisterDiagnosticsBatchReport {
545 entries,
546 total: started.elapsed(),
547 }
548 }
549}
550
551impl PocketIcDiagnosticsExt for PocketIc {
552 fn collect_canister_diagnostics(
553 &self,
554 request: CanisterDiagnosticsRequest,
555 ) -> CanisterDiagnosticsReport {
556 let status = capture_diagnostic_call(|| {
557 self.canister_status(request.canister_id, Some(request.status_sender))
558 });
559 let logs = capture_diagnostic_call(|| {
560 self.fetch_canister_logs(request.canister_id, request.log_sender)
561 })
562 .map(|records| render_log_records(records, request.log_limits));
563
564 CanisterDiagnosticsReport {
565 request,
566 status,
567 logs,
568 }
569 }
570}
571
572fn capture_diagnostic_call<T>(
573 call: impl FnOnce() -> Result<T, RejectResponse>,
574) -> Result<T, CanisterDiagnosticFailure> {
575 match catch_unwind(AssertUnwindSafe(call)) {
576 Ok(Ok(value)) => Ok(value),
577 Ok(Err(response)) => Err(CanisterDiagnosticFailure::Rejected(response)),
578 Err(payload) => {
579 let message = transport::panic_payload_to_string(payload.as_ref());
580 Err(diagnostic_panic_failure(message))
581 }
582 }
583}
584
585fn diagnostic_panic_failure(message: String) -> CanisterDiagnosticFailure {
586 if transport::is_dead_instance_transport_error(&message) {
587 CanisterDiagnosticFailure::InstanceUnavailable { message }
588 } else {
589 CanisterDiagnosticFailure::Panicked { message }
590 }
591}
592
593fn render_log_records(
594 records: Vec<CanisterLogRecord>,
595 limits: CanisterLogRenderLimits,
596) -> CanisterDiagnosticLogs {
597 let total_records = records.len();
598 let total_content_bytes = records.iter().fold(0usize, |total, record| {
599 total.saturating_add(record.content.len())
600 });
601 let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
602 let mut retained_bytes = 0usize;
603 let mut omitted_records = 0usize;
604 let mut omitted_content_bytes = 0usize;
605
606 for record in records {
607 if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
608 omitted_records = omitted_records.saturating_add(1);
609 omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
610 continue;
611 }
612
613 let available = limits.byte_limit.saturating_sub(retained_bytes);
614 let retained = record.content.len().min(available);
615 let omitted = record.content.len().saturating_sub(retained);
616 let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
617 retained_bytes = retained_bytes.saturating_add(retained);
618 omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
619 rendered.push(CanisterDiagnosticLogRecord {
620 index: record.idx,
621 timestamp_nanos: record.timestamp_nanos,
622 content,
623 original_content_bytes: record.content.len(),
624 omitted_content_bytes: omitted,
625 });
626 }
627
628 CanisterDiagnosticLogs {
629 records: rendered,
630 total_records,
631 total_content_bytes,
632 omitted_records,
633 omitted_content_bytes,
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use std::cell::Cell;
640
641 use candid::Principal;
642 use pocket_ic::CanisterLogRecord;
643
644 use super::{
645 CanisterDiagnosticFailure, CanisterDiagnosticsReport, CanisterDiagnosticsRequest,
646 CanisterLogRenderLimits, LabeledCanisterDiagnosticsRequest, PocketIcDiagnosticsExt,
647 render_log_records,
648 };
649
650 struct PanickingThenReporting {
651 calls: Cell<usize>,
652 }
653
654 impl PocketIcDiagnosticsExt for PanickingThenReporting {
655 fn collect_canister_diagnostics(
656 &self,
657 request: CanisterDiagnosticsRequest,
658 ) -> CanisterDiagnosticsReport {
659 let call = self.calls.get();
660 self.calls.set(call + 1);
661 assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
662 CanisterDiagnosticsReport {
663 request,
664 status: Err(CanisterDiagnosticFailure::Panicked {
665 message: "synthetic status failure".to_owned(),
666 }),
667 logs: Err(CanisterDiagnosticFailure::Panicked {
668 message: "synthetic log failure".to_owned(),
669 }),
670 }
671 }
672 }
673
674 #[test]
675 fn labeled_batch_retains_order_and_continues_after_entry_panic() {
676 let collector = PanickingThenReporting {
677 calls: Cell::new(0),
678 };
679 let first = CanisterDiagnosticsRequest::new(
680 Principal::from_slice(&[1]),
681 Principal::from_slice(&[2]),
682 Principal::from_slice(&[3]),
683 );
684 let second = CanisterDiagnosticsRequest::new(
685 Principal::from_slice(&[4]),
686 Principal::from_slice(&[5]),
687 Principal::from_slice(&[6]),
688 );
689 let report = collector.collect_canister_diagnostics_batch(&[
690 LabeledCanisterDiagnosticsRequest::new("root", first),
691 LabeledCanisterDiagnosticsRequest::new("worker", second),
692 ]);
693
694 assert_eq!(collector.calls.get(), 2);
695 assert_eq!(report.entries().len(), 2);
696 assert_eq!(report.entries()[0].label(), "root");
697 assert_eq!(report.entries()[0].report().request(), first);
698 assert_eq!(report.entries()[1].label(), "worker");
699 assert_eq!(report.entries()[1].report().request(), second);
700 assert!(
701 report
702 .entries()
703 .iter()
704 .all(|entry| entry.entry_elapsed() <= report.total())
705 );
706 assert_eq!(report.failures().count(), 2);
707 assert!(!report.is_success());
708 let compact = report.render_compact();
709 assert!(compact.contains("label=\"root\""));
710 assert!(compact.contains("label=\"worker\""));
711 assert!(compact.contains("synthetic first-entry diagnostic panic"));
712 }
713
714 #[test]
715 fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
716 let logs = render_log_records(
717 vec![
718 CanisterLogRecord {
719 idx: 7,
720 timestamp_nanos: 11,
721 content: vec![b'f', 0x80, b'o'],
722 },
723 CanisterLogRecord {
724 idx: 8,
725 timestamp_nanos: 12,
726 content: b"bar".to_vec(),
727 },
728 ],
729 CanisterLogRenderLimits::new(1, 2),
730 );
731
732 assert_eq!(logs.total_records(), 2);
733 assert_eq!(logs.total_content_bytes(), 6);
734 assert_eq!(logs.omitted_records(), 1);
735 assert_eq!(logs.omitted_content_bytes(), 4);
736 assert!(logs.was_truncated());
737 assert_eq!(logs.records().len(), 1);
738 assert_eq!(logs.records()[0].content(), "f�");
739 assert_eq!(logs.records()[0].original_content_bytes(), 3);
740 assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
741 assert!(logs.records()[0].was_truncated());
742 let rendered = logs.to_string();
743 assert!(rendered.contains("f�"));
744 assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
745 }
746
747 #[test]
748 fn zero_log_bounds_retain_only_aggregate_truncation() {
749 let logs = render_log_records(
750 vec![CanisterLogRecord {
751 idx: 1,
752 timestamp_nanos: 2,
753 content: b"hello".to_vec(),
754 }],
755 CanisterLogRenderLimits::new(0, 0),
756 );
757
758 assert!(logs.records().is_empty());
759 assert_eq!(logs.omitted_records(), 1);
760 assert_eq!(logs.omitted_content_bytes(), 5);
761 assert!(logs.was_truncated());
762 assert_eq!(
763 logs.to_string(),
764 "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
765 );
766 }
767}