Skip to main content

ic_testkit/pic/
diagnostics.rs

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