Skip to main content

ic_testkit/pic/
diagnostics.rs

1use std::{
2    fmt, io,
3    time::{Duration, Instant},
4};
5
6use crate::pocket_ic::{CanisterLogRecord, CanisterStatusResult, ClientError, PocketIc};
7use ic_host_artifacts::batch::{BatchLabelError, validate_labels};
8use ic_platform_types::principal::ExternalPrincipal as Principal;
9
10/// Default maximum number of canister-log records retained in diagnostics.
11pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
12
13/// Default maximum aggregate raw canister-log bytes retained in diagnostics.
14pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
15
16/// Explicit bounds applied when canister logs are converted to diagnostic text.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub struct CanisterLogRenderLimits {
19    /// Maximum retained records; zero retains none.
20    pub record_limit: usize,
21    /// Maximum retained raw-content bytes; zero retains none.
22    pub byte_limit: usize,
23}
24
25impl Default for CanisterLogRenderLimits {
26    fn default() -> Self {
27        Self {
28            record_limit: DEFAULT_CANISTER_LOG_RECORD_LIMIT,
29            byte_limit: DEFAULT_CANISTER_LOG_BYTE_LIMIT,
30        }
31    }
32}
33
34/// Exact controller-aware inputs for one best-effort diagnostic collection.
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub struct CanisterDiagnosticsRequest {
37    /// Canister to inspect.
38    pub canister_id: Principal,
39    /// Exact sender for the status request.
40    pub status_sender: Principal,
41    /// Exact sender for the log request.
42    pub log_sender: Principal,
43    /// Caller-selected log rendering bounds.
44    pub log_limits: CanisterLogRenderLimits,
45}
46
47/// One caller-labeled exact diagnostic request in a collect-all batch.
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct LabeledCanisterDiagnosticsRequest {
50    /// Caller label, checked for emptiness and uniqueness before collection.
51    pub label: String,
52    /// Exact controller-aware diagnostic inputs.
53    pub request: CanisterDiagnosticsRequest,
54}
55
56/// One canister-log record retained as bounded lossy UTF-8 text.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct CanisterDiagnosticLogRecord {
59    index: u64,
60    timestamp_nanos: u64,
61    content: String,
62    original_content_bytes: usize,
63    omitted_content_bytes: usize,
64}
65
66impl CanisterDiagnosticLogRecord {
67    /// Upstream record index.
68    #[must_use]
69    pub const fn index(&self) -> u64 {
70        self.index
71    }
72
73    /// Upstream record timestamp in nanoseconds.
74    #[must_use]
75    pub const fn timestamp_nanos(&self) -> u64 {
76        self.timestamp_nanos
77    }
78
79    /// Retained content converted with lossy UTF-8 decoding.
80    #[must_use]
81    pub fn content(&self) -> &str {
82        &self.content
83    }
84
85    /// Original raw content length before bounding and conversion.
86    #[must_use]
87    pub const fn original_content_bytes(&self) -> usize {
88        self.original_content_bytes
89    }
90
91    /// Raw content bytes omitted from this retained record.
92    #[must_use]
93    pub const fn omitted_content_bytes(&self) -> usize {
94        self.omitted_content_bytes
95    }
96
97    /// Whether this record's content was truncated.
98    #[must_use]
99    pub const fn was_truncated(&self) -> bool {
100        self.omitted_content_bytes != 0
101    }
102}
103
104/// Successfully fetched canister logs after bounded text conversion.
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct CanisterDiagnosticLogs {
107    records: Vec<CanisterDiagnosticLogRecord>,
108    total_records: usize,
109    total_content_bytes: usize,
110    omitted_content_bytes: usize,
111}
112
113impl CanisterDiagnosticLogs {
114    /// Retained records in upstream order.
115    #[must_use]
116    pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
117        &self.records
118    }
119
120    /// Total number of records returned by `PocketIC` before bounding.
121    #[must_use]
122    pub const fn total_records(&self) -> usize {
123        self.total_records
124    }
125
126    /// Total raw content bytes returned by `PocketIC` before bounding.
127    #[must_use]
128    pub const fn total_content_bytes(&self) -> usize {
129        self.total_content_bytes
130    }
131
132    /// Number of whole records omitted by the configured bounds.
133    #[must_use]
134    pub const fn omitted_records(&self) -> usize {
135        self.total_records - self.records.len()
136    }
137
138    /// Aggregate raw content bytes omitted across retained and omitted records.
139    #[must_use]
140    pub const fn omitted_content_bytes(&self) -> usize {
141        self.omitted_content_bytes
142    }
143
144    /// Whether either whole records or record content were truncated.
145    #[must_use]
146    pub const fn was_truncated(&self) -> bool {
147        self.omitted_records() != 0 || self.omitted_content_bytes != 0
148    }
149}
150
151impl fmt::Display for CanisterDiagnosticLogs {
152    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153        if self.records.is_empty() {
154            if self.total_records == 0 {
155                formatter.write_str("<empty>")?;
156            } else {
157                formatter.write_str("<no retained records>")?;
158            }
159        } else {
160            for (position, record) in self.records.iter().enumerate() {
161                if position != 0 {
162                    formatter.write_str(", ")?;
163                }
164                write!(
165                    formatter,
166                    "[{}@{}]={:?}",
167                    record.index, record.timestamp_nanos, record.content
168                )?;
169                if record.was_truncated() {
170                    write!(
171                        formatter,
172                        " (truncated {} bytes)",
173                        record.omitted_content_bytes
174                    )?;
175                }
176            }
177        }
178        if self.was_truncated() {
179            write!(
180                formatter,
181                "; truncated omitted_records={} omitted_content_bytes={}",
182                self.omitted_records(),
183                self.omitted_content_bytes
184            )?;
185        }
186        Ok(())
187    }
188}
189
190/// Independent status and log outcomes for one diagnostic collection.
191#[derive(Debug)]
192pub struct CanisterDiagnosticsReport {
193    /// Exact request used for this report.
194    pub request: CanisterDiagnosticsRequest,
195    /// Status result, independent of log retrieval.
196    pub status: Result<CanisterStatusResult, ClientError>,
197    /// Log result, independent of status retrieval.
198    pub logs: Result<CanisterDiagnosticLogs, ClientError>,
199}
200
201impl CanisterDiagnosticsReport {
202    /// Whether both status and log collection succeeded.
203    #[must_use]
204    pub const fn is_success(&self) -> bool {
205        self.status.is_ok() && self.logs.is_ok()
206    }
207}
208
209/// One ordered labeled entry in a collect-all diagnostics batch.
210#[derive(Debug)]
211pub struct CanisterDiagnosticsBatchEntry {
212    /// Caller-supplied label.
213    pub label: String,
214    /// Structured status and log outcomes for this entry.
215    pub report: CanisterDiagnosticsReport,
216    /// Complete wall-clock time for this target's diagnostic collection.
217    pub entry_elapsed: Duration,
218}
219
220impl CanisterDiagnosticsBatchEntry {
221    /// Whether both status and log collection succeeded for this entry.
222    #[must_use]
223    pub const fn is_success(&self) -> bool {
224        self.report.is_success()
225    }
226}
227
228/// Ordered entries from a sequential collect-all diagnostics batch.
229#[derive(Debug, Default)]
230pub struct CanisterDiagnosticsBatchReport {
231    /// Entries in the supplied request order.
232    pub entries: Vec<CanisterDiagnosticsBatchEntry>,
233    /// Complete wall-clock time for the sequential collect-all batch.
234    pub total: Duration,
235}
236
237impl CanisterDiagnosticsBatchReport {
238    /// Entries with at least one failed status or log operation.
239    pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
240        self.entries.iter().filter(|entry| !entry.is_success())
241    }
242
243    /// Whether every entry collected both status and logs successfully.
244    #[must_use]
245    pub fn is_success(&self) -> bool {
246        self.entries
247            .iter()
248            .all(CanisterDiagnosticsBatchEntry::is_success)
249    }
250}
251
252impl fmt::Display for CanisterDiagnosticsBatchReport {
253    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
254        write!(
255            formatter,
256            "diagnostics={} total={:?}",
257            self.entries.len(),
258            self.total
259        )?;
260        for entry in &self.entries {
261            write!(
262                formatter,
263                "; label={:?} elapsed={:?} {}",
264                entry.label, entry.entry_elapsed, entry.report
265            )?;
266        }
267        Ok(())
268    }
269}
270
271impl fmt::Display for CanisterDiagnosticsReport {
272    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
273        write!(
274            formatter,
275            "canister={} status_sender={} status=",
276            self.request.canister_id, self.request.status_sender
277        )?;
278        match &self.status {
279            Ok(status) => write!(
280                formatter,
281                "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
282                status.status,
283                status.version,
284                status.settings.controllers.len(),
285                status.module_hash.as_ref().map_or(0, Vec::len),
286                status.memory_size,
287                status.cycles,
288            ),
289            Err(failure) => write!(formatter, "<{failure}>"),
290        }?;
291        write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
292        match &self.logs {
293            Err(failure) => write!(formatter, "<{failure}>")?,
294            Ok(logs) => write!(formatter, "{logs}")?,
295        }
296        Ok(())
297    }
298}
299
300/// Reusable structured `PocketIC` failure diagnostics.
301pub trait PocketIcDiagnosticsExt {
302    /// Collect status and bounded logs using the request's exact senders.
303    ///
304    /// Both calls are attempted independently. Typed client failures remain in
305    /// the report so diagnostics can stay subordinate to the original failure.
306    fn collect_canister_diagnostics(
307        &self,
308        request: CanisterDiagnosticsRequest,
309    ) -> CanisterDiagnosticsReport;
310
311    /// Explicitly collect and write one contextual diagnostic report.
312    ///
313    /// Uses the request's exact senders and log limits, with no retry or output
314    /// destination selected implicitly. Unrelated collection/formatting/writer
315    /// panics propagate.
316    /// Ordinary writer errors are returned unchanged. Status/log failures remain
317    /// in the written report; successful output does not mean those calls passed.
318    /// Callers keep their primary failure authoritative and decide whether to
319    /// retain or ignore this secondary reporting result.
320    ///
321    /// # Errors
322    /// Returns the writer’s I/O error when diagnostics cannot be written.
323    fn write_canister_diagnostics(
324        &self,
325        request: CanisterDiagnosticsRequest,
326        context: &str,
327        writer: &mut dyn io::Write,
328    ) -> io::Result<()> {
329        let report = self.collect_canister_diagnostics(request);
330        writeln!(writer, "{context}: {report}")
331    }
332
333    /// Collect every labeled request sequentially in its supplied order.
334    ///
335    /// Every target is attempted even when an earlier entry has client failures.
336    /// Each entry preserves its exact request and independent status/log
337    /// outcomes; no anonymous retry or fallback is performed.
338    /// Invalid labels return Host's [`BatchLabelError`] before collection starts.
339    ///
340    /// # Errors
341    /// Rejects invalid or duplicate labels before collecting diagnostics.
342    fn collect_canister_diagnostics_batch(
343        &self,
344        requests: &[LabeledCanisterDiagnosticsRequest],
345    ) -> Result<CanisterDiagnosticsBatchReport, BatchLabelError> {
346        validate_labels(requests.iter().map(|request| request.label.as_str()))?;
347        let started = Instant::now();
348        let entries = requests
349            .iter()
350            .map(|labeled| {
351                let entry_started = Instant::now();
352                let request = labeled.request;
353                let report = self.collect_canister_diagnostics(request);
354                CanisterDiagnosticsBatchEntry {
355                    label: labeled.label.clone(),
356                    report,
357                    entry_elapsed: entry_started.elapsed(),
358                }
359            })
360            .collect();
361        Ok(CanisterDiagnosticsBatchReport {
362            entries,
363            total: started.elapsed(),
364        })
365    }
366}
367
368impl PocketIcDiagnosticsExt for PocketIc {
369    fn collect_canister_diagnostics(
370        &self,
371        request: CanisterDiagnosticsRequest,
372    ) -> CanisterDiagnosticsReport {
373        let status = self.canister_status(request.canister_id, Some(request.status_sender));
374        let logs = self
375            .fetch_canister_logs(request.canister_id, request.log_sender)
376            .map(|records| render_log_records(records, request.log_limits));
377
378        CanisterDiagnosticsReport {
379            request,
380            status,
381            logs,
382        }
383    }
384}
385
386fn render_log_records(
387    records: Vec<CanisterLogRecord>,
388    limits: CanisterLogRenderLimits,
389) -> CanisterDiagnosticLogs {
390    let total_records = records.len();
391    let mut total_content_bytes = 0usize;
392    let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
393    let mut retained_bytes = 0usize;
394    let mut omitted_content_bytes = 0usize;
395
396    for record in records {
397        total_content_bytes = total_content_bytes.saturating_add(record.content.len());
398        let include = rendered.len() < limits.record_limit && retained_bytes < limits.byte_limit;
399        let retained = if include {
400            record
401                .content
402                .len()
403                .min(limits.byte_limit.saturating_sub(retained_bytes))
404        } else {
405            0
406        };
407        let omitted = record.content.len().saturating_sub(retained);
408        omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
409        if !include {
410            continue;
411        }
412
413        let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
414        retained_bytes = retained_bytes.saturating_add(retained);
415        rendered.push(CanisterDiagnosticLogRecord {
416            index: record.idx,
417            timestamp_nanos: record.timestamp_nanos,
418            content,
419            original_content_bytes: record.content.len(),
420            omitted_content_bytes: omitted,
421        });
422    }
423
424    CanisterDiagnosticLogs {
425        records: rendered,
426        total_records,
427        total_content_bytes,
428        omitted_content_bytes,
429    }
430}
431
432#[cfg(test)]
433mod tests;