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.
301#[expect(
302    async_fn_in_trait,
303    reason = "extension futures may retain caller-owned non-Send callbacks, iterators or writers; pool recipes separately require Send"
304)]
305pub trait PocketIcDiagnosticsExt {
306    /// Collect status and bounded logs using the request's exact senders.
307    ///
308    /// Both calls are attempted independently. Typed client failures remain in
309    /// the report so diagnostics can stay subordinate to the original failure.
310    async fn collect_canister_diagnostics(
311        &self,
312        request: CanisterDiagnosticsRequest,
313    ) -> CanisterDiagnosticsReport;
314
315    /// Explicitly collect and write one contextual diagnostic report.
316    ///
317    /// Uses the request's exact senders and log limits, with no retry or output
318    /// destination selected implicitly. Unrelated collection/formatting/writer
319    /// panics propagate.
320    /// Ordinary writer errors are returned unchanged. Status/log failures remain
321    /// in the written report; successful output does not mean those calls passed.
322    /// Callers keep their primary failure authoritative and decide whether to
323    /// retain or ignore this secondary reporting result.
324    ///
325    /// # Errors
326    /// Returns the writer’s I/O error when diagnostics cannot be written.
327    async fn write_canister_diagnostics(
328        &self,
329        request: CanisterDiagnosticsRequest,
330        context: &str,
331        writer: &mut dyn io::Write,
332    ) -> io::Result<()> {
333        let report = self.collect_canister_diagnostics(request).await;
334        writeln!(writer, "{context}: {report}")
335    }
336
337    /// Collect every labeled request sequentially in its supplied order.
338    ///
339    /// Every target is attempted even when an earlier entry has client failures.
340    /// Each entry preserves its exact request and independent status/log
341    /// outcomes; no anonymous retry or fallback is performed.
342    /// Invalid labels return Host's [`BatchLabelError`] before collection starts.
343    ///
344    /// # Errors
345    /// Rejects invalid or duplicate labels before collecting diagnostics.
346    async fn collect_canister_diagnostics_batch(
347        &self,
348        requests: &[LabeledCanisterDiagnosticsRequest],
349    ) -> Result<CanisterDiagnosticsBatchReport, BatchLabelError> {
350        validate_labels(requests.iter().map(|request| request.label.as_str()))?;
351        let started = Instant::now();
352        let mut entries = Vec::with_capacity(requests.len());
353        for labeled in requests {
354            let entry_started = Instant::now();
355            let request = labeled.request;
356            let report = self.collect_canister_diagnostics(request).await;
357            entries.push(CanisterDiagnosticsBatchEntry {
358                label: labeled.label.clone(),
359                report,
360                entry_elapsed: entry_started.elapsed(),
361            });
362        }
363
364        Ok(CanisterDiagnosticsBatchReport {
365            entries,
366            total: started.elapsed(),
367        })
368    }
369}
370
371impl PocketIcDiagnosticsExt for PocketIc {
372    async fn collect_canister_diagnostics(
373        &self,
374        request: CanisterDiagnosticsRequest,
375    ) -> CanisterDiagnosticsReport {
376        let status = self
377            .canister_status(request.canister_id, Some(request.status_sender))
378            .await;
379        let logs = self
380            .fetch_canister_logs(request.canister_id, request.log_sender)
381            .await
382            .map(|records| render_log_records(records, request.log_limits));
383
384        CanisterDiagnosticsReport {
385            request,
386            status,
387            logs,
388        }
389    }
390}
391
392fn render_log_records(
393    records: Vec<CanisterLogRecord>,
394    limits: CanisterLogRenderLimits,
395) -> CanisterDiagnosticLogs {
396    let total_records = records.len();
397    let mut total_content_bytes = 0usize;
398    let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
399    let mut retained_bytes = 0usize;
400    let mut omitted_content_bytes = 0usize;
401
402    for record in records {
403        total_content_bytes = total_content_bytes.saturating_add(record.content.len());
404        let include = rendered.len() < limits.record_limit && retained_bytes < limits.byte_limit;
405        let retained = if include {
406            record
407                .content
408                .len()
409                .min(limits.byte_limit.saturating_sub(retained_bytes))
410        } else {
411            0
412        };
413        let omitted = record.content.len().saturating_sub(retained);
414        omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
415        if !include {
416            continue;
417        }
418
419        let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
420        retained_bytes = retained_bytes.saturating_add(retained);
421        rendered.push(CanisterDiagnosticLogRecord {
422            index: record.idx,
423            timestamp_nanos: record.timestamp_nanos,
424            content,
425            original_content_bytes: record.content.len(),
426            omitted_content_bytes: omitted,
427        });
428    }
429
430    CanisterDiagnosticLogs {
431        records: rendered,
432        total_records,
433        total_content_bytes,
434        omitted_content_bytes,
435    }
436}
437
438#[cfg(test)]
439mod tests;