Skip to main content

claude_agent_sdk/observability/
tracing_setup.rs

1//! # Tracing Setup for Agent SDK
2//!
3//! This module provides structured tracing setup with spans, events, and
4//! request tracing IDs for comprehensive observability.
5//!
6//! ## Features
7//!
8//! - **Subscriber Setup**: Easy initialization of tracing subscribers
9//! - **Structured Spans**: Pre-configured spans for SDK operations
10//! - **Request Tracing IDs**: Automatic generation and propagation of trace IDs
11//! - **Error Category Integration**: Structured logging with error categories
12//! - **Multiple Output Formats**: JSON, text, and compact formats
13//!
14//! ## Example
15//!
16//! ```no_run
17//! use claude_agent_sdk::observability::tracing_setup::{
18//!     init_tracing, TracingConfig, OutputFormat,
19//! };
20//!
21//! // Initialize tracing at application startup
22//! let config = TracingConfig {
23//!     level: "info".to_string(),
24//!     format: OutputFormat::Json,
25//!     ..Default::default()
26//! };
27//! init_tracing(config);
28//!
29//! // Now all SDK operations will produce structured logs
30//! ```
31
32use std::sync::OnceLock;
33use tracing_subscriber::{
34    fmt::{self, format::FmtSpan},
35    layer::SubscriberExt,
36    util::SubscriberInitExt,
37    EnvFilter, Layer,
38};
39
40/// Output format for tracing
41#[derive(Debug, Clone, Copy, Default)]
42pub enum OutputFormat {
43    /// Human-readable text format
44    #[default]
45    Text,
46    /// JSON format (for structured logging)
47    Json,
48    /// Compact single-line format
49    Compact,
50}
51
52/// Configuration for tracing setup
53#[derive(Debug, Clone)]
54pub struct TracingConfig {
55    /// Minimum log level (trace, debug, info, warn, error)
56    pub level: String,
57    /// Output format
58    pub format: OutputFormat,
59    /// Include thread IDs in logs
60    pub with_thread_ids: bool,
61    /// Include thread names in logs
62    pub with_thread_names: bool,
63    /// Include target in logs
64    pub with_target: bool,
65    /// Include file and line information
66    pub with_file: bool,
67    /// Include line number
68    pub with_line_number: bool,
69    /// Span events to include (creation, enter, exit, close)
70    pub span_events: FmtSpan,
71    /// Use ANSI colors (for text format)
72    pub ansi: bool,
73    /// Custom environment filter override
74    pub env_filter_override: Option<String>,
75}
76
77impl Default for TracingConfig {
78    fn default() -> Self {
79        Self {
80            level: "info".to_string(),
81            format: OutputFormat::default(),
82            with_thread_ids: false,
83            with_thread_names: false,
84            with_target: true,
85            with_file: false,
86            with_line_number: false,
87            span_events: FmtSpan::NONE,
88            ansi: true,
89            env_filter_override: None,
90        }
91    }
92}
93
94impl TracingConfig {
95    /// Create a config for production use (JSON, info level)
96    pub fn production() -> Self {
97        Self {
98            level: "info".to_string(),
99            format: OutputFormat::Json,
100            with_thread_ids: true,
101            with_thread_names: true,
102            with_target: true,
103            with_file: false,
104            with_line_number: false,
105            span_events: FmtSpan::CLOSE,
106            ansi: false,
107            env_filter_override: None,
108        }
109    }
110
111    /// Create a config for development (text, debug level, colors)
112    pub fn development() -> Self {
113        Self {
114            level: "debug".to_string(),
115            format: OutputFormat::Text,
116            with_thread_ids: false,
117            with_thread_names: false,
118            with_target: true,
119            with_file: true,
120            with_line_number: true,
121            span_events: FmtSpan::NEW | FmtSpan::CLOSE,
122            ansi: true,
123            env_filter_override: None,
124        }
125    }
126
127    /// Create a config for testing (compact, trace level)
128    pub fn testing() -> Self {
129        Self {
130            level: "trace".to_string(),
131            format: OutputFormat::Compact,
132            with_thread_ids: false,
133            with_thread_names: false,
134            with_target: false,
135            with_file: false,
136            with_line_number: false,
137            span_events: FmtSpan::NONE,
138            ansi: false,
139            env_filter_override: None,
140        }
141    }
142}
143
144static TRACING_INITIALIZED: OnceLock<bool> = OnceLock::new();
145
146/// Initialize the tracing subscriber with the given configuration.
147///
148/// This should be called once at application startup. Subsequent calls
149/// will be ignored (no-op).
150///
151/// # Arguments
152///
153/// * `config` - Configuration for the tracing setup
154///
155/// # Example
156///
157/// ```no_run
158/// use claude_agent_sdk::observability::tracing_setup::{
159///     init_tracing, TracingConfig, OutputFormat,
160/// };
161///
162/// let config = TracingConfig {
163///     level: "info".into(),
164///     format: OutputFormat::Json,
165///     ..Default::default()
166/// };
167/// init_tracing(config);
168/// ```
169pub fn init_tracing(config: TracingConfig) {
170    // Only initialize once
171    if TRACING_INITIALIZED.get().is_some() {
172        return;
173    }
174
175    // Build the env filter
176    let env_filter = if let Some(ref filter) = config.env_filter_override {
177        EnvFilter::new(filter)
178    } else {
179        EnvFilter::try_from_default_env()
180            .unwrap_or_else(|_| EnvFilter::new(&config.level))
181    };
182
183    // Create the formatting layer based on config
184    let result = TRACING_INITIALIZED.set(true);
185
186    if result.is_err() {
187        // Already initialized
188        return;
189    }
190
191    match config.format {
192        OutputFormat::Json => {
193            let layer = fmt::layer()
194                .json()
195                .with_thread_ids(config.with_thread_ids)
196                .with_thread_names(config.with_thread_names)
197                .with_target(config.with_target)
198                .with_file(config.with_file)
199                .with_line_number(config.with_line_number)
200                .with_span_events(config.span_events)
201                .with_filter(env_filter);
202
203            if let Err(e) = tracing_subscriber::registry().with(layer).try_init() {
204                eprintln!("Failed to initialize tracing: {:?}", e);
205            }
206        }
207        OutputFormat::Text => {
208            let layer = fmt::layer()
209                .with_thread_ids(config.with_thread_ids)
210                .with_thread_names(config.with_thread_names)
211                .with_target(config.with_target)
212                .with_file(config.with_file)
213                .with_line_number(config.with_line_number)
214                .with_span_events(config.span_events)
215                .with_ansi(config.ansi)
216                .with_filter(env_filter);
217
218            if let Err(e) = tracing_subscriber::registry().with(layer).try_init() {
219                eprintln!("Failed to initialize tracing: {:?}", e);
220            }
221        }
222        OutputFormat::Compact => {
223            let layer = fmt::layer()
224                .compact()
225                .with_thread_ids(config.with_thread_ids)
226                .with_thread_names(config.with_thread_names)
227                .with_target(config.with_target)
228                .with_file(config.with_file)
229                .with_line_number(config.with_line_number)
230                .with_ansi(config.ansi)
231                .with_filter(env_filter);
232
233            if let Err(e) = tracing_subscriber::registry().with(layer).try_init() {
234                eprintln!("Failed to initialize tracing: {:?}", e);
235            }
236        }
237    }
238}
239
240/// Initialize tracing with default configuration.
241///
242/// Uses the `RUST_LOG` environment variable if set, otherwise defaults to `info`.
243pub fn init_default() {
244    init_tracing(TracingConfig::default());
245}
246
247/// Check if tracing has been initialized.
248pub fn is_initialized() -> bool {
249    TRACING_INITIALIZED.get().is_some()
250}
251
252// ============================================================================
253// Request Tracing ID Support
254// ============================================================================
255
256use std::sync::atomic::{AtomicU64, Ordering};
257use uuid::Uuid;
258
259static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
260
261/// Generate a unique request tracing ID.
262///
263/// The ID is a combination of:
264/// - A UUID v4 prefix (8 characters)
265/// - A monotonically increasing counter
266///
267/// Format: `{uuid_prefix}-{counter}` (e.g., `a1b2c3d4-000001`)
268pub fn generate_request_id() -> String {
269    let uuid = Uuid::new_v4();
270    let uuid_prefix = &uuid.to_string().replace('-', "")[..8];
271    let counter = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
272    format!("{}-{:06}", uuid_prefix, counter)
273}
274
275/// Generate a unique span ID for distributed tracing.
276pub fn generate_span_id() -> String {
277    let uuid = Uuid::new_v4();
278    uuid.to_string().replace('-', "")[..16].to_string()
279}
280
281// ============================================================================
282// Span Helpers for SDK Operations
283// ============================================================================
284
285/// Create a span for a query operation.
286#[macro_export]
287macro_rules! query_span {
288    ($request_id:expr) => {
289        tracing::info_span!(
290            "query",
291            request_id = %$request_id,
292            sdk.component = "query",
293            sdk.version = env!("CARGO_PKG_VERSION")
294        )
295    };
296}
297
298/// Create a span for a transport operation.
299#[macro_export]
300macro_rules! transport_span {
301    ($operation:expr, $transport_type:expr) => {
302        tracing::debug_span!(
303            "transport",
304            operation = %$operation,
305            transport_type = %$transport_type,
306            sdk.component = "transport"
307        )
308    };
309}
310
311/// Create a span for a skill operation.
312#[macro_export]
313macro_rules! skill_span {
314    ($skill_name:expr, $operation:expr) => {
315        tracing::info_span!(
316            "skill",
317            skill_name = %$skill_name,
318            operation = %$operation,
319            sdk.component = "skills"
320        )
321    };
322}
323
324/// Create a span for a connection pool operation.
325#[macro_export]
326macro_rules! pool_span {
327    ($operation:expr) => {
328        tracing::debug_span!(
329            "connection_pool",
330            operation = %$operation,
331            sdk.component = "pool"
332        )
333    };
334}
335
336/// Create a span for an MCP operation.
337#[macro_export]
338macro_rules! mcp_span {
339    ($tool_name:expr, $operation:expr) => {
340        tracing::info_span!(
341            "mcp",
342            tool_name = %$tool_name,
343            operation = %$operation,
344            sdk.component = "mcp"
345        )
346    };
347}
348
349// ============================================================================
350// Error Category Logging Helpers
351// ============================================================================
352
353/// Log an error with its category and structured fields.
354#[macro_export]
355macro_rules! log_error_with_category {
356    ($error:expr, $category:expr, $message:expr) => {
357        match $category {
358            $crate::errors::ErrorCategory::Network => {
359                tracing::error!(
360                    error.category = "network",
361                    error.code = %$error.error_code(),
362                    error.retryable = $error.is_retryable(),
363                    error.http_status = $error.http_status().code(),
364                    message = %$message,
365                    error = %$error
366                )
367            }
368            $crate::errors::ErrorCategory::Process => {
369                tracing::error!(
370                    error.category = "process",
371                    error.code = %$error.error_code(),
372                    error.retryable = $error.is_retryable(),
373                    message = %$message,
374                    error = %$error
375                )
376            }
377            $crate::errors::ErrorCategory::Parsing => {
378                tracing::error!(
379                    error.category = "parsing",
380                    error.code = %$error.error_code(),
381                    error.retryable = false,
382                    message = %$message,
383                    error = %$error
384                )
385            }
386            $crate::errors::ErrorCategory::Configuration => {
387                tracing::error!(
388                    error.category = "configuration",
389                    error.code = %$error.error_code(),
390                    error.retryable = false,
391                    message = %$message,
392                    error = %$error
393                )
394            }
395            $crate::errors::ErrorCategory::Validation => {
396                tracing::error!(
397                    error.category = "validation",
398                    error.code = %$error.error_code(),
399                    error.retryable = false,
400                    message = %$message,
401                    error = %$error
402                )
403            }
404            $crate::errors::ErrorCategory::Permission => {
405                tracing::error!(
406                    error.category = "permission",
407                    error.code = %$error.error_code(),
408                    error.retryable = false,
409                    error.http_status = $error.http_status().code(),
410                    message = %$message,
411                    error = %$error
412                )
413            }
414            $crate::errors::ErrorCategory::Resource => {
415                tracing::error!(
416                    error.category = "resource",
417                    error.code = %$error.error_code(),
418                    error.retryable = $error.is_retryable(),
419                    message = %$message,
420                    error = %$error
421                )
422            }
423            $crate::errors::ErrorCategory::Internal => {
424                tracing::error!(
425                    error.category = "internal",
426                    error.code = %$error.error_code(),
427                    error.retryable = false,
428                    message = %$message,
429                    error = %$error
430                )
431            }
432            $crate::errors::ErrorCategory::External => {
433                tracing::error!(
434                    error.category = "external",
435                    error.code = %$error.error_code(),
436                    error.retryable = $error.is_retryable(),
437                    message = %$message,
438                    error = %$error
439                )
440            }
441        }
442    };
443}
444
445/// Log a warning for a retryable error.
446#[macro_export]
447macro_rules! log_retryable_error {
448    ($error:expr, $attempt:expr, $max_attempts:expr, $message:expr) => {
449        tracing::warn!(
450            error.category = ?$error.category(),
451            error.code = %$error.error_code(),
452            retry.attempt = $attempt,
453            retry.max_attempts = $max_attempts,
454            message = %$message,
455            error = %$error,
456            "Retryable error, will retry"
457        )
458    };
459}
460
461// ============================================================================
462// Structured Metrics Logging
463// ============================================================================
464
465/// Log a timing metric.
466pub fn log_timing(operation: &str, duration_ms: u64, labels: &[(&str, &str)]) {
467    let labels_str = labels
468        .iter()
469        .map(|(k, v)| format!("{}={}", k, v))
470        .collect::<Vec<_>>()
471        .join(",");
472
473    tracing::info!(
474        metric.name = operation,
475        metric.kind = "timing",
476        metric.value_ms = duration_ms,
477        metric.labels = %labels_str,
478        "Operation completed"
479    );
480}
481
482/// Log a counter increment.
483pub fn log_counter(name: &str, increment: u64, labels: &[(&str, &str)]) {
484    let labels_str = labels
485        .iter()
486        .map(|(k, v)| format!("{}={}", k, v))
487        .collect::<Vec<_>>()
488        .join(",");
489
490    tracing::debug!(
491        metric.name = name,
492        metric.kind = "counter",
493        metric.increment = increment,
494        metric.labels = %labels_str,
495        "Counter incremented"
496    );
497}
498
499/// Log a gauge value.
500pub fn log_gauge(name: &str, value: f64, labels: &[(&str, &str)]) {
501    let labels_str = labels
502        .iter()
503        .map(|(k, v)| format!("{}={}", k, v))
504        .collect::<Vec<_>>()
505        .join(",");
506
507    tracing::debug!(
508        metric.name = name,
509        metric.kind = "gauge",
510        metric.value = value,
511        metric.labels = %labels_str,
512        "Gauge recorded"
513    );
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn test_generate_request_id() {
522        let id1 = generate_request_id();
523        let id2 = generate_request_id();
524
525        // IDs should be different (counter incremented)
526        assert_ne!(id1, id2);
527
528        // Should have the correct format: {8-char-uuid}-{6-digit-counter}
529        assert_eq!(id1.len(), 15); // 8 + 1 + 6
530        assert!(id1.contains('-'));
531
532        let parts: Vec<&str> = id1.split('-').collect();
533        assert_eq!(parts.len(), 2);
534        assert_eq!(parts[0].len(), 8);
535        assert_eq!(parts[1].len(), 6);
536    }
537
538    #[test]
539    fn test_generate_span_id() {
540        let span_id = generate_span_id();
541        assert_eq!(span_id.len(), 16);
542        assert!(span_id.chars().all(|c| c.is_ascii_hexdigit()));
543    }
544
545    #[test]
546    fn test_tracing_config_defaults() {
547        let config = TracingConfig::default();
548        assert_eq!(config.level, "info");
549        assert!(matches!(config.format, OutputFormat::Text));
550        assert!(config.with_target);
551        assert!(!config.with_file);
552    }
553
554    #[test]
555    fn test_tracing_config_production() {
556        let config = TracingConfig::production();
557        assert_eq!(config.level, "info");
558        assert!(matches!(config.format, OutputFormat::Json));
559        assert!(config.with_thread_ids);
560        assert!(!config.ansi);
561    }
562
563    #[test]
564    fn test_tracing_config_development() {
565        let config = TracingConfig::development();
566        assert_eq!(config.level, "debug");
567        assert!(matches!(config.format, OutputFormat::Text));
568        assert!(config.ansi);
569        assert!(config.with_file);
570    }
571
572    #[test]
573    fn test_is_initialized_before_init() {
574        // Can't test this easily since it's global state
575        // Just ensure the function exists and compiles
576        let _ = is_initialized();
577    }
578
579    #[test]
580    fn test_log_timing() {
581        // This test just ensures the function compiles and runs
582        // The actual output goes to the tracing subscriber
583        log_timing("test_operation", 100, &[("key", "value")]);
584    }
585
586    #[test]
587    fn test_log_counter() {
588        log_counter("test_counter", 1, &[("endpoint", "/api/test")]);
589    }
590
591    #[test]
592    fn test_log_gauge() {
593        log_gauge("test_gauge", 42.5, &[("location", "room1")]);
594    }
595}