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};
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    /// Collect every labeled request sequentially in its supplied order.
337    ///
338    /// Every target is attempted even when an earlier entry fails or panics.
339    /// Each entry preserves its exact request and independent status/log
340    /// outcomes; no anonymous retry or fallback is performed.
341    fn collect_canister_diagnostics_batch(
342        &self,
343        requests: &[LabeledCanisterDiagnosticsRequest],
344    ) -> Result<CanisterDiagnosticsBatchReport, CanisterDiagnosticsBatchContractError> {
345        validate_diagnostics_batch_labels(requests)?;
346        let started = Instant::now();
347        let entries = requests
348            .iter()
349            .map(|labeled| {
350                let entry_started = Instant::now();
351                let request = labeled.request;
352                let report =
353                    transport::capture_operation(|| self.collect_canister_diagnostics(request))
354                        .unwrap_or_else(|source| CanisterDiagnosticsReport {
355                            request,
356                            status: Err(PocketIcCallFailure::Operation(source.clone())),
357                            logs: Err(PocketIcCallFailure::Operation(source)),
358                        });
359                CanisterDiagnosticsBatchEntry {
360                    label: labeled.label.clone(),
361                    report,
362                    entry_elapsed: entry_started.elapsed(),
363                }
364            })
365            .collect();
366        Ok(CanisterDiagnosticsBatchReport {
367            entries,
368            total: started.elapsed(),
369        })
370    }
371}
372
373fn validate_diagnostics_batch_labels(
374    requests: &[LabeledCanisterDiagnosticsRequest],
375) -> Result<(), CanisterDiagnosticsBatchContractError> {
376    let mut seen = HashMap::new();
377    for (index, request) in requests.iter().enumerate() {
378        let label = request.label.as_str();
379        if label.is_empty() {
380            return Err(CanisterDiagnosticsBatchContractError::EmptyLabel { index });
381        }
382        if let Some(first_index) = seen.insert(label, index) {
383            return Err(CanisterDiagnosticsBatchContractError::DuplicateLabel {
384                label: label.to_owned(),
385                first_index,
386                duplicate_index: index,
387            });
388        }
389    }
390    Ok(())
391}
392
393impl fmt::Display for CanisterDiagnosticsBatchContractError {
394    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
395        match self {
396            Self::EmptyLabel { index } => {
397                write!(
398                    formatter,
399                    "diagnostics batch label at index {index} is empty"
400                )
401            }
402            Self::DuplicateLabel {
403                label,
404                first_index,
405                duplicate_index,
406            } => write!(
407                formatter,
408                "diagnostics batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
409            ),
410        }
411    }
412}
413
414impl std::error::Error for CanisterDiagnosticsBatchContractError {}
415
416impl PocketIcDiagnosticsExt for PocketIc {
417    fn collect_canister_diagnostics(
418        &self,
419        request: CanisterDiagnosticsRequest,
420    ) -> CanisterDiagnosticsReport {
421        let status =
422            capture_call(|| self.canister_status(request.canister_id, Some(request.status_sender)));
423        let logs =
424            capture_call(|| self.fetch_canister_logs(request.canister_id, request.log_sender))
425                .map(|records| render_log_records(records, request.log_limits));
426
427        CanisterDiagnosticsReport {
428            request,
429            status,
430            logs,
431        }
432    }
433}
434
435fn render_log_records(
436    records: Vec<CanisterLogRecord>,
437    limits: CanisterLogRenderLimits,
438) -> CanisterDiagnosticLogs {
439    let total_records = records.len();
440    let mut total_content_bytes = 0usize;
441    let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
442    let mut retained_bytes = 0usize;
443    let mut omitted_content_bytes = 0usize;
444
445    for record in records {
446        total_content_bytes = total_content_bytes.saturating_add(record.content.len());
447        if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
448            omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
449            continue;
450        }
451
452        let available = limits.byte_limit.saturating_sub(retained_bytes);
453        let retained = record.content.len().min(available);
454        let omitted = record.content.len().saturating_sub(retained);
455        let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
456        retained_bytes = retained_bytes.saturating_add(retained);
457        omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
458        rendered.push(CanisterDiagnosticLogRecord {
459            index: record.idx,
460            timestamp_nanos: record.timestamp_nanos,
461            content,
462            original_content_bytes: record.content.len(),
463            omitted_content_bytes: omitted,
464        });
465    }
466
467    CanisterDiagnosticLogs {
468        records: rendered,
469        total_records,
470        total_content_bytes,
471        omitted_content_bytes,
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use std::cell::Cell;
478
479    use candid::Principal;
480    use pocket_ic::CanisterLogRecord;
481
482    use super::{
483        CanisterDiagnosticsBatchContractError, CanisterDiagnosticsReport,
484        CanisterDiagnosticsRequest, CanisterLogRenderLimits, LabeledCanisterDiagnosticsRequest,
485        PocketIcCallFailure, PocketIcDiagnosticsExt, capture_call, render_log_records,
486    };
487
488    #[test]
489    fn diagnostic_transport_classification_survives_context_and_io_wrapping() {
490        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\" }) }";
491        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\" })) }";
492        for message in [REFUSED, RESET, "application worker channel closed"] {
493            let error = capture_call::<()>(|| panic!("{message}"))
494                .expect_err("diagnostic operation should capture the panic");
495            let expected = message != "application worker channel closed";
496            let PocketIcCallFailure::Operation(source) = &error else {
497                panic!("expected the original operation error");
498            };
499            assert_eq!(source.message(), message);
500            assert_eq!(source.is_transport(), expected);
501            let chained = std::error::Error::source(&error)
502                .and_then(|cause| cause.downcast_ref::<super::super::PocketIcOperationError>())
503                .expect("diagnostic error exposes its original typed cause");
504            assert_eq!(chained, source);
505            assert_eq!(
506                super::super::is_dead_pocket_ic_transport_error(&error),
507                expected,
508            );
509            let wrapped = std::io::Error::other(error);
510            assert_eq!(
511                super::super::is_dead_pocket_ic_transport_error(&wrapped),
512                expected,
513            );
514        }
515    }
516
517    struct PanickingThenReporting {
518        calls: Cell<usize>,
519    }
520
521    impl PocketIcDiagnosticsExt for PanickingThenReporting {
522        fn collect_canister_diagnostics(
523            &self,
524            request: CanisterDiagnosticsRequest,
525        ) -> CanisterDiagnosticsReport {
526            let call = self.calls.get();
527            self.calls.set(call + 1);
528            assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
529            CanisterDiagnosticsReport {
530                request,
531                status: Err(PocketIcCallFailure::Operation(
532                    super::super::PocketIcOperationError::new("synthetic status failure"),
533                )),
534                logs: Err(PocketIcCallFailure::Operation(
535                    super::super::PocketIcOperationError::new("synthetic log failure"),
536                )),
537            }
538        }
539    }
540
541    #[test]
542    fn labeled_batch_retains_order_and_continues_after_entry_panic() {
543        let collector = PanickingThenReporting {
544            calls: Cell::new(0),
545        };
546        let first = CanisterDiagnosticsRequest {
547            canister_id: Principal::from_slice(&[1]),
548            status_sender: Principal::from_slice(&[2]),
549            log_sender: Principal::from_slice(&[3]),
550            log_limits: CanisterLogRenderLimits::default(),
551        };
552        let second = CanisterDiagnosticsRequest {
553            canister_id: Principal::from_slice(&[4]),
554            status_sender: Principal::from_slice(&[5]),
555            log_sender: Principal::from_slice(&[6]),
556            log_limits: CanisterLogRenderLimits::default(),
557        };
558        let report = collector
559            .collect_canister_diagnostics_batch(&[
560                LabeledCanisterDiagnosticsRequest {
561                    label: "root".into(),
562                    request: first,
563                },
564                LabeledCanisterDiagnosticsRequest {
565                    label: "worker".into(),
566                    request: second,
567                },
568            ])
569            .expect("valid labeled diagnostics batch");
570
571        assert_eq!(collector.calls.get(), 2);
572        assert_eq!(report.entries.len(), 2);
573        assert_eq!(report.entries[0].label, "root");
574        assert_eq!(report.entries[0].report.request, first);
575        assert_eq!(report.entries[1].label, "worker");
576        assert_eq!(report.entries[1].report.request, second);
577        assert!(
578            report
579                .entries
580                .iter()
581                .all(|entry| entry.entry_elapsed <= report.total)
582        );
583        assert_eq!(report.failures().count(), 2);
584        assert!(!report.is_success());
585        let compact = report.to_string();
586        assert!(compact.contains("label=\"root\""));
587        assert!(compact.contains("label=\"worker\""));
588        assert!(compact.contains("synthetic first-entry diagnostic panic"));
589    }
590
591    #[test]
592    fn diagnostic_batch_rejects_invalid_labels_before_collection() {
593        let collector = PanickingThenReporting {
594            calls: Cell::new(0),
595        };
596        let request = CanisterDiagnosticsRequest {
597            canister_id: Principal::from_slice(&[1]),
598            status_sender: Principal::from_slice(&[2]),
599            log_sender: Principal::from_slice(&[3]),
600            log_limits: CanisterLogRenderLimits::default(),
601        };
602        let empty = collector
603            .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest {
604                label: String::new(),
605                request,
606            }])
607            .expect_err("empty label must reject diagnostics batch");
608        assert_eq!(
609            empty,
610            CanisterDiagnosticsBatchContractError::EmptyLabel { index: 0 }
611        );
612        assert_eq!(collector.calls.get(), 0);
613
614        let duplicate = collector
615            .collect_canister_diagnostics_batch(&[
616                LabeledCanisterDiagnosticsRequest {
617                    label: "same".into(),
618                    request,
619                },
620                LabeledCanisterDiagnosticsRequest {
621                    label: "same".into(),
622                    request,
623                },
624            ])
625            .expect_err("duplicate labels must reject diagnostics batch");
626        assert_eq!(
627            duplicate,
628            CanisterDiagnosticsBatchContractError::DuplicateLabel {
629                label: "same".to_owned(),
630                first_index: 0,
631                duplicate_index: 1,
632            }
633        );
634        assert_eq!(collector.calls.get(), 0);
635    }
636
637    #[test]
638    fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
639        let logs = render_log_records(
640            vec![
641                CanisterLogRecord {
642                    idx: 7,
643                    timestamp_nanos: 11,
644                    content: vec![b'f', 0x80, b'o'],
645                },
646                CanisterLogRecord {
647                    idx: 8,
648                    timestamp_nanos: 12,
649                    content: b"bar".to_vec(),
650                },
651            ],
652            CanisterLogRenderLimits {
653                record_limit: 1,
654                byte_limit: 2,
655            },
656        );
657
658        assert_eq!(logs.total_records(), 2);
659        assert_eq!(logs.total_content_bytes(), 6);
660        assert_eq!(logs.omitted_records(), 1);
661        assert_eq!(logs.omitted_content_bytes(), 4);
662        assert!(logs.was_truncated());
663        assert_eq!(logs.records().len(), 1);
664        assert_eq!(logs.records()[0].content(), "f�");
665        assert_eq!(logs.records()[0].original_content_bytes(), 3);
666        assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
667        assert!(logs.records()[0].was_truncated());
668        let rendered = logs.to_string();
669        assert!(rendered.contains("f�"));
670        assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
671    }
672
673    #[test]
674    fn zero_log_bounds_retain_only_aggregate_truncation() {
675        for (record_limit, byte_limit) in [(0, 0), (0, 16), (16, 0)] {
676            let logs = render_log_records(
677                vec![CanisterLogRecord {
678                    idx: 1,
679                    timestamp_nanos: 2,
680                    content: b"hello".to_vec(),
681                }],
682                CanisterLogRenderLimits {
683                    record_limit,
684                    byte_limit,
685                },
686            );
687
688            assert_eq!(logs.records(), []);
689            assert_eq!(logs.total_records(), 1);
690            assert_eq!(logs.total_content_bytes(), 5);
691            assert_eq!(logs.omitted_records(), 1);
692            assert_eq!(logs.omitted_content_bytes(), 5);
693            assert!(logs.was_truncated());
694            assert_eq!(
695                logs.to_string(),
696                "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
697            );
698        }
699    }
700
701    #[test]
702    fn logs_within_bounds_preserve_empty_records_and_raw_byte_totals() {
703        let limits = CanisterLogRenderLimits {
704            record_limit: 3,
705            byte_limit: 3,
706        };
707        let empty = render_log_records(vec![], limits);
708        assert_eq!(empty.total_records(), 0);
709        assert_eq!(empty.total_content_bytes(), 0);
710        assert_eq!(empty.omitted_records(), 0);
711        assert_eq!(empty.omitted_content_bytes(), 0);
712        assert!(!empty.was_truncated());
713        assert_eq!(empty.to_string(), "<empty>");
714
715        let logs = render_log_records(
716            vec![
717                CanisterLogRecord {
718                    idx: 1,
719                    timestamp_nanos: 2,
720                    content: vec![],
721                },
722                CanisterLogRecord {
723                    idx: 3,
724                    timestamp_nanos: 4,
725                    content: vec![0x80, b'a'],
726                },
727                CanisterLogRecord {
728                    idx: 5,
729                    timestamp_nanos: 6,
730                    content: vec![],
731                },
732            ],
733            limits,
734        );
735        assert_eq!(logs.total_records(), 3);
736        assert_eq!(logs.records().len(), 3);
737        assert_eq!(logs.total_content_bytes(), 2);
738        assert_eq!(logs.records()[0].content(), "");
739        assert_eq!(logs.records()[1].content(), "�a");
740        assert_eq!(logs.records()[2].content(), "");
741        assert_eq!(logs.omitted_records(), 0);
742        assert_eq!(logs.omitted_content_bytes(), 0);
743        assert!(!logs.was_truncated());
744    }
745}