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}