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