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