canic-cli 0.79.10

Operator CLI for Canic fleet setup, builds, evidence, catalog, backup, and restore workflows
Documentation
use crate::{
    metrics::{
        MetricsCommandError,
        model::{MetricEntry, MetricValue, MetricsKind, MetricsReport},
        options::MetricsOptions,
    },
    output,
};
use canic_host::table::{ColumnAlign, render_table};

const DEFAULT_LABEL_MAX_CHARS: usize = 56;

pub(super) fn write_metrics_report(
    options: &MetricsOptions,
    report: &MetricsReport,
) -> Result<(), MetricsCommandError> {
    if options.json {
        return output::write_pretty_json::<_, MetricsCommandError>(options.out.as_deref(), report);
    }

    output::write_text::<MetricsCommandError>(
        options.out.as_deref(),
        &render_metrics_report(report, options.verbose),
    )
}

fn render_metrics_report(report: &MetricsReport, verbose: bool) -> String {
    [
        format!(
            "Deployment: {} (network {}, metrics {})",
            report.deployment,
            report.network,
            metrics_kind_label(report.kind)
        ),
        String::new(),
        if verbose {
            render_verbose_metrics_table(report)
        } else {
            render_default_metrics_table(report)
        },
    ]
    .join("\n")
}

fn render_default_metrics_table(report: &MetricsReport) -> String {
    let mut rows = Vec::new();
    for canister in &report.canisters {
        if canister.entries.is_empty() {
            rows.push([
                canister.role.clone(),
                canister.status.clone(),
                "-".to_string(),
                canister.error.clone().unwrap_or_else(|| "-".to_string()),
                "-".to_string(),
                "-".to_string(),
                "-".to_string(),
            ]);
            continue;
        }

        for entry in &canister.entries {
            let value = metric_value_columns(&entry.value);
            rows.push([
                canister.role.clone(),
                canister.status.clone(),
                metric_family_label(entry),
                metric_detail_label(entry, Some(DEFAULT_LABEL_MAX_CHARS)),
                value.count,
                value.average_per_count,
                value.amount,
            ]);
        }
    }

    render_table(
        &[
            "ROLE", "STATUS", "METRIC", "LABELS", "COUNT", "AVG/CALL", "AMOUNT",
        ],
        &rows,
        &[
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Right,
            ColumnAlign::Right,
            ColumnAlign::Right,
        ],
    )
}

fn render_verbose_metrics_table(report: &MetricsReport) -> String {
    let mut rows = Vec::new();
    for canister in &report.canisters {
        if canister.entries.is_empty() {
            rows.push([
                canister.role.clone(),
                canister.canister_id.clone(),
                metrics_kind_label(report.kind).to_string(),
                canister.status.clone(),
                "-".to_string(),
                canister.error.clone().unwrap_or_else(|| "-".to_string()),
                "-".to_string(),
                "-".to_string(),
                "-".to_string(),
                "-".to_string(),
                "-".to_string(),
            ]);
            continue;
        }

        for entry in &canister.entries {
            let value = metric_value_columns(&entry.value);
            rows.push([
                canister.role.clone(),
                canister.canister_id.clone(),
                metrics_kind_label(report.kind).to_string(),
                canister.status.clone(),
                metric_family_label(entry),
                metric_detail_label(entry, None),
                entry.principal.clone().unwrap_or_else(|| "-".to_string()),
                value.count,
                value.average_per_count,
                value.total,
                value.amount,
            ]);
        }
    }

    render_table(
        &[
            "ROLE",
            "CANISTER_ID",
            "KIND",
            "STATUS",
            "METRIC",
            "LABELS",
            "PRINCIPAL",
            "COUNT",
            "AVG/CALL",
            "TOTAL",
            "AMOUNT",
        ],
        &rows,
        &[
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Right,
            ColumnAlign::Right,
            ColumnAlign::Right,
            ColumnAlign::Right,
        ],
    )
}

struct MetricValueColumns {
    count: String,
    average_per_count: String,
    total: String,
    amount: String,
}

fn metric_value_columns(value: &MetricValue) -> MetricValueColumns {
    match value {
        MetricValue::Count { count } => MetricValueColumns {
            count: count.to_string(),
            average_per_count: "-".to_string(),
            total: "-".to_string(),
            amount: "-".to_string(),
        },
        MetricValue::CountAndU64 { count, value_u64 } => MetricValueColumns {
            count: count.to_string(),
            average_per_count: average_per_count(*value_u64, *count),
            total: value_u64.to_string(),
            amount: "-".to_string(),
        },
        MetricValue::U128 { value } => MetricValueColumns {
            count: "-".to_string(),
            average_per_count: "-".to_string(),
            total: "-".to_string(),
            amount: value.to_string(),
        },
    }
}

