clnrm_core/cli/
telemetry.rs1use crate::{
7 error::{CleanroomError, Result},
8 telemetry::{init_otel, OtelConfig, OtelGuard},
9};
10use std::env;
11use tracing::{span, Level};
12
13#[derive(Clone, Debug)]
16pub struct CliOtelConfig {
17 pub service_name: String,
19 pub service_version: String,
20 pub deployment_env: String,
22 pub sample_ratio: f64,
24 pub export_endpoint: Option<String>,
26 pub export_format: ExportFormat,
27 pub enable_console_output: bool,
29}
30
31#[derive(Clone, Debug)]
32pub enum ExportFormat {
33 OtlpHttp,
35 OtlpGrpc,
37 Stdout,
39 StdoutNdjson,
41}
42
43pub struct CliTelemetry {
46 _guard: Option<OtelGuard>,
48 config: CliOtelConfig,
50}
51
52impl CliTelemetry {
53 pub fn init(config: CliOtelConfig) -> Result<Self> {
56 let otel_config = CliTelemetry::create_otel_config_static(&config)?;
58
59 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 pub fn is_enabled(&self) -> bool {
74 self.config.is_enabled()
75 }
76
77 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 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 fn create_otel_config_static(config: &CliOtelConfig) -> Result<OtelConfig> {
111 use crate::telemetry::Export;
112
113 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 let headers = Self::load_secure_headers_static()?;
135
136 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 fn load_secure_headers_static() -> Result<Option<std::collections::HashMap<String, String>>> {
154 let mut headers = std::collections::HashMap::new();
155
156 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 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 fn is_safe_header_key(key: &str) -> bool {
181 let safe_keys = ["authorization", "api-key", "user-agent"];
183 safe_keys.contains(&key.to_lowercase().as_str())
184 }
185}
186
187impl CliOtelConfig {
188 pub fn is_enabled(&self) -> bool {
190 self.sample_ratio > 0.0 && self.export_endpoint.is_some()
192 }
193
194 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, export_endpoint: Some("http://localhost:4318".to_string()),
202 export_format: ExportFormat::Stdout, enable_console_output: true,
204 }
205 }
206
207 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, export_endpoint: env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(),
215 export_format: ExportFormat::OtlpHttp,
216 enable_console_output: false, }
218 }
219
220 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 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}