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_content_bytes: usize,
259}
260
261impl CanisterDiagnosticLogs {
262 #[must_use]
264 pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
265 &self.records
266 }
267
268 #[must_use]
270 pub const fn total_records(&self) -> usize {
271 self.total_records
272 }
273
274 #[must_use]
276 pub const fn total_content_bytes(&self) -> usize {
277 self.total_content_bytes
278 }
279
280 #[must_use]
282 pub const fn omitted_records(&self) -> usize {
283 self.total_records - self.records.len()
284 }
285
286 #[must_use]
288 pub const fn omitted_content_bytes(&self) -> usize {
289 self.omitted_content_bytes
290 }
291
292 #[must_use]
294 pub const fn was_truncated(&self) -> bool {
295 self.omitted_records() != 0 || self.omitted_content_bytes != 0
296 }
297}
298
299impl fmt::Display for CanisterDiagnosticLogs {
300 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
301 if self.records.is_empty() {
302 if self.total_records == 0 {
303 formatter.write_str("<empty>")?;
304 } else {
305 formatter.write_str("<no retained records>")?;
306 }
307 } else {
308 for (position, record) in self.records.iter().enumerate() {
309 if position != 0 {
310 formatter.write_str(", ")?;
311 }
312 write!(
313 formatter,
314 "[{}@{}]={:?}",
315 record.index, record.timestamp_nanos, record.content
316 )?;
317 if record.was_truncated() {
318 write!(
319 formatter,
320 " (truncated {} bytes)",
321 record.omitted_content_bytes
322 )?;
323 }
324 }
325 }
326 if self.was_truncated() {
327 write!(
328 formatter,
329 "; truncated omitted_records={} omitted_content_bytes={}",
330 self.omitted_records(),
331 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 seen = HashMap::new();
577 for (index, request) in requests.iter().enumerate() {
578 let label = request.label.as_str();
579 if label.is_empty() {
580 return Err(CanisterDiagnosticsBatchContractError::EmptyLabel { index });
581 }
582 if let Some(first_index) = seen.insert(label, index) {
583 return Err(CanisterDiagnosticsBatchContractError::DuplicateLabel {
584 label: label.to_owned(),
585 first_index,
586 duplicate_index: index,
587 });
588 }
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 mut total_content_bytes = 0usize;
664 let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
665 let mut retained_bytes = 0usize;
666 let mut omitted_content_bytes = 0usize;
667
668 for record in records {
669 total_content_bytes = total_content_bytes.saturating_add(record.content.len());
670 if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
671 omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
672 continue;
673 }
674
675 let available = limits.byte_limit.saturating_sub(retained_bytes);
676 let retained = record.content.len().min(available);
677 let omitted = record.content.len().saturating_sub(retained);
678 let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
679 retained_bytes = retained_bytes.saturating_add(retained);
680 omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
681 rendered.push(CanisterDiagnosticLogRecord {
682 index: record.idx,
683 timestamp_nanos: record.timestamp_nanos,
684 content,
685 original_content_bytes: record.content.len(),
686 omitted_content_bytes: omitted,
687 });
688 }
689
690 CanisterDiagnosticLogs {
691 records: rendered,
692 total_records,
693 total_content_bytes,
694 omitted_content_bytes,
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use std::cell::Cell;
701
702 use candid::Principal;
703 use pocket_ic::CanisterLogRecord;
704
705 use super::{
706 CanisterDiagnosticFailure, CanisterDiagnosticsBatchContractError,
707 CanisterDiagnosticsReport, CanisterDiagnosticsRequest, CanisterLogRenderLimits,
708 LabeledCanisterDiagnosticsRequest, PocketIcDiagnosticsExt, capture_diagnostic_call,
709 render_log_records,
710 };
711
712 #[test]
713 fn diagnostic_transport_classification_survives_context_and_io_wrapping() {
714 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\" }) }";
715 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\" })) }";
716 for message in [REFUSED, RESET, "application worker channel closed"] {
717 let error = capture_diagnostic_call::<()>(|| panic!("{message}"))
718 .expect_err("diagnostic operation should capture the panic");
719 let expected = message != "application worker channel closed";
720 assert_eq!(
721 matches!(
722 &error,
723 CanisterDiagnosticFailure::InstanceUnavailable { .. }
724 ),
725 expected,
726 );
727 assert_eq!(
728 super::super::is_dead_pocket_ic_transport_error(&error),
729 expected,
730 );
731 let wrapped = std::io::Error::other(error);
732 assert_eq!(
733 super::super::is_dead_pocket_ic_transport_error(&wrapped),
734 expected,
735 );
736 }
737 }
738
739 struct PanickingThenReporting {
740 calls: Cell<usize>,
741 }
742
743 impl PocketIcDiagnosticsExt for PanickingThenReporting {
744 fn collect_canister_diagnostics(
745 &self,
746 request: CanisterDiagnosticsRequest,
747 ) -> CanisterDiagnosticsReport {
748 let call = self.calls.get();
749 self.calls.set(call + 1);
750 assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
751 CanisterDiagnosticsReport {
752 request,
753 status: Err(CanisterDiagnosticFailure::Panicked {
754 message: "synthetic status failure".to_owned(),
755 }),
756 logs: Err(CanisterDiagnosticFailure::Panicked {
757 message: "synthetic log failure".to_owned(),
758 }),
759 }
760 }
761 }
762
763 #[test]
764 fn labeled_batch_retains_order_and_continues_after_entry_panic() {
765 let collector = PanickingThenReporting {
766 calls: Cell::new(0),
767 };
768 let first = CanisterDiagnosticsRequest::new(
769 Principal::from_slice(&[1]),
770 Principal::from_slice(&[2]),
771 Principal::from_slice(&[3]),
772 );
773 let second = CanisterDiagnosticsRequest::new(
774 Principal::from_slice(&[4]),
775 Principal::from_slice(&[5]),
776 Principal::from_slice(&[6]),
777 );
778 let report = collector
779 .collect_canister_diagnostics_batch(&[
780 LabeledCanisterDiagnosticsRequest::new("root", first),
781 LabeledCanisterDiagnosticsRequest::new("worker", second),
782 ])
783 .expect("valid labeled diagnostics batch");
784
785 assert_eq!(collector.calls.get(), 2);
786 assert_eq!(report.entries().len(), 2);
787 assert_eq!(report.entries()[0].label(), "root");
788 assert_eq!(report.entries()[0].report().request(), first);
789 assert_eq!(report.entries()[1].label(), "worker");
790 assert_eq!(report.entries()[1].report().request(), second);
791 assert!(
792 report
793 .entries()
794 .iter()
795 .all(|entry| entry.entry_elapsed() <= report.total())
796 );
797 assert_eq!(report.failures().count(), 2);
798 assert!(!report.is_success());
799 let compact = report.render_compact();
800 assert!(compact.contains("label=\"root\""));
801 assert!(compact.contains("label=\"worker\""));
802 assert!(compact.contains("synthetic first-entry diagnostic panic"));
803 }
804
805 #[test]
806 fn diagnostic_batch_rejects_invalid_labels_before_collection() {
807 let collector = PanickingThenReporting {
808 calls: Cell::new(0),
809 };
810 let request = CanisterDiagnosticsRequest::new(
811 Principal::from_slice(&[1]),
812 Principal::from_slice(&[2]),
813 Principal::from_slice(&[3]),
814 );
815 let empty = collector
816 .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest::new(
817 "", request,
818 )])
819 .expect_err("empty label must reject diagnostics batch");
820 assert_eq!(
821 empty,
822 CanisterDiagnosticsBatchContractError::EmptyLabel { index: 0 }
823 );
824 assert_eq!(collector.calls.get(), 0);
825
826 let duplicate = collector
827 .collect_canister_diagnostics_batch(&[
828 LabeledCanisterDiagnosticsRequest::new("same", request),
829 LabeledCanisterDiagnosticsRequest::new("same", request),
830 ])
831 .expect_err("duplicate labels must reject diagnostics batch");
832 assert_eq!(
833 duplicate,
834 CanisterDiagnosticsBatchContractError::DuplicateLabel {
835 label: "same".to_owned(),
836 first_index: 0,
837 duplicate_index: 1,
838 }
839 );
840 assert_eq!(collector.calls.get(), 0);
841 }
842
843 #[test]
844 fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
845 let logs = render_log_records(
846 vec![
847 CanisterLogRecord {
848 idx: 7,
849 timestamp_nanos: 11,
850 content: vec![b'f', 0x80, b'o'],
851 },
852 CanisterLogRecord {
853 idx: 8,
854 timestamp_nanos: 12,
855 content: b"bar".to_vec(),
856 },
857 ],
858 CanisterLogRenderLimits::new(1, 2),
859 );
860
861 assert_eq!(logs.total_records(), 2);
862 assert_eq!(logs.total_content_bytes(), 6);
863 assert_eq!(logs.omitted_records(), 1);
864 assert_eq!(logs.omitted_content_bytes(), 4);
865 assert!(logs.was_truncated());
866 assert_eq!(logs.records().len(), 1);
867 assert_eq!(logs.records()[0].content(), "f�");
868 assert_eq!(logs.records()[0].original_content_bytes(), 3);
869 assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
870 assert!(logs.records()[0].was_truncated());
871 let rendered = logs.to_string();
872 assert!(rendered.contains("f�"));
873 assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
874 }
875
876 #[test]
877 fn zero_log_bounds_retain_only_aggregate_truncation() {
878 for (record_limit, byte_limit) in [(0, 0), (0, 16), (16, 0)] {
879 let logs = render_log_records(
880 vec![CanisterLogRecord {
881 idx: 1,
882 timestamp_nanos: 2,
883 content: b"hello".to_vec(),
884 }],
885 CanisterLogRenderLimits::new(record_limit, byte_limit),
886 );
887
888 assert_eq!(logs.records(), []);
889 assert_eq!(logs.total_records(), 1);
890 assert_eq!(logs.total_content_bytes(), 5);
891 assert_eq!(logs.omitted_records(), 1);
892 assert_eq!(logs.omitted_content_bytes(), 5);
893 assert!(logs.was_truncated());
894 assert_eq!(
895 logs.to_string(),
896 "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
897 );
898 }
899 }
900
901 #[test]
902 fn logs_within_bounds_preserve_empty_records_and_raw_byte_totals() {
903 let limits = CanisterLogRenderLimits::new(3, 3);
904 let empty = render_log_records(vec![], limits);
905 assert_eq!(empty.total_records(), 0);
906 assert_eq!(empty.total_content_bytes(), 0);
907 assert_eq!(empty.omitted_records(), 0);
908 assert_eq!(empty.omitted_content_bytes(), 0);
909 assert!(!empty.was_truncated());
910 assert_eq!(empty.to_string(), "<empty>");
911
912 let logs = render_log_records(
913 vec![
914 CanisterLogRecord {
915 idx: 1,
916 timestamp_nanos: 2,
917 content: vec![],
918 },
919 CanisterLogRecord {
920 idx: 3,
921 timestamp_nanos: 4,
922 content: vec![0x80, b'a'],
923 },
924 CanisterLogRecord {
925 idx: 5,
926 timestamp_nanos: 6,
927 content: vec![],
928 },
929 ],
930 limits,
931 );
932 assert_eq!(logs.total_records(), 3);
933 assert_eq!(logs.records().len(), 3);
934 assert_eq!(logs.total_content_bytes(), 2);
935 assert_eq!(logs.records()[0].content(), "");
936 assert_eq!(logs.records()[1].content(), "�a");
937 assert_eq!(logs.records()[2].content(), "");
938 assert_eq!(logs.omitted_records(), 0);
939 assert_eq!(logs.omitted_content_bytes(), 0);
940 assert!(!logs.was_truncated());
941 }
942}