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
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LabeledCanisterDiagnosticsRequest {
label: String,
request: CanisterDiagnosticsRequest,
}
impl LabeledCanisterDiagnosticsRequest {
#[must_use]
pub fn new(label: impl Into<String>, request: CanisterDiagnosticsRequest) -> Self {
Self {
label: label.into(),
request,
}
}
#[must_use]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
pub const fn request(&self) -> CanisterDiagnosticsRequest {
self.request
}
#[must_use]
pub fn into_parts(self) -> (String, CanisterDiagnosticsRequest) {
(self.label, self.request)
}
}
#[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()
}
#[must_use]
pub const fn is_success(&self) -> bool {
self.status.is_ok() && self.logs.is_ok()
}
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()
}
}
#[derive(Debug)]
pub struct CanisterDiagnosticsBatchEntry {
label: String,
report: CanisterDiagnosticsReport,
}
impl CanisterDiagnosticsBatchEntry {
#[must_use]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
pub const fn report(&self) -> &CanisterDiagnosticsReport {
&self.report
}
#[must_use]
pub const fn is_success(&self) -> bool {
self.report.is_success()
}
#[must_use]
pub fn into_parts(self) -> (String, CanisterDiagnosticsReport) {
(self.label, self.report)
}
}
#[derive(Debug, Default)]
pub struct CanisterDiagnosticsBatchReport {
entries: Vec<CanisterDiagnosticsBatchEntry>,
}
impl CanisterDiagnosticsBatchReport {
#[must_use]
pub fn entries(&self) -> &[CanisterDiagnosticsBatchEntry] {
&self.entries
}
pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
self.entries.iter().filter(|entry| !entry.is_success())
}
#[must_use]
pub fn is_success(&self) -> bool {
self.entries
.iter()
.all(CanisterDiagnosticsBatchEntry::is_success)
}
#[must_use]
pub fn into_entries(self) -> Vec<CanisterDiagnosticsBatchEntry> {
self.entries
}
#[must_use]
pub fn render_compact(&self) -> String {
self.to_string()
}
}
impl fmt::Display for CanisterDiagnosticsBatchReport {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "diagnostics={}", self.entries.len())?;
for entry in &self.entries {
write!(formatter, "; label={:?} {}", entry.label, entry.report)?;
}
Ok(())
}
}
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;
fn collect_canister_diagnostics_batch(
&self,
requests: &[LabeledCanisterDiagnosticsRequest],
) -> CanisterDiagnosticsBatchReport {
let entries = requests
.iter()
.map(|labeled| {
let request = labeled.request;
let report = catch_unwind(AssertUnwindSafe(|| {
self.collect_canister_diagnostics(request)
}))
.unwrap_or_else(|payload| {
let message = transport::panic_payload_to_string(payload.as_ref());
CanisterDiagnosticsReport {
request,
status: Err(diagnostic_panic_failure(message.clone())),
logs: Err(diagnostic_panic_failure(message)),
}
});
CanisterDiagnosticsBatchEntry {
label: labeled.label.clone(),
report,
}
})
.collect();
CanisterDiagnosticsBatchReport { entries }
}
}
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());
Err(diagnostic_panic_failure(message))
}
}
}
fn diagnostic_panic_failure(message: String) -> CanisterDiagnosticFailure {
if transport::is_dead_instance_transport_error(&message) {
CanisterDiagnosticFailure::InstanceUnavailable { message }
} else {
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 std::cell::Cell;
use candid::Principal;
use pocket_ic::CanisterLogRecord;
use super::{
CanisterDiagnosticFailure, CanisterDiagnosticsReport, CanisterDiagnosticsRequest,
CanisterLogRenderLimits, LabeledCanisterDiagnosticsRequest, PocketIcDiagnosticsExt,
render_log_records,
};
struct PanickingThenReporting {
calls: Cell<usize>,
}
impl PocketIcDiagnosticsExt for PanickingThenReporting {
fn collect_canister_diagnostics(
&self,
request: CanisterDiagnosticsRequest,
) -> CanisterDiagnosticsReport {
let call = self.calls.get();
self.calls.set(call + 1);
assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
CanisterDiagnosticsReport {
request,
status: Err(CanisterDiagnosticFailure::Panicked {
message: "synthetic status failure".to_owned(),
}),
logs: Err(CanisterDiagnosticFailure::Panicked {
message: "synthetic log failure".to_owned(),
}),
}
}
}
#[test]
fn labeled_batch_retains_order_and_continues_after_entry_panic() {
let collector = PanickingThenReporting {
calls: Cell::new(0),
};
let first = CanisterDiagnosticsRequest::new(
Principal::from_slice(&[1]),
Principal::from_slice(&[2]),
Principal::from_slice(&[3]),
);
let second = CanisterDiagnosticsRequest::new(
Principal::from_slice(&[4]),
Principal::from_slice(&[5]),
Principal::from_slice(&[6]),
);
let report = collector.collect_canister_diagnostics_batch(&[
LabeledCanisterDiagnosticsRequest::new("root", first),
LabeledCanisterDiagnosticsRequest::new("worker", second),
]);
assert_eq!(collector.calls.get(), 2);
assert_eq!(report.entries().len(), 2);
assert_eq!(report.entries()[0].label(), "root");
assert_eq!(report.entries()[0].report().request(), first);
assert_eq!(report.entries()[1].label(), "worker");
assert_eq!(report.entries()[1].report().request(), second);
assert_eq!(report.failures().count(), 2);
assert!(!report.is_success());
let compact = report.render_compact();
assert!(compact.contains("label=\"root\""));
assert!(compact.contains("label=\"worker\""));
assert!(compact.contains("synthetic first-entry diagnostic panic"));
}
#[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"
);
}
}