gemini_genai_rs/telemetry/
mod.rs

1//! Observability layer — OpenTelemetry tracing, structured logging, Prometheus metrics.
2//!
3//! All components are feature-gated for zero overhead when disabled:
4//! - `tracing-subscriber`: console logging — installs a fmt/EnvFilter subscriber
5//!   via `TelemetryConfig::init` (the `tracing` facade itself is always available)
6//! - `metrics`: Prometheus metric definitions and export
7//! - `otel-otlp`: OTLP trace and metric export to any OTel collector
8//! - `otel-gcp`: Google Cloud-native trace and metric export (Cloud Trace + Cloud Monitoring)
9
10pub mod logging;
11pub mod metrics;
12pub mod spans;
13
14/// Telemetry configuration.
15#[derive(Debug, Clone)]
16pub struct TelemetryConfig {
17    /// Enable structured logging. Installing the subscriber requires the
18    /// `tracing-subscriber` crate feature; without it this flag is inert.
19    pub logging_enabled: bool,
20    /// Log level filter (e.g., "info", "debug", "gemini_genai_rs=debug").
21    pub log_filter: String,
22    /// Use JSON format for logs (production). If false, uses pretty format (development).
23    pub json_logs: bool,
24    /// Enable Prometheus metrics endpoint.
25    pub metrics_enabled: bool,
26    /// Prometheus listen address (e.g., "0.0.0.0:9090").
27    pub metrics_addr: Option<String>,
28    /// Enable OTel trace export (requires `otel-otlp` or `otel-gcp` feature).
29    pub otel_traces: bool,
30    /// Enable OTel metrics export (requires `otel-otlp` or `otel-gcp` feature).
31    pub otel_metrics: bool,
32    /// OTel service name for resource identification.
33    pub otel_service_name: String,
34    /// OTLP collector endpoint (e.g. `http://localhost:4317`). `None` lets the
35    /// exporter fall back to the standard `OTEL_EXPORTER_OTLP_ENDPOINT` env var
36    /// and its default. Passed to the exporter builder directly; nothing here
37    /// mutates the process environment.
38    pub otel_endpoint: Option<String>,
39    /// Google Cloud project ID for GCP-native OTel export.
40    /// If None, auto-detects from ADC or environment.
41    pub otel_gcp_project: Option<String>,
42}
43
44impl Default for TelemetryConfig {
45    fn default() -> Self {
46        Self {
47            logging_enabled: true,
48            log_filter: "info".to_string(),
49            json_logs: false,
50            metrics_enabled: false,
51            metrics_addr: None,
52            otel_traces: false,
53            otel_metrics: false,
54            otel_service_name: "gemini-genai-rs".to_string(),
55            otel_endpoint: None,
56            otel_gcp_project: None,
57        }
58    }
59}
60
61/// Guard that keeps telemetry systems alive while held.
62/// Drop this to flush and shutdown OTel exporters.
63#[derive(Default)]
64pub struct TelemetryGuard {
65    #[cfg(feature = "otel-base")]
66    _tracer_provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>,
67    #[cfg(feature = "otel-base")]
68    _meter_provider: Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
69    #[cfg(not(feature = "otel-base"))]
70    _private: (),
71}
72
73impl TelemetryConfig {
74    /// Initialize telemetry subsystems based on configuration.
75    ///
76    /// When `otel-otlp` is enabled and `otel_traces`/`otel_metrics` are set,
77    /// this configures OTLP exporters that send data to whatever endpoint is set
78    /// via the standard `OTEL_EXPORTER_OTLP_ENDPOINT` env var (defaults to
79    /// `http://localhost:4317` for gRPC).
80    ///
81    /// When `otel-gcp` is enabled, use `init_gcp()` to set up Google Cloud-native
82    /// exporters, or configure providers manually and call `init_with_tracer()`.
83    ///
84    /// The returned `TelemetryGuard` must be held alive for the duration of the
85    /// application. Dropping it triggers a flush and shutdown of all exporters.
86    pub fn init(&self) -> Result<TelemetryGuard, Box<dyn std::error::Error>> {
87        #[allow(unused_mut)]
88        let mut guard = TelemetryGuard::default();
89
90        // --- OTel OTLP providers (must be created before tracing subscriber) ---
91        #[cfg(feature = "otel-otlp")]
92        let otel_tracer = if self.otel_traces {
93            use opentelemetry_otlp::WithExportConfig as _;
94            let mut builder = opentelemetry_otlp::SpanExporter::builder().with_tonic();
95            if let Some(endpoint) = &self.otel_endpoint {
96                builder = builder.with_endpoint(endpoint.clone());
97            }
98            let exporter = builder.build()?;
99            let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
100                .with_batch_exporter(exporter)
101                .with_resource(self.otel_resource())
102                .build();
103            let tracer = opentelemetry::trace::TracerProvider::tracer(
104                &provider,
105                self.otel_service_name.clone(),
106            );
107            guard._tracer_provider = Some(provider);
108            Some(tracer)
109        } else {
110            None
111        };
112
113        #[cfg(feature = "otel-otlp")]
114        if self.otel_metrics {
115            use opentelemetry_otlp::WithExportConfig as _;
116            let mut builder = opentelemetry_otlp::MetricExporter::builder().with_tonic();
117            if let Some(endpoint) = &self.otel_endpoint {
118                builder = builder.with_endpoint(endpoint.clone());
119            }
120            let exporter = builder.build()?;
121            let provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder()
122                .with_periodic_exporter(exporter)
123                .with_resource(self.otel_resource())
124                .build();
125            opentelemetry::global::set_meter_provider(provider.clone());
126            guard._meter_provider = Some(provider);
127        }
128
129        // --- Tracing subscriber ---
130        #[cfg(feature = "tracing-subscriber")]
131        if self.logging_enabled {
132            #[cfg(feature = "otel-otlp")]
133            {
134                self.init_tracing_subscriber_with_tracer(otel_tracer)
135                    .map_err(|e| -> Box<dyn std::error::Error> { e })?;
136            }
137            #[cfg(not(feature = "otel-otlp"))]
138            {
139                self.init_tracing_subscriber()
140                    .map_err(|e| -> Box<dyn std::error::Error> { e })?;
141            }
142        }
143
144        Ok(guard)
145    }
146
147    /// Initialize telemetry with Google Cloud-native exporters (Cloud Trace + Cloud Monitoring).
148    ///
149    /// This is the GCP counterpart to `init()`. It uses `opentelemetry-gcloud-trace` for
150    /// span export and `opentelemetry_gcloud_monitoring_exporter` for metrics.
151    ///
152    /// If `otel_gcp_project` is set, it is used as the GCP project ID. Otherwise the
153    /// project ID is auto-detected from ADC or the environment.
154    ///
155    /// The returned `TelemetryGuard` must be held alive for the duration of the
156    /// application. Dropping it triggers a flush and shutdown of all exporters.
157    #[cfg(feature = "otel-gcp")]
158    pub async fn init_gcp(
159        &self,
160    ) -> Result<TelemetryGuard, Box<dyn std::error::Error + Send + Sync>> {
161        use opentelemetry_gcloud_trace::GcpCloudTraceExporterBuilder;
162
163        let mut guard = TelemetryGuard::default();
164
165        // --- GCP Cloud Trace provider ---
166        let otel_tracer = if self.otel_traces {
167            let gcp_trace_builder = if let Some(ref project_id) = self.otel_gcp_project {
168                GcpCloudTraceExporterBuilder::new(project_id.clone())
169                    .with_resource(self.otel_resource())
170            } else {
171                GcpCloudTraceExporterBuilder::for_default_project_id()
172                    .await?
173                    .with_resource(self.otel_resource())
174            };
175
176            let tracer_provider = gcp_trace_builder.create_provider().await?;
177            let tracer = gcp_trace_builder.install(&tracer_provider).await?;
178            opentelemetry::global::set_tracer_provider(tracer_provider.clone());
179            guard._tracer_provider = Some(tracer_provider);
180            Some(tracer)
181        } else {
182            None
183        };
184
185        // --- GCP Cloud Monitoring metrics ---
186        if self.otel_metrics {
187            use opentelemetry_gcloud_monitoring_exporter::{
188                GCPMetricsExporter, GCPMetricsExporterConfig,
189            };
190
191            let mut metrics_cfg = GCPMetricsExporterConfig {
192                prefix: format!("custom.googleapis.com/{}", self.otel_service_name),
193                ..Default::default()
194            };
195            if let Some(ref project_id) = self.otel_gcp_project {
196                metrics_cfg.project_id = Some(project_id.clone());
197            }
198            let metrics_exporter = GCPMetricsExporter::init(metrics_cfg).await?;
199
200            use opentelemetry_sdk::metrics::periodic_reader_with_async_runtime::PeriodicReader;
201            let reader =
202                PeriodicReader::builder(metrics_exporter, opentelemetry_sdk::runtime::Tokio)
203                    .build();
204
205            let meter_provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder()
206                .with_resource(self.otel_resource())
207                .with_reader(reader)
208                .build();
209            opentelemetry::global::set_meter_provider(meter_provider.clone());
210            guard._meter_provider = Some(meter_provider);
211        }
212
213        // --- Tracing subscriber ---
214        #[cfg(feature = "tracing-subscriber")]
215        if self.logging_enabled {
216            self.init_tracing_subscriber_with_tracer(otel_tracer)?;
217        }
218
219        Ok(guard)
220    }
221
222    /// Set up the tracing subscriber with no OTel tracer layer (plain logging mode).
223    ///
224    /// Used when `tracing-subscriber` is on but neither `otel-otlp` nor `otel-gcp`
225    /// provides a tracer, or when called from `init()` without OTLP.
226    #[cfg(feature = "tracing-subscriber")]
227    #[allow(dead_code)]
228    fn init_tracing_subscriber(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
229        self.init_tracing_subscriber_with_tracer(None)
230    }
231
232    /// Set up the tracing subscriber, optionally wiring in an OTel tracer layer.
233    ///
234    /// Shared implementation used by both `init()` (OTLP path) and `init_gcp()`.
235    #[cfg(feature = "tracing-subscriber")]
236    fn init_tracing_subscriber_with_tracer(
237        &self,
238        #[cfg(feature = "otel-base")] otel_tracer: Option<opentelemetry_sdk::trace::Tracer>,
239        #[cfg(not(feature = "otel-base"))] _otel_tracer: Option<()>,
240    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
241        use tracing_subscriber::EnvFilter;
242        use tracing_subscriber::prelude::*;
243
244        let filter =
245            EnvFilter::try_new(&self.log_filter).unwrap_or_else(|_| EnvFilter::new("info"));
246
247        let fmt_layer = if self.json_logs {
248            tracing_subscriber::fmt::layer().json().boxed()
249        } else {
250            tracing_subscriber::fmt::layer().boxed()
251        };
252
253        let registry = tracing_subscriber::registry().with(filter).with(fmt_layer);
254
255        #[cfg(feature = "otel-base")]
256        {
257            if let Some(tracer) = otel_tracer {
258                let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
259                let subscriber = registry.with(otel_layer);
260                tracing::subscriber::set_global_default(subscriber)
261                    .map_err(|e| format!("Failed to set tracing subscriber: {e}"))?;
262            } else {
263                tracing::subscriber::set_global_default(registry)
264                    .map_err(|e| format!("Failed to set tracing subscriber: {e}"))?;
265            }
266        }
267
268        #[cfg(not(feature = "otel-base"))]
269        {
270            tracing::subscriber::set_global_default(registry)
271                .map_err(|e| format!("Failed to set tracing subscriber: {e}"))?;
272        }
273
274        Ok(())
275    }
276
277    /// Build an OTel resource with the configured service name.
278    ///
279    /// Gated on the exporter features (not `otel-base`): the only callers are
280    /// the OTLP and GCP provider-construction paths, so under a bare
281    /// `otel-base` build this would be dead code.
282    #[cfg(any(feature = "otel-otlp", feature = "otel-gcp"))]
283    pub(crate) fn otel_resource(&self) -> opentelemetry_sdk::Resource {
284        use opentelemetry::KeyValue;
285        opentelemetry_sdk::Resource::builder_empty()
286            .with_attributes([KeyValue::new(
287                "service.name",
288                self.otel_service_name.clone(),
289            )])
290            .build()
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn default_config_values() {
300        let config = TelemetryConfig::default();
301        assert!(config.logging_enabled);
302        assert_eq!(config.log_filter, "info");
303        assert!(!config.json_logs);
304        assert!(!config.metrics_enabled);
305        assert!(config.metrics_addr.is_none());
306        assert!(!config.otel_traces);
307        assert!(!config.otel_metrics);
308        assert_eq!(config.otel_service_name, "gemini-genai-rs");
309        assert!(config.otel_gcp_project.is_none());
310    }
311
312    #[test]
313    fn config_builder_pattern() {
314        let config = TelemetryConfig {
315            logging_enabled: false,
316            log_filter: "debug".to_string(),
317            json_logs: true,
318            metrics_enabled: true,
319            metrics_addr: Some("0.0.0.0:9090".to_string()),
320            otel_traces: true,
321            otel_metrics: true,
322            otel_service_name: "my-service".to_string(),
323            otel_endpoint: None,
324            otel_gcp_project: Some("my-project".to_string()),
325        };
326        assert!(!config.logging_enabled);
327        assert_eq!(config.log_filter, "debug");
328        assert!(config.json_logs);
329        assert!(config.metrics_enabled);
330        assert_eq!(config.metrics_addr.as_deref(), Some("0.0.0.0:9090"));
331        assert!(config.otel_traces);
332        assert!(config.otel_metrics);
333        assert_eq!(config.otel_service_name, "my-service");
334        assert_eq!(config.otel_gcp_project.as_deref(), Some("my-project"));
335    }
336
337    #[test]
338    fn telemetry_guard_default() {
339        let _guard = TelemetryGuard::default();
340        // Verifies that TelemetryGuard::default() compiles and doesn't panic.
341    }
342}