Skip to main content

otel_log_wrapper/
lib.rs

1//! One-line OpenTelemetry setup for services that use `tracing`.
2//!
3//! ```no_run
4//! otel_log_wrapper::init!()?;
5//! tracing::info!("service started");
6//! # Ok::<(), otel_log_wrapper::Error>(())
7//! ```
8
9use std::fmt;
10use std::sync::OnceLock;
11
12use init_tracing_opentelemetry::resource::DetectResource;
13use init_tracing_opentelemetry::{LogFormat, TracingConfig};
14use tracing::level_filters::LevelFilter;
15
16pub use init_tracing_opentelemetry;
17pub use opentelemetry;
18pub use tracing;
19
20pub const DEFAULT_ENDPOINT: &str = "http://localhost:4317";
21pub const DEFAULT_PROTOCOL: &str = "grpc";
22
23static GLOBAL_GUARD: OnceLock<LoggerGuard> = OnceLock::new();
24
25pub type LoggerGuard = init_tracing_opentelemetry::Guard;
26
27#[macro_export]
28macro_rules! init {
29    () => {
30        $crate::LoggerConfig::builder(env!("CARGO_PKG_NAME"))
31            .service_version(env!("CARGO_PKG_VERSION"))
32            .init()
33    };
34}
35
36#[derive(Debug)]
37pub enum Error {
38    Init(init_tracing_opentelemetry::Error),
39    AlreadyInitialized,
40}
41
42impl fmt::Display for Error {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Init(err) => write!(f, "{err}"),
46            Self::AlreadyInitialized => write!(f, "logger already initialized"),
47        }
48    }
49}
50
51impl std::error::Error for Error {}
52
53impl From<init_tracing_opentelemetry::Error> for Error {
54    fn from(value: init_tracing_opentelemetry::Error) -> Self {
55        Self::Init(value)
56    }
57}
58
59#[derive(Debug, Clone)]
60pub struct LoggerConfig {
61    pub service_name: &'static str,
62    pub service_version: Option<&'static str>,
63    pub endpoint: Option<String>,
64    pub protocol: Option<String>,
65    pub log_directives: Option<String>,
66    pub default_level: LevelFilter,
67    pub format: LoggerFormat,
68    pub metrics: bool,
69    pub global_subscriber: bool,
70    pub startup_message: bool,
71    pub build_metadata: BuildMetadata,
72}
73
74impl LoggerConfig {
75    #[must_use]
76    pub fn builder(service_name: &'static str) -> LoggerConfigBuilder {
77        LoggerConfigBuilder {
78            inner: Self::new(service_name),
79        }
80    }
81
82    #[must_use]
83    pub fn new(service_name: &'static str) -> Self {
84        Self {
85            service_name,
86            service_version: None,
87            endpoint: None,
88            protocol: None,
89            log_directives: None,
90            default_level: LevelFilter::INFO,
91            format: LoggerFormat::default(),
92            metrics: true,
93            global_subscriber: true,
94            startup_message: true,
95            build_metadata: BuildMetadata::default(),
96        }
97    }
98
99    pub fn init(self) -> Result<&'static LoggerGuard, Error> {
100        init_logger(self)
101    }
102
103    pub fn init_guard(self) -> Result<LoggerGuard, Error> {
104        init_logger_guard(self)
105    }
106}
107
108#[derive(Debug, Clone, Copy, Default)]
109pub enum LoggerFormat {
110    #[default]
111    Human,
112    Pretty,
113    Full,
114    Compact,
115    Json,
116}
117
118impl LoggerFormat {
119    fn apply(self, config: TracingConfig) -> TracingConfig {
120        match self {
121            Self::Human | Self::Full => config.with_format(LogFormat::Full),
122            Self::Pretty => config.with_format(LogFormat::Pretty),
123            Self::Compact => config.with_format(LogFormat::Compact),
124            Self::Json => config.with_format(LogFormat::Json),
125        }
126    }
127}
128
129impl fmt::Display for LoggerFormat {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            Self::Human => f.write_str("human"),
133            Self::Pretty => f.write_str("pretty"),
134            Self::Full => f.write_str("full"),
135            Self::Compact => f.write_str("compact"),
136            Self::Json => f.write_str("json"),
137        }
138    }
139}
140
141#[derive(Debug, Clone, Copy, Default)]
142pub struct BuildMetadata {
143    pub commit_short: Option<&'static str>,
144    pub commit_hash: Option<&'static str>,
145    pub branch: Option<&'static str>,
146    pub build_time: Option<&'static str>,
147}
148
149impl BuildMetadata {
150    fn resource_attributes(self) -> impl Iterator<Item = (&'static str, &'static str)> {
151        [
152            ("vcs.commit.short", self.commit_short),
153            ("vcs.commit.hash", self.commit_hash),
154            ("vcs.branch", self.branch),
155            ("build.time", self.build_time),
156        ]
157        .into_iter()
158        .filter_map(|(key, value)| value.map(|value| (key, value)))
159    }
160}
161
162#[derive(Debug)]
163pub struct LoggerConfigBuilder {
164    inner: LoggerConfig,
165}
166
167impl LoggerConfigBuilder {
168    #[must_use]
169    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
170        self.inner.endpoint = Some(endpoint.into());
171        self
172    }
173
174    #[must_use]
175    pub fn protocol(mut self, protocol: impl Into<String>) -> Self {
176        self.inner.protocol = Some(protocol.into());
177        self
178    }
179
180    #[must_use]
181    pub fn log_directives(mut self, directives: impl Into<String>) -> Self {
182        self.inner.log_directives = Some(directives.into());
183        self
184    }
185
186    #[must_use]
187    pub fn default_level(mut self, level: LevelFilter) -> Self {
188        self.inner.default_level = level;
189        self
190    }
191
192    #[must_use]
193    pub fn format(mut self, format: LoggerFormat) -> Self {
194        self.inner.format = format;
195        self
196    }
197
198    #[must_use]
199    pub fn metrics(mut self, enabled: bool) -> Self {
200        self.inner.metrics = enabled;
201        self
202    }
203
204    #[must_use]
205    pub fn global_subscriber(mut self, enabled: bool) -> Self {
206        self.inner.global_subscriber = enabled;
207        self
208    }
209
210    #[must_use]
211    pub fn startup_message(mut self, enabled: bool) -> Self {
212        self.inner.startup_message = enabled;
213        self
214    }
215
216    #[must_use]
217    pub fn service_version(mut self, version: &'static str) -> Self {
218        self.inner.service_version = Some(version);
219        self
220    }
221
222    #[must_use]
223    pub fn commit_short(mut self, commit: &'static str) -> Self {
224        self.inner.build_metadata.commit_short = non_empty(commit);
225        self
226    }
227
228    #[must_use]
229    pub fn commit_hash(mut self, commit: &'static str) -> Self {
230        self.inner.build_metadata.commit_hash = non_empty(commit);
231        self
232    }
233
234    #[must_use]
235    pub fn branch(mut self, branch: &'static str) -> Self {
236        self.inner.build_metadata.branch = non_empty(branch);
237        self
238    }
239
240    #[must_use]
241    pub fn build_time(mut self, build_time: &'static str) -> Self {
242        self.inner.build_metadata.build_time = non_empty(build_time);
243        self
244    }
245
246    #[must_use]
247    pub fn build_metadata(mut self, metadata: BuildMetadata) -> Self {
248        self.inner.build_metadata = metadata;
249        self
250    }
251
252    #[must_use]
253    pub fn build(self) -> LoggerConfig {
254        self.inner
255    }
256
257    pub fn init(self) -> Result<&'static LoggerGuard, Error> {
258        self.build().init()
259    }
260
261    pub fn init_guard(self) -> Result<LoggerGuard, Error> {
262        self.build().init_guard()
263    }
264}
265
266pub fn init_logger(config: LoggerConfig) -> Result<&'static LoggerGuard, Error> {
267    if let Some(guard) = GLOBAL_GUARD.get() {
268        return Ok(guard);
269    }
270
271    let guard = init_logger_guard(config)?;
272    GLOBAL_GUARD
273        .set(guard)
274        .map_err(|_| Error::AlreadyInitialized)?;
275    Ok(GLOBAL_GUARD
276        .get()
277        .expect("global logger guard was just initialized"))
278}
279
280pub fn init_logger_guard(config: LoggerConfig) -> Result<LoggerGuard, Error> {
281    apply_env_defaults(&config);
282
283    if config.startup_message {
284        print_startup_message(&config);
285    }
286
287    let mut resource = DetectResource::default().with_fallback_service_name(config.service_name);
288    if let Some(version) = config.service_version {
289        resource = resource.with_fallback_service_version(version);
290    }
291
292    let tracing_config = config
293        .format
294        .apply(TracingConfig::production())
295        .with_default_level(config.default_level)
296        .with_metrics(config.metrics)
297        .with_global_subscriber(config.global_subscriber)
298        .with_otel_tracer_name(config.service_name)
299        .with_resource_config(resource);
300
301    let tracing_config = if let Some(directives) = config.log_directives {
302        tracing_config.with_log_directives(directives)
303    } else {
304        tracing_config
305    };
306
307    tracing_config.init_subscriber().map_err(Error::from)
308}
309
310fn apply_env_defaults(config: &LoggerConfig) {
311    set_env_if_missing("OTEL_SERVICE_NAME", config.service_name);
312    set_env_if_missing(
313        "OTEL_EXPORTER_OTLP_ENDPOINT",
314        config.endpoint.as_deref().unwrap_or(DEFAULT_ENDPOINT),
315    );
316    set_env_if_missing(
317        "OTEL_EXPORTER_OTLP_PROTOCOL",
318        config.protocol.as_deref().unwrap_or(DEFAULT_PROTOCOL),
319    );
320    merge_resource_attributes(config.build_metadata.resource_attributes());
321}
322
323fn set_env_if_missing(key: &str, value: &str) {
324    if std::env::var_os(key).is_some() {
325        return;
326    }
327
328    // OpenTelemetry setup must happen at process startup before worker threads
329    // are spawned. This crate only writes env vars when no explicit value exists.
330    unsafe {
331        std::env::set_var(key, value);
332    }
333}
334
335fn merge_resource_attributes(attributes: impl Iterator<Item = (&'static str, &'static str)>) {
336    let mut attributes: Vec<String> = attributes
337        .map(|(key, value)| format!("{key}={value}"))
338        .collect();
339    if attributes.is_empty() {
340        return;
341    }
342
343    if let Ok(existing) = std::env::var("OTEL_RESOURCE_ATTRIBUTES") {
344        if !existing.trim().is_empty() {
345            attributes.insert(0, existing);
346        }
347    }
348
349    unsafe {
350        std::env::set_var("OTEL_RESOURCE_ATTRIBUTES", attributes.join(","));
351    }
352}
353
354fn print_startup_message(config: &LoggerConfig) {
355    let endpoint = config.endpoint.as_deref().unwrap_or(DEFAULT_ENDPOINT);
356    let protocol = config.protocol.as_deref().unwrap_or(DEFAULT_PROTOCOL);
357    let version = config.service_version.unwrap_or("unknown");
358    let commit = config.build_metadata.commit_short.unwrap_or("unknown");
359    let level = config
360        .log_directives
361        .as_deref()
362        .unwrap_or("RUST_LOG|OTEL_LOG_LEVEL|info");
363
364    println!(
365        "otel-log-wrapper init service={} version={} commit={} endpoint={} protocol={} format={} metrics={} level={}",
366        config.service_name,
367        version,
368        commit,
369        endpoint,
370        protocol,
371        config.format,
372        config.metrics,
373        level
374    );
375}
376
377fn non_empty(value: &'static str) -> Option<&'static str> {
378    if value.trim().is_empty() {
379        None
380    } else {
381        Some(value)
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use serial_test::serial;
388
389    use super::*;
390
391    #[test]
392    fn default_config_matches_zero_config_expectations() {
393        let config = LoggerConfig::new("test-service");
394
395        assert_eq!(config.service_name, "test-service");
396        assert_eq!(config.endpoint, None);
397        assert_eq!(config.protocol, None);
398        assert_eq!(config.default_level, LevelFilter::INFO);
399        assert!(matches!(config.format, LoggerFormat::Human));
400        assert!(config.metrics);
401        assert!(config.global_subscriber);
402        assert!(config.startup_message);
403    }
404
405    #[test]
406    fn builder_sets_overrides() {
407        let config = LoggerConfig::builder("test-service")
408            .endpoint("http://collector:4317")
409            .protocol("grpc")
410            .log_directives("debug")
411            .default_level(LevelFilter::DEBUG)
412            .format(LoggerFormat::Compact)
413            .metrics(false)
414            .global_subscriber(false)
415            .startup_message(false)
416            .service_version("1.2.3")
417            .commit_short("abc1234")
418            .commit_hash("abc123456789")
419            .branch("main")
420            .build_time("2026-04-26T00:00:00Z")
421            .build();
422
423        assert_eq!(config.service_name, "test-service");
424        assert_eq!(config.endpoint.as_deref(), Some("http://collector:4317"));
425        assert_eq!(config.protocol.as_deref(), Some("grpc"));
426        assert_eq!(config.log_directives.as_deref(), Some("debug"));
427        assert_eq!(config.default_level, LevelFilter::DEBUG);
428        assert!(matches!(config.format, LoggerFormat::Compact));
429        assert!(!config.metrics);
430        assert!(!config.global_subscriber);
431        assert!(!config.startup_message);
432        assert_eq!(config.service_version, Some("1.2.3"));
433        assert_eq!(config.build_metadata.commit_short, Some("abc1234"));
434        assert_eq!(config.build_metadata.commit_hash, Some("abc123456789"));
435        assert_eq!(config.build_metadata.branch, Some("main"));
436        assert_eq!(
437            config.build_metadata.build_time,
438            Some("2026-04-26T00:00:00Z")
439        );
440    }
441
442    #[test]
443    #[serial]
444    fn env_defaults_do_not_override_existing_values() {
445        unsafe {
446            std::env::set_var("OTEL_SERVICE_NAME", "existing-service");
447            std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "http://existing:4317");
448            std::env::set_var("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf");
449            std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES");
450        }
451
452        apply_env_defaults(
453            &LoggerConfig::builder("new-service")
454                .endpoint("http://new:4317")
455                .protocol("grpc")
456                .commit_short("abc1234")
457                .build(),
458        );
459
460        assert_eq!(
461            std::env::var("OTEL_SERVICE_NAME").as_deref(),
462            Ok("existing-service")
463        );
464        assert_eq!(
465            std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").as_deref(),
466            Ok("http://existing:4317")
467        );
468        assert_eq!(
469            std::env::var("OTEL_EXPORTER_OTLP_PROTOCOL").as_deref(),
470            Ok("http/protobuf")
471        );
472        assert_eq!(
473            std::env::var("OTEL_RESOURCE_ATTRIBUTES").as_deref(),
474            Ok("vcs.commit.short=abc1234")
475        );
476
477        unsafe {
478            std::env::remove_var("OTEL_SERVICE_NAME");
479            std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
480            std::env::remove_var("OTEL_EXPORTER_OTLP_PROTOCOL");
481            std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES");
482        }
483    }
484}