Skip to main content

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/// The Prometheus exporter crate, for [`TelemetryConfig::prometheus_recorder`]'s handle.
15#[cfg(feature = "metrics")]
16pub use metrics_exporter_prometheus as metrics_exporter;
17
18/// Telemetry configuration.
19#[derive(Debug, Clone)]
20pub struct TelemetryConfig {
21    /// Enable structured logging. Installing the subscriber requires the
22    /// `tracing-subscriber` crate feature; without it this flag is inert.
23    pub logging_enabled: bool,
24    /// Log level filter (e.g., "info", "debug", "gemini_genai_rs=debug").
25    pub log_filter: String,
26    /// Use JSON format for logs (production). If false, uses pretty format (development).
27    pub json_logs: bool,
28    /// Enable Prometheus metrics endpoint.
29    pub metrics_enabled: bool,
30    /// Prometheus listen address (e.g., "0.0.0.0:9090").
31    pub metrics_addr: Option<String>,
32    /// Enable OTel trace export (requires `otel-otlp` or `otel-gcp` feature).
33    pub otel_traces: bool,
34    /// Enable OTel metrics export (requires `otel-otlp` or `otel-gcp` feature).
35    pub otel_metrics: bool,
36    /// OTel service name for resource identification.
37    pub otel_service_name: String,
38    /// OTLP collector endpoint (e.g. `http://localhost:4317`). `None` lets the
39    /// exporter fall back to the standard `OTEL_EXPORTER_OTLP_ENDPOINT` env var
40    /// and its default. Passed to the exporter builder directly; nothing here
41    /// mutates the process environment.
42    pub otel_endpoint: Option<String>,
43    /// Google Cloud project ID for GCP-native OTel export.
44    /// If None, auto-detects from ADC or environment.
45    pub otel_gcp_project: Option<String>,
46}
47
48impl Default for TelemetryConfig {
49    fn default() -> Self {
50        Self {
51            logging_enabled: true,
52            log_filter: "info".to_string(),
53            json_logs: false,
54            metrics_enabled: false,
55            metrics_addr: None,
56            otel_traces: false,
57            otel_metrics: false,
58            otel_service_name: "gemini-genai-rs".to_string(),
59            otel_endpoint: None,
60            otel_gcp_project: None,
61        }
62    }
63}
64
65/// Guard that keeps telemetry systems alive while held.
66/// Drop this to flush and shutdown OTel exporters.
67#[derive(Default)]
68pub struct TelemetryGuard {
69    #[cfg(feature = "otel-base")]
70    _tracer_provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>,
71    #[cfg(feature = "otel-base")]
72    _meter_provider: Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
73    #[cfg(not(feature = "otel-base"))]
74    _private: (),
75}
76
77/// The exporters a [`TelemetryConfig`] asks for, built but not yet attached
78/// to a subscriber: for an application that builds its own subscriber (to
79/// add layers of its own) and attaches `Exporters::layer` to it when the
80/// `otel-base` feature is enabled.
81#[derive(Default)]
82pub struct Exporters {
83    /// Keeps the providers alive; dropping it flushes and shuts them down.
84    pub guard: TelemetryGuard,
85    #[cfg(feature = "otel-base")]
86    tracer: Option<opentelemetry_sdk::trace::Tracer>,
87}
88
89impl Exporters {
90    /// A tracing layer that exports spans, when trace export is on.
91    #[cfg(feature = "otel-base")]
92    pub fn layer<S>(
93        &self,
94    ) -> Option<tracing_opentelemetry::OpenTelemetryLayer<S, opentelemetry_sdk::trace::Tracer>>
95    where
96        S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>,
97    {
98        self.tracer
99            .clone()
100            .map(|tracer| tracing_opentelemetry::layer().with_tracer(tracer))
101    }
102}
103
104impl TelemetryConfig {
105    /// A config from the standard environment variables:
106    ///
107    /// - `OTEL_EXPORTER_OTLP_ENDPOINT`: turns on OTLP trace and metric
108    ///   export to that collector (`otel-otlp`).
109    /// - `ADK_TELEMETRY=gcp`: turns on Cloud Trace and Cloud Monitoring
110    ///   export (`otel-gcp`, use `TelemetryConfig::build_gcp`), with
111    ///   `GOOGLE_CLOUD_PROJECT` as the project when set.
112    /// - `OTEL_SERVICE_NAME`: the service name (default `gemini-live`).
113    /// - `ADK_METRICS_ADDR`: serve Prometheus metrics at this address, e.g.
114    ///   `0.0.0.0:9464` (`metrics`).
115    /// - `RUST_LOG`: the log filter (default `info`).
116    pub fn from_env() -> Self {
117        let var = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty());
118        let endpoint = var("OTEL_EXPORTER_OTLP_ENDPOINT");
119        let gcp = var("ADK_TELEMETRY").is_some_and(|v| v.eq_ignore_ascii_case("gcp"));
120        let metrics_addr = var("ADK_METRICS_ADDR");
121        Self {
122            log_filter: var("RUST_LOG").unwrap_or_else(|| "info".to_string()),
123            metrics_enabled: metrics_addr.is_some(),
124            metrics_addr,
125            otel_traces: endpoint.is_some() || gcp,
126            otel_metrics: endpoint.is_some() || gcp,
127            otel_service_name: var("OTEL_SERVICE_NAME")
128                .unwrap_or_else(|| "gemini-live".to_string()),
129            otel_endpoint: endpoint,
130            otel_gcp_project: if gcp {
131                var("GOOGLE_CLOUD_PROJECT")
132            } else {
133                None
134            },
135            ..Self::default()
136        }
137    }
138
139    /// Whether this config asks for Cloud Trace and Cloud Monitoring rather
140    /// than OTLP (set by [`from_env`](Self::from_env) for `ADK_TELEMETRY=gcp`).
141    pub fn wants_gcp(&self) -> bool {
142        (self.otel_traces || self.otel_metrics) && self.otel_endpoint.is_none()
143    }
144
145    /// Serve the SDK's metrics (Live sessions, reconnections, bytes, tool
146    /// calls, tokens by modality, HTTP requests) in Prometheus format at
147    /// [`metrics_addr`](Self::metrics_addr). Does nothing when metrics are
148    /// off or no address is set.
149    #[cfg(feature = "metrics")]
150    pub fn install_metrics(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
151        let Some(addr) = self
152            .metrics_addr
153            .as_deref()
154            .filter(|_| self.metrics_enabled)
155        else {
156            return Ok(());
157        };
158        let addr: std::net::SocketAddr = addr.parse()?;
159        metrics_exporter_prometheus::PrometheusBuilder::new()
160            .with_http_listener(addr)
161            .install()?;
162        Ok(())
163    }
164
165    /// Record the SDK's metrics in process and return a handle that renders
166    /// them as Prometheus text, for a server that serves its own
167    /// `/metrics`. Installs the global `metrics` recorder, so call it once.
168    #[cfg(feature = "metrics")]
169    pub fn prometheus_recorder() -> Result<
170        metrics_exporter_prometheus::PrometheusHandle,
171        Box<dyn std::error::Error + Send + Sync>,
172    > {
173        Ok(metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder()?)
174    }
175
176    /// Build the OTLP exporters this config asks for, without installing a
177    /// subscriber. The meter provider, when metrics are on, is installed
178    /// globally.
179    #[cfg(feature = "otel-otlp")]
180    pub fn build_otlp(&self) -> Result<Exporters, Box<dyn std::error::Error + Send + Sync>> {
181        use opentelemetry_otlp::WithExportConfig as _;
182        let mut guard = TelemetryGuard::default();
183        let tracer = if self.otel_traces {
184            let mut builder = opentelemetry_otlp::SpanExporter::builder().with_tonic();
185            if let Some(endpoint) = &self.otel_endpoint {
186                builder = builder.with_endpoint(endpoint.clone());
187            }
188            let exporter = builder.build()?;
189            let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
190                .with_batch_exporter(exporter)
191                .with_resource(self.otel_resource())
192                .build();
193            let tracer = opentelemetry::trace::TracerProvider::tracer(
194                &provider,
195                self.otel_service_name.clone(),
196            );
197            guard._tracer_provider = Some(provider);
198            Some(tracer)
199        } else {
200            None
201        };
202        if self.otel_metrics {
203            let mut builder = opentelemetry_otlp::MetricExporter::builder().with_tonic();
204            if let Some(endpoint) = &self.otel_endpoint {
205                builder = builder.with_endpoint(endpoint.clone());
206            }
207            let exporter = builder.build()?;
208            let provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder()
209                .with_periodic_exporter(exporter)
210                .with_resource(self.otel_resource())
211                .build();
212            opentelemetry::global::set_meter_provider(provider.clone());
213            guard._meter_provider = Some(provider);
214        }
215        Ok(Exporters { guard, tracer })
216    }
217
218    /// Initialize telemetry subsystems based on configuration: the OTLP
219    /// exporters (`TelemetryConfig::build_otlp`, feature `otel-otlp`),
220    /// the Prometheus endpoint ([`install_metrics`](Self::install_metrics),
221    /// feature `metrics`) and a logging subscriber carrying the trace
222    /// exporter (feature `tracing-subscriber`).
223    ///
224    /// With `otel-gcp`, use `TelemetryConfig::init_gcp` instead. To add
225    /// layers of your own, use `build_otlp` and `Exporters::layer`.
226    ///
227    /// The returned `TelemetryGuard` must be held alive for the duration of the
228    /// application. Dropping it triggers a flush and shutdown of all exporters.
229    pub fn init(&self) -> Result<TelemetryGuard, Box<dyn std::error::Error>> {
230        #[cfg(feature = "metrics")]
231        self.install_metrics()
232            .map_err(|e| -> Box<dyn std::error::Error> { e })?;
233
234        #[cfg(feature = "otel-otlp")]
235        let Exporters { guard, tracer } = self
236            .build_otlp()
237            .map_err(|e| -> Box<dyn std::error::Error> { e })?;
238        #[cfg(not(feature = "otel-otlp"))]
239        let guard = TelemetryGuard::default();
240
241        #[cfg(feature = "tracing-subscriber")]
242        if self.logging_enabled {
243            #[cfg(feature = "otel-otlp")]
244            self.init_tracing_subscriber_with_tracer(tracer)
245                .map_err(|e| -> Box<dyn std::error::Error> { e })?;
246            #[cfg(not(feature = "otel-otlp"))]
247            self.init_tracing_subscriber()
248                .map_err(|e| -> Box<dyn std::error::Error> { e })?;
249        }
250
251        Ok(guard)
252    }
253
254    /// Initialize telemetry with Google Cloud-native exporters (Cloud Trace + Cloud Monitoring).
255    ///
256    /// This is the GCP counterpart to `init()`. It uses `opentelemetry-gcloud-trace` for
257    /// span export and `opentelemetry_gcloud_monitoring_exporter` for metrics.
258    ///
259    /// If `otel_gcp_project` is set, it is used as the GCP project ID. Otherwise the
260    /// project ID is auto-detected from ADC or the environment.
261    ///
262    /// The returned `TelemetryGuard` must be held alive for the duration of the
263    /// application. Dropping it triggers a flush and shutdown of all exporters.
264    #[cfg(feature = "otel-gcp")]
265    pub async fn init_gcp(
266        &self,
267    ) -> Result<TelemetryGuard, Box<dyn std::error::Error + Send + Sync>> {
268        #[cfg(feature = "metrics")]
269        self.install_metrics()?;
270        let Exporters { guard, tracer } = self.build_gcp().await?;
271        #[cfg(feature = "tracing-subscriber")]
272        if self.logging_enabled {
273            self.init_tracing_subscriber_with_tracer(tracer)?;
274        }
275        Ok(guard)
276    }
277
278    /// Build the Cloud Trace and Cloud Monitoring exporters this config asks
279    /// for, without installing a subscriber. The meter provider, when
280    /// metrics are on, is installed globally.
281    #[cfg(feature = "otel-gcp")]
282    pub async fn build_gcp(&self) -> Result<Exporters, Box<dyn std::error::Error + Send + Sync>> {
283        use opentelemetry_gcloud_trace::GcpCloudTraceExporterBuilder;
284
285        let mut guard = TelemetryGuard::default();
286
287        // --- GCP Cloud Trace provider ---
288        let otel_tracer = if self.otel_traces {
289            let gcp_trace_builder = if let Some(ref project_id) = self.otel_gcp_project {
290                GcpCloudTraceExporterBuilder::new(project_id.clone())
291                    .with_resource(self.otel_resource())
292            } else {
293                GcpCloudTraceExporterBuilder::for_default_project_id()
294                    .await?
295                    .with_resource(self.otel_resource())
296            };
297
298            let tracer_provider = gcp_trace_builder.create_provider().await?;
299            let tracer = gcp_trace_builder.install(&tracer_provider).await?;
300            opentelemetry::global::set_tracer_provider(tracer_provider.clone());
301            guard._tracer_provider = Some(tracer_provider);
302            Some(tracer)
303        } else {
304            None
305        };
306
307        // --- GCP Cloud Monitoring metrics ---
308        if self.otel_metrics {
309            use opentelemetry_gcloud_monitoring_exporter::{
310                GCPMetricsExporter, GCPMetricsExporterConfig,
311            };
312
313            let mut metrics_cfg = GCPMetricsExporterConfig {
314                prefix: format!("custom.googleapis.com/{}", self.otel_service_name),
315                ..Default::default()
316            };
317            if let Some(ref project_id) = self.otel_gcp_project {
318                metrics_cfg.project_id = Some(project_id.clone());
319            }
320            let metrics_exporter = GCPMetricsExporter::init(metrics_cfg).await?;
321
322            use opentelemetry_sdk::metrics::periodic_reader_with_async_runtime::PeriodicReader;
323            let reader =
324                PeriodicReader::builder(metrics_exporter, opentelemetry_sdk::runtime::Tokio)
325                    .build();
326
327            let meter_provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder()
328                .with_resource(self.otel_resource())
329                .with_reader(reader)
330                .build();
331            opentelemetry::global::set_meter_provider(meter_provider.clone());
332            guard._meter_provider = Some(meter_provider);
333        }
334
335        Ok(Exporters {
336            guard,
337            tracer: otel_tracer,
338        })
339    }
340
341    /// Set up the tracing subscriber with no OTel tracer layer (plain logging mode).
342    ///
343    /// Used when `tracing-subscriber` is on but neither `otel-otlp` nor `otel-gcp`
344    /// provides a tracer, or when called from `init()` without OTLP.
345    #[cfg(feature = "tracing-subscriber")]
346    #[allow(dead_code)]
347    fn init_tracing_subscriber(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
348        self.init_tracing_subscriber_with_tracer(None)
349    }
350
351    /// Set up the tracing subscriber, optionally wiring in an OTel tracer layer.
352    ///
353    /// Shared implementation used by both `init()` (OTLP path) and `init_gcp()`.
354    #[cfg(feature = "tracing-subscriber")]
355    fn init_tracing_subscriber_with_tracer(
356        &self,
357        #[cfg(feature = "otel-base")] otel_tracer: Option<opentelemetry_sdk::trace::Tracer>,
358        #[cfg(not(feature = "otel-base"))] _otel_tracer: Option<()>,
359    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
360        use tracing_subscriber::EnvFilter;
361        use tracing_subscriber::prelude::*;
362
363        let filter =
364            EnvFilter::try_new(&self.log_filter).unwrap_or_else(|_| EnvFilter::new("info"));
365
366        let fmt_layer = if self.json_logs {
367            tracing_subscriber::fmt::layer().json().boxed()
368        } else {
369            tracing_subscriber::fmt::layer().boxed()
370        };
371
372        let registry = tracing_subscriber::registry().with(filter).with(fmt_layer);
373
374        #[cfg(feature = "otel-base")]
375        {
376            if let Some(tracer) = otel_tracer {
377                let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
378                let subscriber = registry.with(otel_layer);
379                tracing::subscriber::set_global_default(subscriber)
380                    .map_err(|e| format!("Failed to set tracing subscriber: {e}"))?;
381            } else {
382                tracing::subscriber::set_global_default(registry)
383                    .map_err(|e| format!("Failed to set tracing subscriber: {e}"))?;
384            }
385        }
386
387        #[cfg(not(feature = "otel-base"))]
388        {
389            tracing::subscriber::set_global_default(registry)
390                .map_err(|e| format!("Failed to set tracing subscriber: {e}"))?;
391        }
392
393        Ok(())
394    }
395
396    /// Build an OTel resource with the configured service name.
397    ///
398    /// Gated on the exporter features (not `otel-base`): the only callers are
399    /// the OTLP and GCP provider-construction paths, so under a bare
400    /// `otel-base` build this would be dead code.
401    #[cfg(any(feature = "otel-otlp", feature = "otel-gcp"))]
402    pub(crate) fn otel_resource(&self) -> opentelemetry_sdk::Resource {
403        use opentelemetry::KeyValue;
404        opentelemetry_sdk::Resource::builder_empty()
405            .with_attributes([KeyValue::new(
406                "service.name",
407                self.otel_service_name.clone(),
408            )])
409            .build()
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn default_config_values() {
419        let config = TelemetryConfig::default();
420        assert!(config.logging_enabled);
421        assert_eq!(config.log_filter, "info");
422        assert!(!config.json_logs);
423        assert!(!config.metrics_enabled);
424        assert!(config.metrics_addr.is_none());
425        assert!(!config.otel_traces);
426        assert!(!config.otel_metrics);
427        assert_eq!(config.otel_service_name, "gemini-genai-rs");
428        assert!(config.otel_gcp_project.is_none());
429    }
430
431    #[test]
432    fn config_builder_pattern() {
433        let config = TelemetryConfig {
434            logging_enabled: false,
435            log_filter: "debug".to_string(),
436            json_logs: true,
437            metrics_enabled: true,
438            metrics_addr: Some("0.0.0.0:9090".to_string()),
439            otel_traces: true,
440            otel_metrics: true,
441            otel_service_name: "my-service".to_string(),
442            otel_endpoint: None,
443            otel_gcp_project: Some("my-project".to_string()),
444        };
445        assert!(!config.logging_enabled);
446        assert_eq!(config.log_filter, "debug");
447        assert!(config.json_logs);
448        assert!(config.metrics_enabled);
449        assert_eq!(config.metrics_addr.as_deref(), Some("0.0.0.0:9090"));
450        assert!(config.otel_traces);
451        assert!(config.otel_metrics);
452        assert_eq!(config.otel_service_name, "my-service");
453        assert_eq!(config.otel_gcp_project.as_deref(), Some("my-project"));
454    }
455
456    #[test]
457    fn telemetry_guard_default() {
458        let _guard = TelemetryGuard::default();
459        // Verifies that TelemetryGuard::default() compiles and doesn't panic.
460    }
461}