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