1use std::{
2 fmt,
3 panic::{AssertUnwindSafe, catch_unwind},
4};
5
6use candid::Principal;
7use pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc, RejectResponse};
8
9use super::transport;
10
11pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
13
14pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub struct CanisterLogRenderLimits {
20 record_limit: usize,
21 byte_limit: usize,
22}
23
24impl CanisterLogRenderLimits {
25 #[must_use]
29 pub const fn new(record_limit: usize, byte_limit: usize) -> Self {
30 Self {
31 record_limit,
32 byte_limit,
33 }
34 }
35
36 #[must_use]
38 pub const fn record_limit(self) -> usize {
39 self.record_limit
40 }
41
42 #[must_use]
44 pub const fn byte_limit(self) -> usize {
45 self.byte_limit
46 }
47}
48
49impl Default for CanisterLogRenderLimits {
50 fn default() -> Self {
51 Self::new(
52 DEFAULT_CANISTER_LOG_RECORD_LIMIT,
53 DEFAULT_CANISTER_LOG_BYTE_LIMIT,
54 )
55 }
56}
57
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub struct CanisterDiagnosticsRequest {
61 canister_id: Principal,
62 status_sender: Principal,
63 log_sender: Principal,
64 log_limits: CanisterLogRenderLimits,
65}
66
67impl CanisterDiagnosticsRequest {
68 #[must_use]
73 pub fn new(canister_id: Principal, status_sender: Principal, log_sender: Principal) -> Self {
74 Self {
75 canister_id,
76 status_sender,
77 log_sender,
78 log_limits: CanisterLogRenderLimits::default(),
79 }
80 }
81
82 #[must_use]
84 pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
85 self.log_limits = limits;
86 self
87 }
88
89 #[must_use]
91 pub const fn canister_id(self) -> Principal {
92 self.canister_id
93 }
94
95 #[must_use]
97 pub const fn status_sender(self) -> Principal {
98 self.status_sender
99 }
100
101 #[must_use]
103 pub const fn log_sender(self) -> Principal {
104 self.log_sender
105 }
106
107 #[must_use]
109 pub const fn log_limits(self) -> CanisterLogRenderLimits {
110 self.log_limits
111 }
112}
113
114#[non_exhaustive]
116#[derive(Debug)]
117pub enum CanisterDiagnosticFailure {
118 Rejected(RejectResponse),
120 InstanceUnavailable {
122 message: String,
124 },
125 Panicked {
127 message: String,
129 },
130}
131
132impl fmt::Display for CanisterDiagnosticFailure {
133 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134 match self {
135 Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
136 Self::InstanceUnavailable { message } => {
137 write!(formatter, "PocketIC instance unavailable: {message}")
138 }
139 Self::Panicked { message } => write!(formatter, "panicked: {message}"),
140 }
141 }
142}
143
144impl std::error::Error for CanisterDiagnosticFailure {}
145
146#[derive(Clone, Debug, Eq, PartialEq)]
148pub struct CanisterDiagnosticLogRecord {
149 index: u64,
150 timestamp_nanos: u64,
151 content: String,
152 original_content_bytes: usize,
153 omitted_content_bytes: usize,
154}
155
156impl CanisterDiagnosticLogRecord {
157 #[must_use]
159 pub const fn index(&self) -> u64 {
160 self.index
161 }
162
163 #[must_use]
165 pub const fn timestamp_nanos(&self) -> u64 {
166 self.timestamp_nanos
167 }
168
169 #[must_use]
171 pub fn content(&self) -> &str {
172 &self.content
173 }
174
175 #[must_use]
177 pub const fn original_content_bytes(&self) -> usize {
178 self.original_content_bytes
179 }
180
181 #[must_use]
183 pub const fn omitted_content_bytes(&self) -> usize {
184 self.omitted_content_bytes
185 }
186
187 #[must_use]
189 pub const fn was_truncated(&self) -> bool {
190 self.omitted_content_bytes != 0
191 }
192}
193
194#[derive(Clone, Debug, Eq, PartialEq)]
196pub struct CanisterDiagnosticLogs {
197 records: Vec<CanisterDiagnosticLogRecord>,
198 total_records: usize,
199 total_content_bytes: usize,
200 omitted_records: usize,
201 omitted_content_bytes: usize,
202}
203
204impl CanisterDiagnosticLogs {
205 #[must_use]
207 pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
208 &self.records
209 }
210
211 #[must_use]
213 pub const fn total_records(&self) -> usize {
214 self.total_records
215 }
216
217 #[must_use]
219 pub const fn total_content_bytes(&self) -> usize {
220 self.total_content_bytes
221 }
222
223 #[must_use]
225 pub const fn omitted_records(&self) -> usize {
226 self.omitted_records
227 }
228
229 #[must_use]
231 pub const fn omitted_content_bytes(&self) -> usize {
232 self.omitted_content_bytes
233 }
234
235 #[must_use]
237 pub const fn was_truncated(&self) -> bool {
238 self.omitted_records != 0 || self.omitted_content_bytes != 0
239 }
240}
241
242impl fmt::Display for CanisterDiagnosticLogs {
243 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
244 if self.records.is_empty() {
245 if self.total_records == 0 {
246 formatter.write_str("<empty>")?;
247 } else {
248 formatter.write_str("<no retained records>")?;
249 }
250 } else {
251 for (position, record) in self.records.iter().enumerate() {
252 if position != 0 {
253 formatter.write_str(", ")?;
254 }
255 write!(
256 formatter,
257 "[{}@{}]={:?}",
258 record.index, record.timestamp_nanos, record.content
259 )?;
260 if record.was_truncated() {
261 write!(
262 formatter,
263 " (truncated {} bytes)",
264 record.omitted_content_bytes
265 )?;
266 }
267 }
268 }
269 if self.was_truncated() {
270 write!(
271 formatter,
272 "; truncated omitted_records={} omitted_content_bytes={}",
273 self.omitted_records, self.omitted_content_bytes
274 )?;
275 }
276 Ok(())
277 }
278}
279
280#[derive(Debug)]
282pub struct CanisterDiagnosticsReport {
283 request: CanisterDiagnosticsRequest,
284 status: Result<CanisterStatusResult, CanisterDiagnosticFailure>,
285 logs: Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
286}
287
288impl CanisterDiagnosticsReport {
289 #[must_use]
291 pub const fn request(&self) -> CanisterDiagnosticsRequest {
292 self.request
293 }
294
295 pub const fn status(&self) -> Result<&CanisterStatusResult, &CanisterDiagnosticFailure> {
297 self.status.as_ref()
298 }
299
300 pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &CanisterDiagnosticFailure> {
302 self.logs.as_ref()
303 }
304
305 pub fn into_parts(
307 self,
308 ) -> (
309 CanisterDiagnosticsRequest,
310 Result<CanisterStatusResult, CanisterDiagnosticFailure>,
311 Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
312 ) {
313 (self.request, self.status, self.logs)
314 }
315
316 #[must_use]
318 pub fn render_compact(&self) -> String {
319 self.to_string()
320 }
321}
322
323impl fmt::Display for CanisterDiagnosticsReport {
324 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
325 write!(
326 formatter,
327 "canister={} status_sender={} status=",
328 self.request.canister_id, self.request.status_sender
329 )?;
330 match &self.status {
331 Ok(status) => write!(
332 formatter,
333 "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
334 status.status,
335 status.version,
336 status.settings.controllers.len(),
337 status.module_hash.as_ref().map_or(0, Vec::len),
338 status.memory_size,
339 status.cycles,
340 ),
341 Err(failure) => write!(formatter, "<{failure}>"),
342 }?;
343 write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
344 match &self.logs {
345 Err(failure) => write!(formatter, "<{failure}>")?,
346 Ok(logs) => write!(formatter, "{logs}")?,
347 }
348 Ok(())
349 }
350}
351
352pub trait PocketIcDiagnosticsExt {
354 fn collect_canister_diagnostics(
359 &self,
360 request: CanisterDiagnosticsRequest,
361 ) -> CanisterDiagnosticsReport;
362}
363
364impl PocketIcDiagnosticsExt for PocketIc {
365 fn collect_canister_diagnostics(
366 &self,
367 request: CanisterDiagnosticsRequest,
368 ) -> CanisterDiagnosticsReport {
369 let status = capture_diagnostic_call(|| {
370 self.canister_status(request.canister_id, Some(request.status_sender))
371 });
372 let logs = capture_diagnostic_call(|| {
373 self.fetch_canister_logs(request.canister_id, request.log_sender)
374 })
375 .map(|records| render_log_records(records, request.log_limits));
376
377 CanisterDiagnosticsReport {
378 request,
379 status,
380 logs,
381 }
382 }
383}
384
385fn capture_diagnostic_call<T>(
386 call: impl FnOnce() -> Result<T, RejectResponse>,
387) -> Result<T, CanisterDiagnosticFailure> {
388 match catch_unwind(AssertUnwindSafe(call)) {
389 Ok(Ok(value)) => Ok(value),
390 Ok(Err(response)) => Err(CanisterDiagnosticFailure::Rejected(response)),
391 Err(payload) => {
392 let message = transport::panic_payload_to_string(payload.as_ref());
393 if transport::is_dead_instance_transport_error(&message) {
394 Err(CanisterDiagnosticFailure::InstanceUnavailable { message })
395 } else {
396 Err(CanisterDiagnosticFailure::Panicked { message })
397 }
398 }
399 }
400}
401
402fn render_log_records(
403 records: Vec<CanisterLogRecord>,
404 limits: CanisterLogRenderLimits,
405) -> CanisterDiagnosticLogs {
406 let total_records = records.len();
407 let total_content_bytes = records.iter().fold(0usize, |total, record| {
408 total.saturating_add(record.content.len())
409 });
410 let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
411 let mut retained_bytes = 0usize;
412 let mut omitted_records = 0usize;
413 let mut omitted_content_bytes = 0usize;
414
415 for record in records {
416 if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
417 omitted_records = omitted_records.saturating_add(1);
418 omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
419 continue;
420 }
421
422 let available = limits.byte_limit.saturating_sub(retained_bytes);
423 let retained = record.content.len().min(available);
424 let omitted = record.content.len().saturating_sub(retained);
425 let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
426 retained_bytes = retained_bytes.saturating_add(retained);
427 omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
428 rendered.push(CanisterDiagnosticLogRecord {
429 index: record.idx,
430 timestamp_nanos: record.timestamp_nanos,
431 content,
432 original_content_bytes: record.content.len(),
433 omitted_content_bytes: omitted,
434 });
435 }
436
437 CanisterDiagnosticLogs {
438 records: rendered,
439 total_records,
440 total_content_bytes,
441 omitted_records,
442 omitted_content_bytes,
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use pocket_ic::CanisterLogRecord;
449
450 use super::{CanisterLogRenderLimits, render_log_records};
451
452 #[test]
453 fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
454 let logs = render_log_records(
455 vec![
456 CanisterLogRecord {
457 idx: 7,
458 timestamp_nanos: 11,
459 content: vec![b'f', 0x80, b'o'],
460 },
461 CanisterLogRecord {
462 idx: 8,
463 timestamp_nanos: 12,
464 content: b"bar".to_vec(),
465 },
466 ],
467 CanisterLogRenderLimits::new(1, 2),
468 );
469
470 assert_eq!(logs.total_records(), 2);
471 assert_eq!(logs.total_content_bytes(), 6);
472 assert_eq!(logs.omitted_records(), 1);
473 assert_eq!(logs.omitted_content_bytes(), 4);
474 assert!(logs.was_truncated());
475 assert_eq!(logs.records().len(), 1);
476 assert_eq!(logs.records()[0].content(), "f�");
477 assert_eq!(logs.records()[0].original_content_bytes(), 3);
478 assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
479 assert!(logs.records()[0].was_truncated());
480 let rendered = logs.to_string();
481 assert!(rendered.contains("f�"));
482 assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
483 }
484
485 #[test]
486 fn zero_log_bounds_retain_only_aggregate_truncation() {
487 let logs = render_log_records(
488 vec![CanisterLogRecord {
489 idx: 1,
490 timestamp_nanos: 2,
491 content: b"hello".to_vec(),
492 }],
493 CanisterLogRenderLimits::new(0, 0),
494 );
495
496 assert!(logs.records().is_empty());
497 assert_eq!(logs.omitted_records(), 1);
498 assert_eq!(logs.omitted_content_bytes(), 5);
499 assert!(logs.was_truncated());
500 assert_eq!(
501 logs.to_string(),
502 "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
503 );
504 }
505}