Skip to main content

ic_testkit/pic/
diagnostics.rs

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