Skip to main content

ic_testkit/pic/
diagnostics.rs

1use crate::batch::{BatchLabelError, validate_labels};
2
3use std::{
4    fmt,
5    panic::{AssertUnwindSafe, catch_unwind},
6    time::{Duration, Instant},
7};
8
9use candid::Principal;
10use pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc, RejectResponse};
11
12use super::transport;
13
14/// Default maximum number of canister-log records retained in diagnostics.
15pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
16
17/// Default maximum aggregate raw canister-log bytes retained in diagnostics.
18pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
19
20/// Explicit bounds applied when canister logs are converted to diagnostic text.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub struct CanisterLogRenderLimits {
23    record_limit: usize,
24    byte_limit: usize,
25}
26
27impl CanisterLogRenderLimits {
28    /// Set exact record and aggregate raw-content byte limits.
29    ///
30    /// Zero is valid for either limit and retains no content in that dimension.
31    #[must_use]
32    pub const fn new(record_limit: usize, byte_limit: usize) -> Self {
33        Self {
34            record_limit,
35            byte_limit,
36        }
37    }
38
39    /// Maximum number of retained records.
40    #[must_use]
41    pub const fn record_limit(self) -> usize {
42        self.record_limit
43    }
44
45    /// Maximum aggregate number of retained raw content bytes.
46    #[must_use]
47    pub const fn byte_limit(self) -> usize {
48        self.byte_limit
49    }
50}
51
52impl Default for CanisterLogRenderLimits {
53    fn default() -> Self {
54        Self::new(
55            DEFAULT_CANISTER_LOG_RECORD_LIMIT,
56            DEFAULT_CANISTER_LOG_BYTE_LIMIT,
57        )
58    }
59}
60
61/// Exact controller-aware inputs for one best-effort diagnostic collection.
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub struct CanisterDiagnosticsRequest {
64    canister_id: Principal,
65    status_sender: Principal,
66    log_sender: Principal,
67    log_limits: CanisterLogRenderLimits,
68}
69
70impl CanisterDiagnosticsRequest {
71    /// Create a request with independent, exact status and log senders.
72    ///
73    /// Anonymous access remains available only by explicitly supplying
74    /// [`Principal::anonymous`] for the corresponding operation.
75    #[must_use]
76    pub fn new(canister_id: Principal, status_sender: Principal, log_sender: Principal) -> Self {
77        Self {
78            canister_id,
79            status_sender,
80            log_sender,
81            log_limits: CanisterLogRenderLimits::default(),
82        }
83    }
84
85    /// Override the bounds used to retain and render fetched log content.
86    #[must_use]
87    pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
88        self.log_limits = limits;
89        self
90    }
91
92    /// Target canister.
93    #[must_use]
94    pub const fn canister_id(self) -> Principal {
95        self.canister_id
96    }
97
98    /// Exact sender supplied to `canister_status`.
99    #[must_use]
100    pub const fn status_sender(self) -> Principal {
101        self.status_sender
102    }
103
104    /// Exact sender supplied to `fetch_canister_logs`.
105    #[must_use]
106    pub const fn log_sender(self) -> Principal {
107        self.log_sender
108    }
109
110    /// Log rendering bounds.
111    #[must_use]
112    pub const fn log_limits(self) -> CanisterLogRenderLimits {
113        self.log_limits
114    }
115}
116
117/// One caller-labeled exact diagnostic request in a collect-all batch.
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct LabeledCanisterDiagnosticsRequest {
120    label: String,
121    request: CanisterDiagnosticsRequest,
122}
123
124/// Structural error that prevents a diagnostics batch from starting.
125#[non_exhaustive]
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub enum CanisterDiagnosticsBatchContractError {
128    /// An entry label was empty.
129    EmptyLabel {
130        /// Zero-based position of the invalid entry.
131        index: usize,
132    },
133    /// Two entries used the same label.
134    DuplicateLabel {
135        /// Duplicated caller label.
136        label: String,
137        /// Position where the label first appeared.
138        first_index: usize,
139        /// Position where the label was repeated.
140        duplicate_index: usize,
141    },
142}
143
144impl LabeledCanisterDiagnosticsRequest {
145    /// Attach a stable caller-facing label to an exact diagnostic request.
146    #[must_use]
147    pub fn new(label: impl Into<String>, request: CanisterDiagnosticsRequest) -> Self {
148        Self {
149            label: label.into(),
150            request,
151        }
152    }
153
154    /// Caller-supplied label retained in the batch report.
155    #[must_use]
156    pub fn label(&self) -> &str {
157        &self.label
158    }
159
160    /// Exact controller-aware request.
161    #[must_use]
162    pub const fn request(&self) -> CanisterDiagnosticsRequest {
163        self.request
164    }
165
166    /// Consume the labeled request into its caller label and exact request.
167    #[must_use]
168    pub fn into_parts(self) -> (String, CanisterDiagnosticsRequest) {
169        (self.label, self.request)
170    }
171}
172
173/// A failed best-effort PocketIC diagnostic call.
174#[non_exhaustive]
175#[derive(Debug)]
176pub enum CanisterDiagnosticFailure {
177    /// PocketIC returned a structured management-canister rejection.
178    Rejected(RejectResponse),
179    /// The PocketIC instance was no longer reachable.
180    InstanceUnavailable {
181        /// Captured transport or panic message.
182        message: String,
183    },
184    /// PocketIC panicked for a reason other than a dead instance.
185    Panicked {
186        /// Captured panic message.
187        message: String,
188    },
189}
190
191impl fmt::Display for CanisterDiagnosticFailure {
192    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193        match self {
194            Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
195            Self::InstanceUnavailable { message } => {
196                write!(formatter, "PocketIC instance unavailable: {message}")
197            }
198            Self::Panicked { message } => write!(formatter, "panicked: {message}"),
199        }
200    }
201}
202
203impl std::error::Error for CanisterDiagnosticFailure {}
204
205/// One canister-log record retained as bounded lossy UTF-8 text.
206#[derive(Clone, Debug, Eq, PartialEq)]
207pub struct CanisterDiagnosticLogRecord {
208    index: u64,
209    timestamp_nanos: u64,
210    content: String,
211    original_content_bytes: usize,
212    omitted_content_bytes: usize,
213}
214
215impl CanisterDiagnosticLogRecord {
216    /// Upstream record index.
217    #[must_use]
218    pub const fn index(&self) -> u64 {
219        self.index
220    }
221
222    /// Upstream record timestamp in nanoseconds.
223    #[must_use]
224    pub const fn timestamp_nanos(&self) -> u64 {
225        self.timestamp_nanos
226    }
227
228    /// Retained content converted with lossy UTF-8 decoding.
229    #[must_use]
230    pub fn content(&self) -> &str {
231        &self.content
232    }
233
234    /// Original raw content length before bounding and conversion.
235    #[must_use]
236    pub const fn original_content_bytes(&self) -> usize {
237        self.original_content_bytes
238    }
239
240    /// Raw content bytes omitted from this retained record.
241    #[must_use]
242    pub const fn omitted_content_bytes(&self) -> usize {
243        self.omitted_content_bytes
244    }
245
246    /// Whether this record's content was truncated.
247    #[must_use]
248    pub const fn was_truncated(&self) -> bool {
249        self.omitted_content_bytes != 0
250    }
251}
252
253/// Successfully fetched canister logs after bounded text conversion.
254#[derive(Clone, Debug, Eq, PartialEq)]
255pub struct CanisterDiagnosticLogs {
256    records: Vec<CanisterDiagnosticLogRecord>,
257    total_records: usize,
258    total_content_bytes: usize,
259    omitted_records: usize,
260    omitted_content_bytes: usize,
261}
262
263impl CanisterDiagnosticLogs {
264    /// Retained records in upstream order.
265    #[must_use]
266    pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
267        &self.records
268    }
269
270    /// Total number of records returned by PocketIC before bounding.
271    #[must_use]
272    pub const fn total_records(&self) -> usize {
273        self.total_records
274    }
275
276    /// Total raw content bytes returned by PocketIC before bounding.
277    #[must_use]
278    pub const fn total_content_bytes(&self) -> usize {
279        self.total_content_bytes
280    }
281
282    /// Number of whole records omitted by the configured bounds.
283    #[must_use]
284    pub const fn omitted_records(&self) -> usize {
285        self.omitted_records
286    }
287
288    /// Aggregate raw content bytes omitted across retained and omitted records.
289    #[must_use]
290    pub const fn omitted_content_bytes(&self) -> usize {
291        self.omitted_content_bytes
292    }
293
294    /// Whether either whole records or record content were truncated.
295    #[must_use]
296    pub const fn was_truncated(&self) -> bool {
297        self.omitted_records != 0 || self.omitted_content_bytes != 0
298    }
299}
300
301impl fmt::Display for CanisterDiagnosticLogs {
302    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
303        if self.records.is_empty() {
304            if self.total_records == 0 {
305                formatter.write_str("<empty>")?;
306            } else {
307                formatter.write_str("<no retained records>")?;
308            }
309        } else {
310            for (position, record) in self.records.iter().enumerate() {
311                if position != 0 {
312                    formatter.write_str(", ")?;
313                }
314                write!(
315                    formatter,
316                    "[{}@{}]={:?}",
317                    record.index, record.timestamp_nanos, record.content
318                )?;
319                if record.was_truncated() {
320                    write!(
321                        formatter,
322                        " (truncated {} bytes)",
323                        record.omitted_content_bytes
324                    )?;
325                }
326            }
327        }
328        if self.was_truncated() {
329            write!(
330                formatter,
331                "; truncated omitted_records={} omitted_content_bytes={}",
332                self.omitted_records, self.omitted_content_bytes
333            )?;
334        }
335        Ok(())
336    }
337}
338
339/// Independent status and log outcomes for one diagnostic collection.
340#[derive(Debug)]
341pub struct CanisterDiagnosticsReport {
342    request: CanisterDiagnosticsRequest,
343    status: Result<CanisterStatusResult, CanisterDiagnosticFailure>,
344    logs: Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
345}
346
347impl CanisterDiagnosticsReport {
348    /// Exact request used for this report.
349    #[must_use]
350    pub const fn request(&self) -> CanisterDiagnosticsRequest {
351        self.request
352    }
353
354    /// Status result, independent of log retrieval.
355    pub const fn status(&self) -> Result<&CanisterStatusResult, &CanisterDiagnosticFailure> {
356        self.status.as_ref()
357    }
358
359    /// Log result, independent of status retrieval.
360    pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &CanisterDiagnosticFailure> {
361        self.logs.as_ref()
362    }
363
364    /// Whether both status and log collection succeeded.
365    #[must_use]
366    pub const fn is_success(&self) -> bool {
367        self.status.is_ok() && self.logs.is_ok()
368    }
369
370    /// Consume the report into its exact request and independent outcomes.
371    pub fn into_parts(
372        self,
373    ) -> (
374        CanisterDiagnosticsRequest,
375        Result<CanisterStatusResult, CanisterDiagnosticFailure>,
376        Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
377    ) {
378        (self.request, self.status, self.logs)
379    }
380
381    /// Render a compact, bounded diagnostic line suitable for failure output.
382    #[must_use]
383    pub fn render_compact(&self) -> String {
384        self.to_string()
385    }
386}
387
388/// One ordered labeled entry in a collect-all diagnostics batch.
389#[derive(Debug)]
390pub struct CanisterDiagnosticsBatchEntry {
391    label: String,
392    report: CanisterDiagnosticsReport,
393    entry_elapsed: Duration,
394}
395
396impl CanisterDiagnosticsBatchEntry {
397    /// Caller-supplied label.
398    #[must_use]
399    pub fn label(&self) -> &str {
400        &self.label
401    }
402
403    /// Structured status and log outcomes for this entry.
404    #[must_use]
405    pub const fn report(&self) -> &CanisterDiagnosticsReport {
406        &self.report
407    }
408
409    /// Whether both status and log collection succeeded for this entry.
410    #[must_use]
411    pub const fn is_success(&self) -> bool {
412        self.report.is_success()
413    }
414
415    /// Complete wall-clock time for this target's diagnostic collection.
416    #[must_use]
417    pub const fn entry_elapsed(&self) -> Duration {
418        self.entry_elapsed
419    }
420
421    /// Consume the entry into its label, structured report, and wall time.
422    #[must_use]
423    pub fn into_parts(self) -> (String, CanisterDiagnosticsReport, Duration) {
424        (self.label, self.report, self.entry_elapsed)
425    }
426}
427
428/// Ordered entries from a sequential collect-all diagnostics batch.
429#[derive(Debug, Default)]
430pub struct CanisterDiagnosticsBatchReport {
431    entries: Vec<CanisterDiagnosticsBatchEntry>,
432    total: Duration,
433}
434
435impl CanisterDiagnosticsBatchReport {
436    /// Entries in the supplied request order.
437    #[must_use]
438    pub fn entries(&self) -> &[CanisterDiagnosticsBatchEntry] {
439        &self.entries
440    }
441
442    /// Entries with at least one failed status or log operation.
443    pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
444        self.entries.iter().filter(|entry| !entry.is_success())
445    }
446
447    /// Whether every entry collected both status and logs successfully.
448    #[must_use]
449    pub fn is_success(&self) -> bool {
450        self.entries
451            .iter()
452            .all(CanisterDiagnosticsBatchEntry::is_success)
453    }
454
455    /// Complete wall-clock time for the sequential collect-all batch.
456    #[must_use]
457    pub const fn total(&self) -> Duration {
458        self.total
459    }
460
461    /// Consume the report into its ordered entries.
462    #[must_use]
463    pub fn into_entries(self) -> Vec<CanisterDiagnosticsBatchEntry> {
464        self.entries
465    }
466
467    /// Render compact bounded diagnostics with retained caller labels.
468    #[must_use]
469    pub fn render_compact(&self) -> String {
470        self.to_string()
471    }
472}
473
474impl fmt::Display for CanisterDiagnosticsBatchReport {
475    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
476        write!(
477            formatter,
478            "diagnostics={} total={:?}",
479            self.entries.len(),
480            self.total
481        )?;
482        for entry in &self.entries {
483            write!(
484                formatter,
485                "; label={:?} elapsed={:?} {}",
486                entry.label, entry.entry_elapsed, entry.report
487            )?;
488        }
489        Ok(())
490    }
491}
492
493impl fmt::Display for CanisterDiagnosticsReport {
494    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
495        write!(
496            formatter,
497            "canister={} status_sender={} status=",
498            self.request.canister_id, self.request.status_sender
499        )?;
500        match &self.status {
501            Ok(status) => write!(
502                formatter,
503                "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
504                status.status,
505                status.version,
506                status.settings.controllers.len(),
507                status.module_hash.as_ref().map_or(0, Vec::len),
508                status.memory_size,
509                status.cycles,
510            ),
511            Err(failure) => write!(formatter, "<{failure}>"),
512        }?;
513        write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
514        match &self.logs {
515            Err(failure) => write!(formatter, "<{failure}>")?,
516            Ok(logs) => write!(formatter, "{logs}")?,
517        }
518        Ok(())
519    }
520}
521
522/// Reusable structured PocketIC failure diagnostics.
523pub trait PocketIcDiagnosticsExt {
524    /// Collect status and bounded logs using the request's exact senders.
525    ///
526    /// Both calls are attempted independently. PocketIC transport panics are
527    /// captured so diagnostics can remain subordinate to the original failure.
528    fn collect_canister_diagnostics(
529        &self,
530        request: CanisterDiagnosticsRequest,
531    ) -> CanisterDiagnosticsReport;
532
533    /// Collect every labeled request sequentially in its supplied order.
534    ///
535    /// Every target is attempted even when an earlier entry fails or panics.
536    /// Each entry preserves its exact request and independent status/log
537    /// outcomes; no anonymous retry or fallback is performed.
538    fn collect_canister_diagnostics_batch(
539        &self,
540        requests: &[LabeledCanisterDiagnosticsRequest],
541    ) -> Result<CanisterDiagnosticsBatchReport, CanisterDiagnosticsBatchContractError> {
542        validate_diagnostics_batch_labels(requests)?;
543        let started = Instant::now();
544        let entries = requests
545            .iter()
546            .map(|labeled| {
547                let entry_started = Instant::now();
548                let request = labeled.request;
549                let report = catch_unwind(AssertUnwindSafe(|| {
550                    self.collect_canister_diagnostics(request)
551                }))
552                .unwrap_or_else(|payload| {
553                    let message = transport::panic_payload_to_string(payload.as_ref());
554                    CanisterDiagnosticsReport {
555                        request,
556                        status: Err(diagnostic_panic_failure(message.clone())),
557                        logs: Err(diagnostic_panic_failure(message)),
558                    }
559                });
560                CanisterDiagnosticsBatchEntry {
561                    label: labeled.label.clone(),
562                    report,
563                    entry_elapsed: entry_started.elapsed(),
564                }
565            })
566            .collect();
567        Ok(CanisterDiagnosticsBatchReport {
568            entries,
569            total: started.elapsed(),
570        })
571    }
572}
573
574fn validate_diagnostics_batch_labels(
575    requests: &[LabeledCanisterDiagnosticsRequest],
576) -> Result<(), CanisterDiagnosticsBatchContractError> {
577    validate_labels(requests.iter().map(|labeled| labeled.label.as_str())).map_err(|error| {
578        match error {
579            BatchLabelError::Empty { index } => {
580                CanisterDiagnosticsBatchContractError::EmptyLabel { index }
581            }
582            BatchLabelError::Duplicate {
583                label,
584                first_index,
585                duplicate_index,
586            } => CanisterDiagnosticsBatchContractError::DuplicateLabel {
587                label,
588                first_index,
589                duplicate_index,
590            },
591        }
592    })
593}
594
595impl fmt::Display for CanisterDiagnosticsBatchContractError {
596    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
597        match self {
598            Self::EmptyLabel { index } => {
599                write!(
600                    formatter,
601                    "diagnostics batch label at index {index} is empty"
602                )
603            }
604            Self::DuplicateLabel {
605                label,
606                first_index,
607                duplicate_index,
608            } => write!(
609                formatter,
610                "diagnostics batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
611            ),
612        }
613    }
614}
615
616impl std::error::Error for CanisterDiagnosticsBatchContractError {}
617
618impl PocketIcDiagnosticsExt for PocketIc {
619    fn collect_canister_diagnostics(
620        &self,
621        request: CanisterDiagnosticsRequest,
622    ) -> CanisterDiagnosticsReport {
623        let status = capture_diagnostic_call(|| {
624            self.canister_status(request.canister_id, Some(request.status_sender))
625        });
626        let logs = capture_diagnostic_call(|| {
627            self.fetch_canister_logs(request.canister_id, request.log_sender)
628        })
629        .map(|records| render_log_records(records, request.log_limits));
630
631        CanisterDiagnosticsReport {
632            request,
633            status,
634            logs,
635        }
636    }
637}
638
639fn capture_diagnostic_call<T>(
640    call: impl FnOnce() -> Result<T, RejectResponse>,
641) -> Result<T, CanisterDiagnosticFailure> {
642    match catch_unwind(AssertUnwindSafe(call)) {
643        Ok(Ok(value)) => Ok(value),
644        Ok(Err(response)) => Err(CanisterDiagnosticFailure::Rejected(response)),
645        Err(payload) => {
646            let message = transport::panic_payload_to_string(payload.as_ref());
647            Err(diagnostic_panic_failure(message))
648        }
649    }
650}
651
652fn diagnostic_panic_failure(message: String) -> CanisterDiagnosticFailure {
653    if transport::is_dead_instance_transport_error(&message) {
654        CanisterDiagnosticFailure::InstanceUnavailable { message }
655    } else {
656        CanisterDiagnosticFailure::Panicked { message }
657    }
658}
659
660fn render_log_records(
661    records: Vec<CanisterLogRecord>,
662    limits: CanisterLogRenderLimits,
663) -> CanisterDiagnosticLogs {
664    let total_records = records.len();
665    let total_content_bytes = records.iter().fold(0usize, |total, record| {
666        total.saturating_add(record.content.len())
667    });
668    let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
669    let mut retained_bytes = 0usize;
670    let mut omitted_records = 0usize;
671    let mut omitted_content_bytes = 0usize;
672
673    for record in records {
674        if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
675            omitted_records = omitted_records.saturating_add(1);
676            omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
677            continue;
678        }
679
680        let available = limits.byte_limit.saturating_sub(retained_bytes);
681        let retained = record.content.len().min(available);
682        let omitted = record.content.len().saturating_sub(retained);
683        let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
684        retained_bytes = retained_bytes.saturating_add(retained);
685        omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
686        rendered.push(CanisterDiagnosticLogRecord {
687            index: record.idx,
688            timestamp_nanos: record.timestamp_nanos,
689            content,
690            original_content_bytes: record.content.len(),
691            omitted_content_bytes: omitted,
692        });
693    }
694
695    CanisterDiagnosticLogs {
696        records: rendered,
697        total_records,
698        total_content_bytes,
699        omitted_records,
700        omitted_content_bytes,
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use std::cell::Cell;
707
708    use candid::Principal;
709    use pocket_ic::CanisterLogRecord;
710
711    use super::{
712        CanisterDiagnosticFailure, CanisterDiagnosticsBatchContractError,
713        CanisterDiagnosticsReport, CanisterDiagnosticsRequest, CanisterLogRenderLimits,
714        LabeledCanisterDiagnosticsRequest, PocketIcDiagnosticsExt, render_log_records,
715    };
716
717    struct PanickingThenReporting {
718        calls: Cell<usize>,
719    }
720
721    impl PocketIcDiagnosticsExt for PanickingThenReporting {
722        fn collect_canister_diagnostics(
723            &self,
724            request: CanisterDiagnosticsRequest,
725        ) -> CanisterDiagnosticsReport {
726            let call = self.calls.get();
727            self.calls.set(call + 1);
728            assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
729            CanisterDiagnosticsReport {
730                request,
731                status: Err(CanisterDiagnosticFailure::Panicked {
732                    message: "synthetic status failure".to_owned(),
733                }),
734                logs: Err(CanisterDiagnosticFailure::Panicked {
735                    message: "synthetic log failure".to_owned(),
736                }),
737            }
738        }
739    }
740
741    #[test]
742    fn labeled_batch_retains_order_and_continues_after_entry_panic() {
743        let collector = PanickingThenReporting {
744            calls: Cell::new(0),
745        };
746        let first = CanisterDiagnosticsRequest::new(
747            Principal::from_slice(&[1]),
748            Principal::from_slice(&[2]),
749            Principal::from_slice(&[3]),
750        );
751        let second = CanisterDiagnosticsRequest::new(
752            Principal::from_slice(&[4]),
753            Principal::from_slice(&[5]),
754            Principal::from_slice(&[6]),
755        );
756        let report = collector
757            .collect_canister_diagnostics_batch(&[
758                LabeledCanisterDiagnosticsRequest::new("root", first),
759                LabeledCanisterDiagnosticsRequest::new("worker", second),
760            ])
761            .expect("valid labeled diagnostics batch");
762
763        assert_eq!(collector.calls.get(), 2);
764        assert_eq!(report.entries().len(), 2);
765        assert_eq!(report.entries()[0].label(), "root");
766        assert_eq!(report.entries()[0].report().request(), first);
767        assert_eq!(report.entries()[1].label(), "worker");
768        assert_eq!(report.entries()[1].report().request(), second);
769        assert!(
770            report
771                .entries()
772                .iter()
773                .all(|entry| entry.entry_elapsed() <= report.total())
774        );
775        assert_eq!(report.failures().count(), 2);
776        assert!(!report.is_success());
777        let compact = report.render_compact();
778        assert!(compact.contains("label=\"root\""));
779        assert!(compact.contains("label=\"worker\""));
780        assert!(compact.contains("synthetic first-entry diagnostic panic"));
781    }
782
783    #[test]
784    fn diagnostic_batch_rejects_invalid_labels_before_collection() {
785        let collector = PanickingThenReporting {
786            calls: Cell::new(0),
787        };
788        let request = CanisterDiagnosticsRequest::new(
789            Principal::from_slice(&[1]),
790            Principal::from_slice(&[2]),
791            Principal::from_slice(&[3]),
792        );
793        let empty = collector
794            .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest::new(
795                "", request,
796            )])
797            .expect_err("empty label must reject diagnostics batch");
798        assert_eq!(
799            empty,
800            CanisterDiagnosticsBatchContractError::EmptyLabel { index: 0 }
801        );
802        assert_eq!(collector.calls.get(), 0);
803
804        let duplicate = collector
805            .collect_canister_diagnostics_batch(&[
806                LabeledCanisterDiagnosticsRequest::new("same", request),
807                LabeledCanisterDiagnosticsRequest::new("same", request),
808            ])
809            .expect_err("duplicate labels must reject diagnostics batch");
810        assert_eq!(
811            duplicate,
812            CanisterDiagnosticsBatchContractError::DuplicateLabel {
813                label: "same".to_owned(),
814                first_index: 0,
815                duplicate_index: 1,
816            }
817        );
818        assert_eq!(collector.calls.get(), 0);
819    }
820
821    #[test]
822    fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
823        let logs = render_log_records(
824            vec![
825                CanisterLogRecord {
826                    idx: 7,
827                    timestamp_nanos: 11,
828                    content: vec![b'f', 0x80, b'o'],
829                },
830                CanisterLogRecord {
831                    idx: 8,
832                    timestamp_nanos: 12,
833                    content: b"bar".to_vec(),
834                },
835            ],
836            CanisterLogRenderLimits::new(1, 2),
837        );
838
839        assert_eq!(logs.total_records(), 2);
840        assert_eq!(logs.total_content_bytes(), 6);
841        assert_eq!(logs.omitted_records(), 1);
842        assert_eq!(logs.omitted_content_bytes(), 4);
843        assert!(logs.was_truncated());
844        assert_eq!(logs.records().len(), 1);
845        assert_eq!(logs.records()[0].content(), "f�");
846        assert_eq!(logs.records()[0].original_content_bytes(), 3);
847        assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
848        assert!(logs.records()[0].was_truncated());
849        let rendered = logs.to_string();
850        assert!(rendered.contains("f�"));
851        assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
852    }
853
854    #[test]
855    fn zero_log_bounds_retain_only_aggregate_truncation() {
856        let logs = render_log_records(
857            vec![CanisterLogRecord {
858                idx: 1,
859                timestamp_nanos: 2,
860                content: b"hello".to_vec(),
861            }],
862            CanisterLogRenderLimits::new(0, 0),
863        );
864
865        assert_eq!(logs.records(), []);
866        assert_eq!(logs.omitted_records(), 1);
867        assert_eq!(logs.omitted_content_bytes(), 5);
868        assert!(logs.was_truncated());
869        assert_eq!(
870            logs.to_string(),
871            "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
872        );
873    }
874}