Skip to main content

ic_testkit/pic/
diagnostics.rs

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
11/// Default maximum number of canister-log records retained in diagnostics.
12pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
13
14/// Default maximum aggregate raw canister-log bytes retained in diagnostics.
15pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
16
17/// Explicit bounds applied when canister logs are converted to diagnostic text.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub struct CanisterLogRenderLimits {
20    record_limit: usize,
21    byte_limit: usize,
22}
23
24impl CanisterLogRenderLimits {
25    /// Set exact record and aggregate raw-content byte limits.
26    ///
27    /// Zero is valid for either limit and retains no content in that dimension.
28    #[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    /// Maximum number of retained records.
37    #[must_use]
38    pub const fn record_limit(self) -> usize {
39        self.record_limit
40    }
41
42    /// Maximum aggregate number of retained raw content bytes.
43    #[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/// Exact controller-aware inputs for one best-effort diagnostic collection.
59#[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    /// Create a request with independent, exact status and log senders.
69    ///
70    /// Anonymous access remains available only by explicitly supplying
71    /// [`Principal::anonymous`] for the corresponding operation.
72    #[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    /// Override the bounds used to retain and render fetched log content.
83    #[must_use]
84    pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
85        self.log_limits = limits;
86        self
87    }
88
89    /// Target canister.
90    #[must_use]
91    pub const fn canister_id(self) -> Principal {
92        self.canister_id
93    }
94
95    /// Exact sender supplied to `canister_status`.
96    #[must_use]
97    pub const fn status_sender(self) -> Principal {
98        self.status_sender
99    }
100
101    /// Exact sender supplied to `fetch_canister_logs`.
102    #[must_use]
103    pub const fn log_sender(self) -> Principal {
104        self.log_sender
105    }
106
107    /// Log rendering bounds.
108    #[must_use]
109    pub const fn log_limits(self) -> CanisterLogRenderLimits {
110        self.log_limits
111    }
112}
113
114/// One caller-labeled exact diagnostic request in a collect-all batch.
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct LabeledCanisterDiagnosticsRequest {
117    label: String,
118    request: CanisterDiagnosticsRequest,
119}
120
121impl LabeledCanisterDiagnosticsRequest {
122    /// Attach a stable caller-facing label to an exact diagnostic request.
123    #[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    /// Caller-supplied label retained in the batch report.
132    #[must_use]
133    pub fn label(&self) -> &str {
134        &self.label
135    }
136
137    /// Exact controller-aware request.
138    #[must_use]
139    pub const fn request(&self) -> CanisterDiagnosticsRequest {
140        self.request
141    }
142
143    /// Consume the labeled request into its caller label and exact request.
144    #[must_use]
145    pub fn into_parts(self) -> (String, CanisterDiagnosticsRequest) {
146        (self.label, self.request)
147    }
148}
149
150/// A failed best-effort PocketIC diagnostic call.
151#[non_exhaustive]
152#[derive(Debug)]
153pub enum CanisterDiagnosticFailure {
154    /// PocketIC returned a structured management-canister rejection.
155    Rejected(RejectResponse),
156    /// The PocketIC instance was no longer reachable.
157    InstanceUnavailable {
158        /// Captured transport or panic message.
159        message: String,
160    },
161    /// PocketIC panicked for a reason other than a dead instance.
162    Panicked {
163        /// Captured panic message.
164        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/// One canister-log record retained as bounded lossy UTF-8 text.
183#[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    /// Upstream record index.
194    #[must_use]
195    pub const fn index(&self) -> u64 {
196        self.index
197    }
198
199    /// Upstream record timestamp in nanoseconds.
200    #[must_use]
201    pub const fn timestamp_nanos(&self) -> u64 {
202        self.timestamp_nanos
203    }
204
205    /// Retained content converted with lossy UTF-8 decoding.
206    #[must_use]
207    pub fn content(&self) -> &str {
208        &self.content
209    }
210
211    /// Original raw content length before bounding and conversion.
212    #[must_use]
213    pub const fn original_content_bytes(&self) -> usize {
214        self.original_content_bytes
215    }
216
217    /// Raw content bytes omitted from this retained record.
218    #[must_use]
219    pub const fn omitted_content_bytes(&self) -> usize {
220        self.omitted_content_bytes
221    }
222
223    /// Whether this record's content was truncated.
224    #[must_use]
225    pub const fn was_truncated(&self) -> bool {
226        self.omitted_content_bytes != 0
227    }
228}
229
230/// Successfully fetched canister logs after bounded text conversion.
231#[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    /// Retained records in upstream order.
242    #[must_use]
243    pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
244        &self.records
245    }
246
247    /// Total number of records returned by PocketIC before bounding.
248    #[must_use]
249    pub const fn total_records(&self) -> usize {
250        self.total_records
251    }
252
253    /// Total raw content bytes returned by PocketIC before bounding.
254    #[must_use]
255    pub const fn total_content_bytes(&self) -> usize {
256        self.total_content_bytes
257    }
258
259    /// Number of whole records omitted by the configured bounds.
260    #[must_use]
261    pub const fn omitted_records(&self) -> usize {
262        self.omitted_records
263    }
264
265    /// Aggregate raw content bytes omitted across retained and omitted records.
266    #[must_use]
267    pub const fn omitted_content_bytes(&self) -> usize {
268        self.omitted_content_bytes
269    }
270
271    /// Whether either whole records or record content were truncated.
272    #[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/// Independent status and log outcomes for one diagnostic collection.
317#[derive(Debug)]
318pub struct CanisterDiagnosticsReport {
319    request: CanisterDiagnosticsRequest,
320    status: Result<CanisterStatusResult, CanisterDiagnosticFailure>,
321    logs: Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
322}
323
324impl CanisterDiagnosticsReport {
325    /// Exact request used for this report.
326    #[must_use]
327    pub const fn request(&self) -> CanisterDiagnosticsRequest {
328        self.request
329    }
330
331    /// Status result, independent of log retrieval.
332    pub const fn status(&self) -> Result<&CanisterStatusResult, &CanisterDiagnosticFailure> {
333        self.status.as_ref()
334    }
335
336    /// Log result, independent of status retrieval.
337    pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &CanisterDiagnosticFailure> {
338        self.logs.as_ref()
339    }
340
341    /// Whether both status and log collection succeeded.
342    #[must_use]
343    pub const fn is_success(&self) -> bool {
344        self.status.is_ok() && self.logs.is_ok()
345    }
346
347    /// Consume the report into its exact request and independent outcomes.
348    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    /// Render a compact, bounded diagnostic line suitable for failure output.
359    #[must_use]
360    pub fn render_compact(&self) -> String {
361        self.to_string()
362    }
363}
364
365/// One ordered labeled entry in a collect-all diagnostics batch.
366#[derive(Debug)]
367pub struct CanisterDiagnosticsBatchEntry {
368    label: String,
369    report: CanisterDiagnosticsReport,
370}
371
372impl CanisterDiagnosticsBatchEntry {
373    /// Caller-supplied label.
374    #[must_use]
375    pub fn label(&self) -> &str {
376        &self.label
377    }
378
379    /// Structured status and log outcomes for this entry.
380    #[must_use]
381    pub const fn report(&self) -> &CanisterDiagnosticsReport {
382        &self.report
383    }
384
385    /// Whether both status and log collection succeeded for this entry.
386    #[must_use]
387    pub const fn is_success(&self) -> bool {
388        self.report.is_success()
389    }
390
391    /// Consume the entry into its caller label and structured report.
392    #[must_use]
393    pub fn into_parts(self) -> (String, CanisterDiagnosticsReport) {
394        (self.label, self.report)
395    }
396}
397
398/// Ordered entries from a sequential collect-all diagnostics batch.
399#[derive(Debug, Default)]
400pub struct CanisterDiagnosticsBatchReport {
401    entries: Vec<CanisterDiagnosticsBatchEntry>,
402}
403
404impl CanisterDiagnosticsBatchReport {
405    /// Entries in the supplied request order.
406    #[must_use]
407    pub fn entries(&self) -> &[CanisterDiagnosticsBatchEntry] {
408        &self.entries
409    }
410
411    /// Entries with at least one failed status or log operation.
412    pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
413        self.entries.iter().filter(|entry| !entry.is_success())
414    }
415
416    /// Whether every entry collected both status and logs successfully.
417    #[must_use]
418    pub fn is_success(&self) -> bool {
419        self.entries
420            .iter()
421            .all(CanisterDiagnosticsBatchEntry::is_success)
422    }
423
424    /// Consume the report into its ordered entries.
425    #[must_use]
426    pub fn into_entries(self) -> Vec<CanisterDiagnosticsBatchEntry> {
427        self.entries
428    }
429
430    /// Render compact bounded diagnostics with retained caller labels.
431    #[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
476/// Reusable structured PocketIC failure diagnostics.
477pub trait PocketIcDiagnosticsExt {
478    /// Collect status and bounded logs using the request's exact senders.
479    ///
480    /// Both calls are attempted independently. PocketIC transport panics are
481    /// captured so diagnostics can remain subordinate to the original failure.
482    fn collect_canister_diagnostics(
483        &self,
484        request: CanisterDiagnosticsRequest,
485    ) -> CanisterDiagnosticsReport;
486
487    /// Collect every labeled request sequentially in its supplied order.
488    ///
489    /// Every target is attempted even when an earlier entry fails or panics.
490    /// Each entry preserves its exact request and independent status/log
491    /// outcomes; no anonymous retry or fallback is performed.
492    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}