Skip to main content

pmcp/server/observability/
config.rs

1//! Observability configuration.
2//!
3//! Configuration can be loaded from:
4//! 1. TOML file (`.pmcp-config.toml`)
5//! 2. Environment variables (with `PMCP_OBSERVABILITY_` prefix)
6//!
7//! Environment variables override TOML configuration.
8//!
9//! # Example TOML Configuration
10//!
11//! ```toml
12//! [observability]
13//! enabled = true
14//! backend = "cloudwatch"
15//! max_depth = 10
16//! sample_rate = 1.0
17//!
18//! [observability.fields]
19//! capture_tool_name = true
20//! capture_arguments_hash = false
21//! capture_client_ip = false
22//!
23//! [observability.cloudwatch]
24//! namespace = "PMCP/Servers"
25//! emf_enabled = true
26//! ```
27
28use super::backend::CloudWatchConfig;
29use serde::{Deserialize, Serialize};
30use std::path::Path;
31
32/// Main observability configuration.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(default)]
35pub struct ObservabilityConfig {
36    /// Master switch for observability.
37    pub enabled: bool,
38
39    /// Backend selection: "console", "cloudwatch", "null".
40    pub backend: String,
41
42    /// Maximum composition depth (loop prevention).
43    pub max_depth: u32,
44
45    /// Sampling rate (0.0 - 1.0, for high-volume servers).
46    pub sample_rate: f64,
47
48    /// Tracing configuration.
49    pub tracing: TracingConfig,
50
51    /// Field capture configuration.
52    pub fields: FieldsConfig,
53
54    /// Metrics configuration.
55    pub metrics: MetricsConfig,
56
57    /// CloudWatch-specific configuration.
58    pub cloudwatch: CloudWatchConfig,
59
60    /// Console-specific configuration.
61    pub console: ConsoleConfig,
62}
63
64impl Default for ObservabilityConfig {
65    fn default() -> Self {
66        Self {
67            enabled: true,
68            backend: "console".to_string(),
69            max_depth: 10,
70            sample_rate: 1.0,
71            tracing: TracingConfig::default(),
72            fields: FieldsConfig::default(),
73            metrics: MetricsConfig::default(),
74            cloudwatch: CloudWatchConfig::default(),
75            console: ConsoleConfig::default(),
76        }
77    }
78}
79
80impl ObservabilityConfig {
81    /// Load configuration from file and environment.
82    ///
83    /// Priority (highest to lowest):
84    /// 1. Environment variables
85    /// 2. TOML configuration file
86    /// 3. Default values
87    pub fn load() -> Result<Self, ConfigError> {
88        // Load from .pmcp-config.toml if it exists, otherwise use defaults
89        let mut config = if let Ok(contents) = std::fs::read_to_string(".pmcp-config.toml") {
90            Self::from_toml(&contents)?
91        } else {
92            Self::default()
93        };
94
95        // Override with environment variables
96        config.apply_env_overrides();
97
98        Ok(config)
99    }
100
101    /// Load configuration from a specific file path.
102    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
103        let contents = std::fs::read_to_string(path.as_ref()).map_err(|e| ConfigError::Io {
104            path: path.as_ref().display().to_string(),
105            error: e.to_string(),
106        })?;
107        let mut config = Self::from_toml(&contents)?;
108        config.apply_env_overrides();
109        Ok(config)
110    }
111
112    /// Parse configuration from TOML content.
113    pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
114        // Try to parse the full config structure first
115        #[derive(Deserialize)]
116        struct FullConfig {
117            #[serde(default)]
118            observability: ObservabilityConfig,
119        }
120
121        let full: FullConfig =
122            toml::from_str(content).map_err(|e| ConfigError::Parse(e.to_string()))?;
123
124        Ok(full.observability)
125    }
126
127    /// Apply environment variable overrides.
128    fn apply_env_overrides(&mut self) {
129        // Master switch
130        if let Ok(enabled) = std::env::var("PMCP_OBSERVABILITY_ENABLED") {
131            if let Ok(v) = enabled.parse() {
132                self.enabled = v;
133            }
134        }
135
136        // Backend selection
137        if let Ok(backend) = std::env::var("PMCP_OBSERVABILITY_BACKEND") {
138            self.backend = backend;
139        }
140
141        // Max depth
142        if let Ok(max_depth) = std::env::var("PMCP_OBSERVABILITY_MAX_DEPTH") {
143            if let Ok(v) = max_depth.parse() {
144                self.max_depth = v;
145            }
146        }
147
148        // Sample rate
149        if let Ok(sample_rate) = std::env::var("PMCP_OBSERVABILITY_SAMPLE_RATE") {
150            if let Ok(v) = sample_rate.parse() {
151                self.sample_rate = v;
152            }
153        }
154
155        // Field capture overrides
156        if let Ok(v) = std::env::var("PMCP_OBSERVABILITY_CAPTURE_TOOL_NAME") {
157            if let Ok(b) = v.parse() {
158                self.fields.capture_tool_name = b;
159            }
160        }
161        if let Ok(v) = std::env::var("PMCP_OBSERVABILITY_CAPTURE_ARGUMENTS_HASH") {
162            if let Ok(b) = v.parse() {
163                self.fields.capture_arguments_hash = b;
164            }
165        }
166        if let Ok(v) = std::env::var("PMCP_OBSERVABILITY_CAPTURE_CLIENT_IP") {
167            if let Ok(b) = v.parse() {
168                self.fields.capture_client_ip = b;
169            }
170        }
171        if let Ok(v) = std::env::var("PMCP_OBSERVABILITY_CAPTURE_RESPONSE_SIZE") {
172            if let Ok(b) = v.parse() {
173                self.fields.capture_response_size = b;
174            }
175        }
176
177        // CloudWatch overrides
178        if let Ok(namespace) = std::env::var("PMCP_CLOUDWATCH_NAMESPACE") {
179            self.cloudwatch.namespace = namespace;
180        }
181        if let Ok(emf) = std::env::var("PMCP_CLOUDWATCH_EMF_ENABLED") {
182            if let Ok(v) = emf.parse() {
183                self.cloudwatch.emf_enabled = v;
184            }
185        }
186
187        // Console overrides
188        if let Ok(pretty) = std::env::var("PMCP_CONSOLE_PRETTY") {
189            if let Ok(v) = pretty.parse() {
190                self.console.pretty = v;
191            }
192        }
193    }
194
195    /// Check if sampling should capture this request.
196    ///
197    /// Uses the configured sample rate to randomly decide.
198    /// Uses a simple time-based entropy source to avoid requiring
199    /// the `rand` crate.
200    pub fn should_sample(&self) -> bool {
201        if self.sample_rate >= 1.0 {
202            return true;
203        }
204        if self.sample_rate <= 0.0 {
205            return false;
206        }
207        // Simple sampling using time-based entropy
208        // This is not cryptographically secure but sufficient for sampling
209        let nanos = std::time::SystemTime::now()
210            .duration_since(std::time::UNIX_EPOCH)
211            .unwrap_or_default()
212            .subsec_nanos();
213        let random_value = (nanos as f64) / (u32::MAX as f64);
214        random_value < self.sample_rate
215    }
216
217    /// Create a disabled configuration.
218    pub fn disabled() -> Self {
219        Self {
220            enabled: false,
221            ..Default::default()
222        }
223    }
224
225    /// Create a development configuration with console output.
226    pub fn development() -> Self {
227        Self {
228            enabled: true,
229            backend: "console".to_string(),
230            console: ConsoleConfig {
231                pretty: true,
232                verbose: false,
233            },
234            ..Default::default()
235        }
236    }
237
238    /// Create a production configuration with `CloudWatch`.
239    pub fn production() -> Self {
240        Self {
241            enabled: true,
242            backend: "cloudwatch".to_string(),
243            cloudwatch: CloudWatchConfig::default(),
244            ..Default::default()
245        }
246    }
247}
248
249/// Tracing configuration.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(default)]
252pub struct TracingConfig {
253    /// Enable distributed tracing.
254    pub enabled: bool,
255
256    /// Header name for HTTP trace propagation.
257    pub trace_header: String,
258
259    /// Field name for Lambda payload trace propagation.
260    pub trace_field: String,
261}
262
263impl Default for TracingConfig {
264    fn default() -> Self {
265        Self {
266            enabled: true,
267            trace_header: "X-Trace-ID".to_string(),
268            trace_field: "_trace".to_string(),
269        }
270    }
271}
272
273/// Field capture configuration.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(default)]
276#[allow(clippy::struct_excessive_bools)]
277pub struct FieldsConfig {
278    /// Capture tool names in logs.
279    pub capture_tool_name: bool,
280
281    /// Capture resource URIs in logs.
282    pub capture_resource_uri: bool,
283
284    /// Capture prompt names in logs.
285    pub capture_prompt_name: bool,
286
287    /// Capture argument hashes (for correlation without exposing data).
288    pub capture_arguments_hash: bool,
289
290    /// Capture full arguments (privacy-sensitive, use with caution).
291    pub capture_full_arguments: bool,
292
293    /// Capture user ID from `AuthContext`.
294    pub capture_user_id: bool,
295
296    /// Capture client type.
297    pub capture_client_type: bool,
298
299    /// Capture client version.
300    pub capture_client_version: bool,
301
302    /// Capture client IP (privacy-sensitive, default off).
303    pub capture_client_ip: bool,
304
305    /// Capture session ID.
306    pub capture_session_id: bool,
307
308    /// Capture response size.
309    pub capture_response_size: bool,
310
311    /// Capture error details.
312    pub capture_error_details: bool,
313}
314
315impl Default for FieldsConfig {
316    fn default() -> Self {
317        Self {
318            capture_tool_name: true,
319            capture_resource_uri: true,
320            capture_prompt_name: true,
321            capture_arguments_hash: false,
322            capture_full_arguments: false,
323            capture_user_id: true,
324            capture_client_type: true,
325            capture_client_version: true,
326            capture_client_ip: false, // Privacy-sensitive
327            capture_session_id: true,
328            capture_response_size: true,
329            capture_error_details: true,
330        }
331    }
332}
333
334/// Metrics configuration.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336#[serde(default)]
337#[allow(clippy::struct_excessive_bools)]
338pub struct MetricsConfig {
339    /// Emit request count metrics.
340    pub request_count: bool,
341
342    /// Emit request duration metrics.
343    pub request_duration: bool,
344
345    /// Emit error rate metrics.
346    pub error_rate: bool,
347
348    /// Emit per-tool usage metrics.
349    pub tool_usage: bool,
350
351    /// Emit per-resource usage metrics.
352    pub resource_usage: bool,
353
354    /// Emit per-prompt usage metrics.
355    pub prompt_usage: bool,
356
357    /// Custom metric prefix.
358    pub prefix: String,
359}
360
361impl Default for MetricsConfig {
362    fn default() -> Self {
363        Self {
364            request_count: true,
365            request_duration: true,
366            error_rate: true,
367            tool_usage: true,
368            resource_usage: true,
369            prompt_usage: true,
370            prefix: "mcp".to_string(),
371        }
372    }
373}
374
375/// Console backend configuration.
376#[derive(Debug, Clone, Serialize, Deserialize)]
377#[serde(default)]
378pub struct ConsoleConfig {
379    /// Pretty print (human-readable format).
380    pub pretty: bool,
381
382    /// Include full event details (verbose mode).
383    pub verbose: bool,
384}
385
386impl Default for ConsoleConfig {
387    fn default() -> Self {
388        Self {
389            pretty: true,
390            verbose: false,
391        }
392    }
393}
394
395/// Configuration errors.
396#[derive(Debug)]
397pub enum ConfigError {
398    /// IO error reading configuration file.
399    Io {
400        /// Path to the configuration file.
401        path: String,
402        /// Error message.
403        error: String,
404    },
405    /// Parse error in configuration.
406    Parse(String),
407}
408
409impl std::fmt::Display for ConfigError {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        match self {
412            Self::Io { path, error } => {
413                write!(f, "Failed to read config file '{path}': {error}")
414            },
415            Self::Parse(e) => write!(f, "Failed to parse config: {e}"),
416        }
417    }
418}
419
420impl std::error::Error for ConfigError {}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_default_config() {
428        let config = ObservabilityConfig::default();
429
430        assert!(config.enabled);
431        assert_eq!(config.backend, "console");
432        assert_eq!(config.max_depth, 10);
433        assert!((config.sample_rate - 1.0).abs() < f64::EPSILON);
434        assert!(config.fields.capture_tool_name);
435        assert!(!config.fields.capture_client_ip);
436    }
437
438    #[test]
439    fn test_from_toml() {
440        let toml = r#"
441            [observability]
442            enabled = true
443            backend = "cloudwatch"
444            max_depth = 5
445            sample_rate = 0.5
446
447            [observability.fields]
448            capture_tool_name = true
449            capture_client_ip = true
450
451            [observability.cloudwatch]
452            namespace = "MyApp/MCP"
453            emf_enabled = true
454        "#;
455
456        let config = ObservabilityConfig::from_toml(toml).unwrap();
457
458        assert!(config.enabled);
459        assert_eq!(config.backend, "cloudwatch");
460        assert_eq!(config.max_depth, 5);
461        assert!((config.sample_rate - 0.5).abs() < f64::EPSILON);
462        assert!(config.fields.capture_tool_name);
463        assert!(config.fields.capture_client_ip);
464        assert_eq!(config.cloudwatch.namespace, "MyApp/MCP");
465    }
466
467    #[test]
468    fn test_disabled_config() {
469        let config = ObservabilityConfig::disabled();
470        assert!(!config.enabled);
471    }
472
473    #[test]
474    fn test_development_config() {
475        let config = ObservabilityConfig::development();
476        assert!(config.enabled);
477        assert_eq!(config.backend, "console");
478        assert!(config.console.pretty);
479    }
480
481    #[test]
482    fn test_production_config() {
483        let config = ObservabilityConfig::production();
484        assert!(config.enabled);
485        assert_eq!(config.backend, "cloudwatch");
486    }
487
488    #[test]
489    fn test_should_sample_always() {
490        let config = ObservabilityConfig {
491            sample_rate: 1.0,
492            ..Default::default()
493        };
494
495        // Should always sample at 100%
496        for _ in 0..100 {
497            assert!(config.should_sample());
498        }
499    }
500
501    #[test]
502    fn test_should_sample_never() {
503        let config = ObservabilityConfig {
504            sample_rate: 0.0,
505            ..Default::default()
506        };
507
508        // Should never sample at 0%
509        for _ in 0..100 {
510            assert!(!config.should_sample());
511        }
512    }
513
514    #[test]
515    fn test_tracing_config_defaults() {
516        let config = TracingConfig::default();
517
518        assert!(config.enabled);
519        assert_eq!(config.trace_header, "X-Trace-ID");
520        assert_eq!(config.trace_field, "_trace");
521    }
522
523    #[test]
524    fn test_metrics_config_defaults() {
525        let config = MetricsConfig::default();
526
527        assert!(config.request_count);
528        assert!(config.request_duration);
529        assert!(config.error_rate);
530        assert_eq!(config.prefix, "mcp");
531    }
532}