fn metric_family_label(entry: &MetricEntry) -> String {
    entry
        .labels
        .first()
        .cloned()
        .unwrap_or_else(|| "-".to_string())
}

fn metric_detail_label(entry: &MetricEntry, max_chars: Option<usize>) -> String {
    let label = if entry.labels.len() > 1 {
        entry.labels[1..].join("/")
    } else {
        "-".to_string()
    };
    match max_chars {
        Some(max_chars) => compact_cell(&label, max_chars),
        None => label,
    }
}

fn compact_cell(value: &str, max_chars: usize) -> String {
    if value.chars().count() <= max_chars {
        return value.to_string();
    }

    let prefix_chars = max_chars.saturating_sub(3);
    format!(
        "{}...",
        value.chars().take(prefix_chars).collect::<String>()
    )
}

fn average_per_count(total: u64, count: u64) -> String {
    if count == 0 {
        return "-".to_string();
    }

    let whole = total / count;
    let remainder = total % count;
    if remainder == 0 {
        return whole.to_string();
    }

    let count = u128::from(count);
    let tenths = ((u128::from(remainder) * 10) + (count / 2)) / count;
    if tenths == 10 {
        (whole + 1).to_string()
    } else {
        format!("{whole}.{tenths}")
    }
}

const fn metrics_kind_label(kind: MetricsKind) -> &'static str {
    match kind {
        MetricsKind::Core => "core",
        MetricsKind::Placement => "placement",
        MetricsKind::Platform => "platform",
        MetricsKind::Runtime => "runtime",
        MetricsKind::Security => "security",
        MetricsKind::Storage => "storage",
    }
}

// -----------------------------------------------------------------------------
// Tests

#[cfg(test)]
mod tests {
    use super::*;
    use crate::metrics::model::MetricsCanisterReport;

    const CANISTER_ID: &str = "renrk-eyaaa-aaaaa-aaada-cai";
    const PRINCIPAL: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
    const LONG_LABEL: &str =
        "endpoint/update/canic_prepare_delegated_token_with_a_very_long_runtime_probe_label";

    fn report() -> MetricsReport {
        MetricsReport {
            deployment: "demo-local".to_string(),
            network: "local".to_string(),
            kind: MetricsKind::Runtime,
            canisters: vec![MetricsCanisterReport {
                role: "app".to_string(),
                canister_id: CANISTER_ID.to_string(),
                status: "ok".to_string(),
                entries: vec![
                    MetricEntry {
                        labels: ["perf", LONG_LABEL]
                            .into_iter()
                            .map(str::to_string)
                            .collect(),
                        principal: Some(PRINCIPAL.to_string()),
                        value: MetricValue::CountAndU64 {
                            count: 3,
                            value_u64: 15,
                        },
                    },
                    MetricEntry {
                        labels: ["intent", "reserve", "ok"]
                            .into_iter()
                            .map(str::to_string)
                            .collect(),
                        principal: None,
                        value: MetricValue::Count { count: 2 },
                    },
                    MetricEntry {
                        labels: ["cycles_funding", "attached"]
                            .into_iter()
                            .map(str::to_string)
                            .collect(),
                        principal: None,
                        value: MetricValue::U128 { value: 1_000 },
                    },
                ],
                error: None,
            }],
        }
    }

    #[test]
    fn default_metrics_table_splits_count_and_average_without_verbose_columns() {
        let output = render_metrics_report(&report(), false);

        assert!(output.contains("COUNT"));
        assert!(output.contains("AVG/CALL"));
        assert!(output.contains("AMOUNT"));
        assert!(output.contains("perf"));
        assert!(output.contains('3'));
        assert!(output.contains('5'));
        assert!(output.contains("..."));
        assert!(!output.contains("CANISTER_ID"));
        assert!(!output.contains("PRINCIPAL"));
        assert!(!output.contains("TOTAL"));
        assert!(!output.contains(CANISTER_ID));
        assert!(!output.contains(PRINCIPAL));
        assert!(!output.contains(LONG_LABEL));
    }

    #[test]
    fn verbose_metrics_table_keeps_ids_principals_and_raw_totals() {
        let output = render_metrics_report(&report(), true);

        assert!(output.contains("CANISTER_ID"));
        assert!(output.contains("PRINCIPAL"));
        assert!(output.contains("TOTAL"));
        assert!(output.contains(CANISTER_ID));
        assert!(output.contains(PRINCIPAL));
        assert!(output.contains(LONG_LABEL));
        assert!(output.contains("15"));
    }

    #[test]
    fn averages_count_and_total_with_one_decimal_place_when_needed() {
        assert_eq!(average_per_count(15, 3), "5");
        assert_eq!(average_per_count(10, 4), "2.5");
        assert_eq!(average_per_count(1, 0), "-");
    }
}