pmcp/server/observability/
config.rs1use super::backend::CloudWatchConfig;
29use serde::{Deserialize, Serialize};
30use std::path::Path;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(default)]
35pub struct ObservabilityConfig {
36 pub enabled: bool,
38
39 pub backend: String,
41
42 pub max_depth: u32,
44
45 pub sample_rate: f64,
47
48 pub tracing: TracingConfig,
50
51 pub fields: FieldsConfig,
53
54 pub metrics: MetricsConfig,
56
57 pub cloudwatch: CloudWatchConfig,
59
60 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 pub fn load() -> Result<Self, ConfigError> {
88 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 config.apply_env_overrides();
97
98 Ok(config)
99 }
100
101 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 pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
114 #[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 fn apply_env_overrides(&mut self) {
129 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 if let Ok(backend) = std::env::var("PMCP_OBSERVABILITY_BACKEND") {
138 self.backend = backend;
139 }
140
141 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 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 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 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 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 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 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 pub fn disabled() -> Self {
219 Self {
220 enabled: false,
221 ..Default::default()
222 }
223 }
224
225 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(default)]
252pub struct TracingConfig {
253 pub enabled: bool,
255
256 pub trace_header: String,
258
259 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#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(default)]
276#[allow(clippy::struct_excessive_bools)]
277pub struct FieldsConfig {
278 pub capture_tool_name: bool,
280
281 pub capture_resource_uri: bool,
283
284 pub capture_prompt_name: bool,
286
287 pub capture_arguments_hash: bool,
289
290 pub capture_full_arguments: bool,
292
293 pub capture_user_id: bool,
295
296 pub capture_client_type: bool,
298
299 pub capture_client_version: bool,
301
302 pub capture_client_ip: bool,
304
305 pub capture_session_id: bool,
307
308 pub capture_response_size: bool,
310
311 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, capture_session_id: true,
328 capture_response_size: true,
329 capture_error_details: true,
330 }
331 }
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336#[serde(default)]
337#[allow(clippy::struct_excessive_bools)]
338pub struct MetricsConfig {
339 pub request_count: bool,
341
342 pub request_duration: bool,
344
345 pub error_rate: bool,
347
348 pub tool_usage: bool,
350
351 pub resource_usage: bool,
353
354 pub prompt_usage: bool,
356
357 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#[derive(Debug, Clone, Serialize, Deserialize)]
377#[serde(default)]
378pub struct ConsoleConfig {
379 pub pretty: bool,
381
382 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#[derive(Debug)]
397pub enum ConfigError {
398 Io {
400 path: String,
402 error: String,
404 },
405 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 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 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}