From b6a5eec58b79362c87aa4438101eec5bf03e304a Mon Sep 17 00:00:00 2001 From: Spaully Date: Wed, 22 Jul 2026 15:11:05 +0000 Subject: [PATCH 1/2] feat(campaign): add structured tracing and diagnostics surface for observability Implement issue #31 with a feature-gated diagnostics system: - Adds diag feature flag (default off) to campaign/Cargo.toml - Adds CampaignMetrics struct and DataKey::DiagnosticMetrics variant - Adds metrics_view read-only entrypoint (always available) - Adds emit_diagnostics contract entrypoint (no-op when diag off) - Adds diagnostics_emit event function gated behind #[cfg(feature = diag)] - Tracks counters for donations, milestone releases, and refunds - Creates docs/observability.md with user-facing documentation - Adds diagnostics test coverage --- campaign/Cargo.toml | 4 ++ campaign/src/event.rs | 11 ++++ campaign/src/lib.rs | 53 +++++++++++++++-- campaign/src/multi_asset_release.rs | 7 +++ campaign/src/release_milestone.rs | 8 ++- campaign/src/storage.rs | 35 +++++++++++- campaign/src/test/diagnostics_tests.rs | 75 ++++++++++++++++++++++++ campaign/src/types.rs | 18 ++++++ docs/observability.md | 79 ++++++++++++++++++++++++++ 9 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 campaign/src/test/diagnostics_tests.rs create mode 100644 docs/observability.md diff --git a/campaign/Cargo.toml b/campaign/Cargo.toml index 55c52fc..2f76b72 100644 --- a/campaign/Cargo.toml +++ b/campaign/Cargo.toml @@ -11,6 +11,10 @@ crate-type = ["cdylib", "rlib"] soroban-sdk = { workspace = true } common = { workspace = true } +[features] +default = [] +diag = [] + [dev-dependencies] soroban-sdk = { version = "26.0.1", features = ["testutils"] } # Inherit the workspace-wide ed25519-dalek = 2.2.0 exact pin (see diff --git a/campaign/src/event.rs b/campaign/src/event.rs index b075e98..b4ce3d6 100644 --- a/campaign/src/event.rs +++ b/campaign/src/event.rs @@ -6,6 +6,8 @@ use soroban_sdk::{Address, Env, String, Symbol}; +use crate::types::CampaignMetrics; + /// Emitted when a donation is received by the campaign. pub fn donation_received( env: &Env, @@ -100,3 +102,12 @@ pub fn contract_unfrozen(env: &Env, admin: &Address, timestamp: u64) { env.events() .publish(("campaign", "contract_unfrozen"), (admin, timestamp)); } + +/// Emit current diagnostic metrics as an event. +/// Only compiled when the `diag` feature is enabled. +#[cfg(feature = "diag")] +pub fn diagnostics_emit(env: &Env, metrics: &CampaignMetrics) { + let ledger = env.ledger().sequence(); + env.events() + .publish(("campaign", "diagnostics"), (metrics, ledger)); +} diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index 098abda..9fb7ce3 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -30,14 +30,16 @@ use storage::{ increment_donor_asset_donation, is_frozen, release_lock, set_campaign, set_donor, set_frozen, set_milestone, storage_get_donation_count, storage_get_release_count, storage_get_total_raised, storage_get_unique_donor_count, storage_increment_asset_raised, - storage_increment_donation_count, storage_increment_unique_donor_count, - storage_set_total_raised, + storage_increment_donation_count, + storage_increment_unique_donor_count, storage_set_total_raised, }; +#[cfg(feature = "diag")] +use storage::storage_increment_diagnostic_counter; use types::{ - AssetInfo, CampaignData, CampaignInitializedEvent, CampaignReport, CampaignStatus, - CampaignStatusResponse, DashboardMetrics, DonorRecord, Error, MilestoneData, MilestoneStatus, - PlatformSummary, StellarAsset, + AssetInfo, CampaignData, CampaignInitializedEvent, CampaignMetrics, CampaignReport, + CampaignStatus, CampaignStatusResponse, DashboardMetrics, DonorRecord, Error, MilestoneData, + MilestoneStatus, PlatformSummary, StellarAsset, }; pub const VERSION: u32 = 1; @@ -271,6 +273,12 @@ impl CampaignContract { env.ledger().timestamp(), ); + // Track diagnostic counter (no-op when `diag` feature is disabled) + #[cfg(feature = "diag")] + storage_increment_diagnostic_counter(&env, |m: &mut CampaignMetrics| { + m.donations_total += 1; + }); + // Issue #242 – Release reentrancy lock release_lock(&env); } @@ -327,6 +335,34 @@ impl CampaignContract { } } + /// Emit current diagnostics as a `diagnostics` event. + /// + /// When the `diag` feature is disabled the event is not published + /// (the function body is empty). No auth required (view-like call). + pub fn emit_diagnostics(env: Env) { + #[cfg(feature = "diag")] + { + let metrics = crate::storage::storage_get_diagnostic_metrics(&env); + event::diagnostics_emit(&env, &metrics); + let mut metrics = metrics; + metrics.last_diagnostics_ledger = env.ledger().sequence(); + crate::storage::storage_set_diagnostic_metrics(&env, &metrics); + } + } + + /// Returns diagnostic counters for the campaign contract. + /// + /// When the `diag` feature is disabled (default), returns all zeros. + /// When `diag` is enabled, returns live counters tracked in storage. + /// No auth required (read-only view). + pub fn metrics_view(env: Env) -> CampaignMetrics { + #[cfg(feature = "diag")] + { + return crate::storage::storage_get_diagnostic_metrics(&env); + } + CampaignMetrics::default() + } + /// Returns compact metrics for campaign dashboards. pub fn get_dashboard_metrics(env: Env) -> DashboardMetrics { let summary = Self::get_platform_summary(env); @@ -483,6 +519,12 @@ impl CampaignContract { (&donor, donor_record.total_donated), ); + // Track diagnostic counter (no-op when `diag` feature is disabled) + #[cfg(feature = "diag")] + storage_increment_diagnostic_counter(&env, |m: &mut CampaignMetrics| { + m.refunds_total += 1; + }); + // Issue #242 – Release reentrancy lock release_lock(&env); } @@ -841,6 +883,7 @@ pub fn validate_milestone_transition( mod test { pub mod budget_invariant_tests; pub mod claim_refund_tests; + pub mod diagnostics_tests; pub mod get_campaign_status_tests; pub mod integration_tests; pub mod invariant_tests; diff --git a/campaign/src/multi_asset_release.rs b/campaign/src/multi_asset_release.rs index ada1805..02949c6 100644 --- a/campaign/src/multi_asset_release.rs +++ b/campaign/src/multi_asset_release.rs @@ -4,6 +4,9 @@ use crate::storage::{ storage_get_asset_raised, storage_get_total_raised, storage_increment_release_count, storage_set_asset_raised, storage_set_total_raised, }; +#[cfg(feature = "diag")] +use crate::storage::storage_increment_diagnostic_counter; +use crate::types::CampaignMetrics; use crate::types::{Error, MilestoneStatus}; use soroban_sdk::{panic_with_error, symbol_short, token, Address, Env}; @@ -197,6 +200,10 @@ pub fn release_milestone_multi_asset(env: &Env, milestone_index: u32, recipient: let new_total_raised = total_raised.checked_sub(total_released).unwrap_or(0).max(0); storage_set_total_raised(env, new_total_raised); storage_increment_release_count(env); + #[cfg(feature = "diag")] + storage_increment_diagnostic_counter(env, |m: &mut CampaignMetrics| { + m.milestones_released_total += 1; + }); // Issue #242 – Release reentrancy lock release_lock(env); diff --git a/campaign/src/release_milestone.rs b/campaign/src/release_milestone.rs index 47ca2b9..5ff48d0 100644 --- a/campaign/src/release_milestone.rs +++ b/campaign/src/release_milestone.rs @@ -3,7 +3,9 @@ use crate::storage::{ acquire_lock, get_campaign, get_milestone, is_frozen, release_lock, set_milestone, storage_increment_release_count, }; -use crate::types::{Error, MilestoneStatus}; +#[cfg(feature = "diag")] +use crate::storage::storage_increment_diagnostic_counter; +use crate::types::{CampaignMetrics, Error, MilestoneStatus}; use soroban_sdk::{panic_with_error, token, Address, Env}; /// Issue #207 – `release_milestone` function @@ -125,6 +127,10 @@ pub fn release_milestone(env: &Env, milestone_index: u32, recipient: Address) { milestone.released_to = Some(recipient); set_milestone(env, milestone_index, &milestone); storage_increment_release_count(env); + #[cfg(feature = "diag")] + storage_increment_diagnostic_counter(env, |m: &mut CampaignMetrics| { + m.milestones_released_total += 1; + }); // Issue #242 – Release reentrancy lock release_lock(env); diff --git a/campaign/src/storage.rs b/campaign/src/storage.rs index 5553f4f..aa44316 100644 --- a/campaign/src/storage.rs +++ b/campaign/src/storage.rs @@ -1,6 +1,6 @@ // src/storage.rs -use crate::types::{CampaignData, DataKey, DonorRecord, Error, MilestoneData}; +use crate::types::{CampaignData, CampaignMetrics, DataKey, DonorRecord, Error, MilestoneData}; use soroban_sdk::{panic_with_error, Address, Env}; // ─── TTL Constants ──────────────────────────────────────────────────────────── @@ -371,6 +371,39 @@ pub fn set_frozen(env: &Env, frozen: bool) { bump_persistent(env, &key); } +// ─── Diagnostic metrics (feature-gated) ────────────────────────────────────── + +/// Load the diagnostic metrics counter record. +/// Returns default (all zeros) if never written. +#[cfg(feature = "diag")] +pub fn storage_get_diagnostic_metrics(env: &Env) -> CampaignMetrics { + let value: CampaignMetrics = env + .storage() + .persistent() + .get(&DataKey::DiagnosticMetrics) + .unwrap_or_default(); + bump_persistent(env, &DataKey::DiagnosticMetrics); + value +} + +/// Persist the diagnostic metrics. +#[cfg(feature = "diag")] +pub fn storage_set_diagnostic_metrics(env: &Env, metrics: &CampaignMetrics) { + env.storage() + .persistent() + .set(&DataKey::DiagnosticMetrics, metrics); + bump_persistent(env, &DataKey::DiagnosticMetrics); +} + +/// Increment a diagnostic counter in storage. +/// No-op when the `diag` feature is disabled (the function body is not compiled). +#[cfg(feature = "diag")] +pub fn storage_increment_diagnostic_counter(env: &Env, field: fn(&mut CampaignMetrics)) { + let mut metrics = storage_get_diagnostic_metrics(env); + field(&mut metrics); + storage_set_diagnostic_metrics(env, &metrics); +} + // ─── Bulk TTL refresh ───────────────────────────────────────────────────────── /// Refresh TTL for all core persistent keys in a single call. diff --git a/campaign/src/test/diagnostics_tests.rs b/campaign/src/test/diagnostics_tests.rs new file mode 100644 index 0000000..b61fa83 --- /dev/null +++ b/campaign/src/test/diagnostics_tests.rs @@ -0,0 +1,75 @@ +#![cfg(test)] + +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Env, String}; + +use super::with_contract; +use crate::types::{CampaignMetrics, StellarAsset}; +use crate::CampaignContract; + +fn setup_basic_env(env: &Env) { + env.mock_all_auths(); + with_contract(env, || { + let creator = soroban_sdk::Address::generate(env); + let mut assets: soroban_sdk::Vec = soroban_sdk::Vec::new(env); + assets.push_back(StellarAsset { + asset_code: String::from_str(env, "XLM"), + issuer: Some(soroban_sdk::Address::generate(env)), + }); + let mut milestones: soroban_sdk::Vec = + soroban_sdk::Vec::new(env); + milestones.push_back(crate::types::MilestoneData { + index: 0, + target_amount: 1000, + released_amount: 0, + description_hash: soroban_sdk::BytesN::from_array(env, &[1u8; 32]), + status: crate::types::MilestoneStatus::Locked, + released_at: None, + released_at_ledger: None, + release_tx: None, + released_to: None, + }); + + CampaignContract::initialize( + env.clone(), + creator, + 1000, + env.ledger().timestamp() + 86400, + assets, + milestones, + 0, + ) + .unwrap(); + }); +} + +#[test] +fn test_metrics_view_returns_defaults_before_any_ops() { + let env = Env::default(); + setup_basic_env(&env); + with_contract(&env, || { + let metrics = CampaignContract::metrics_view(env.clone()); + assert_eq!(metrics.donations_total, 0); + assert_eq!(metrics.milestones_released_total, 0); + assert_eq!(metrics.refunds_total, 0); + }); +} + +#[test] +fn test_emit_diagnostics_does_not_panic() { + let env = Env::default(); + setup_basic_env(&env); + with_contract(&env, || { + CampaignContract::emit_diagnostics(env.clone()); + }); +} + +#[test] +fn test_metrics_view_returns_struct() { + let env = Env::default(); + let metrics = CampaignMetrics::default(); + assert_eq!(metrics.donations_total, 0); + assert_eq!(metrics.milestones_released_total, 0); + assert_eq!(metrics.refunds_total, 0); + assert_eq!(metrics.last_diagnostics_ledger, 0); +} diff --git a/campaign/src/types.rs b/campaign/src/types.rs index fd3c2bc..3d592a9 100644 --- a/campaign/src/types.rs +++ b/campaign/src/types.rs @@ -182,6 +182,22 @@ pub const WIRE_CODE_TABLE: &[(Error, u32)] = &[ (Error::InvalidPage, 84), ]; +/// Diagnostic counters for the campaign contract. +/// +/// Only populated when the `diag` feature is enabled. The `metrics_view` +/// entrypoint always exists but returns all zeros when the feature is off. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct CampaignMetrics { + /// Total number of successful donation calls. + pub donations_total: u64, + /// Total number of completed milestone releases. + pub milestones_released_total: u64, + /// Total number of successfully processed refunds. + pub refunds_total: u64, + /// Ledger sequence when diagnostics were last emitted. + pub last_diagnostics_ledger: u32, +} #[cfg(test)] mod error_code_tests { #[test] @@ -375,6 +391,8 @@ pub enum DataKey { ReentrancyLock, /// Freeze flag; present and true = contract is frozen, mutating ops blocked. Frozen, + /// Diagnostic counters (only written when feature `diag` is enabled). + DiagnosticMetrics, } // ─── Asset types ────────────────────────────────────────────────────────────── diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..47ab36a --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,79 @@ +# Observability — Diagnostic Metrics & Events + +The campaign contract supports an optional `diag` feature that enables structured +tracing and runtime counters for observability. When the feature is disabled +(default), all diagnostic code is compiled away — zero storage overhead, zero +event emission. + +## Feature Flag + +| Flag | Default | Description | +|--------|---------|------------------------------------------------------| +| `diag` | off | Enables diagnostic counters and `diagnostics` events | + +Enable at build time: + +```bash +cargo build -p milestonex-campaign --features diag --target wasm32v1-none --release +``` + +## Metrics View + +`metrics_view` — always available; returns all-zero counters when `diag` is off. + +### `CampaignMetrics` + +| Counter | Type | Description | +|---------------------------|---------|-------------------------------------------| +| `donations_total` | `u64` | Successful donation calls | +| `milestones_released_total` | `u64` | Completed milestone releases | +| `refunds_total` | `u64` | Successfully processed refunds | +| `last_diagnostics_ledger` | `u32` | Ledger sequence of last `emit_diagnostics` | + +## Diagnostics Event + +`emit_diagnostics` publishes a `("campaign", "diagnostics")` event containing +the current `CampaignMetrics` struct and the ledger sequence. The event is only +emitted when the `diag` feature is enabled; when disabled the entrypoint is a +no-op. + +### Event payload (feature `diag` on) + +```json +{ + "topic": ["campaign", "diagnostics"], + "data": { + "metrics": { + "donations_total": 42, + "milestones_released_total": 3, + "refunds_total": 1, + "last_diagnostics_ledger": 20100 + }, + "ledger": 20100 + } +} +``` + +## Usage + +```rust +// Read counters (always available) +let metrics = contract_client.metrics_view(); + +// Emit a diagnostics event (only emits when built with --features diag) +contract_client.emit_diagnostics(); +``` + +## Testing + +Run diagnostics tests with the default (diag off) configuration: + +```bash +cargo test -p milestonex-campaign -- diagnostics +``` + +Run diagnostics tests with the feature enabled: + +```bash +cargo test -p milestonex-campaign --features diag -- diagnostics +``` From 5aaa960f24cc726a7b38d72f1a50e8c17b13c9ae Mon Sep 17 00:00:00 2001 From: Precious Paul Date: Wed, 22 Jul 2026 16:11:05 +0000 Subject: [PATCH 2/2] fix(diag): gate CampaignMetrics imports and suppress unused-env warnings All CampaignMetrics imports in files that only use it within to prevent unused-import errors when the feature is off (default): - campaign/src/event.rs: gate CampaignMetrics import - campaign/src/multi_asset_release.rs: gate CampaignMetrics import - campaign/src/release_milestone.rs: gate CampaignMetrics import - campaign/src/storage.rs: gate CampaignMetrics import The env parameter in emit_diagnostics and metrics_view is only consumed inside #[cfg(feature = "diag")] branches; add a #[cfg(not(feature = "diag"))] let _ = env binding to silence the unused-variable warning under the default (diag-off) compilation. --- campaign/src/event.rs | 1 + campaign/src/lib.rs | 12 ++++++++---- campaign/src/multi_asset_release.rs | 3 ++- campaign/src/release_milestone.rs | 6 ++++-- campaign/src/storage.rs | 4 +++- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/campaign/src/event.rs b/campaign/src/event.rs index b4ce3d6..d8c952e 100644 --- a/campaign/src/event.rs +++ b/campaign/src/event.rs @@ -6,6 +6,7 @@ use soroban_sdk::{Address, Env, String, Symbol}; +#[cfg(feature = "diag")] use crate::types::CampaignMetrics; /// Emitted when a donation is received by the campaign. diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index 9fb7ce3..afc92fd 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -25,16 +25,16 @@ pub mod types; pub mod views; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec}; +#[cfg(feature = "diag")] +use storage::storage_increment_diagnostic_counter; use storage::{ acquire_lock, get_campaign, get_donor, get_donor_asset_donation, get_milestone, increment_donor_asset_donation, is_frozen, release_lock, set_campaign, set_donor, set_frozen, set_milestone, storage_get_donation_count, storage_get_release_count, storage_get_total_raised, storage_get_unique_donor_count, storage_increment_asset_raised, - storage_increment_donation_count, - storage_increment_unique_donor_count, storage_set_total_raised, + storage_increment_donation_count, storage_increment_unique_donor_count, + storage_set_total_raised, }; -#[cfg(feature = "diag")] -use storage::storage_increment_diagnostic_counter; use types::{ AssetInfo, CampaignData, CampaignInitializedEvent, CampaignMetrics, CampaignReport, @@ -348,6 +348,8 @@ impl CampaignContract { metrics.last_diagnostics_ledger = env.ledger().sequence(); crate::storage::storage_set_diagnostic_metrics(&env, &metrics); } + #[cfg(not(feature = "diag"))] + let _ = env; } /// Returns diagnostic counters for the campaign contract. @@ -360,6 +362,8 @@ impl CampaignContract { { return crate::storage::storage_get_diagnostic_metrics(&env); } + #[cfg(not(feature = "diag"))] + let _ = env; CampaignMetrics::default() } diff --git a/campaign/src/multi_asset_release.rs b/campaign/src/multi_asset_release.rs index 02949c6..d53f3a8 100644 --- a/campaign/src/multi_asset_release.rs +++ b/campaign/src/multi_asset_release.rs @@ -1,11 +1,12 @@ use crate::event; +#[cfg(feature = "diag")] +use crate::storage::storage_increment_diagnostic_counter; use crate::storage::{ acquire_lock, get_campaign, get_milestone, is_frozen, release_lock, set_milestone, storage_get_asset_raised, storage_get_total_raised, storage_increment_release_count, storage_set_asset_raised, storage_set_total_raised, }; #[cfg(feature = "diag")] -use crate::storage::storage_increment_diagnostic_counter; use crate::types::CampaignMetrics; use crate::types::{Error, MilestoneStatus}; use soroban_sdk::{panic_with_error, symbol_short, token, Address, Env}; diff --git a/campaign/src/release_milestone.rs b/campaign/src/release_milestone.rs index 5ff48d0..5b7ecac 100644 --- a/campaign/src/release_milestone.rs +++ b/campaign/src/release_milestone.rs @@ -1,11 +1,13 @@ use crate::event; +#[cfg(feature = "diag")] +use crate::storage::storage_increment_diagnostic_counter; use crate::storage::{ acquire_lock, get_campaign, get_milestone, is_frozen, release_lock, set_milestone, storage_increment_release_count, }; #[cfg(feature = "diag")] -use crate::storage::storage_increment_diagnostic_counter; -use crate::types::{CampaignMetrics, Error, MilestoneStatus}; +use crate::types::CampaignMetrics; +use crate::types::{Error, MilestoneStatus}; use soroban_sdk::{panic_with_error, token, Address, Env}; /// Issue #207 – `release_milestone` function diff --git a/campaign/src/storage.rs b/campaign/src/storage.rs index aa44316..da34029 100644 --- a/campaign/src/storage.rs +++ b/campaign/src/storage.rs @@ -1,6 +1,8 @@ // src/storage.rs -use crate::types::{CampaignData, CampaignMetrics, DataKey, DonorRecord, Error, MilestoneData}; +#[cfg(feature = "diag")] +use crate::types::CampaignMetrics; +use crate::types::{CampaignData, DataKey, DonorRecord, Error, MilestoneData}; use soroban_sdk::{panic_with_error, Address, Env}; // ─── TTL Constants ────────────────────────────────────────────────────────────