use std::{
fmt,
panic::{AssertUnwindSafe, catch_unwind},
};
use candid::Principal;
use pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc, RejectResponse};
use super::transport;
pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CanisterLogRenderLimits {
record_limit: usize,
byte_limit: usize,
}
impl CanisterLogRenderLimits {
#[must_use]
pub const fn new(record_limit: usize, byte_limit: usize) -> Self {
Self {
record_limit,
byte_limit,
}
}
#[must_use]
pub const fn record_limit(self) -> usize {
self.record_limit
}
#[must_use]
pub const fn byte_limit(self) -> usize {
self.byte_limit
}
}
impl Default for CanisterLogRenderLimits {
fn default() -> Self {
Self::new(
DEFAULT_CANISTER_LOG_RECORD_LIMIT,
DEFAULT_CANISTER_LOG_BYTE_LIMIT,
)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CanisterDiagnosticsRequest {
canister_id: Principal,
status_sender: Principal,
log_sender: Principal,
log_limits: CanisterLogRenderLimits,
}
impl CanisterDiagnosticsRequest {
#[must_use]
pub fn new(canister_id: Principal, status_sender: Principal, log_sender: Principal) -> Self {
Self {
canister_id,
status_sender,
log_sender,
log_limits: CanisterLogRenderLimits::default(),
}
}
#[must_use]
pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
self.log_limits = limits;
self
}
#[must_use]
pub const fn canister_id(self) -> Principal {
self.canister_id
}
#[must_use]
pub const fn status_sender(self) -> Principal {
self.status_sender
}
#[must_use]
pub const fn log_sender(self) -> Principal {
self.log_sender
}
#[must_use]
pub const fn log_limits(self) -> CanisterLogRenderLimits {
self.log_limits
}
}
#[non_exhaustive]
#[derive(Debug)]
pub enum CanisterDiagnosticFailure {
Rejected(RejectResponse),
InstanceUnavailable {
message: String,
},
Panicked {
message: String,
},
}
impl fmt::Display for CanisterDiagnosticFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
Self::InstanceUnavailable { message } => {
write!(formatter, "PocketIC instance unavailable: {message}")
}
Self::Panicked { message } => write!(formatter, "panicked: {message}"),
}
}
}
impl std::error::Error for CanisterDiagnosticFailure {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CanisterDiagnosticLogRecord {
index: u64,
timestamp_nanos: u64,
content: String,
original_content_bytes: usize,
omitted_content_bytes: usize,
}
impl CanisterDiagnosticLogRecord {
#[must_use]
pub const fn index(&self) -> u64 {
self.index
}
#[must_use]
pub const fn timestamp_nanos(&self) -> u64 {
self.timestamp_nanos
}
#[must_use]
pub fn content(&self) -> &str {
&self.content
}
#[must_use]
pub const fn original_content_bytes(&self) -> usize {
self.original_content_bytes
}
#[must_use]
pub const fn omitted_content_bytes(&self) -> usize {
self.omitted_content_bytes
}
#[must_use]
pub const fn was_truncated(&self) -> bool {
self.omitted_content_bytes != 0
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CanisterDiagnosticLogs {
records: Vec<CanisterDiagnosticLogRecord>,
total_records: usize,
total_content_bytes: usize,
omitted_records: usize,
omitted_content_bytes: usize,
}
impl CanisterDiagnosticLogs {
#[must_use]
pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
&self.records
}
#[must_use]
pub const fn total_records(&self) -> usize {
self.total_records
}
#[must_use]
pub const fn total_content_bytes(&self) -> usize {
self.total_content_bytes
}
#[must_use]
pub const fn omitted_records(&self) -> usize {
self.omitted_records
}
#[must_use]
pub const fn omitted_content_bytes(&self) -> usize {
self.omitted_content_bytes
}
#[must_use]
pub const fn was_truncated(&self) -> bool {
self.omitted_records != 0 || self.omitted_content_bytes != 0
}
}
impl fmt::Display for CanisterDiagnosticLogs {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.records.is_empty() {
if self.total_records == 0 {
formatter.write_str("<empty>")?;
} else {
formatter.write_str("<no retained records>")?;
}
} else {
for (position, record) in self.records.iter().enumerate() {
if position != 0 {
formatter.write_str(", ")?;
}
write!(
formatter,
"[{}@{}]={:?}",
record.index, record.timestamp_nanos, record.content
)?;
if record.was_truncated() {
write!(
formatter,
" (truncated {} bytes)",
record.omitted_content_bytes
)?;
}
}
}
if self.was_truncated() {
write!(
formatter,
"; truncated omitted_records={} omitted_content_bytes={}",
self.omitted_records, self.omitted_content_bytes
)?;
}
Ok(())
}
}
#[derive(Debug)]
pub struct CanisterDiagnosticsReport {
request: CanisterDiagnosticsRequest,
status: Result<CanisterStatusResult, CanisterDiagnosticFailure>,
logs: Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
}
impl CanisterDiagnosticsReport {
#[must_use]
pub const fn request(&self) -> CanisterDiagnosticsRequest {
self.request
}
pub const fn status(&self) -> Result<&CanisterStatusResult, &CanisterDiagnosticFailure> {
self.status.as_ref()
}
pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &CanisterDiagnosticFailure> {
self.logs.as_ref()
}
pub fn into_parts(
self,
) -> (
CanisterDiagnosticsRequest,
Result<CanisterStatusResult, CanisterDiagnosticFailure>,
Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
) {
(self.request, self.status, self.logs)
}
#[must_use]
pub fn render_compact(&self) -> String {
self.to_string()
}
}
impl fmt::Display for CanisterDiagnosticsReport {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"canister={} status_sender={} status=",
self.request.canister_id, self.request.status_sender
)?;
match &self.status {
Ok(status) => write!(
formatter,
"ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
status.status,
status.version,
status.settings.controllers.len(),
status.module_hash.as_ref().map_or(0, Vec::len),
status.memory_size,
status.cycles,
),
Err(failure) => write!(formatter, "<{failure}>"),
}?;
write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
match &self.logs {
Err(failure) => write!(formatter, "<{failure}>")?,
Ok(logs) => write!(formatter, "{logs}")?,
}
Ok(())
}
}
pub trait PocketIcDiagnosticsExt {
fn collect_canister_diagnostics(
&self,
request: CanisterDiagnosticsRequest,
) -> CanisterDiagnosticsReport;
}
impl PocketIcDiagnosticsExt for PocketIc {
fn collect_canister_diagnostics(
&self,
request: CanisterDiagnosticsRequest,
) -> CanisterDiagnosticsReport {
let status = capture_diagnostic_call(|| {
self.canister_status(request.canister_id, Some(request.status_sender))
});
let logs = capture_diagnostic_call(|| {
self.fetch_canister_logs(request.canister_id, request.log_sender)
})
.map(|records| render_log_records(records, request.log_limits));
CanisterDiagnosticsReport {
request,
status,
logs,
}
}
}
fn capture_diagnostic_call<T>(
call: impl FnOnce() -> Result<T, RejectResponse>,
) -> Result<T, CanisterDiagnosticFailure> {
match catch_unwind(AssertUnwindSafe(call)) {
Ok(Ok(value)) => Ok(value),
Ok(Err(response)) => Err(CanisterDiagnosticFailure::Rejected(response)),
Err(payload) => {
let message = transport::panic_payload_to_string(payload.as_ref());
if transport::is_dead_instance_transport_error(&message) {
Err(CanisterDiagnosticFailure::InstanceUnavailable { message })
} else {
Err(CanisterDiagnosticFailure::Panicked { message })
}
}
}
}
fn render_log_records(
records: Vec<CanisterLogRecord>,
limits: CanisterLogRenderLimits,
) -> CanisterDiagnosticLogs {
let total_records = records.len();
let total_content_bytes = records.iter().fold(0usize, |total, record| {
total.saturating_add(record.content.len())
});
let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
let mut retained_bytes = 0usize;
let mut omitted_records = 0usize;
let mut omitted_content_bytes = 0usize;
for record in records {
if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
omitted_records = omitted_records.saturating_add(1);
omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
continue;
}
let available = limits.byte_limit.saturating_sub(retained_bytes);
let retained = record.content.len().min(available);
let omitted = record.content.len().saturating_sub(retained);
let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
retained_bytes = retained_bytes.saturating_add(retained);
omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
rendered.push(CanisterDiagnosticLogRecord {
index: record.idx,
timestamp_nanos: record.timestamp_nanos,
content,
original_content_bytes: record.content.len(),
omitted_content_bytes: omitted,
});
}
CanisterDiagnosticLogs {
records: rendered,
total_records,
total_content_bytes,
omitted_records,
omitted_content_bytes,
}
}
#[cfg(test)]
mod tests {
use pocket_ic::CanisterLogRecord;
use super::{CanisterLogRenderLimits, render_log_records};
#[test]
fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
let logs = render_log_records(
vec![
CanisterLogRecord {
idx: 7,
timestamp_nanos: 11,
content: vec![b'f', 0x80, b'o'],
},
CanisterLogRecord {
idx: 8,
timestamp_nanos: 12,
content: b"bar".to_vec(),
},
],
CanisterLogRenderLimits::new(1, 2),
);
assert_eq!(logs.total_records(), 2);
assert_eq!(logs.total_content_bytes(), 6);
assert_eq!(logs.omitted_records(), 1);
assert_eq!(logs.omitted_content_bytes(), 4);
assert!(logs.was_truncated());
assert_eq!(logs.records().len(), 1);
assert_eq!(logs.records()[0].content(), "f�");
assert_eq!(logs.records()[0].original_content_bytes(), 3);
assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
assert!(logs.records()[0].was_truncated());
let rendered = logs.to_string();
assert!(rendered.contains("f�"));
assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
}
#[test]
fn zero_log_bounds_retain_only_aggregate_truncation() {
let logs = render_log_records(
vec![CanisterLogRecord {
idx: 1,
timestamp_nanos: 2,
content: b"hello".to_vec(),
}],
CanisterLogRenderLimits::new(0, 0),
);
assert!(logs.records().is_empty());
assert_eq!(logs.omitted_records(), 1);
assert_eq!(logs.omitted_content_bytes(), 5);
assert!(logs.was_truncated());
assert_eq!(
logs.to_string(),
"<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
);
}
}