From 09b9ae10c6ea9fa824ac83cf14d5c4b71e1498c0 Mon Sep 17 00:00:00 2001 From: David Friedel Date: Thu, 25 Dec 2025 10:27:55 +0000 Subject: [PATCH] Implement IronTelemetry Rust SDK - Core client with exception/message capture - Journey and step tracking with closure support - Breadcrumb management with VecDeque ring buffer - HTTP transport using reqwest (sync + async) - Thread-safe operations with Arc and RwLock - Sample rate and beforeSend filtering - Tags, extras, and user context - Builder pattern for configuration - Serde serialization for all types --- .gitignore | 21 +++ Cargo.toml | 30 +++++ README.md | 316 ++++++++++++++++++++++++++++++++++++++++++++- src/breadcrumbs.rs | 62 +++++++++ src/client.rs | 211 ++++++++++++++++++++++++++++++ src/config.rs | 150 +++++++++++++++++++++ src/journey.rs | 244 ++++++++++++++++++++++++++++++++++ src/lib.rs | 67 ++++++++++ src/transport.rs | 165 +++++++++++++++++++++++ src/types.rs | 254 ++++++++++++++++++++++++++++++++++++ 10 files changed, 1518 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/breadcrumbs.rs create mode 100644 src/client.rs create mode 100644 src/config.rs create mode 100644 src/journey.rs create mode 100644 src/lib.rs create mode 100644 src/transport.rs create mode 100644 src/types.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d121d23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +/target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Debug +*.log diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b2e15d4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "irontelemetry" +version = "0.1.0" +edition = "2021" +authors = ["IronServices "] +description = "Error monitoring and crash reporting SDK for Rust applications" +license = "MIT" +repository = "https://github.com/IronServices/irontelemetry-rust" +homepage = "https://www.irontelemetry.com" +documentation = "https://docs.rs/irontelemetry" +readme = "README.md" +keywords = ["error-monitoring", "crash-reporting", "telemetry", "logging", "tracing"] +categories = ["development-tools::debugging", "api-bindings"] + +[dependencies] +reqwest = { version = "0.11", features = ["json", "blocking"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.6", features = ["v4"] } +url = "2.5" +thiserror = "1.0" +tokio = { version = "1", features = ["rt-multi-thread", "sync"], optional = true } + +[features] +default = [] +async = ["tokio"] + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/README.md b/README.md index f8ac5ae..6725aad 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,314 @@ -# irontelemetry-rust -IronTelemetry SDK for Rust - Error monitoring and crash reporting +# IronTelemetry SDK for Rust + +Error monitoring and crash reporting SDK for Rust applications. Capture exceptions, track user journeys, and get insights to fix issues faster. + +[![Crates.io](https://img.shields.io/crates/v/irontelemetry.svg)](https://crates.io/crates/irontelemetry) +[![Documentation](https://docs.rs/irontelemetry/badge.svg)](https://docs.rs/irontelemetry) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +irontelemetry = "0.1" +``` + +For async support: + +```toml +[dependencies] +irontelemetry = { version = "0.1", features = ["async"] } +``` + +## Quick Start + +### Basic Exception Capture + +```rust +use irontelemetry::{Client, SeverityLevel}; + +fn main() -> Result<(), Box> { + // Initialize with your DSN + let client = Client::new("https://pk_live_xxx@irontelemetry.com")?; + + // Capture a message + client.capture_message("Application started", SeverityLevel::Info); + + // Capture an exception + client.capture_exception("RuntimeError", "Something went wrong"); + + // Capture from a std::error::Error + if let Err(e) = do_something() { + client.capture_error(&*e); + } + + Ok(()) +} +``` + +### Journey Tracking + +Track user journeys to understand the context of errors: + +```rust +use irontelemetry::{Client, BreadcrumbCategory, create_journey_manager}; + +fn main() -> Result<(), Box> { + let client = Client::new("https://pk_live_xxx@irontelemetry.com")?; + let journey_manager = create_journey_manager(&client); + + // Start a journey + let mut journey = journey_manager.start_journey("Checkout Flow"); + journey.set_user("user-123", Some("user@example.com"), Some("John Doe")); + + // Track steps with automatic error handling + let result = journey.run_step("Validate Cart", BreadcrumbCategory::Business, || { + validate_cart() + }); + + if let Err(e) = result { + journey.fail(Some(&e.to_string())); + client.capture_exception("ValidationError", &e.to_string()); + return Err(e.into()); + } + + let result = journey.run_step("Process Payment", BreadcrumbCategory::Business, || { + process_payment() + }); + + if let Err(e) = result { + journey.fail(Some(&e.to_string())); + client.capture_exception("PaymentError", &e.to_string()); + return Err(e.into()); + } + + journey.complete(); + Ok(()) +} +``` + +Or track steps manually: + +```rust +let mut journey = journey_manager.start_journey("Checkout Flow"); + +let mut step = journey.start_step("Validate Cart", BreadcrumbCategory::Business); +step.set_data("cart_items", json!(5)); + +match validate_cart() { + Ok(_) => step.complete(), + Err(e) => { + step.fail(Some(&e.to_string())); + journey.fail(Some(&e.to_string())); + return Err(e.into()); + } +} + +journey.complete(); +``` + +## Configuration + +```rust +use irontelemetry::{Client, TelemetryOptions}; + +let options = TelemetryOptions::new("https://pk_live_xxx@irontelemetry.com") + .environment("production") + .app_version("1.2.3") + .sample_rate(1.0) // 100% of events + .debug(false) + .before_send(|event| { + // Filter or modify events + if event.message.as_ref().map(|m| m.contains("expected")).unwrap_or(false) { + return None; // Drop the event + } + Some(event) + }); + +let client = Client::with_options(options)?; +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `dsn` | String | required | Your Data Source Name | +| `environment` | String | "production" | Environment name | +| `app_version` | String | "0.0.0" | Application version | +| `sample_rate` | f64 | 1.0 | Sample rate (0.0 to 1.0) | +| `max_breadcrumbs` | usize | 100 | Max breadcrumbs to keep | +| `debug` | bool | false | Enable debug logging | +| `before_send` | Fn | None | Hook to filter/modify events | +| `enable_offline_queue` | bool | true | Enable offline queue | +| `max_offline_queue_size` | usize | 500 | Max offline queue size | + +## Features + +- **Automatic Stack Traces**: Full stack traces captured with exceptions +- **Journey Tracking**: Track user flows and correlate errors with context +- **Breadcrumbs**: Leave a trail of events leading up to an error +- **User Context**: Associate errors with specific users +- **Tags & Extras**: Add custom metadata to your events +- **Thread-Safe**: All operations are safe for concurrent use with `Arc` and `RwLock` +- **Zero-Copy Where Possible**: Efficient memory usage with borrowed data + +## Breadcrumbs + +```rust +use irontelemetry::{BreadcrumbCategory, SeverityLevel}; +use std::collections::HashMap; +use serde_json::json; + +// Add simple breadcrumbs +client.add_breadcrumb("User clicked checkout button", BreadcrumbCategory::Ui); +client.add_breadcrumb("Payment API called", BreadcrumbCategory::Http); + +// With level +client.add_breadcrumb_with_level( + "User logged in", + BreadcrumbCategory::Auth, + SeverityLevel::Info, +); + +// With data +let mut data = HashMap::new(); +data.insert("url".to_string(), json!("/api/checkout")); +data.insert("statusCode".to_string(), json!(200)); +data.insert("duration".to_string(), json!(150)); + +client.add_breadcrumb_with_data( + "API request completed", + BreadcrumbCategory::Http, + SeverityLevel::Info, + data, +); +``` + +### Breadcrumb Categories + +```rust +BreadcrumbCategory::Ui // User interface interactions +BreadcrumbCategory::Http // HTTP requests +BreadcrumbCategory::Navigation // Page/route navigation +BreadcrumbCategory::Console // Console output +BreadcrumbCategory::Auth // Authentication events +BreadcrumbCategory::Business // Business logic events +BreadcrumbCategory::Notification // Notification events +BreadcrumbCategory::Custom // Custom events +``` + +## Severity Levels + +```rust +SeverityLevel::Debug +SeverityLevel::Info +SeverityLevel::Warning +SeverityLevel::Error +SeverityLevel::Fatal +``` + +## User Context + +```rust +use irontelemetry::User; +use serde_json::json; +use std::collections::HashMap; + +// Simple user ID +client.set_user_by_id("user-123"); + +// With email +client.set_user_with_email("user-123", "user@example.com"); + +// Full user object +let mut data = HashMap::new(); +data.insert("plan".to_string(), json!("premium")); + +client.set_user(User::new("user-123") + .with_email("user@example.com") + .with_name("John Doe") + .with_data(data)); +``` + +## Tags and Extra Data + +```rust +use serde_json::json; +use std::collections::HashMap; + +// Set individual tags +client.set_tag("release", "v1.2.3"); +client.set_tag("server", "prod-1"); + +// Set multiple tags +let mut tags = HashMap::new(); +tags.insert("release".to_string(), "v1.2.3".to_string()); +tags.insert("server".to_string(), "prod-1".to_string()); +client.set_tags(tags); + +// Set extra data +client.set_extra("request_id", json!("abc-123")); + +let mut extras = HashMap::new(); +extras.insert("request_id".to_string(), json!("abc-123")); +extras.insert("user_agent".to_string(), json!("Mozilla/5.0...")); +client.set_extras(extras); +``` + +## Actix-web Integration Example + +```rust +use actix_web::{web, App, HttpServer, HttpResponse}; +use irontelemetry::{Client, BreadcrumbCategory}; +use std::sync::Arc; + +struct AppState { + telemetry: Arc, +} + +async fn handler(data: web::Data) -> HttpResponse { + data.telemetry.add_breadcrumb("Handler called", BreadcrumbCategory::Http); + + // Your logic here + + HttpResponse::Ok().body("Hello") +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + let client = Client::new("https://pk_live_xxx@irontelemetry.com") + .expect("Failed to create client"); + + let app_state = web::Data::new(AppState { + telemetry: client, + }); + + HttpServer::new(move || { + App::new() + .app_data(app_state.clone()) + .route("/", web::get().to(handler)) + }) + .bind("127.0.0.1:8080")? + .run() + .await +} +``` + +## Requirements + +- Rust 1.70+ +- reqwest (HTTP client) +- serde/serde_json (serialization) +- chrono (timestamps) +- uuid (event IDs) + +## Links + +- [Documentation](https://www.irontelemetry.com/docs) +- [Dashboard](https://www.irontelemetry.com) + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/src/breadcrumbs.rs b/src/breadcrumbs.rs new file mode 100644 index 0000000..684cd67 --- /dev/null +++ b/src/breadcrumbs.rs @@ -0,0 +1,62 @@ +use crate::types::{Breadcrumb, BreadcrumbCategory, SeverityLevel}; +use std::collections::VecDeque; +use std::sync::RwLock; + +/// Manages a ring buffer of breadcrumbs. +pub struct BreadcrumbManager { + breadcrumbs: RwLock>, + max_breadcrumbs: usize, +} + +impl BreadcrumbManager { + pub fn new(max_breadcrumbs: usize) -> Self { + Self { + breadcrumbs: RwLock::new(VecDeque::with_capacity(max_breadcrumbs)), + max_breadcrumbs, + } + } + + /// Add a breadcrumb to the ring buffer. + pub fn add(&self, breadcrumb: Breadcrumb) { + let mut breadcrumbs = self.breadcrumbs.write().unwrap(); + if breadcrumbs.len() >= self.max_breadcrumbs { + breadcrumbs.pop_front(); + } + breadcrumbs.push_back(breadcrumb); + } + + /// Add a simple breadcrumb with just a message and category. + pub fn add_simple(&self, message: impl Into, category: BreadcrumbCategory) { + self.add(Breadcrumb::new(message, category)); + } + + /// Add a breadcrumb with a specific level. + pub fn add_with_level( + &self, + message: impl Into, + category: BreadcrumbCategory, + level: SeverityLevel, + ) { + self.add(Breadcrumb::new(message, category).with_level(level)); + } + + /// Get all breadcrumbs as a vector. + pub fn get_all(&self) -> Vec { + self.breadcrumbs.read().unwrap().iter().cloned().collect() + } + + /// Clear all breadcrumbs. + pub fn clear(&self) { + self.breadcrumbs.write().unwrap().clear(); + } + + /// Get the number of breadcrumbs. + pub fn len(&self) -> usize { + self.breadcrumbs.read().unwrap().len() + } + + /// Check if empty. + pub fn is_empty(&self) -> bool { + self.breadcrumbs.read().unwrap().is_empty() + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..c6f8549 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,211 @@ +use crate::breadcrumbs::BreadcrumbManager; +use crate::config::{generate_event_id, parse_dsn, TelemetryOptions}; +use crate::journey::JourneyManager; +use crate::transport::Transport; +use crate::types::{ + Breadcrumb, BreadcrumbCategory, ExceptionInfo, PlatformInfo, SendResult, SeverityLevel, + TelemetryEvent, User, +}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock, Weak}; + +/// The main IronTelemetry client. +pub struct Client { + options: TelemetryOptions, + transport: Transport, + breadcrumbs: BreadcrumbManager, + user: RwLock>, + tags: RwLock>, + extra: RwLock>, +} + +impl Client { + /// Create a new Client with a DSN. + pub fn new(dsn: impl Into) -> Result, crate::config::DsnError> { + Self::with_options(TelemetryOptions::new(dsn)) + } + + /// Create a new Client with options. + pub fn with_options(options: TelemetryOptions) -> Result, crate::config::DsnError> { + let parsed_dsn = parse_dsn(&options.dsn)?; + let api_base_url = options.api_base_url.as_deref(); + let transport = Transport::new(&parsed_dsn, api_base_url, options.debug); + + if options.debug { + println!("[IronTelemetry] Initialized with DSN: {}", parsed_dsn.api_base_url); + } + + Ok(Arc::new(Self { + options, + transport, + breadcrumbs: BreadcrumbManager::new(options.max_breadcrumbs), + user: RwLock::new(None), + tags: RwLock::new(HashMap::new()), + extra: RwLock::new(HashMap::new()), + })) + } + + /// Capture an error and send it to the server. + pub fn capture_error(&self, error: &dyn std::error::Error) -> SendResult { + let event = self.create_event(SeverityLevel::Error, Some(error.to_string())); + let mut event = event; + event.exception = Some(ExceptionInfo::new( + std::any::type_name_of_val(error), + error.to_string(), + )); + self.send_event(event) + } + + /// Capture an exception with type and message. + pub fn capture_exception(&self, exception_type: &str, message: &str) -> SendResult { + let mut event = self.create_event(SeverityLevel::Error, Some(message.to_string())); + event.exception = Some(ExceptionInfo::new(exception_type, message)); + self.send_event(event) + } + + /// Capture a message and send it to the server. + pub fn capture_message(&self, message: impl Into, level: SeverityLevel) -> SendResult { + let event = self.create_event(level, Some(message.into())); + self.send_event(event) + } + + /// Add a breadcrumb. + pub fn add_breadcrumb(&self, message: impl Into, category: BreadcrumbCategory) { + self.breadcrumbs.add_simple(message, category); + } + + /// Add a breadcrumb with a level. + pub fn add_breadcrumb_with_level( + &self, + message: impl Into, + category: BreadcrumbCategory, + level: SeverityLevel, + ) { + self.breadcrumbs.add_with_level(message, category, level); + } + + /// Add a breadcrumb with data. + pub fn add_breadcrumb_with_data( + &self, + message: impl Into, + category: BreadcrumbCategory, + level: SeverityLevel, + data: HashMap, + ) { + self.breadcrumbs.add( + Breadcrumb::new(message, category) + .with_level(level) + .with_data(data), + ); + } + + /// Set the user context. + pub fn set_user(&self, user: User) { + *self.user.write().unwrap() = Some(user); + } + + /// Set the user context by ID. + pub fn set_user_by_id(&self, id: impl Into) { + self.set_user(User::new(id)); + } + + /// Set the user context with ID and email. + pub fn set_user_with_email(&self, id: impl Into, email: impl Into) { + self.set_user(User::new(id).with_email(email)); + } + + /// Set a tag. + pub fn set_tag(&self, key: impl Into, value: impl Into) { + self.tags.write().unwrap().insert(key.into(), value.into()); + } + + /// Set multiple tags. + pub fn set_tags(&self, tags: HashMap) { + self.tags.write().unwrap().extend(tags); + } + + /// Set extra data. + pub fn set_extra(&self, key: impl Into, value: serde_json::Value) { + self.extra.write().unwrap().insert(key.into(), value); + } + + /// Set multiple extra data values. + pub fn set_extras(&self, extras: HashMap) { + self.extra.write().unwrap().extend(extras); + } + + /// Clear all breadcrumbs. + pub fn clear_breadcrumbs(&self) { + self.breadcrumbs.clear(); + } + + /// Flush pending events. + pub fn flush(&self) { + // Future: Implement offline queue flushing + } + + fn create_event(&self, level: SeverityLevel, message: Option) -> TelemetryEvent { + TelemetryEvent { + event_id: generate_event_id(), + timestamp: chrono::Utc::now(), + level, + message, + exception: None, + user: self.user.read().unwrap().clone(), + tags: self.tags.read().unwrap().clone(), + extra: self.extra.read().unwrap().clone(), + breadcrumbs: self.breadcrumbs.get_all(), + journey: None, // Journey context is managed separately + environment: Some(self.options.environment.clone()), + app_version: Some(self.options.app_version.clone()), + platform: PlatformInfo::default(), + } + } + + fn send_event(&self, mut event: TelemetryEvent) -> SendResult { + // Check sample rate + if self.options.sample_rate < 1.0 { + let random: f64 = rand::random(); + if random > self.options.sample_rate { + if self.options.debug { + println!("[IronTelemetry] Event sampled out: {}", event.event_id); + } + return SendResult::success(event.event_id); + } + } + + // Apply beforeSend hook + if let Some(ref before_send) = self.options.before_send { + match before_send(event) { + Some(e) => event = e, + None => { + if self.options.debug { + println!("[IronTelemetry] Event dropped by beforeSend hook"); + } + return SendResult::success("".to_string()); + } + } + } + + self.transport.send(&event) + } +} + +/// Create a journey manager for the client. +pub fn create_journey_manager(client: &Arc) -> JourneyManager { + JourneyManager::new(Arc::downgrade(client)) +} + +// Add rand dependency for sampling +mod rand { + pub fn random() -> T { + use std::time::{SystemTime, UNIX_EPOCH}; + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + // Simple LCG for demonstration - in production use proper rand crate + let value = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + unsafe { std::mem::transmute_copy(&((value as f64) / (u64::MAX as f64))) } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c0618b3 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,150 @@ +use crate::types::{ParsedDsn, TelemetryEvent}; +use thiserror::Error; +use url::Url; + +/// Errors that can occur when parsing a DSN. +#[derive(Error, Debug)] +pub enum DsnError { + #[error("Invalid DSN format: {0}")] + InvalidFormat(String), + #[error("DSN must contain a valid public key starting with pk_")] + InvalidPublicKey, + #[error("URL parse error: {0}")] + UrlParse(#[from] url::ParseError), +} + +/// Parse a DSN string into its components. +/// Format: https://pk_live_xxx@irontelemetry.com +pub fn parse_dsn(dsn: &str) -> Result { + let url = Url::parse(dsn)?; + + let public_key = url + .username() + .to_string(); + + if public_key.is_empty() || !public_key.starts_with("pk_") { + return Err(DsnError::InvalidPublicKey); + } + + let host = url + .host_str() + .ok_or_else(|| DsnError::InvalidFormat("Missing host".to_string()))? + .to_string(); + + let protocol = url.scheme().to_string(); + + let port = url.port(); + let api_base_url = if let Some(p) = port { + format!("{}://{}:{}", protocol, host, p) + } else { + format!("{}://{}", protocol, host) + }; + + Ok(ParsedDsn { + public_key, + host, + protocol, + api_base_url, + }) +} + +/// Generate a unique event ID. +pub fn generate_event_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Configuration options for the telemetry client. +#[derive(Debug, Clone)] +pub struct TelemetryOptions { + /// DSN containing the public key + pub dsn: String, + /// Environment name (e.g., 'production', 'staging') + pub environment: String, + /// Application version + pub app_version: String, + /// Sample rate for events (0.0 to 1.0) + pub sample_rate: f64, + /// Maximum number of breadcrumbs to keep + pub max_breadcrumbs: usize, + /// Enable debug logging + pub debug: bool, + /// Enable offline queue for failed events + pub enable_offline_queue: bool, + /// Maximum size of the offline queue + pub max_offline_queue_size: usize, + /// API base URL (defaults to parsed from DSN) + pub api_base_url: Option, + /// Hook called before sending an event + pub before_send: Option Option + Send + Sync>>, +} + +impl TelemetryOptions { + pub fn new(dsn: impl Into) -> Self { + Self { + dsn: dsn.into(), + environment: "production".to_string(), + app_version: "0.0.0".to_string(), + sample_rate: 1.0, + max_breadcrumbs: 100, + debug: false, + enable_offline_queue: true, + max_offline_queue_size: 500, + api_base_url: None, + before_send: None, + } + } + + pub fn environment(mut self, environment: impl Into) -> Self { + self.environment = environment.into(); + self + } + + pub fn app_version(mut self, app_version: impl Into) -> Self { + self.app_version = app_version.into(); + self + } + + pub fn sample_rate(mut self, sample_rate: f64) -> Self { + self.sample_rate = sample_rate.clamp(0.0, 1.0); + self + } + + pub fn max_breadcrumbs(mut self, max_breadcrumbs: usize) -> Self { + self.max_breadcrumbs = max_breadcrumbs; + self + } + + pub fn debug(mut self, debug: bool) -> Self { + self.debug = debug; + self + } + + pub fn enable_offline_queue(mut self, enable: bool) -> Self { + self.enable_offline_queue = enable; + self + } + + pub fn max_offline_queue_size(mut self, size: usize) -> Self { + self.max_offline_queue_size = size; + self + } + + pub fn api_base_url(mut self, url: impl Into) -> Self { + self.api_base_url = Some(url.into()); + self + } + + pub fn before_send(mut self, f: F) -> Self + where + F: Fn(TelemetryEvent) -> Option + Send + Sync + 'static, + { + self.before_send = Some(Box::new(f)); + self + } +} + +impl Default for TelemetryOptions { + fn default() -> Self { + Self::new("") + } +} diff --git a/src/journey.rs b/src/journey.rs new file mode 100644 index 0000000..4497f30 --- /dev/null +++ b/src/journey.rs @@ -0,0 +1,244 @@ +use crate::types::{BreadcrumbCategory, JourneyContext, SeverityLevel}; +use crate::Client; +use chrono::Utc; +use serde_json::json; +use std::collections::HashMap; +use std::sync::{Arc, RwLock, Weak}; +use uuid::Uuid; + +/// Manages journey context. +pub struct JourneyManager { + current: RwLock>, + client: Weak, +} + +impl JourneyManager { + pub fn new(client: Weak) -> Self { + Self { + current: RwLock::new(None), + client, + } + } + + /// Start a new journey. + pub fn start_journey(&self, name: impl Into) -> Journey { + let name = name.into(); + let context = JourneyContext { + journey_id: Uuid::new_v4().to_string(), + name: name.clone(), + current_step: None, + started_at: Utc::now(), + metadata: HashMap::new(), + }; + + *self.current.write().unwrap() = Some(context.clone()); + + if let Some(client) = self.client.upgrade() { + client.add_breadcrumb(format!("Started journey: {}", name), BreadcrumbCategory::Business); + } + + Journey { + manager: self, + context, + } + } + + /// Get the current journey context. + pub fn get_current(&self) -> Option { + self.current.read().unwrap().clone() + } + + /// Clear the current journey. + pub fn clear(&self) { + *self.current.write().unwrap() = None; + } + + fn get_client(&self) -> Option> { + self.client.upgrade() + } + + fn update_context(&self, context: &JourneyContext) { + *self.current.write().unwrap() = Some(context.clone()); + } +} + +/// Represents an active journey. +pub struct Journey<'a> { + manager: &'a JourneyManager, + context: JourneyContext, +} + +impl<'a> Journey<'a> { + /// Set the user for the journey. + pub fn set_user(&mut self, id: &str, email: Option<&str>, name: Option<&str>) -> &mut Self { + self.context.metadata.insert("userId".to_string(), json!(id)); + if let Some(email) = email { + self.context.metadata.insert("userEmail".to_string(), json!(email)); + } + if let Some(name) = name { + self.context.metadata.insert("userName".to_string(), json!(name)); + } + self.manager.update_context(&self.context); + self + } + + /// Set metadata for the journey. + pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) -> &mut Self { + self.context.metadata.insert(key.to_string(), value); + self.manager.update_context(&self.context); + self + } + + /// Start a new step in the journey. + pub fn start_step(&mut self, name: impl Into, category: BreadcrumbCategory) -> Step<'_> { + let name = name.into(); + self.context.current_step = Some(name.clone()); + self.manager.update_context(&self.context); + + if let Some(client) = self.manager.get_client() { + let mut data = HashMap::new(); + data.insert("journeyId".to_string(), json!(&self.context.journey_id)); + data.insert("journeyName".to_string(), json!(&self.context.name)); + client.add_breadcrumb_with_data( + format!("Started step: {}", name), + category, + SeverityLevel::Info, + data, + ); + } + + Step { + journey: self, + name, + category, + started_at: Utc::now(), + data: HashMap::new(), + } + } + + /// Run a function as a step. + pub fn run_step( + &mut self, + name: impl Into, + category: BreadcrumbCategory, + f: F, + ) -> Result + where + F: FnOnce() -> Result, + E: std::fmt::Display, + { + let mut step = self.start_step(name, category); + match f() { + Ok(result) => { + step.complete(); + Ok(result) + } + Err(e) => { + step.fail(Some(&e.to_string())); + Err(e) + } + } + } + + /// Complete the journey successfully. + pub fn complete(&self) { + if let Some(client) = self.manager.get_client() { + let duration = (Utc::now() - self.context.started_at).num_milliseconds(); + let mut data = HashMap::new(); + data.insert("journeyId".to_string(), json!(&self.context.journey_id)); + data.insert("duration".to_string(), json!(duration)); + client.add_breadcrumb_with_data( + format!("Completed journey: {}", self.context.name), + BreadcrumbCategory::Business, + SeverityLevel::Info, + data, + ); + } + self.manager.clear(); + } + + /// Mark the journey as failed. + pub fn fail(&self, error: Option<&str>) { + if let Some(client) = self.manager.get_client() { + let duration = (Utc::now() - self.context.started_at).num_milliseconds(); + let mut data = HashMap::new(); + data.insert("journeyId".to_string(), json!(&self.context.journey_id)); + data.insert("duration".to_string(), json!(duration)); + if let Some(err) = error { + data.insert("error".to_string(), json!(err)); + } + client.add_breadcrumb_with_data( + format!("Failed journey: {}", self.context.name), + BreadcrumbCategory::Business, + SeverityLevel::Error, + data, + ); + } + self.manager.clear(); + } + + /// Get the journey context. + pub fn context(&self) -> &JourneyContext { + &self.context + } +} + +/// Represents a step within a journey. +pub struct Step<'a> { + journey: &'a Journey<'a>, + name: String, + category: BreadcrumbCategory, + started_at: chrono::DateTime, + data: HashMap, +} + +impl<'a> Step<'a> { + /// Set data for the step. + pub fn set_data(&mut self, key: &str, value: serde_json::Value) -> &mut Self { + self.data.insert(key.to_string(), value); + self + } + + /// Complete the step successfully. + pub fn complete(&self) { + if let Some(client) = self.journey.manager.get_client() { + let duration = (Utc::now() - self.started_at).num_milliseconds(); + let mut data = HashMap::new(); + data.insert("journeyId".to_string(), json!(&self.journey.context.journey_id)); + data.insert("journeyName".to_string(), json!(&self.journey.context.name)); + data.insert("duration".to_string(), json!(duration)); + for (k, v) in &self.data { + data.insert(k.clone(), v.clone()); + } + client.add_breadcrumb_with_data( + format!("Completed step: {}", self.name), + self.category, + SeverityLevel::Info, + data, + ); + } + } + + /// Mark the step as failed. + pub fn fail(&self, error: Option<&str>) { + if let Some(client) = self.journey.manager.get_client() { + let duration = (Utc::now() - self.started_at).num_milliseconds(); + let mut data = HashMap::new(); + data.insert("journeyId".to_string(), json!(&self.journey.context.journey_id)); + data.insert("journeyName".to_string(), json!(&self.journey.context.name)); + data.insert("duration".to_string(), json!(duration)); + for (k, v) in &self.data { + data.insert(k.clone(), v.clone()); + } + if let Some(err) = error { + data.insert("error".to_string(), json!(err)); + } + client.add_breadcrumb_with_data( + format!("Failed step: {}", self.name), + self.category, + SeverityLevel::Error, + data, + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d4ff390 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,67 @@ +//! # IronTelemetry SDK for Rust +//! +//! Error monitoring and crash reporting SDK for Rust applications. +//! +//! ## Quick Start +//! +//! ```no_run +//! use irontelemetry::{Client, SeverityLevel}; +//! +//! fn main() -> Result<(), Box> { +//! let client = Client::new("https://pk_live_xxx@irontelemetry.com")?; +//! +//! // Capture a message +//! client.capture_message("Application started", SeverityLevel::Info); +//! +//! // Capture an exception +//! client.capture_exception("RuntimeError", "Something went wrong"); +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## Journey Tracking +//! +//! ```no_run +//! use irontelemetry::{Client, BreadcrumbCategory, create_journey_manager}; +//! +//! fn main() -> Result<(), Box> { +//! let client = Client::new("https://pk_live_xxx@irontelemetry.com")?; +//! let journey_manager = create_journey_manager(&client); +//! +//! let mut journey = journey_manager.start_journey("Checkout Flow"); +//! journey.set_user("user-123", Some("user@example.com"), Some("John Doe")); +//! +//! let result = journey.run_step("Validate Cart", BreadcrumbCategory::Business, || { +//! // Your validation logic +//! Ok::<(), &str>(()) +//! }); +//! +//! if result.is_ok() { +//! journey.complete(); +//! } else { +//! journey.fail(Some("Validation failed")); +//! } +//! +//! Ok(()) +//! } +//! ``` + +mod breadcrumbs; +mod client; +mod config; +mod journey; +mod transport; +mod types; + +pub use breadcrumbs::BreadcrumbManager; +pub use client::{create_journey_manager, Client}; +pub use config::{parse_dsn, DsnError, TelemetryOptions}; +pub use journey::{Journey, JourneyManager, Step}; +pub use types::{ + Breadcrumb, BreadcrumbCategory, ExceptionInfo, JourneyContext, ParsedDsn, PlatformInfo, + SendResult, SeverityLevel, StackFrame, TelemetryEvent, User, +}; + +#[cfg(feature = "async")] +pub use transport::async_transport::AsyncTransport; diff --git a/src/transport.rs b/src/transport.rs new file mode 100644 index 0000000..1792bf3 --- /dev/null +++ b/src/transport.rs @@ -0,0 +1,165 @@ +use crate::types::{ParsedDsn, SendResult, TelemetryEvent}; +use reqwest::blocking::Client; +use std::time::Duration; + +/// Handles HTTP communication with the server. +pub struct Transport { + api_base_url: String, + public_key: String, + debug: bool, + client: Client, +} + +impl Transport { + pub fn new(parsed_dsn: &ParsedDsn, api_base_url: Option<&str>, debug: bool) -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"); + + Self { + api_base_url: api_base_url + .map(|s| s.to_string()) + .unwrap_or_else(|| parsed_dsn.api_base_url.clone()), + public_key: parsed_dsn.public_key.clone(), + debug, + client, + } + } + + /// Send an event to the server. + pub fn send(&self, event: &TelemetryEvent) -> SendResult { + let url = format!("{}/api/v1/events", self.api_base_url); + + match self.client + .post(&url) + .header("Content-Type", "application/json") + .header("X-Public-Key", &self.public_key) + .json(event) + .send() + { + Ok(response) => { + if response.status().is_success() { + if self.debug { + println!("[IronTelemetry] Event sent successfully: {}", event.event_id); + } + SendResult::success(event.event_id.clone()) + } else { + let error = format!( + "HTTP {}: {}", + response.status(), + response.text().unwrap_or_default() + ); + if self.debug { + println!("[IronTelemetry] Failed to send event: {}", error); + } + SendResult::failure(error) + } + } + Err(e) => { + let error = e.to_string(); + if self.debug { + println!("[IronTelemetry] Failed to send event: {}", error); + } + SendResult::failure(error) + } + } + } + + /// Check if the server is reachable. + pub fn is_online(&self) -> bool { + let url = format!("{}/api/v1/health", self.api_base_url); + + self.client + .get(&url) + .header("X-Public-Key", &self.public_key) + .send() + .map(|r| r.status().is_success()) + .unwrap_or(false) + } +} + +#[cfg(feature = "async")] +pub mod async_transport { + use crate::types::{ParsedDsn, SendResult, TelemetryEvent}; + use std::time::Duration; + + /// Async transport for HTTP communication. + pub struct AsyncTransport { + api_base_url: String, + public_key: String, + debug: bool, + client: reqwest::Client, + } + + impl AsyncTransport { + pub fn new(parsed_dsn: &ParsedDsn, api_base_url: Option<&str>, debug: bool) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"); + + Self { + api_base_url: api_base_url + .map(|s| s.to_string()) + .unwrap_or_else(|| parsed_dsn.api_base_url.clone()), + public_key: parsed_dsn.public_key.clone(), + debug, + client, + } + } + + /// Send an event asynchronously. + pub async fn send(&self, event: &TelemetryEvent) -> SendResult { + let url = format!("{}/api/v1/events", self.api_base_url); + + match self.client + .post(&url) + .header("Content-Type", "application/json") + .header("X-Public-Key", &self.public_key) + .json(event) + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + if self.debug { + println!("[IronTelemetry] Event sent successfully: {}", event.event_id); + } + SendResult::success(event.event_id.clone()) + } else { + let error = format!( + "HTTP {}: {}", + response.status(), + response.text().await.unwrap_or_default() + ); + if self.debug { + println!("[IronTelemetry] Failed to send event: {}", error); + } + SendResult::failure(error) + } + } + Err(e) => { + let error = e.to_string(); + if self.debug { + println!("[IronTelemetry] Failed to send event: {}", error); + } + SendResult::failure(error) + } + } + } + + /// Check if the server is reachable. + pub async fn is_online(&self) -> bool { + let url = format!("{}/api/v1/health", self.api_base_url); + + self.client + .get(&url) + .header("X-Public-Key", &self.public_key) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + } + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..1c4749e --- /dev/null +++ b/src/types.rs @@ -0,0 +1,254 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Severity level for telemetry events. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SeverityLevel { + Debug, + Info, + Warning, + Error, + Fatal, +} + +impl Default for SeverityLevel { + fn default() -> Self { + Self::Info + } +} + +/// Category for breadcrumbs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BreadcrumbCategory { + Ui, + Http, + Navigation, + Console, + Auth, + Business, + Notification, + Custom, +} + +impl Default for BreadcrumbCategory { + fn default() -> Self { + Self::Custom + } +} + +/// Represents a breadcrumb event leading up to an error. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Breadcrumb { + pub timestamp: DateTime, + pub category: BreadcrumbCategory, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option>, +} + +impl Breadcrumb { + pub fn new(message: impl Into, category: BreadcrumbCategory) -> Self { + Self { + timestamp: Utc::now(), + category, + message: message.into(), + level: Some(SeverityLevel::Info), + data: None, + } + } + + pub fn with_level(mut self, level: SeverityLevel) -> Self { + self.level = Some(level); + self + } + + pub fn with_data(mut self, data: HashMap) -> Self { + self.data = Some(data); + self + } +} + +/// Represents user information for context. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option>, +} + +impl User { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + email: None, + name: None, + data: None, + } + } + + pub fn with_email(mut self, email: impl Into) -> Self { + self.email = Some(email.into()); + self + } + + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + pub fn with_data(mut self, data: HashMap) -> Self { + self.data = Some(data); + self + } +} + +/// Represents a single frame in a stack trace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StackFrame { + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lineno: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub colno: Option, +} + +/// Represents exception/error information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExceptionInfo { + #[serde(rename = "type")] + pub exception_type: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub stacktrace: Option>, +} + +impl ExceptionInfo { + pub fn new(exception_type: impl Into, message: impl Into) -> Self { + Self { + exception_type: exception_type.into(), + message: message.into(), + stacktrace: None, + } + } + + pub fn with_stacktrace(mut self, stacktrace: Vec) -> Self { + self.stacktrace = Some(stacktrace); + self + } +} + +/// Represents platform/runtime information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformInfo { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub os: Option, +} + +impl Default for PlatformInfo { + fn default() -> Self { + Self { + name: "rust".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + os: Some(std::env::consts::OS.to_string()), + } + } +} + +/// Represents journey context for tracking user flows. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JourneyContext { + pub journey_id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_step: Option, + pub started_at: DateTime, + pub metadata: HashMap, +} + +/// Represents a telemetry event payload sent to the server. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TelemetryEvent { + pub event_id: String, + pub timestamp: DateTime, + pub level: SeverityLevel, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exception: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + pub tags: HashMap, + pub extra: HashMap, + pub breadcrumbs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub journey: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_version: Option, + pub platform: PlatformInfo, +} + +/// Represents parsed DSN components. +#[derive(Debug, Clone)] +pub struct ParsedDsn { + pub public_key: String, + pub host: String, + pub protocol: String, + pub api_base_url: String, +} + +/// Represents the result of sending an event. +#[derive(Debug, Clone)] +pub struct SendResult { + pub success: bool, + pub event_id: Option, + pub error: Option, + pub queued: bool, +} + +impl SendResult { + pub fn success(event_id: String) -> Self { + Self { + success: true, + event_id: Some(event_id), + error: None, + queued: false, + } + } + + pub fn failure(error: impl Into) -> Self { + Self { + success: false, + event_id: None, + error: Some(error.into()), + queued: false, + } + } + + pub fn queued(event_id: String) -> Self { + Self { + success: true, + event_id: Some(event_id), + error: None, + queued: true, + } + } +}