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