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/// A failed best-effort PocketIC diagnostic call.
115#[non_exhaustive]
116#[derive(Debug)]
117pub enum CanisterDiagnosticFailure {
118    /// PocketIC returned a structured management-canister rejection.
119    Rejected(RejectResponse),
120    /// The PocketIC instance was no longer reachable.
121    InstanceUnavailable {
122        /// Captured transport or panic message.
123        message: String,
124    },
125    /// PocketIC panicked for a reason other than a dead instance.
126    Panicked {
127        /// Captured panic message.
128        message: String,
129    },
130}
131
132impl fmt::Display for CanisterDiagnosticFailure {
133    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
136            Self::InstanceUnavailable { message } => {
137                write!(formatter, "PocketIC instance unavailable: {message}")
138            }
139            Self::Panicked { message } => write!(formatter, "panicked: {message}"),
140        }
141    }
142}
143
144impl std::error::Error for CanisterDiagnosticFailure {}
145
146/// One canister-log record retained as bounded lossy UTF-8 text.
147#[derive(Clone, Debug, Eq, PartialEq)]
148pub struct CanisterDiagnosticLogRecord {
149    index: u64,
150    timestamp_nanos: u64,
151    content: String,
152    original_content_bytes: usize,
153    omitted_content_bytes: usize,
154}
155
156impl CanisterDiagnosticLogRecord {
157    /// Upstream record index.
158    #[must_use]
159    pub const fn index(&self) -> u64 {
160        self.index
161    }
162
163    /// Upstream record timestamp in nanoseconds.
164    #[must_use]
165    pub const fn timestamp_nanos(&self) -> u64 {
166        self.timestamp_nanos
167    }
168
169    /// Retained content converted with lossy UTF-8 decoding.
170    #[must_use]
171    pub fn content(&self) -> &str {
172        &self.content
173    }
174
175    /// Original raw content length before bounding and conversion.
176    #[must_use]
177    pub const fn original_content_bytes(&self) -> usize {
178        self.original_content_bytes
179    }
180
181    /// Raw content bytes omitted from this retained record.
182    #[must_use]
183    pub const fn omitted_content_bytes(&self) -> usize {
184        self.omitted_content_bytes
185    }
186
187    /// Whether this record's content was truncated.
188    #[must_use]
189    pub const fn was_truncated(&self) -> bool {
190        self.omitted_content_bytes != 0
191    }
192}
193
194/// Successfully fetched canister logs after bounded text conversion.
195#[derive(Clone, Debug, Eq, PartialEq)]
196pub struct CanisterDiagnosticLogs {
197    records: Vec<CanisterDiagnosticLogRecord>,
198    total_records: usize,
199    total_content_bytes: usize,
200    omitted_records: usize,
201    omitted_content_bytes: usize,
202}
203
204impl CanisterDiagnosticLogs {
205    /// Retained records in upstream order.
206    #[must_use]
207    pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
208        &self.records
209    }
210
211    /// Total number of records returned by PocketIC before bounding.
212    #[must_use]
213    pub const fn total_records(&self) -> usize {
214        self.total_records
215    }
216
217    /// Total raw content bytes returned by PocketIC before bounding.
218    #[must_use]
219    pub const fn total_content_bytes(&self) -> usize {
220        self.total_content_bytes
221    }
222
223    /// Number of whole records omitted by the configured bounds.
224    #[must_use]
225    pub const fn omitted_records(&self) -> usize {
226        self.omitted_records
227    }
228
229    /// Aggregate raw content bytes omitted across retained and omitted records.
230    #[must_use]
231    pub const fn omitted_content_bytes(&self) -> usize {
232        self.omitted_content_bytes
233    }
234
235    /// Whether either whole records or record content were truncated.
236    #[must_use]
237    pub const fn was_truncated(&self) -> bool {
238        self.omitted_records != 0 || self.omitted_content_bytes != 0
239    }
240}
241
242impl fmt::Display for CanisterDiagnosticLogs {
243    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
244        if self.records.is_empty() {
245            if self.total_records == 0 {
246                formatter.write_str("<empty>")?;
247            } else {
248                formatter.write_str("<no retained records>")?;
249            }
250        } else {
251            for (position, record) in self.records.iter().enumerate() {
252                if position != 0 {
253                    formatter.write_str(", ")?;
254                }
255                write!(
256                    formatter,
257                    "[{}@{}]={:?}",
258                    record.index, record.timestamp_nanos, record.content
259                )?;
260                if record.was_truncated() {
261                    write!(
262                        formatter,
263                        " (truncated {} bytes)",
264                        record.omitted_content_bytes
265                    )?;
266                }
267            }
268        }
269        if self.was_truncated() {
270            write!(
271                formatter,
272                "; truncated omitted_records={} omitted_content_bytes={}",
273                self.omitted_records, self.omitted_content_bytes
274            )?;
275        }
276        Ok(())
277    }
278}
279
280/// Independent status and log outcomes for one diagnostic collection.
281#[derive(Debug)]
282pub struct CanisterDiagnosticsReport {
283    request: CanisterDiagnosticsRequest,
284    status: Result<CanisterStatusResult, CanisterDiagnosticFailure>,
285    logs: Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
286}
287
288impl CanisterDiagnosticsReport {
289    /// Exact request used for this report.
290    #[must_use]
291    pub const fn request(&self) -> CanisterDiagnosticsRequest {
292        self.request
293    }
294
295    /// Status result, independent of log retrieval.
296    pub const fn status(&self) -> Result<&CanisterStatusResult, &CanisterDiagnosticFailure> {
297        self.status.as_ref()
298    }
299
300    /// Log result, independent of status retrieval.
301    pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &CanisterDiagnosticFailure> {
302        self.logs.as_ref()
303    }
304
305    /// Consume the report into its exact request and independent outcomes.
306    pub fn into_parts(
307        self,
308    ) -> (
309        CanisterDiagnosticsRequest,
310        Result<CanisterStatusResult, CanisterDiagnosticFailure>,
311        Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
312    ) {
313        (self.request, self.status, self.logs)
314    }
315
316    /// Render a compact, bounded diagnostic line suitable for failure output.
317    #[must_use]
318    pub fn render_compact(&self) -> String {
319        self.to_string()
320    }
321}
322
323impl fmt::Display for CanisterDiagnosticsReport {
324    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
325        write!(
326            formatter,
327            "canister={} status_sender={} status=",
328            self.request.canister_id, self.request.status_sender
329        )?;
330        match &self.status {
331            Ok(status) => write!(
332                formatter,
333                "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
334                status.status,
335                status.version,
336                status.settings.controllers.len(),
337                status.module_hash.as_ref().map_or(0, Vec::len),
338                status.memory_size,
339                status.cycles,
340            ),
341            Err(failure) => write!(formatter, "<{failure}>"),
342        }?;
343        write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
344        match &self.logs {
345            Err(failure) => write!(formatter, "<{failure}>")?,
346            Ok(logs) => write!(formatter, "{logs}")?,
347        }
348        Ok(())
349    }
350}
351
352/// Reusable structured PocketIC failure diagnostics.
353pub trait PocketIcDiagnosticsExt {
354    /// Collect status and bounded logs using the request's exact senders.
355    ///
356    /// Both calls are attempted independently. PocketIC transport panics are
357    /// captured so diagnostics can remain subordinate to the original failure.
358    fn collect_canister_diagnostics(
359        &self,
360        request: CanisterDiagnosticsRequest,
361    ) -> CanisterDiagnosticsReport;
362}
363
364impl PocketIcDiagnosticsExt for PocketIc {
365    fn collect_canister_diagnostics(
366        &self,
367        request: CanisterDiagnosticsRequest,
368    ) -> CanisterDiagnosticsReport {
369        let status = capture_diagnostic_call(|| {
370            self.canister_status(request.canister_id, Some(request.status_sender))
371        });
372        let logs = capture_diagnostic_call(|| {
373            self.fetch_canister_logs(request.canister_id, request.log_sender)
374        })
375        .map(|records| render_log_records(records, request.log_limits));
376
377        CanisterDiagnosticsReport {
378            request,
379            status,
380            logs,
381        }
382    }
383}
384
385fn capture_diagnostic_call<T>(
386    call: impl FnOnce() -> Result<T, RejectResponse>,
387) -> Result<T, CanisterDiagnosticFailure> {
388    match catch_unwind(AssertUnwindSafe(call)) {
389        Ok(Ok(value)) => Ok(value),
390        Ok(Err(response)) => Err(CanisterDiagnosticFailure::Rejected(response)),
391        Err(payload) => {
392            let message = transport::panic_payload_to_string(payload.as_ref());
393            if transport::is_dead_instance_transport_error(&message) {
394                Err(CanisterDiagnosticFailure::InstanceUnavailable { message })
395            } else {
396                Err(CanisterDiagnosticFailure::Panicked { message })
397            }
398        }
399    }
400}
401
402fn render_log_records(
403    records: Vec<CanisterLogRecord>,
404    limits: CanisterLogRenderLimits,
405) -> CanisterDiagnosticLogs {
406    let total_records = records.len();
407    let total_content_bytes = records.iter().fold(0usize, |total, record| {
408        total.saturating_add(record.content.len())
409    });
410    let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
411    let mut retained_bytes = 0usize;
412    let mut omitted_records = 0usize;
413    let mut omitted_content_bytes = 0usize;
414
415    for record in records {
416        if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
417            omitted_records = omitted_records.saturating_add(1);
418            omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
419            continue;
420        }
421
422        let available = limits.byte_limit.saturating_sub(retained_bytes);
423        let retained = record.content.len().min(available);
424        let omitted = record.content.len().saturating_sub(retained);
425        let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
426        retained_bytes = retained_bytes.saturating_add(retained);
427        omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
428        rendered.push(CanisterDiagnosticLogRecord {
429            index: record.idx,
430            timestamp_nanos: record.timestamp_nanos,
431            content,
432            original_content_bytes: record.content.len(),
433            omitted_content_bytes: omitted,
434        });
435    }
436
437    CanisterDiagnosticLogs {
438        records: rendered,
439        total_records,
440        total_content_bytes,
441        omitted_records,
442        omitted_content_bytes,
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use pocket_ic::CanisterLogRecord;
449
450    use super::{CanisterLogRenderLimits, render_log_records};
451
452    #[test]
453    fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
454        let logs = render_log_records(
455            vec![
456                CanisterLogRecord {
457                    idx: 7,
458                    timestamp_nanos: 11,
459                    content: vec![b'f', 0x80, b'o'],
460                },
461                CanisterLogRecord {
462                    idx: 8,
463                    timestamp_nanos: 12,
464                    content: b"bar".to_vec(),
465                },
466            ],
467            CanisterLogRenderLimits::new(1, 2),
468        );
469
470        assert_eq!(logs.total_records(), 2);
471        assert_eq!(logs.total_content_bytes(), 6);
472        assert_eq!(logs.omitted_records(), 1);
473        assert_eq!(logs.omitted_content_bytes(), 4);
474        assert!(logs.was_truncated());
475        assert_eq!(logs.records().len(), 1);
476        assert_eq!(logs.records()[0].content(), "f�");
477        assert_eq!(logs.records()[0].original_content_bytes(), 3);
478        assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
479        assert!(logs.records()[0].was_truncated());
480        let rendered = logs.to_string();
481        assert!(rendered.contains("f�"));
482        assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
483    }
484
485    #[test]
486    fn zero_log_bounds_retain_only_aggregate_truncation() {
487        let logs = render_log_records(
488            vec![CanisterLogRecord {
489                idx: 1,
490                timestamp_nanos: 2,
491                content: b"hello".to_vec(),
492            }],
493            CanisterLogRenderLimits::new(0, 0),
494        );
495
496        assert!(logs.records().is_empty());
497        assert_eq!(logs.omitted_records(), 1);
498        assert_eq!(logs.omitted_content_bytes(), 5);
499        assert!(logs.was_truncated());
500        assert_eq!(
501            logs.to_string(),
502            "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
503        );
504    }
505}