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