clnrm_core/cli/
telemetry.rs

1//! CLI OpenTelemetry Integration
2//!
3//! Provides secure, configuration-driven OpenTelemetry initialization for the CLI.
4//! Follows core team best practices: no unwrap(), no hardcoded values, proper error handling.
5
6use crate::{
7    error::{CleanroomError, Result},
8    telemetry::{init_otel, OtelConfig, OtelGuard},
9};
10use std::env;
11use tracing::{span, Level};
12
13/// CLI-specific telemetry configuration
14/// Secure by design - no hardcoded secrets or environment variables
15#[derive(Clone, Debug)]
16pub struct CliOtelConfig {
17    /// Service identification
18    pub service_name: String,
19    pub service_version: String,
20    /// Environment configuration
21    pub deployment_env: String,
22    /// Sampling configuration
23    pub sample_ratio: f64,
24    /// Export configuration (secure - no secrets stored)
25    pub export_endpoint: Option<String>,
26    pub export_format: ExportFormat,
27    /// Local development settings
28    pub enable_console_output: bool,
29}
30
31#[derive(Clone, Debug)]
32pub enum ExportFormat {
33    /// OTLP HTTP to collector
34    OtlpHttp,
35    /// OTLP gRPC to collector
36    OtlpGrpc,
37    /// Console output for local development
38    Stdout,
39    /// NDJSON output for log aggregation
40    StdoutNdjson,
41}
42
43/// CLI telemetry manager
44/// Handles OTel lifecycle and provides span creation capabilities
45pub struct CliTelemetry {
46    /// OTel guard (automatically flushes on drop)
47    _guard: Option<OtelGuard>,
48    /// Configuration for reference
49    config: CliOtelConfig,
50}
51
52impl CliTelemetry {
53    /// Initialize CLI telemetry with secure configuration
54    /// No unwrap() calls - proper error handling throughout
55    pub fn init(config: CliOtelConfig) -> Result<Self> {
56        // Create OTel configuration from CLI config
57        let otel_config = CliTelemetry::create_otel_config_static(&config)?;
58
59        // Initialize OTel if enabled (lazy initialization)
60        let guard = if config.is_enabled() {
61            Some(init_otel(otel_config)?)
62        } else {
63            None
64        };
65
66        Ok(Self {
67            _guard: guard,
68            config,
69        })
70    }
71
72    /// Check if telemetry is enabled
73    pub fn is_enabled(&self) -> bool {
74        self.config.is_enabled()
75    }
76
77    /// Create a CLI operation span
78    /// Returns None if OTel is disabled
79    pub fn create_cli_span(&self, operation: &str) -> Option<tracing::Span> {
80        if !self.is_enabled() {
81            return None;
82        }
83
84        Some(span!(
85            Level::INFO,
86            "clnrm.cli.operation",
87            operation = operation,
88            cli.version = %self.config.service_version,
89            deployment.env = %self.config.deployment_env,
90        ))
91    }
92
93    /// Create a command execution span
94    pub fn create_command_span(&self, command: &str, args: &[String]) -> Option<tracing::Span> {
95        if !self.is_enabled() {
96            return None;
97        }
98
99        Some(span!(
100            Level::INFO,
101            "clnrm.cli.command",
102            command = command,
103            args = ?args,
104            deployment.env = %self.config.deployment_env,
105        ))
106    }
107
108    /// Convert CLI config to OTel config (static version for init)
109    /// Secure - no hardcoded values, all from configuration
110    fn create_otel_config_static(config: &CliOtelConfig) -> Result<OtelConfig> {
111        use crate::telemetry::Export;
112
113        // Convert endpoint to &'static str if needed
114        let export = match config.export_format {
115            ExportFormat::OtlpHttp => {
116                let endpoint = match &config.export_endpoint {
117                    Some(ep) => Box::leak(ep.clone().into_boxed_str()) as &'static str,
118                    None => "http://localhost:4318",
119                };
120                Export::OtlpHttp { endpoint }
121            }
122            ExportFormat::OtlpGrpc => {
123                let endpoint = match &config.export_endpoint {
124                    Some(ep) => Box::leak(ep.clone().into_boxed_str()) as &'static str,
125                    None => "http://localhost:4317",
126                };
127                Export::OtlpGrpc { endpoint }
128            }
129            ExportFormat::Stdout => Export::Stdout,
130            ExportFormat::StdoutNdjson => Export::StdoutNdjson,
131        };
132
133        // Load secure headers
134        let headers = Self::load_secure_headers_static()?;
135
136        // Convert String to &'static str by leaking (acceptable for telemetry config that lives for program lifetime)
137        let service_name: &'static str = Box::leak(config.service_name.clone().into_boxed_str());
138        let deployment_env: &'static str =
139            Box::leak(config.deployment_env.clone().into_boxed_str());
140
141        Ok(OtelConfig {
142            service_name,
143            deployment_env,
144            sample_ratio: config.sample_ratio,
145            export,
146            enable_fmt_layer: config.enable_console_output,
147            headers,
148        })
149    }
150
151    /// Load secure headers from environment variables (static version)
152    /// No hardcoded secrets - all from env vars
153    fn load_secure_headers_static() -> Result<Option<std::collections::HashMap<String, String>>> {
154        let mut headers = std::collections::HashMap::new();
155
156        // Load OTLP headers from environment variables
157        // Format: OTEL_EXPORTER_OTLP_HEADERS_key=value
158        for (key, value) in env::vars() {
159            if key.starts_with("OTEL_EXPORTER_OTLP_HEADERS_") {
160                let header_key = key
161                    .strip_prefix("OTEL_EXPORTER_OTLP_HEADERS_")
162                    .ok_or_else(|| CleanroomError::internal_error("Invalid header key format"))?
163                    .to_lowercase();
164
165                // Validate header key for security
166                if Self::is_safe_header_key(&header_key) {
167                    headers.insert(header_key, value);
168                }
169            }
170        }
171
172        Ok(if headers.is_empty() {
173            None
174        } else {
175            Some(headers)
176        })
177    }
178
179    /// Validate header key for security (static version)
180    fn is_safe_header_key(key: &str) -> bool {
181        // Only allow safe header keys, no injection vulnerabilities
182        let safe_keys = ["authorization", "api-key", "user-agent"];
183        safe_keys.contains(&key.to_lowercase().as_str())
184    }
185}
186
187impl CliOtelConfig {
188    /// Check if telemetry is enabled
189    pub fn is_enabled(&self) -> bool {
190        // Enable if sample ratio > 0 and endpoint is configured
191        self.sample_ratio > 0.0 && self.export_endpoint.is_some()
192    }
193
194    /// Create default development configuration
195    pub fn development() -> Self {
196        Self {
197            service_name: "clnrm-cli".to_string(),
198            service_version: env!("CARGO_PKG_VERSION").to_string(),
199            deployment_env: "development".to_string(),
200            sample_ratio: 1.0, // Sample everything in dev
201            export_endpoint: Some("http://localhost:4318".to_string()),
202            export_format: ExportFormat::Stdout, // Console output for dev
203            enable_console_output: true,
204        }
205    }
206
207    /// Create production configuration
208    pub fn production() -> Self {
209        Self {
210            service_name: "clnrm-cli".to_string(),
211            service_version: env!("CARGO_PKG_VERSION").to_string(),
212            deployment_env: "production".to_string(),
213            sample_ratio: 0.1, // Sample 10% in production
214            export_endpoint: env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(),
215            export_format: ExportFormat::OtlpHttp,
216            enable_console_output: false, // No console output in prod
217        }
218    }
219
220    /// Load configuration from environment variables
221    pub fn from_env() -> Result<Self> {
222        Ok(Self {
223            service_name: env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "clnrm-cli".to_string()),
224            service_version: env!("CARGO_PKG_VERSION").to_string(),
225            deployment_env: env::var("OTEL_DEPLOYMENT_ENV")
226                .unwrap_or_else(|_| "development".to_string()),
227            sample_ratio: env::var("OTEL_SAMPLE_RATIO")
228                .unwrap_or_else(|_| "1.0".to_string())
229                .parse()
230                .map_err(|e| {
231                    CleanroomError::internal_error(format!("Invalid sample ratio: {}", e))
232                })?,
233            export_endpoint: env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(),
234            export_format: Self::parse_export_format(
235                &env::var("OTEL_EXPORT_FORMAT").unwrap_or_else(|_| "stdout".to_string()),
236            )?,
237            enable_console_output: env::var("OTEL_ENABLE_CONSOLE")
238                .unwrap_or_else(|_| "true".to_string())
239                .parse()
240                .map_err(|e| {
241                    CleanroomError::internal_error(format!("Invalid console setting: {}", e))
242                })?,
243        })
244    }
245
246    /// Parse export format from string
247    fn parse_export_format(format: &str) -> Result<ExportFormat> {
248        match format.to_lowercase().as_str() {
249            "otlp-http" | "otlp_http" => Ok(ExportFormat::OtlpHttp),
250            "otlp-grpc" | "otlp_grpc" => Ok(ExportFormat::OtlpGrpc),
251            "stdout" => Ok(ExportFormat::Stdout),
252            "ndjson" => Ok(ExportFormat::StdoutNdjson),
253            _ => Err(CleanroomError::internal_error(format!(
254                "Unknown export format: {}",
255                format
256            ))),
257        }
258    }
259}