1use std::{
2 collections::HashMap,
3 fmt, io,
4 time::{Duration, Instant},
5};
6
7use ic_platform::pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc};
8use ic_platform::principal::ExternalPrincipal as Principal;
9
10use super::{
11 PocketIcCallFailure,
12 transport::{self, capture_call},
13};
14
15pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
17
18pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub struct CanisterLogRenderLimits {
24 pub record_limit: usize,
26 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub struct CanisterDiagnosticsRequest {
42 pub canister_id: Principal,
44 pub status_sender: Principal,
46 pub log_sender: Principal,
48 pub log_limits: CanisterLogRenderLimits,
50}
51
52#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct LabeledCanisterDiagnosticsRequest {
55 pub label: String,
57 pub request: CanisterDiagnosticsRequest,
59}
60
61#[non_exhaustive]
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub enum CanisterDiagnosticsBatchContractError {
65 EmptyLabel {
67 index: usize,
69 },
70 DuplicateLabel {
72 label: String,
74 first_index: usize,
76 duplicate_index: usize,
78 },
79}
80
81#[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 #[must_use]
94 pub const fn index(&self) -> u64 {
95 self.index
96 }
97
98 #[must_use]
100 pub const fn timestamp_nanos(&self) -> u64 {
101 self.timestamp_nanos
102 }
103
104 #[must_use]
106 pub fn content(&self) -> &str {
107 &self.content
108 }
109
110 #[must_use]
112 pub const fn original_content_bytes(&self) -> usize {
113 self.original_content_bytes
114 }
115
116 #[must_use]
118 pub const fn omitted_content_bytes(&self) -> usize {
119 self.omitted_content_bytes
120 }
121
122 #[must_use]
124 pub const fn was_truncated(&self) -> bool {
125 self.omitted_content_bytes != 0
126 }
127}
128
129#[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 #[must_use]
141 pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
142 &self.records
143 }
144
145 #[must_use]
147 pub const fn total_records(&self) -> usize {
148 self.total_records
149 }
150
151 #[must_use]
153 pub const fn total_content_bytes(&self) -> usize {
154 self.total_content_bytes
155 }
156
157 #[must_use]
159 pub const fn omitted_records(&self) -> usize {
160 self.total_records - self.records.len()
161 }
162
163 #[must_use]
165 pub const fn omitted_content_bytes(&self) -> usize {
166 self.omitted_content_bytes
167 }
168
169 #[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#[derive(Debug)]
217pub struct CanisterDiagnosticsReport {
218 pub request: CanisterDiagnosticsRequest,
220 pub status: Result<CanisterStatusResult, PocketIcCallFailure>,
222 pub logs: Result<CanisterDiagnosticLogs, PocketIcCallFailure>,
224}
225
226impl CanisterDiagnosticsReport {
227 #[must_use]
229 pub const fn is_success(&self) -> bool {
230 self.status.is_ok() && self.logs.is_ok()
231 }
232}
233
234#[derive(Debug)]
236pub struct CanisterDiagnosticsBatchEntry {
237 pub label: String,
239 pub report: CanisterDiagnosticsReport,
241 pub entry_elapsed: Duration,
243}
244
245impl CanisterDiagnosticsBatchEntry {
246 #[must_use]
248 pub const fn is_success(&self) -> bool {
249 self.report.is_success()
250 }
251}
252
253#[derive(Debug, Default)]
255pub struct CanisterDiagnosticsBatchReport {
256 pub entries: Vec<CanisterDiagnosticsBatchEntry>,
258 pub total: Duration,
260}
261
262impl CanisterDiagnosticsBatchReport {
263 pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
265 self.entries.iter().filter(|entry| !entry.is_success())
266 }
267
268 #[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
325pub trait PocketIcDiagnosticsExt {
327 fn collect_canister_diagnostics(
332 &self,
333 request: CanisterDiagnosticsRequest,
334 ) -> CanisterDiagnosticsReport;
335
336 fn write_canister_diagnostics(
346 &self,
347 request: CanisterDiagnosticsRequest,
348 context: &str,
349 writer: &mut dyn io::Write,
350 ) -> io::Result<()> {
351 transport::capture_operation(|| {
352 let report = self.collect_canister_diagnostics(request);
353 writeln!(writer, "{context}: {report}")
354 })
355 .map_err(io::Error::other)?
356 }
357
358 fn collect_canister_diagnostics_batch(
364 &self,
365 requests: &[LabeledCanisterDiagnosticsRequest],
366 ) -> Result<CanisterDiagnosticsBatchReport, CanisterDiagnosticsBatchContractError> {
367 validate_diagnostics_batch_labels(requests)?;
368 let started = Instant::now();
369 let entries = requests
370 .iter()
371 .map(|labeled| {
372 let entry_started = Instant::now();
373 let request = labeled.request;
374 let report =
375 transport::capture_operation(|| self.collect_canister_diagnostics(request))
376 .unwrap_or_else(|source| CanisterDiagnosticsReport {
377 request,
378 status: Err(PocketIcCallFailure::Operation(source.clone())),
379 logs: Err(PocketIcCallFailure::Operation(source)),
380 });
381 CanisterDiagnosticsBatchEntry {
382 label: labeled.label.clone(),
383 report,
384 entry_elapsed: entry_started.elapsed(),
385 }
386 })
387 .collect();
388 Ok(CanisterDiagnosticsBatchReport {
389 entries,
390 total: started.elapsed(),
391 })
392 }
393}
394
395fn validate_diagnostics_batch_labels(
396 requests: &[LabeledCanisterDiagnosticsRequest],
397) -> Result<(), CanisterDiagnosticsBatchContractError> {
398 let mut seen = HashMap::new();
399 for (index, request) in requests.iter().enumerate() {
400 let label = request.label.as_str();
401 if label.is_empty() {
402 return Err(CanisterDiagnosticsBatchContractError::EmptyLabel { index });
403 }
404 if let Some(first_index) = seen.insert(label, index) {
405 return Err(CanisterDiagnosticsBatchContractError::DuplicateLabel {
406 label: label.to_owned(),
407 first_index,
408 duplicate_index: index,
409 });
410 }
411 }
412 Ok(())
413}
414
415impl fmt::Display for CanisterDiagnosticsBatchContractError {
416 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
417 match self {
418 Self::EmptyLabel { index } => {
419 write!(
420 formatter,
421 "diagnostics batch label at index {index} is empty"
422 )
423 }
424 Self::DuplicateLabel {
425 label,
426 first_index,
427 duplicate_index,
428 } => write!(
429 formatter,
430 "diagnostics batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
431 ),
432 }
433 }
434}
435
436impl std::error::Error for CanisterDiagnosticsBatchContractError {}
437
438impl PocketIcDiagnosticsExt for PocketIc {
439 fn collect_canister_diagnostics(
440 &self,
441 request: CanisterDiagnosticsRequest,
442 ) -> CanisterDiagnosticsReport {
443 let status =
444 capture_call(|| self.canister_status(request.canister_id, Some(request.status_sender)));
445 let logs =
446 capture_call(|| self.fetch_canister_logs(request.canister_id, request.log_sender))
447 .map(|records| render_log_records(records, request.log_limits));
448
449 CanisterDiagnosticsReport {
450 request,
451 status,
452 logs,
453 }
454 }
455}
456
457fn render_log_records(
458 records: Vec<CanisterLogRecord>,
459 limits: CanisterLogRenderLimits,
460) -> CanisterDiagnosticLogs {
461 let total_records = records.len();
462 let mut total_content_bytes = 0usize;
463 let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
464 let mut retained_bytes = 0usize;
465 let mut omitted_content_bytes = 0usize;
466
467 for record in records {
468 total_content_bytes = total_content_bytes.saturating_add(record.content.len());
469 if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
470 omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
471 continue;
472 }
473
474 let available = limits.byte_limit.saturating_sub(retained_bytes);
475 let retained = record.content.len().min(available);
476 let omitted = record.content.len().saturating_sub(retained);
477 let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
478 retained_bytes = retained_bytes.saturating_add(retained);
479 omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
480 rendered.push(CanisterDiagnosticLogRecord {
481 index: record.idx,
482 timestamp_nanos: record.timestamp_nanos,
483 content,
484 original_content_bytes: record.content.len(),
485 omitted_content_bytes: omitted,
486 });
487 }
488
489 CanisterDiagnosticLogs {
490 records: rendered,
491 total_records,
492 total_content_bytes,
493 omitted_content_bytes,
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use std::cell::Cell;
500
501 use ic_platform::pocket_ic::CanisterLogRecord;
502 use ic_platform::principal::ExternalPrincipal as Principal;
503
504 use super::{
505 CanisterDiagnosticsBatchContractError, CanisterDiagnosticsReport,
506 CanisterDiagnosticsRequest, CanisterLogRenderLimits, LabeledCanisterDiagnosticsRequest,
507 PocketIcCallFailure, PocketIcDiagnosticsExt, capture_call, render_log_records,
508 };
509
510 #[test]
511 fn diagnostic_transport_classification_survives_context_and_io_wrapping() {
512 const REFUSED: &str = "HTTP failure: reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/read/get_time\", source: ConnectError(\"tcp connect error\", Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }) }";
513 const RESET: &str = "HTTP failure: reqwest::Error { kind: Request, url: \"http://127.0.0.1:49238/instances/0/read/get_time\", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(Io, Os { code: 54, kind: ConnectionReset, message: \"Connection reset by peer\" })) }";
514 for message in [REFUSED, RESET, "application worker channel closed"] {
515 let error = capture_call::<()>(|| panic!("{message}"))
516 .expect_err("diagnostic operation should capture the panic");
517 let expected = message != "application worker channel closed";
518 let PocketIcCallFailure::Operation(source) = &error else {
519 panic!("expected the original operation error");
520 };
521 assert_eq!(source.message(), message);
522 assert_eq!(source.is_transport(), expected);
523 let chained = std::error::Error::source(&error)
524 .and_then(|cause| cause.downcast_ref::<super::super::PocketIcOperationError>())
525 .expect("diagnostic error exposes its original typed cause");
526 assert_eq!(chained, source);
527 assert_eq!(
528 super::super::is_dead_pocket_ic_transport_error(&error),
529 expected,
530 );
531 let wrapped = std::io::Error::other(error);
532 assert_eq!(
533 super::super::is_dead_pocket_ic_transport_error(&wrapped),
534 expected,
535 );
536 }
537 }
538
539 struct PanickingThenReporting {
540 calls: Cell<usize>,
541 request: Cell<Option<CanisterDiagnosticsRequest>>,
542 }
543
544 impl PocketIcDiagnosticsExt for PanickingThenReporting {
545 fn collect_canister_diagnostics(
546 &self,
547 request: CanisterDiagnosticsRequest,
548 ) -> CanisterDiagnosticsReport {
549 let call = self.calls.get();
550 self.calls.set(call + 1);
551 self.request.set(Some(request));
552 assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
553 CanisterDiagnosticsReport {
554 request,
555 status: Err(PocketIcCallFailure::Operation(
556 super::super::PocketIcOperationError::new("synthetic status failure"),
557 )),
558 logs: Err(PocketIcCallFailure::Operation(
559 super::super::PocketIcOperationError::new("synthetic log failure"),
560 )),
561 }
562 }
563 }
564
565 struct PanickingWriter;
566
567 impl std::io::Write for PanickingWriter {
568 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
569 panic!("synthetic writer panic");
570 }
571 fn flush(&mut self) -> std::io::Result<()> {
572 Ok(())
573 }
574 }
575
576 #[test]
577 fn explicit_reporting_preserves_requests_and_contains_secondary_failures() {
578 let collector = PanickingThenReporting {
579 calls: Cell::new(0),
580 request: Cell::new(None),
581 };
582 let request = CanisterDiagnosticsRequest {
583 canister_id: Principal::from_slice(&[1]),
584 status_sender: Principal::from_slice(&[2]),
585 log_sender: Principal::from_slice(&[3]),
586 log_limits: CanisterLogRenderLimits::default(),
587 };
588 let mut output = Vec::new();
589 let error = collector
590 .write_canister_diagnostics(request, "install", &mut output)
591 .expect_err("collection panic becomes secondary error");
592 assert_eq!(output, [] as [u8; 0]);
593 assert_eq!(collector.calls.get(), 1);
594 assert_eq!(collector.request.get(), Some(request));
595 assert!(
596 error
597 .get_ref()
598 .unwrap()
599 .downcast_ref::<super::super::PocketIcOperationError>()
600 .unwrap()
601 .message()
602 .contains("synthetic first-entry diagnostic panic")
603 );
604
605 collector
606 .write_canister_diagnostics(request, "install", &mut output)
607 .unwrap();
608 let text = String::from_utf8(output).unwrap();
609 assert!(text.starts_with("install: "));
610 assert!(
611 text.contains("synthetic status failure") && text.contains("synthetic log failure")
612 );
613 assert_eq!(collector.calls.get(), 2);
614 assert_eq!(collector.request.get(), Some(request));
615
616 let mut full_writer = std::io::Cursor::new([0_u8; 0]);
617 let error = collector
618 .write_canister_diagnostics(request, "install", &mut full_writer)
619 .expect_err("ordinary writer failure is retained");
620 assert_eq!(error.kind(), std::io::ErrorKind::WriteZero);
621 let error = collector
622 .write_canister_diagnostics(request, "install", &mut PanickingWriter)
623 .expect_err("writer panic becomes secondary error");
624 assert!(
625 error
626 .get_ref()
627 .unwrap()
628 .downcast_ref::<super::super::PocketIcOperationError>()
629 .unwrap()
630 .message()
631 .contains("synthetic writer panic")
632 );
633 assert_eq!(
634 collector.calls.get(),
635 4,
636 "one explicit collection per call, with no retries"
637 );
638 }
639
640 #[test]
641 fn labeled_batch_retains_order_and_continues_after_entry_panic() {
642 let collector = PanickingThenReporting {
643 calls: Cell::new(0),
644 request: Cell::new(None),
645 };
646 let first = CanisterDiagnosticsRequest {
647 canister_id: Principal::from_slice(&[1]),
648 status_sender: Principal::from_slice(&[2]),
649 log_sender: Principal::from_slice(&[3]),
650 log_limits: CanisterLogRenderLimits::default(),
651 };
652 let second = CanisterDiagnosticsRequest {
653 canister_id: Principal::from_slice(&[4]),
654 status_sender: Principal::from_slice(&[5]),
655 log_sender: Principal::from_slice(&[6]),
656 log_limits: CanisterLogRenderLimits::default(),
657 };
658 let report = collector
659 .collect_canister_diagnostics_batch(&[
660 LabeledCanisterDiagnosticsRequest {
661 label: "root".into(),
662 request: first,
663 },
664 LabeledCanisterDiagnosticsRequest {
665 label: "worker".into(),
666 request: second,
667 },
668 ])
669 .expect("valid labeled diagnostics batch");
670
671 assert_eq!(collector.calls.get(), 2);
672 assert_eq!(report.entries.len(), 2);
673 assert_eq!(report.entries[0].label, "root");
674 assert_eq!(report.entries[0].report.request, first);
675 assert_eq!(report.entries[1].label, "worker");
676 assert_eq!(report.entries[1].report.request, second);
677 assert!(
678 report
679 .entries
680 .iter()
681 .all(|entry| entry.entry_elapsed <= report.total)
682 );
683 assert_eq!(report.failures().count(), 2);
684 assert!(!report.is_success());
685 let compact = report.to_string();
686 assert!(compact.contains("label=\"root\""));
687 assert!(compact.contains("label=\"worker\""));
688 assert!(compact.contains("synthetic first-entry diagnostic panic"));
689 }
690
691 #[test]
692 fn diagnostic_batch_rejects_invalid_labels_before_collection() {
693 let collector = PanickingThenReporting {
694 calls: Cell::new(0),
695 request: Cell::new(None),
696 };
697 let request = CanisterDiagnosticsRequest {
698 canister_id: Principal::from_slice(&[1]),
699 status_sender: Principal::from_slice(&[2]),
700 log_sender: Principal::from_slice(&[3]),
701 log_limits: CanisterLogRenderLimits::default(),
702 };
703 let empty = collector
704 .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest {
705 label: String::new(),
706 request,
707 }])
708 .expect_err("empty label must reject diagnostics batch");
709 assert_eq!(
710 empty,
711 CanisterDiagnosticsBatchContractError::EmptyLabel { index: 0 }
712 );
713 assert_eq!(collector.calls.get(), 0);
714
715 let duplicate = collector
716 .collect_canister_diagnostics_batch(&[
717 LabeledCanisterDiagnosticsRequest {
718 label: "same".into(),
719 request,
720 },
721 LabeledCanisterDiagnosticsRequest {
722 label: "same".into(),
723 request,
724 },
725 ])
726 .expect_err("duplicate labels must reject diagnostics batch");
727 assert_eq!(
728 duplicate,
729 CanisterDiagnosticsBatchContractError::DuplicateLabel {
730 label: "same".to_owned(),
731 first_index: 0,
732 duplicate_index: 1,
733 }
734 );
735 assert_eq!(collector.calls.get(), 0);
736 }
737
738 #[test]
739 fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
740 let logs = render_log_records(
741 vec![
742 CanisterLogRecord {
743 idx: 7,
744 timestamp_nanos: 11,
745 content: vec![b'f', 0x80, b'o'],
746 },
747 CanisterLogRecord {
748 idx: 8,
749 timestamp_nanos: 12,
750 content: b"bar".to_vec(),
751 },
752 ],
753 CanisterLogRenderLimits {
754 record_limit: 1,
755 byte_limit: 2,
756 },
757 );
758
759 assert_eq!(logs.total_records(), 2);
760 assert_eq!(logs.total_content_bytes(), 6);
761 assert_eq!(logs.omitted_records(), 1);
762 assert_eq!(logs.omitted_content_bytes(), 4);
763 assert!(logs.was_truncated());
764 assert_eq!(logs.records().len(), 1);
765 assert_eq!(logs.records()[0].content(), "f�");
766 assert_eq!(logs.records()[0].original_content_bytes(), 3);
767 assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
768 assert!(logs.records()[0].was_truncated());
769 let rendered = logs.to_string();
770 assert!(rendered.contains("f�"));
771 assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
772 }
773
774 #[test]
775 fn zero_log_bounds_retain_only_aggregate_truncation() {
776 for (record_limit, byte_limit) in [(0, 0), (0, 16), (16, 0)] {
777 let logs = render_log_records(
778 vec![CanisterLogRecord {
779 idx: 1,
780 timestamp_nanos: 2,
781 content: b"hello".to_vec(),
782 }],
783 CanisterLogRenderLimits {
784 record_limit,
785 byte_limit,
786 },
787 );
788
789 assert_eq!(logs.records(), []);
790 assert_eq!(logs.total_records(), 1);
791 assert_eq!(logs.total_content_bytes(), 5);
792 assert_eq!(logs.omitted_records(), 1);
793 assert_eq!(logs.omitted_content_bytes(), 5);
794 assert!(logs.was_truncated());
795 assert_eq!(
796 logs.to_string(),
797 "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
798 );
799 }
800 }
801
802 #[test]
803 fn logs_within_bounds_preserve_empty_records_and_raw_byte_totals() {
804 let limits = CanisterLogRenderLimits {
805 record_limit: 3,
806 byte_limit: 3,
807 };
808 let empty = render_log_records(vec![], limits);
809 assert_eq!(empty.total_records(), 0);
810 assert_eq!(empty.total_content_bytes(), 0);
811 assert_eq!(empty.omitted_records(), 0);
812 assert_eq!(empty.omitted_content_bytes(), 0);
813 assert!(!empty.was_truncated());
814 assert_eq!(empty.to_string(), "<empty>");
815
816 let logs = render_log_records(
817 vec![
818 CanisterLogRecord {
819 idx: 1,
820 timestamp_nanos: 2,
821 content: vec![],
822 },
823 CanisterLogRecord {
824 idx: 3,
825 timestamp_nanos: 4,
826 content: vec![0x80, b'a'],
827 },
828 CanisterLogRecord {
829 idx: 5,
830 timestamp_nanos: 6,
831 content: vec![],
832 },
833 ],
834 limits,
835 );
836 assert_eq!(logs.total_records(), 3);
837 assert_eq!(logs.records().len(), 3);
838 assert_eq!(logs.total_content_bytes(), 2);
839 assert_eq!(logs.records()[0].content(), "");
840 assert_eq!(logs.records()[1].content(), "�a");
841 assert_eq!(logs.records()[2].content(), "");
842 assert_eq!(logs.omitted_records(), 0);
843 assert_eq!(logs.omitted_content_bytes(), 0);
844 assert!(!logs.was_truncated());
845 }
846}