Skip to main content

glide/
telemetry.rs

1// Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0
2//! OpenTelemetry integration.
3//!
4//! Enables exporting **traces** (per-command spans, sampled) and **metrics**
5//! (connection/retry/timeout counters, …) from the underlying `glide-core` to an
6//! OpenTelemetry collector or a local file. Mirrors the OpenTelemetry surface of
7//! the other GLIDE wrappers (Python/Node/Go/Java).
8//!
9//! Telemetry is a **process-global, one-shot** setting: call [`init`] exactly
10//! once, early in your program, before creating clients. Subsequent calls are
11//! ignored (the first configuration wins).
12//!
13//! ```rust,no_run
14//! use std::time::Duration;
15//! use glide::telemetry::{self, OpenTelemetryConfig, TelemetryExporter};
16//!
17//! # #[tokio::main]
18//! # async fn main() -> glide::ValkeyResult<()> {
19//! // Export traces to a collector over gRPC, sampling 5% of commands.
20//! let config = OpenTelemetryConfig::builder()
21//!     .with_flush_interval(Duration::from_millis(1000))
22//!     .with_trace_exporter(
23//!         TelemetryExporter::grpc("http://localhost:4317"),
24//!         Some(5),
25//!     )
26//!     .build();
27//! telemetry::init(config)?;
28//! assert!(telemetry::is_initialized());
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! # Runtime requirement
34//! [`init`] installs background batch exporters on the Tokio runtime, so it must
35//! be called from **within a Tokio runtime context** (e.g. inside
36//! `#[tokio::main]` or a `Runtime::block_on`).
37
38use crate::ValkeyResult;
39use crate::error::GlideError;
40use glide_core::{
41    DEFAULT_TRACE_SAMPLE_PERCENTAGE, GlideOpenTelemetry, GlideOpenTelemetryConfigBuilder,
42    GlideOpenTelemetrySignalsExporter,
43};
44use std::path::PathBuf;
45use std::time::Duration;
46
47/// The default trace sampling percentage used when none is supplied
48/// (see [`OpenTelemetryConfig::builder`]).
49pub const DEFAULT_TRACE_SAMPLE_PERCENT: u32 = DEFAULT_TRACE_SAMPLE_PERCENTAGE;
50
51/// Where a telemetry signal (traces or metrics) is exported to.
52///
53/// Mirrors `GlideOpenTelemetrySignalsExporter`.
54#[derive(Debug, Clone, PartialEq, Eq)]
55#[non_exhaustive]
56pub enum TelemetryExporter {
57    /// Send to a collector over OTLP/gRPC at the given endpoint
58    /// (e.g. `http://localhost:4317`).
59    Grpc(String),
60    /// Send to a collector over OTLP/HTTP at the given endpoint
61    /// (e.g. `http://localhost:4318`).
62    Http(String),
63    /// Write signals to a file/folder at the given path (no collector needed).
64    File(PathBuf),
65}
66
67impl TelemetryExporter {
68    /// Convenience constructor for a gRPC exporter endpoint.
69    pub fn grpc(endpoint: impl Into<String>) -> Self {
70        TelemetryExporter::Grpc(endpoint.into())
71    }
72
73    /// Convenience constructor for an HTTP exporter endpoint.
74    pub fn http(endpoint: impl Into<String>) -> Self {
75        TelemetryExporter::Http(endpoint.into())
76    }
77
78    /// Convenience constructor for a file exporter path.
79    pub fn file(path: impl Into<PathBuf>) -> Self {
80        TelemetryExporter::File(path.into())
81    }
82
83    pub(crate) fn to_core(&self) -> GlideOpenTelemetrySignalsExporter {
84        match self {
85            TelemetryExporter::Grpc(s) => GlideOpenTelemetrySignalsExporter::Grpc(s.clone()),
86            TelemetryExporter::Http(s) => GlideOpenTelemetrySignalsExporter::Http(s.clone()),
87            TelemetryExporter::File(p) => GlideOpenTelemetrySignalsExporter::File(p.clone()),
88        }
89    }
90}
91
92/// A fully-built OpenTelemetry configuration, ready to pass to [`init`].
93///
94/// Construct one with [`OpenTelemetryConfig::builder`]. If neither a trace nor a
95/// metrics exporter is configured, initialisation is a no-op.
96#[derive(Clone)]
97pub struct OpenTelemetryConfig {
98    inner: GlideOpenTelemetryConfigBuilder,
99}
100
101impl OpenTelemetryConfig {
102    /// Start building an OpenTelemetry configuration.
103    pub fn builder() -> OpenTelemetryConfigBuilder {
104        OpenTelemetryConfigBuilder {
105            inner: GlideOpenTelemetryConfigBuilder::default(),
106        }
107    }
108}
109
110/// Builder for [`OpenTelemetryConfig`]. Mirrors `GlideOpenTelemetryConfigBuilder`.
111#[derive(Clone)]
112pub struct OpenTelemetryConfigBuilder {
113    inner: GlideOpenTelemetryConfigBuilder,
114}
115
116impl OpenTelemetryConfigBuilder {
117    /// Set the interval between consecutive exports of telemetry data.
118    ///
119    /// Must be non-zero; a zero interval is rejected by [`init`].
120    #[must_use]
121    pub fn with_flush_interval(mut self, interval: Duration) -> Self {
122        self.inner = self.inner.with_flush_interval(interval);
123        self
124    }
125
126    /// Enable trace export to `exporter`, sampling `sample_percentage` percent of
127    /// commands (0–100). `None` uses [`DEFAULT_TRACE_SAMPLE_PERCENT`]. A
128    /// percentage above 100 is rejected by [`init`].
129    #[must_use]
130    pub fn with_trace_exporter(
131        mut self,
132        exporter: TelemetryExporter,
133        sample_percentage: Option<u32>,
134    ) -> Self {
135        self.inner = self
136            .inner
137            .with_trace_exporter(exporter.to_core(), sample_percentage);
138        self
139    }
140
141    /// Enable metrics export to `exporter`.
142    #[must_use]
143    pub fn with_metrics_exporter(mut self, exporter: TelemetryExporter) -> Self {
144        self.inner = self.inner.with_metrics_exporter(exporter.to_core());
145        self
146    }
147
148    /// Finish building the configuration.
149    pub fn build(self) -> OpenTelemetryConfig {
150        OpenTelemetryConfig { inner: self.inner }
151    }
152}
153
154/// Initialise process-global OpenTelemetry with the given configuration.
155///
156/// Idempotent: the first successful call wins and later calls are no-ops
157/// (returning `Ok`). Must be called from within a Tokio runtime (see the
158/// [module docs](self)).
159///
160/// # Errors
161/// Returns [`GlideError::Configuration`] if the configuration is invalid (zero
162/// flush interval, trace sample percentage > 100) or an exporter fails to
163/// initialise.
164pub fn init(config: OpenTelemetryConfig) -> ValkeyResult<()> {
165    GlideOpenTelemetry::initialise(config.inner.build())
166        .map_err(|e| GlideError::Configuration(format!("OpenTelemetry init failed: {e}")))
167}
168
169/// Whether OpenTelemetry has been successfully initialised in this process.
170pub fn is_initialized() -> bool {
171    GlideOpenTelemetry::is_initialized()
172}
173
174/// Shut down OpenTelemetry, flushing any pending signals. Safe to call even if
175/// telemetry was never initialised.
176pub fn shutdown() {
177    GlideOpenTelemetry::shutdown();
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn exporter_constructors_and_mapping() {
186        assert_eq!(
187            TelemetryExporter::grpc("http://h:4317"),
188            TelemetryExporter::Grpc("http://h:4317".into())
189        );
190        assert_eq!(
191            TelemetryExporter::http("http://h:4318"),
192            TelemetryExporter::Http("http://h:4318".into())
193        );
194        assert_eq!(
195            TelemetryExporter::file("/tmp/sig"),
196            TelemetryExporter::File(PathBuf::from("/tmp/sig"))
197        );
198        // Lowering to the core type preserves the variant + payload.
199        let core: GlideOpenTelemetrySignalsExporter = TelemetryExporter::grpc("g").to_core();
200        assert!(matches!(core, GlideOpenTelemetrySignalsExporter::Grpc(s) if s == "g"));
201    }
202
203    #[test]
204    fn default_sample_percent_matches_core() {
205        assert_eq!(
206            DEFAULT_TRACE_SAMPLE_PERCENT,
207            DEFAULT_TRACE_SAMPLE_PERCENTAGE
208        );
209    }
210
211    #[tokio::test]
212    async fn init_rejects_zero_flush_interval() {
213        let config = OpenTelemetryConfig::builder()
214            .with_flush_interval(Duration::from_millis(0))
215            .with_trace_exporter(TelemetryExporter::file("/tmp/otel-zero"), Some(1))
216            .build();
217        let err = init(config).expect_err("zero flush interval must be rejected");
218        assert!(matches!(err, GlideError::Configuration(_)));
219    }
220
221    #[tokio::test]
222    async fn init_rejects_sample_percentage_over_100() {
223        let config = OpenTelemetryConfig::builder()
224            .with_flush_interval(Duration::from_millis(100))
225            .with_trace_exporter(TelemetryExporter::file("/tmp/otel-bad-sample"), Some(101))
226            .build();
227        let err = init(config).expect_err("sample percentage > 100 must be rejected");
228        assert!(matches!(err, GlideError::Configuration(_)));
229    }
230
231    // NOTE: a successful `init` is process-global and one-shot, so it cannot be
232    // combined with the rejection tests above in the same process without making
233    // them order-dependent. The happy path (file exporter) + idempotency +
234    // is_initialized are covered by the dedicated integration test
235    // `tests/it_telemetry.rs`, which runs in its own process.
236}