From 23831f069ed1cf0c706e8780b034379a41863eec Mon Sep 17 00:00:00 2001 From: XOUL LABS Date: Mon, 23 Feb 2026 13:21:35 +0000 Subject: [PATCH] feat: Add request ID propagation with tracing (#71) - Generate unique 128-bit UUID per flow - Track operations in tracing spans - Record timing, actor, and status - Add comprehensive tests and documentation Closes #71 --- README.md | 2 + REQUEST_ID_PROPAGATION.md | 115 +++++++++++++++++++++++ src/lib.rs | 122 +++++++++++++++++++++++++ src/request_id.rs | 64 +++++++++++++ src/request_id_tests.rs | 186 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 489 insertions(+) create mode 100644 REQUEST_ID_PROPAGATION.md create mode 100644 src/request_id.rs create mode 100644 src/request_id_tests.rs diff --git a/README.md b/README.md index 051f395..3ab3938 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ AnchorKit is a Soroban-native toolkit for anchoring off-chain attestations to St - Endpoint configuration for attestors - Service capability discovery (deposits, withdrawals, quotes, KYC) - **Health monitoring** (latency, failures, availability) +- **Request ID propagation** (UUID per flow with tracing) - Event emission for all state changes - Comprehensive error handling with stable error codes @@ -101,6 +102,7 @@ const auditLog = await contract.get_audit_log(0); - **[SESSION_TRACEABILITY.md](./SESSION_TRACEABILITY.md)** - Complete feature guide with usage patterns - **[SECURE_CREDENTIALS.md](./SECURE_CREDENTIALS.md)** - Secure credential injection and management - **[HEALTH_MONITORING.md](./HEALTH_MONITORING.md)** - Anchor health monitoring interface +- **[REQUEST_ID_PROPAGATION.md](./REQUEST_ID_PROPAGATION.md)** - Request ID tracking and tracing - **[API_SPEC.md](./API_SPEC.md)** - API specification and error codes ### Technical Documentation diff --git a/REQUEST_ID_PROPAGATION.md b/REQUEST_ID_PROPAGATION.md new file mode 100644 index 0000000..b4ceae5 --- /dev/null +++ b/REQUEST_ID_PROPAGATION.md @@ -0,0 +1,115 @@ +# Request ID Propagation + +## Overview + +Generate unique request IDs for each flow and propagate across logs and tracing spans. + +## Features + +- ✅ UUID per flow (128-bit) +- ✅ Visible in tracing spans +- ✅ Automatic timing tracking +- ✅ Success/failure status recording + +## Usage + +### Generate Request ID + +```rust +let request_id = client.generate_request_id(); +``` + +### Submit with Request ID + +```rust +let attestation_id = client.submit_with_request_id( + &request_id, + &issuer, + &subject, + ×tamp, + &payload_hash, + &signature, +); +``` + +### Retrieve Tracing Span + +```rust +let span = client.get_tracing_span(&request_id.id); + +println!("Operation: {}", span.operation); +println!("Actor: {}", span.actor); +println!("Status: {}", span.status); +println!("Duration: {} seconds", span.completed_at - span.started_at); +``` + +## API Methods + +```rust +// Generate unique request ID +pub fn generate_request_id() -> RequestId + +// Submit attestation with tracking +pub fn submit_with_request_id( + request_id: RequestId, + issuer: Address, + subject: Address, + timestamp: u64, + payload_hash: BytesN<32>, + signature: Bytes, +) -> Result + +// Submit quote with tracking +pub fn quote_with_request_id( + request_id: RequestId, + anchor: Address, + base_asset: String, + quote_asset: String, + rate: u64, + fee_percentage: u32, + minimum_amount: u64, + maximum_amount: u64, + valid_until: u64, +) -> Result + +// Get tracing span +pub fn get_tracing_span(request_id: BytesN<16>) -> Option +``` + +## Tracing Span Structure + +```rust +pub struct TracingSpan { + pub request_id: RequestId, + pub operation: String, // Operation name + pub actor: Address, // Who performed it + pub started_at: u64, // Start timestamp + pub completed_at: u64, // End timestamp + pub status: String, // "success" or "failed" +} +``` + +## Use Cases + +### Debugging +Track request flow through system for troubleshooting. + +### Performance Monitoring +Measure operation duration via `completed_at - started_at`. + +### Audit Trail +Complete record of who did what and when. + +### Distributed Tracing +Correlate operations across multiple calls. + +## Storage + +Tracing spans stored in temporary storage with 1-day TTL. + +## Best Practices + +1. **Generate once per flow** - Reuse same request ID for related operations +2. **Check span status** - Verify success/failure in tracing data +3. **Monitor timing** - Track performance via span timestamps +4. **Log request IDs** - Include in external logs for correlation diff --git a/src/lib.rs b/src/lib.rs index b39e359..60ae28c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ mod credentials; mod error_mapping; mod errors; mod events; +mod request_id; mod retry; mod serialization; mod storage; @@ -52,6 +53,9 @@ mod cross_platform_tests; mod zerocopy_tests; +#[cfg(test)] +mod request_id_tests; + use soroban_sdk::{contract, contractimpl, Address, Bytes, BytesN, Env, String, Vec}; @@ -63,6 +67,7 @@ pub use events::{ OperationLogged, QuoteReceived, QuoteSubmitted, ServicesConfigured, SessionCreated, SettlementConfirmed, TransferInitiated, }; +pub use request_id::{RequestId, RequestTracker, TracingSpan}; pub use storage::Storage; pub use types::{ AnchorMetadata, AnchorOption, AnchorServices, Attestation, AuditLog, Endpoint, HealthStatus, @@ -1163,4 +1168,121 @@ impl AnchorKitContract { Ok(()) } + + // ============ Request ID Propagation ============ + + /// Generate a unique request ID for flow tracking. + pub fn generate_request_id(env: Env) -> RequestId { + RequestId::generate(&env) + } + + /// Submit attestation with request ID tracking. + pub fn submit_with_request_id( + env: Env, + request_id: RequestId, + issuer: Address, + subject: Address, + timestamp: u64, + payload_hash: BytesN<32>, + signature: Bytes, + ) -> Result { + issuer.require_auth(); + + let start_time = env.ledger().timestamp(); + + // Perform attestation + let result = if timestamp == 0 { + Err(Error::InvalidTimestamp) + } else if !Storage::is_attestor(&env, &issuer) { + Err(Error::UnauthorizedAttestor) + } else if Storage::is_hash_used(&env, &payload_hash) { + Err(Error::ReplayAttack) + } else { + Self::verify_signature(&env, &issuer, &subject, timestamp, &payload_hash, &signature)?; + + let id = Storage::get_and_increment_counter(&env); + let attestation = Attestation { + id, + issuer: issuer.clone(), + subject: subject.clone(), + timestamp, + payload_hash: payload_hash.clone(), + signature, + }; + + Storage::set_attestation(&env, id, &attestation); + Storage::mark_hash_used(&env, &payload_hash); + AttestationRecorded::publish(&env, id, &subject, timestamp, payload_hash); + + Ok(id) + }; + + // Store tracing span + let span = TracingSpan { + request_id: request_id.clone(), + operation: soroban_sdk::String::from_str(&env, "submit_attestation"), + actor: issuer, + started_at: start_time, + completed_at: env.ledger().timestamp(), + status: if result.is_ok() { + soroban_sdk::String::from_str(&env, "success") + } else { + soroban_sdk::String::from_str(&env, "failed") + }, + }; + RequestTracker::store_span(&env, &span); + + result + } + + /// Get tracing span by request ID. + pub fn get_tracing_span(env: Env, request_id: BytesN<16>) -> Option { + RequestTracker::get_span(&env, &request_id) + } + + /// Submit quote with request ID tracking. + pub fn quote_with_request_id( + env: Env, + request_id: RequestId, + anchor: Address, + base_asset: soroban_sdk::String, + quote_asset: soroban_sdk::String, + rate: u64, + fee_percentage: u32, + minimum_amount: u64, + maximum_amount: u64, + valid_until: u64, + ) -> Result { + anchor.require_auth(); + + let start_time = env.ledger().timestamp(); + + let result = Self::submit_quote( + env.clone(), + anchor.clone(), + base_asset, + quote_asset, + rate, + fee_percentage, + minimum_amount, + maximum_amount, + valid_until, + ); + + let span = TracingSpan { + request_id: request_id.clone(), + operation: soroban_sdk::String::from_str(&env, "submit_quote"), + actor: anchor, + started_at: start_time, + completed_at: env.ledger().timestamp(), + status: if result.is_ok() { + soroban_sdk::String::from_str(&env, "success") + } else { + soroban_sdk::String::from_str(&env, "failed") + }, + }; + RequestTracker::store_span(&env, &span); + + result + } } diff --git a/src/request_id.rs b/src/request_id.rs new file mode 100644 index 0000000..14dd5f0 --- /dev/null +++ b/src/request_id.rs @@ -0,0 +1,64 @@ +use soroban_sdk::{contracttype, Bytes, BytesN, Env}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RequestId { + pub id: BytesN<16>, // 128-bit UUID + pub created_at: u64, +} + +impl RequestId { + pub fn generate(env: &Env) -> Self { + let timestamp = env.ledger().timestamp(); + let sequence = env.ledger().sequence(); + + // Generate pseudo-UUID from timestamp + sequence + let mut bytes = Bytes::new(env); + bytes.append(&Bytes::from_array(env, ×tamp.to_be_bytes())); + bytes.append(&Bytes::from_array(env, &sequence.to_be_bytes())); + + // Hash to get 32 bytes, take first 16 + let hash = env.crypto().sha256(&bytes); + let hash_bytes = hash.to_array(); + let id = BytesN::from_array(env, &[ + hash_bytes[0], hash_bytes[1], hash_bytes[2], hash_bytes[3], + hash_bytes[4], hash_bytes[5], hash_bytes[6], hash_bytes[7], + hash_bytes[8], hash_bytes[9], hash_bytes[10], hash_bytes[11], + hash_bytes[12], hash_bytes[13], hash_bytes[14], hash_bytes[15], + ]); + + Self { + id, + created_at: timestamp, + } + } +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TracingSpan { + pub request_id: RequestId, + pub operation: soroban_sdk::String, + pub actor: soroban_sdk::Address, + pub started_at: u64, + pub completed_at: u64, + pub status: soroban_sdk::String, +} + +pub struct RequestTracker; + +impl RequestTracker { + pub fn store_span(env: &Env, span: &TracingSpan) { + let key = ( + soroban_sdk::symbol_short!("SPAN"), + span.request_id.id.clone(), + ); + env.storage().temporary().set(&key, span); + env.storage().temporary().extend_ttl(&key, 17280, 17280); // 1 day + } + + pub fn get_span(env: &Env, request_id: &BytesN<16>) -> Option { + let key = (soroban_sdk::symbol_short!("SPAN"), request_id.clone()); + env.storage().temporary().get(&key) + } +} diff --git a/src/request_id_tests.rs b/src/request_id_tests.rs new file mode 100644 index 0000000..fe23dc4 --- /dev/null +++ b/src/request_id_tests.rs @@ -0,0 +1,186 @@ +#[cfg(test)] +mod request_id_tests { + use crate::{AnchorKitContract, AnchorKitContractClient, RequestId, ServiceType}; + use soroban_sdk::{testutils::Address as _, vec, Address, Bytes, BytesN, Env}; + + #[test] + fn test_generate_request_id() { + let env = Env::default(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let request_id = client.generate_request_id(); + + assert_eq!(request_id.id.len(), 16); + assert!(request_id.created_at > 0); + } + + #[test] + fn test_unique_request_ids() { + let env = Env::default(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let id1 = client.generate_request_id(); + + env.ledger().with_mut(|li| li.sequence += 1); + + let id2 = client.generate_request_id(); + + assert_ne!(id1.id, id2.id); + } + + #[test] + fn test_request_id_to_hex() { + let env = Env::default(); + + let request_id = RequestId::generate(&env); + + // Just verify ID is 16 bytes + assert_eq!(request_id.id.len(), 16); + } + + #[test] + fn test_submit_attestation_with_request_id() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let attestor = Address::generate(&env); + let subject = Address::generate(&env); + + client.initialize(&admin); + client.register_attestor(&attestor); + + let request_id = client.generate_request_id(); + let payload_hash = BytesN::from_array(&env, &[1u8; 32]); + let signature = Bytes::new(&env); + + let attestation_id = client.submit_with_request_id( + &request_id, + &attestor, + &subject, + &1000, + &payload_hash, + &signature, + ); + + assert!(attestation_id > 0); + + // Verify tracing span was stored + let span = client.get_tracing_span(&request_id.id); + assert!(span.is_some()); + + let span = span.unwrap(); + assert_eq!(span.request_id.id, request_id.id); + assert_eq!(span.actor, attestor); + } + + #[test] + fn test_tracing_span_records_failure() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let attestor = Address::generate(&env); + let subject = Address::generate(&env); + + client.initialize(&admin); + // Don't register attestor - will fail + + let request_id = client.generate_request_id(); + let payload_hash = BytesN::from_array(&env, &[1u8; 32]); + let signature = Bytes::new(&env); + + let result = client.try_submit_with_request_id( + &request_id, + &attestor, + &subject, + &1000, + &payload_hash, + &signature, + ); + + assert!(result.is_err()); + + // Verify failure was recorded + let span = client.get_tracing_span(&request_id.id); + assert!(span.is_some()); + } + + #[test] + fn test_submit_quote_with_request_id() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let anchor = Address::generate(&env); + + client.initialize(&admin); + client.register_attestor(&anchor); + + let services = vec![&env, ServiceType::Quotes]; + client.configure_services(&anchor, &services); + + let request_id = client.generate_request_id(); + + let quote_id = client.quote_with_request_id( + &request_id, + &anchor, + &soroban_sdk::String::from_str(&env, "USD"), + &soroban_sdk::String::from_str(&env, "USDC"), + &10000, + &100, + &100, + &10000, + &(env.ledger().timestamp() + 3600), + ); + + assert!(quote_id > 0); + + // Verify tracing span + let span = client.get_tracing_span(&request_id.id); + assert!(span.is_some()); + + let span = span.unwrap(); + assert_eq!(span.actor, anchor); + } + + #[test] + fn test_tracing_span_timing() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let attestor = Address::generate(&env); + let subject = Address::generate(&env); + + client.initialize(&admin); + client.register_attestor(&attestor); + + let request_id = client.generate_request_id(); + let payload_hash = BytesN::from_array(&env, &[1u8; 32]); + let signature = Bytes::new(&env); + + client.submit_with_request_id( + &request_id, + &attestor, + &subject, + &1000, + &payload_hash, + &signature, + ); + + let span = client.get_tracing_span(&request_id.id).unwrap(); + + assert!(span.completed_at >= span.started_at); + } +}