Skip to main content

sideways_otel/
lib.rs

1//! # Sideways `OTel` 🦀
2//!
3//! > *Observability from the side - because crabs walk sideways, and so should your telemetry.*
4//!
5//! A production-ready telemetry library for Rust services that provides
6//! vendor-neutral **OpenTelemetry** tracing, metrics, and logs, exported via
7//! OTLP to any compatible backend (a local Collector, Honeycomb, or anything
8//! else that speaks OTLP).
9//!
10//! ## Features
11//!
12//! - Easy one-line initialization for traces, metrics, and logs
13//! - Graceful degradation when the OTLP endpoint is unavailable
14//! - Environment-based configuration using standard `OTEL_*` variables
15//! - Health check filtering to reduce noise
16//! - Native OpenTelemetry metrics API - no vendor-specific macros required
17//! - W3C Trace Context + Baggage propagation installed by default, matching
18//!   the `OTEL_PROPAGATORS` spec default
19//!
20//! ## Quick Start
21//!
22//! ```rust,no_run
23//! use sideways_otel::prelude::*;
24//! use sideways_otel::{init_telemetry, TelemetryConfig};
25//!
26//! fn main() {
27//!     let config = TelemetryConfig::from_env();
28//!     let telemetry = init_telemetry(&config);
29//!
30//!     tracing::info!("Application started");
31//!
32//!     let requests = counter("requests.handled");
33//!     requests.add(1, &[KeyValue::new("status", "success")]);
34//!
35//!     telemetry.shutdown();
36//! }
37//! ```
38
39pub mod metrics;
40pub mod prelude;
41pub mod propagation;
42pub mod resource;
43pub mod span;
44pub mod tracing;
45
46use std::env;
47use std::sync::OnceLock;
48use thiserror::Error;
49
50static SERVICE_NAME: OnceLock<String> = OnceLock::new();
51
52/// The service name configured at [`init_telemetry`] time, used as the
53/// instrumentation scope name for [`prelude::meter`]. Falls back to
54/// `TelemetryConfig::default().service_name` if telemetry hasn't been
55/// initialized yet (e.g. in tests).
56pub(crate) fn configured_service_name() -> &'static str {
57    SERVICE_NAME
58        .get()
59        .map_or("sideways-otel-service", String::as_str)
60}
61
62/// Which OTLP wire protocol to export over.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum OtlpProtocol {
65    /// OTLP/gRPC (the default). Typically port 4317.
66    #[default]
67    Grpc,
68    /// OTLP/HTTP with protobuf-encoded bodies. Typically port 4318. Useful
69    /// where gRPC/HTTP2 is blocked (some proxies/gateways) or where a vendor
70    /// only exposes an HTTP ingestion endpoint.
71    HttpProtobuf,
72}
73
74/// A `W3C`-standard context propagation format. Corresponds to the values
75/// accepted by the standard `OTEL_PROPAGATORS` environment variable, though
76/// only `tracecontext` and `baggage` are currently supported here - the spec
77/// also defines `b3`, `b3multi`, `jaeger`, `xray`, and `ottrace`, each of
78/// which needs its own crate (`opentelemetry-zipkin`, etc.). Add support for
79/// those as real use cases show up rather than pre-building the full matrix.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum PropagatorKind {
82    /// W3C Trace Context (the `traceparent`/`tracestate` headers) - carries
83    /// trace/span IDs across process boundaries. Part of the `OTEL_PROPAGATORS`
84    /// spec default and enabled here by default for the same reason.
85    TraceContext,
86    /// W3C Baggage (the `baggage` header) - carries arbitrary user-defined
87    /// key-value context across process boundaries, independent of any
88    /// specific trace. Also part of the spec default.
89    Baggage,
90}
91
92#[derive(Debug, Error)]
93pub enum TelemetryError {
94    #[error("OpenTelemetry tracing disabled via OTEL_TRACES_ENABLED=false")]
95    TracingDisabled,
96
97    #[error("OpenTelemetry metrics disabled via OTEL_METRICS_ENABLED=false")]
98    MetricsDisabled,
99
100    #[error("Failed to build OTLP exporter: {0}")]
101    ExporterBuild(String),
102}
103
104/// Configuration for telemetry initialization.
105#[derive(Debug, Clone)]
106pub struct TelemetryConfig {
107    /// Service name, sent as the `service.name` resource attribute.
108    pub service_name: String,
109    /// Extra resource attributes attached to every span/metric/log.
110    pub resource_attributes: Vec<(String, String)>,
111
112    /// Enable/disable trace export (default: true).
113    pub traces_enabled: bool,
114    /// Enable/disable metrics export (default: true).
115    pub metrics_enabled: bool,
116    /// Enable/disable log export (default: true).
117    pub logs_enabled: bool,
118
119    /// Which OTLP wire protocol to use (default: [`OtlpProtocol::Grpc`]).
120    pub otlp_protocol: OtlpProtocol,
121    /// OTLP endpoint, e.g. `http://localhost:4317`. When `None`, the
122    /// exporter falls back to the protocol-specific default
123    /// (`http://localhost:4317` for gRPC, `http://localhost:4318` for
124    /// HTTP/protobuf).
125    pub otlp_endpoint: Option<String>,
126    /// Extra headers sent with every OTLP export request (e.g. API keys).
127    pub otlp_headers: Vec<(String, String)>,
128
129    /// `RUST_LOG` filter for both console output and the `OTel` logs bridge.
130    pub rust_log: String,
131    /// Enable JSON-formatted console logging (default: false).
132    pub json_logging: bool,
133
134    /// Metrics export interval, in milliseconds (default: 60000).
135    pub metrics_export_interval_ms: u64,
136
137    /// Which context propagation format(s) to install globally (default:
138    /// `[TraceContext, Baggage]`, matching the `OTEL_PROPAGATORS` spec
139    /// default). An empty list disables propagation entirely.
140    pub propagators: Vec<PropagatorKind>,
141}
142
143impl Default for TelemetryConfig {
144    fn default() -> Self {
145        Self {
146            service_name: "sideways-otel-service".to_string(),
147            resource_attributes: Vec::new(),
148            traces_enabled: true,
149            metrics_enabled: true,
150            logs_enabled: true,
151            otlp_protocol: OtlpProtocol::default(),
152            otlp_endpoint: None,
153            otlp_headers: Vec::new(),
154            rust_log: "info".to_string(),
155            json_logging: false,
156            metrics_export_interval_ms: 60_000,
157            propagators: vec![PropagatorKind::TraceContext, PropagatorKind::Baggage],
158        }
159    }
160}
161
162impl TelemetryConfig {
163    /// Load configuration from standard `OTEL_*` environment variables.
164    #[must_use]
165    pub fn from_env() -> Self {
166        let mut config = Self::default();
167
168        if let Ok(name) = env::var("OTEL_SERVICE_NAME") {
169            config.service_name = name;
170        }
171        if let Ok(attrs) = env::var("OTEL_RESOURCE_ATTRIBUTES") {
172            config.resource_attributes = Self::parse_pairs(&attrs, '=');
173        }
174
175        if let Ok(enabled) = env::var("OTEL_TRACES_ENABLED")
176            && enabled.eq_ignore_ascii_case("false")
177        {
178            config.traces_enabled = false;
179        }
180        if let Ok(enabled) = env::var("OTEL_METRICS_ENABLED")
181            && enabled.eq_ignore_ascii_case("false")
182        {
183            config.metrics_enabled = false;
184        }
185        if let Ok(enabled) = env::var("OTEL_LOGS_ENABLED")
186            && enabled.eq_ignore_ascii_case("false")
187        {
188            config.logs_enabled = false;
189        }
190
191        if let Ok(protocol) = env::var("OTEL_EXPORTER_OTLP_PROTOCOL") {
192            match protocol.as_str() {
193                "grpc" => config.otlp_protocol = OtlpProtocol::Grpc,
194                "http/protobuf" => config.otlp_protocol = OtlpProtocol::HttpProtobuf,
195                other => eprintln!(
196                    "⚠️  Unsupported OTEL_EXPORTER_OTLP_PROTOCOL '{other}' (expected 'grpc' or 'http/protobuf'), defaulting to grpc"
197                ),
198            }
199        }
200        if let Ok(endpoint) = env::var("OTEL_EXPORTER_OTLP_ENDPOINT") {
201            config.otlp_endpoint = Some(endpoint);
202        }
203        if let Ok(headers) = env::var("OTEL_EXPORTER_OTLP_HEADERS") {
204            config.otlp_headers = Self::parse_pairs(&headers, '=');
205        }
206
207        if let Ok(rust_log) = env::var("RUST_LOG") {
208            config.rust_log = rust_log;
209        }
210        if let Ok(enabled) = env::var("JSON_LOGGING")
211            && enabled.eq_ignore_ascii_case("true")
212        {
213            config.json_logging = true;
214        }
215        if let Ok(interval) = env::var("OTEL_METRIC_EXPORT_INTERVAL")
216            && let Ok(ms) = interval.parse()
217        {
218            config.metrics_export_interval_ms = ms;
219        }
220
221        if let Ok(propagators) = env::var("OTEL_PROPAGATORS") {
222            config.propagators = propagators
223                .split(',')
224                .filter_map(|name| match name.trim() {
225                    "tracecontext" => Some(PropagatorKind::TraceContext),
226                    "baggage" => Some(PropagatorKind::Baggage),
227                    "none" => None,
228                    other => {
229                        eprintln!(
230                            "⚠️  Unsupported OTEL_PROPAGATORS entry '{other}' (expected 'tracecontext', 'baggage', or 'none'), skipping"
231                        );
232                        None
233                    }
234                })
235                .collect();
236        }
237
238        config
239    }
240
241    /// Parse a comma-separated list of `key<sep>value` pairs, the format
242    /// used by `OTEL_RESOURCE_ATTRIBUTES` and `OTEL_EXPORTER_OTLP_HEADERS`.
243    fn parse_pairs(raw: &str, sep: char) -> Vec<(String, String)> {
244        raw.split(',')
245            .filter_map(|pair| {
246                let mut parts = pair.trim().splitn(2, sep);
247                let key = parts.next()?.trim();
248                let value = parts.next()?.trim();
249                if key.is_empty() {
250                    None
251                } else {
252                    Some((key.to_string(), value.to_string()))
253                }
254            })
255            .collect()
256    }
257
258    /// Create a builder for custom configuration.
259    #[must_use]
260    pub fn builder() -> TelemetryConfigBuilder {
261        TelemetryConfigBuilder::default()
262    }
263}
264
265/// Builder for `TelemetryConfig`.
266#[derive(Debug, Default)]
267pub struct TelemetryConfigBuilder {
268    config: TelemetryConfig,
269}
270
271impl TelemetryConfigBuilder {
272    #[must_use]
273    pub fn service_name(mut self, name: impl Into<String>) -> Self {
274        self.config.service_name = name.into();
275        self
276    }
277
278    #[must_use]
279    pub fn resource_attributes(mut self, attributes: Vec<(String, String)>) -> Self {
280        self.config.resource_attributes = attributes;
281        self
282    }
283
284    #[must_use]
285    pub fn with_resource_attribute(
286        mut self,
287        key: impl Into<String>,
288        value: impl Into<String>,
289    ) -> Self {
290        self.config
291            .resource_attributes
292            .push((key.into(), value.into()));
293        self
294    }
295
296    #[must_use]
297    pub fn traces_enabled(mut self, enabled: bool) -> Self {
298        self.config.traces_enabled = enabled;
299        self
300    }
301
302    #[must_use]
303    pub fn metrics_enabled(mut self, enabled: bool) -> Self {
304        self.config.metrics_enabled = enabled;
305        self
306    }
307
308    #[must_use]
309    pub fn logs_enabled(mut self, enabled: bool) -> Self {
310        self.config.logs_enabled = enabled;
311        self
312    }
313
314    #[must_use]
315    pub fn otlp_protocol(mut self, protocol: OtlpProtocol) -> Self {
316        self.config.otlp_protocol = protocol;
317        self
318    }
319
320    #[must_use]
321    pub fn otlp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
322        self.config.otlp_endpoint = Some(endpoint.into());
323        self
324    }
325
326    #[must_use]
327    pub fn otlp_headers(mut self, headers: Vec<(String, String)>) -> Self {
328        self.config.otlp_headers = headers;
329        self
330    }
331
332    #[must_use]
333    pub fn with_otlp_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
334        self.config.otlp_headers.push((key.into(), value.into()));
335        self
336    }
337
338    #[must_use]
339    pub fn rust_log(mut self, filter: impl Into<String>) -> Self {
340        self.config.rust_log = filter.into();
341        self
342    }
343
344    #[must_use]
345    pub fn json_logging(mut self, enabled: bool) -> Self {
346        self.config.json_logging = enabled;
347        self
348    }
349
350    #[must_use]
351    pub fn metrics_export_interval_ms(mut self, ms: u64) -> Self {
352        self.config.metrics_export_interval_ms = ms;
353        self
354    }
355
356    /// Set which context propagation format(s) to install globally. Pass an
357    /// empty `Vec` to disable propagation entirely.
358    #[must_use]
359    pub fn propagators(mut self, propagators: Vec<PropagatorKind>) -> Self {
360        self.config.propagators = propagators;
361        self
362    }
363
364    #[must_use]
365    pub fn build(self) -> TelemetryConfig {
366        self.config
367    }
368}
369
370/// Telemetry components that need to be kept alive for the duration of the
371/// application and flushed/shutdown on exit.
372pub struct Telemetry {
373    pub tracer_provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>,
374    pub meter_provider: Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
375    pub logger_provider: Option<opentelemetry_sdk::logs::SdkLoggerProvider>,
376}
377
378impl Telemetry {
379    /// Flush and shut down all initialized providers. Call this before the
380    /// application exits to avoid losing buffered telemetry.
381    pub fn shutdown(&self) {
382        if let Some(tp) = &self.tracer_provider
383            && let Err(err) = tp.shutdown()
384        {
385            eprintln!("⚠️  Sideways OTel: tracer provider shutdown failed: {err}");
386        }
387        if let Some(mp) = &self.meter_provider
388            && let Err(err) = mp.shutdown()
389        {
390            eprintln!("⚠️  Sideways OTel: meter provider shutdown failed: {err}");
391        }
392        if let Some(lp) = &self.logger_provider
393            && let Err(err) = lp.shutdown()
394        {
395            eprintln!("⚠️  Sideways OTel: logger provider shutdown failed: {err}");
396        }
397    }
398}
399
400/// Describe the OTLP endpoint for log output: the configured endpoint if
401/// set, otherwise the protocol's default.
402fn describe_endpoint(config: &TelemetryConfig) -> &str {
403    config.otlp_endpoint.as_deref().unwrap_or(match config.otlp_protocol {
404        OtlpProtocol::Grpc => "http://localhost:4317 (grpc default)",
405        OtlpProtocol::HttpProtobuf => "http://localhost:4318 (http/protobuf default)",
406    })
407}
408
409/// Initialize telemetry with the given configuration, without installing a
410/// global `tracing` subscriber.
411///
412/// This will:
413/// 1. Install the configured context propagator(s) globally (independent of
414///    whether trace export is enabled - propagation still matters for
415///    correlating incoming/outgoing requests)
416/// 2. Initialize OTLP trace export (if enabled)
417/// 3. Initialize OTLP metrics export (if enabled)
418/// 4. Initialize OTLP log export (if enabled)
419/// 5. Build (but not install) the combined `tracing` layer - console output,
420///    plus the `OTel` span layer and logs bridge when tracing/logs are enabled
421///
422/// Returns the `Telemetry` struct (kept alive for the duration of the
423/// application, shut down on exit via [`Telemetry::shutdown`]) alongside the
424/// layer. The caller is responsible for installing it, typically by
425/// composing it onto their own `Registry` with any additional layers of
426/// their own:
427///
428/// ```rust,no_run
429/// use sideways_otel::{init_telemetry_layer, TelemetryConfig};
430/// use tracing_subscriber::prelude::*;
431///
432/// let config = TelemetryConfig::from_env();
433/// let (telemetry, sideways_layer) = init_telemetry_layer(&config);
434///
435/// tracing_subscriber::registry()
436///     .with(sideways_layer)
437///     // .with(my_own_layer)
438///     .init();
439/// ```
440///
441/// This is what lets a consuming application add its own `tracing` layers
442/// (a Sentry layer, a custom filter, etc.) - or, with `traces_enabled`,
443/// `metrics_enabled`, and `logs_enabled` all `false`, install nothing but its
444/// own layers while still getting propagation and the global tracer/meter
445/// providers wired up. Use [`init_telemetry`] instead if you don't need to
446/// add anything of your own.
447#[must_use]
448pub fn init_telemetry_layer(config: &TelemetryConfig) -> (Telemetry, tracing::BoxedLayer) {
449    eprintln!("🦀 Sideways OTel: Initializing...");
450
451    let _ = SERVICE_NAME.set(config.service_name.clone());
452
453    propagation::init_propagator(config);
454
455    let resource = resource::build_resource(config);
456
457    let endpoint_description = describe_endpoint(config);
458
459    let (tracer_provider, logger_provider, layer) = if config.traces_enabled {
460        match tracing::init_otlp_tracing(config, resource.clone()) {
461            Ok((tp, lp, layer)) => {
462                eprintln!("✅ Sideways OTel: tracing initialized -> {endpoint_description}");
463                if lp.is_some() {
464                    eprintln!("✅ Sideways OTel: log export initialized");
465                }
466                (Some(tp), lp, layer)
467            }
468            Err(err) => {
469                eprintln!("⚠️  Sideways OTel: tracing unavailable: {err}");
470                (None, None, tracing::console_layer(config))
471            }
472        }
473    } else {
474        eprintln!("📊 Sideways OTel: tracing disabled");
475        (None, None, tracing::console_layer(config))
476    };
477
478    let meter_provider = if config.metrics_enabled {
479        match metrics::init_otlp_metrics(config, resource) {
480            Ok(mp) => {
481                eprintln!("✅ Sideways OTel: metrics initialized -> {endpoint_description}");
482                Some(mp)
483            }
484            Err(err) => {
485                eprintln!("⚠️  Sideways OTel: metrics unavailable: {err}");
486                None
487            }
488        }
489    } else {
490        eprintln!("📊 Sideways OTel: metrics disabled");
491        None
492    };
493
494    (
495        Telemetry {
496            tracer_provider,
497            meter_provider,
498            logger_provider,
499        },
500        layer,
501    )
502}
503
504/// Initialize telemetry and install the resulting layer as the global
505/// `tracing` subscriber. A convenience wrapper around
506/// [`init_telemetry_layer`] for the common case where the application
507/// doesn't need to add any `tracing` layers of its own - if it does, call
508/// [`init_telemetry_layer`] directly instead.
509///
510/// Returns a `Telemetry` struct that must be kept alive for the duration of
511/// the application and shut down on exit via [`Telemetry::shutdown`].
512///
513/// If a global `tracing` subscriber has already been installed (e.g. by
514/// another library, or a previous init call), this logs to stderr and
515/// leaves the existing subscriber in place rather than panicking.
516#[must_use]
517pub fn init_telemetry(config: &TelemetryConfig) -> Telemetry {
518    use tracing_subscriber::layer::SubscriberExt;
519    use tracing_subscriber::util::SubscriberInitExt;
520
521    let (telemetry, layer) = init_telemetry_layer(config);
522
523    if let Err(err) = tracing_subscriber::registry().with(layer).try_init() {
524        eprintln!("❌ Sideways OTel: failed to install global tracing subscriber: {err}");
525    }
526
527    telemetry
528}