1use std::{
2 fmt, io,
3 time::{Duration, Instant},
4};
5
6use crate::pocket_ic::{CanisterLogRecord, CanisterStatusResult, ClientError, PocketIc};
7use ic_host_artifacts::batch::{BatchLabelError, validate_labels};
8use ic_platform_types::principal::ExternalPrincipal as Principal;
9
10pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
12
13pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub struct CanisterLogRenderLimits {
19 pub record_limit: usize,
21 pub byte_limit: usize,
23}
24
25impl Default for CanisterLogRenderLimits {
26 fn default() -> Self {
27 Self {
28 record_limit: DEFAULT_CANISTER_LOG_RECORD_LIMIT,
29 byte_limit: DEFAULT_CANISTER_LOG_BYTE_LIMIT,
30 }
31 }
32}
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub struct CanisterDiagnosticsRequest {
37 pub canister_id: Principal,
39 pub status_sender: Principal,
41 pub log_sender: Principal,
43 pub log_limits: CanisterLogRenderLimits,
45}
46
47#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct LabeledCanisterDiagnosticsRequest {
50 pub label: String,
52 pub request: CanisterDiagnosticsRequest,
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct CanisterDiagnosticLogRecord {
59 index: u64,
60 timestamp_nanos: u64,
61 content: String,
62 original_content_bytes: usize,
63 omitted_content_bytes: usize,
64}
65
66impl CanisterDiagnosticLogRecord {
67 #[must_use]
69 pub const fn index(&self) -> u64 {
70 self.index
71 }
72
73 #[must_use]
75 pub const fn timestamp_nanos(&self) -> u64 {
76 self.timestamp_nanos
77 }
78
79 #[must_use]
81 pub fn content(&self) -> &str {
82 &self.content
83 }
84
85 #[must_use]
87 pub const fn original_content_bytes(&self) -> usize {
88 self.original_content_bytes
89 }
90
91 #[must_use]
93 pub const fn omitted_content_bytes(&self) -> usize {
94 self.omitted_content_bytes
95 }
96
97 #[must_use]
99 pub const fn was_truncated(&self) -> bool {
100 self.omitted_content_bytes != 0
101 }
102}
103
104#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct CanisterDiagnosticLogs {
107 records: Vec<CanisterDiagnosticLogRecord>,
108 total_records: usize,
109 total_content_bytes: usize,
110 omitted_content_bytes: usize,
111}
112
113impl CanisterDiagnosticLogs {
114 #[must_use]
116 pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
117 &self.records
118 }
119
120 #[must_use]
122 pub const fn total_records(&self) -> usize {
123 self.total_records
124 }
125
126 #[must_use]
128 pub const fn total_content_bytes(&self) -> usize {
129 self.total_content_bytes
130 }
131
132 #[must_use]
134 pub const fn omitted_records(&self) -> usize {
135 self.total_records - self.records.len()
136 }
137
138 #[must_use]
140 pub const fn omitted_content_bytes(&self) -> usize {
141 self.omitted_content_bytes
142 }
143
144 #[must_use]
146 pub const fn was_truncated(&self) -> bool {
147 self.omitted_records() != 0 || self.omitted_content_bytes != 0
148 }
149}
150
151impl fmt::Display for CanisterDiagnosticLogs {
152 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153 if self.records.is_empty() {
154 if self.total_records == 0 {
155 formatter.write_str("<empty>")?;
156 } else {
157 formatter.write_str("<no retained records>")?;
158 }
159 } else {
160 for (position, record) in self.records.iter().enumerate() {
161 if position != 0 {
162 formatter.write_str(", ")?;
163 }
164 write!(
165 formatter,
166 "[{}@{}]={:?}",
167 record.index, record.timestamp_nanos, record.content
168 )?;
169 if record.was_truncated() {
170 write!(
171 formatter,
172 " (truncated {} bytes)",
173 record.omitted_content_bytes
174 )?;
175 }
176 }
177 }
178 if self.was_truncated() {
179 write!(
180 formatter,
181 "; truncated omitted_records={} omitted_content_bytes={}",
182 self.omitted_records(),
183 self.omitted_content_bytes
184 )?;
185 }
186 Ok(())
187 }
188}
189
190#[derive(Debug)]
192pub struct CanisterDiagnosticsReport {
193 pub request: CanisterDiagnosticsRequest,
195 pub status: Result<CanisterStatusResult, ClientError>,
197 pub logs: Result<CanisterDiagnosticLogs, ClientError>,
199}
200
201impl CanisterDiagnosticsReport {
202 #[must_use]
204 pub const fn is_success(&self) -> bool {
205 self.status.is_ok() && self.logs.is_ok()
206 }
207}
208
209#[derive(Debug)]
211pub struct CanisterDiagnosticsBatchEntry {
212 pub label: String,
214 pub report: CanisterDiagnosticsReport,
216 pub entry_elapsed: Duration,
218}
219
220impl CanisterDiagnosticsBatchEntry {
221 #[must_use]
223 pub const fn is_success(&self) -> bool {
224 self.report.is_success()
225 }
226}
227
228#[derive(Debug, Default)]
230pub struct CanisterDiagnosticsBatchReport {
231 pub entries: Vec<CanisterDiagnosticsBatchEntry>,
233 pub total: Duration,
235}
236
237impl CanisterDiagnosticsBatchReport {
238 pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
240 self.entries.iter().filter(|entry| !entry.is_success())
241 }
242
243 #[must_use]
245 pub fn is_success(&self) -> bool {
246 self.entries
247 .iter()
248 .all(CanisterDiagnosticsBatchEntry::is_success)
249 }
250}
251
252impl fmt::Display for CanisterDiagnosticsBatchReport {
253 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
254 write!(
255 formatter,
256 "diagnostics={} total={:?}",
257 self.entries.len(),
258 self.total
259 )?;
260 for entry in &self.entries {
261 write!(
262 formatter,
263 "; label={:?} elapsed={:?} {}",
264 entry.label, entry.entry_elapsed, entry.report
265 )?;
266 }
267 Ok(())
268 }
269}
270
271impl fmt::Display for CanisterDiagnosticsReport {
272 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
273 write!(
274 formatter,
275 "canister={} status_sender={} status=",
276 self.request.canister_id, self.request.status_sender
277 )?;
278 match &self.status {
279 Ok(status) => write!(
280 formatter,
281 "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
282 status.status,
283 status.version,
284 status.settings.controllers.len(),
285 status.module_hash.as_ref().map_or(0, Vec::len),
286 status.memory_size,
287 status.cycles,
288 ),
289 Err(failure) => write!(formatter, "<{failure}>"),
290 }?;
291 write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
292 match &self.logs {
293 Err(failure) => write!(formatter, "<{failure}>")?,
294 Ok(logs) => write!(formatter, "{logs}")?,
295 }
296 Ok(())
297 }
298}
299
300pub trait PocketIcDiagnosticsExt {
302 fn collect_canister_diagnostics(
307 &self,
308 request: CanisterDiagnosticsRequest,
309 ) -> CanisterDiagnosticsReport;
310
311 fn write_canister_diagnostics(
324 &self,
325 request: CanisterDiagnosticsRequest,
326 context: &str,
327 writer: &mut dyn io::Write,
328 ) -> io::Result<()> {
329 let report = self.collect_canister_diagnostics(request);
330 writeln!(writer, "{context}: {report}")
331 }
332
333 fn collect_canister_diagnostics_batch(
343 &self,
344 requests: &[LabeledCanisterDiagnosticsRequest],
345 ) -> Result<CanisterDiagnosticsBatchReport, BatchLabelError> {
346 validate_labels(requests.iter().map(|request| request.label.as_str()))?;
347 let started = Instant::now();
348 let entries = requests
349 .iter()
350 .map(|labeled| {
351 let entry_started = Instant::now();
352 let request = labeled.request;
353 let report = self.collect_canister_diagnostics(request);
354 CanisterDiagnosticsBatchEntry {
355 label: labeled.label.clone(),
356 report,
357 entry_elapsed: entry_started.elapsed(),
358 }
359 })
360 .collect();
361 Ok(CanisterDiagnosticsBatchReport {
362 entries,
363 total: started.elapsed(),
364 })
365 }
366}
367
368impl PocketIcDiagnosticsExt for PocketIc {
369 fn collect_canister_diagnostics(
370 &self,
371 request: CanisterDiagnosticsRequest,
372 ) -> CanisterDiagnosticsReport {
373 let status = self.canister_status(request.canister_id, Some(request.status_sender));
374 let logs = self
375 .fetch_canister_logs(request.canister_id, request.log_sender)
376 .map(|records| render_log_records(records, request.log_limits));
377
378 CanisterDiagnosticsReport {
379 request,
380 status,
381 logs,
382 }
383 }
384}
385
386fn render_log_records(
387 records: Vec<CanisterLogRecord>,
388 limits: CanisterLogRenderLimits,
389) -> CanisterDiagnosticLogs {
390 let total_records = records.len();
391 let mut total_content_bytes = 0usize;
392 let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
393 let mut retained_bytes = 0usize;
394 let mut omitted_content_bytes = 0usize;
395
396 for record in records {
397 total_content_bytes = total_content_bytes.saturating_add(record.content.len());
398 let include = rendered.len() < limits.record_limit && retained_bytes < limits.byte_limit;
399 let retained = if include {
400 record
401 .content
402 .len()
403 .min(limits.byte_limit.saturating_sub(retained_bytes))
404 } else {
405 0
406 };
407 let omitted = record.content.len().saturating_sub(retained);
408 omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
409 if !include {
410 continue;
411 }
412
413 let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
414 retained_bytes = retained_bytes.saturating_add(retained);
415 rendered.push(CanisterDiagnosticLogRecord {
416 index: record.idx,
417 timestamp_nanos: record.timestamp_nanos,
418 content,
419 original_content_bytes: record.content.len(),
420 omitted_content_bytes: omitted,
421 });
422 }
423
424 CanisterDiagnosticLogs {
425 records: rendered,
426 total_records,
427 total_content_bytes,
428 omitted_content_bytes,
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use std::cell::Cell;
435
436 use crate::pocket_ic::CanisterLogRecord;
437 use ic_host_artifacts::batch::BatchLabelError;
438 use ic_platform_types::principal::ExternalPrincipal as Principal;
439
440 use super::{
441 CanisterDiagnosticsReport, CanisterDiagnosticsRequest, CanisterLogRenderLimits,
442 LabeledCanisterDiagnosticsRequest, PocketIcDiagnosticsExt, render_log_records,
443 };
444
445 struct Reporting {
446 calls: Cell<usize>,
447 request: Cell<Option<CanisterDiagnosticsRequest>>,
448 }
449
450 impl PocketIcDiagnosticsExt for Reporting {
451 fn collect_canister_diagnostics(
452 &self,
453 request: CanisterDiagnosticsRequest,
454 ) -> CanisterDiagnosticsReport {
455 self.calls.set(self.calls.get() + 1);
456 self.request.set(Some(request));
457 CanisterDiagnosticsReport {
458 request,
459 status: Err(crate::pocket_ic::ClientError::Protocol(
460 "synthetic status failure".into(),
461 )),
462 logs: Err(crate::pocket_ic::ClientError::Protocol(
463 "synthetic log failure".into(),
464 )),
465 }
466 }
467 }
468
469 #[test]
470 fn explicit_reporting_preserves_requests_and_writer_failures() {
471 let collector = Reporting {
472 calls: Cell::new(0),
473 request: Cell::new(None),
474 };
475 let request = CanisterDiagnosticsRequest {
476 canister_id: Principal::from_slice(&[1]),
477 status_sender: Principal::from_slice(&[2]),
478 log_sender: Principal::from_slice(&[3]),
479 log_limits: CanisterLogRenderLimits::default(),
480 };
481 let mut output = Vec::new();
482 collector
483 .write_canister_diagnostics(request, "install", &mut output)
484 .unwrap();
485 let text = String::from_utf8(output).unwrap();
486 assert!(
487 text.contains("synthetic status failure") && text.contains("synthetic log failure")
488 );
489 let error = collector
490 .write_canister_diagnostics(request, "install", &mut std::io::Cursor::new([0_u8; 0]))
491 .unwrap_err();
492 assert_eq!(error.kind(), std::io::ErrorKind::WriteZero);
493 assert_eq!(collector.calls.get(), 2);
494 assert_eq!(collector.request.get(), Some(request));
495 }
496
497 #[test]
498 fn labeled_batch_retains_order_and_continues_after_entry_failure() {
499 let collector = Reporting {
500 calls: Cell::new(0),
501 request: Cell::new(None),
502 };
503 let first = CanisterDiagnosticsRequest {
504 canister_id: Principal::from_slice(&[1]),
505 status_sender: Principal::from_slice(&[2]),
506 log_sender: Principal::from_slice(&[3]),
507 log_limits: CanisterLogRenderLimits::default(),
508 };
509 let second = CanisterDiagnosticsRequest {
510 canister_id: Principal::from_slice(&[4]),
511 status_sender: Principal::from_slice(&[5]),
512 log_sender: Principal::from_slice(&[6]),
513 log_limits: CanisterLogRenderLimits::default(),
514 };
515 let report = collector
516 .collect_canister_diagnostics_batch(&[
517 LabeledCanisterDiagnosticsRequest {
518 label: "root".into(),
519 request: first,
520 },
521 LabeledCanisterDiagnosticsRequest {
522 label: "worker".into(),
523 request: second,
524 },
525 ])
526 .expect("valid labeled diagnostics batch");
527
528 assert_eq!(collector.calls.get(), 2);
529 assert_eq!(report.entries.len(), 2);
530 assert_eq!(report.entries[0].label, "root");
531 assert_eq!(report.entries[0].report.request, first);
532 assert_eq!(report.entries[1].label, "worker");
533 assert_eq!(report.entries[1].report.request, second);
534 assert!(
535 report
536 .entries
537 .iter()
538 .all(|entry| entry.entry_elapsed <= report.total)
539 );
540 assert_eq!(report.failures().count(), 2);
541 assert!(!report.is_success());
542 let compact = report.to_string();
543 assert!(compact.contains("label=\"root\""));
544 assert!(compact.contains("label=\"worker\""));
545 assert!(compact.contains("synthetic status failure"));
546 }
547
548 #[test]
549 fn diagnostic_batch_rejects_invalid_labels_before_collection() {
550 let collector = Reporting {
551 calls: Cell::new(0),
552 request: Cell::new(None),
553 };
554 let request = CanisterDiagnosticsRequest {
555 canister_id: Principal::from_slice(&[1]),
556 status_sender: Principal::from_slice(&[2]),
557 log_sender: Principal::from_slice(&[3]),
558 log_limits: CanisterLogRenderLimits::default(),
559 };
560 let empty = collector
561 .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest {
562 label: String::new(),
563 request,
564 }])
565 .expect_err("empty label must reject diagnostics batch");
566 assert_eq!(empty, BatchLabelError::Empty { index: 0 });
567 assert_eq!(collector.calls.get(), 0);
568
569 let duplicate = collector
570 .collect_canister_diagnostics_batch(&[
571 LabeledCanisterDiagnosticsRequest {
572 label: "same".into(),
573 request,
574 },
575 LabeledCanisterDiagnosticsRequest {
576 label: "same".into(),
577 request,
578 },
579 ])
580 .expect_err("duplicate labels must reject diagnostics batch");
581 assert_eq!(
582 duplicate,
583 BatchLabelError::Duplicate {
584 label: "same".to_owned(),
585 first_index: 0,
586 duplicate_index: 1,
587 }
588 );
589 assert_eq!(collector.calls.get(), 0);
590 }
591
592 #[test]
593 fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
594 let logs = render_log_records(
595 vec![
596 CanisterLogRecord {
597 idx: 7,
598 timestamp_nanos: 11,
599 content: vec![b'f', 0x80, b'o'],
600 },
601 CanisterLogRecord {
602 idx: 8,
603 timestamp_nanos: 12,
604 content: b"bar".to_vec(),
605 },
606 ],
607 CanisterLogRenderLimits {
608 record_limit: 1,
609 byte_limit: 2,
610 },
611 );
612
613 assert_eq!(logs.total_records(), 2);
614 assert_eq!(logs.total_content_bytes(), 6);
615 assert_eq!(logs.omitted_records(), 1);
616 assert_eq!(logs.omitted_content_bytes(), 4);
617 assert!(logs.was_truncated());
618 assert_eq!(logs.records().len(), 1);
619 assert_eq!(logs.records()[0].content(), "f�");
620 assert_eq!(logs.records()[0].original_content_bytes(), 3);
621 assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
622 assert!(logs.records()[0].was_truncated());
623 let rendered = logs.to_string();
624 assert!(rendered.contains("f�"));
625 assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
626 }
627
628 #[test]
629 fn zero_log_bounds_retain_only_aggregate_truncation() {
630 for (record_limit, byte_limit) in [(0, 0), (0, 16), (16, 0)] {
631 let logs = render_log_records(
632 vec![CanisterLogRecord {
633 idx: 1,
634 timestamp_nanos: 2,
635 content: b"hello".to_vec(),
636 }],
637 CanisterLogRenderLimits {
638 record_limit,
639 byte_limit,
640 },
641 );
642
643 assert_eq!(logs.records(), []);
644 assert_eq!(logs.total_records(), 1);
645 assert_eq!(logs.total_content_bytes(), 5);
646 assert_eq!(logs.omitted_records(), 1);
647 assert_eq!(logs.omitted_content_bytes(), 5);
648 assert!(logs.was_truncated());
649 assert_eq!(
650 logs.to_string(),
651 "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
652 );
653 }
654 }
655
656 #[test]
657 fn logs_within_bounds_preserve_empty_records_and_raw_byte_totals() {
658 let limits = CanisterLogRenderLimits {
659 record_limit: 3,
660 byte_limit: 3,
661 };
662 let empty = render_log_records(vec![], limits);
663 assert_eq!(empty.total_records(), 0);
664 assert_eq!(empty.total_content_bytes(), 0);
665 assert_eq!(empty.omitted_records(), 0);
666 assert_eq!(empty.omitted_content_bytes(), 0);
667 assert!(!empty.was_truncated());
668 assert_eq!(empty.to_string(), "<empty>");
669
670 let logs = render_log_records(
671 vec![
672 CanisterLogRecord {
673 idx: 1,
674 timestamp_nanos: 2,
675 content: vec![],
676 },
677 CanisterLogRecord {
678 idx: 3,
679 timestamp_nanos: 4,
680 content: vec![0x80, b'a'],
681 },
682 CanisterLogRecord {
683 idx: 5,
684 timestamp_nanos: 6,
685 content: vec![],
686 },
687 ],
688 limits,
689 );
690 assert_eq!(logs.total_records(), 3);
691 assert_eq!(logs.records().len(), 3);
692 assert_eq!(logs.total_content_bytes(), 2);
693 assert_eq!(logs.records()[0].content(), "");
694 assert_eq!(logs.records()[1].content(), "�a");
695 assert_eq!(logs.records()[2].content(), "");
696 assert_eq!(logs.omitted_records(), 0);
697 assert_eq!(logs.omitted_content_bytes(), 0);
698 assert!(!logs.was_truncated());
699 }
700}