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