1use 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 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}