Skip to main content

rskit_observability/
logs.rs

1use std::time::Duration;
2
3use serde::Deserialize;
4
5use rskit_errors::{AppError, AppResult, ErrorCode};
6
7use crate::tracer::{OtlpProtocol, SERVICE_NAME};
8
9/// Configuration for an OpenTelemetry log pipeline.
10#[derive(Debug, Clone, Deserialize)]
11pub struct LogsConfig {
12    /// Logical service name attached to every log record.
13    pub service_name: String,
14    /// OTLP collector endpoint. When `None`, logs are not exported.
15    pub otlp_endpoint: Option<String>,
16    /// Export transport protocol.
17    pub protocol: OtlpProtocol,
18    /// Maximum time to wait when flushing logs on shutdown.
19    pub export_timeout: Duration,
20}
21
22/// Injectable OpenTelemetry logger provider handle.
23#[derive(Clone)]
24pub struct LogsHandle {
25    provider: opentelemetry_sdk::logs::SdkLoggerProvider,
26}
27
28impl LogsHandle {
29    /// Return the SDK logger provider for constructor injection.
30    #[must_use]
31    pub fn provider(&self) -> opentelemetry_sdk::logs::SdkLoggerProvider {
32        self.provider.clone()
33    }
34
35    /// Flush and shut down the provider.
36    pub fn shutdown(&self) -> AppResult<()> {
37        self.provider
38            .shutdown()
39            .map_err(|e| AppError::new(ErrorCode::Internal, format!("OTLP log shutdown: {e}")))
40    }
41}
42
43/// Build an injectable OpenTelemetry log provider without touching global state.
44///
45/// When `otlp_endpoint` is `None`, the provider is built without exporters and
46/// acts as an explicit no-export pipeline.
47pub fn init_logs(cfg: &LogsConfig) -> AppResult<LogsHandle> {
48    use opentelemetry::KeyValue;
49    use opentelemetry_sdk::{Resource, logs::SdkLoggerProvider};
50
51    let resource = Resource::builder_empty()
52        .with_attributes([KeyValue::new(SERVICE_NAME, cfg.service_name.clone())])
53        .build();
54    let builder = SdkLoggerProvider::builder().with_resource(resource);
55
56    if let Some(endpoint) = &cfg.otlp_endpoint {
57        #[cfg(not(feature = "otlp"))]
58        {
59            let _ = endpoint;
60            Err(AppError::new(
61                ErrorCode::InvalidInput,
62                "OTLP log exporter requires the `otlp` feature",
63            ))?;
64        }
65
66        #[cfg(feature = "otlp")]
67        {
68            use opentelemetry_otlp::{LogExporter, WithExportConfig};
69
70            let exporter = match cfg.protocol {
71                OtlpProtocol::Grpc => LogExporter::builder()
72                    .with_tonic()
73                    .with_endpoint(endpoint)
74                    .with_timeout(cfg.export_timeout)
75                    .build(),
76                OtlpProtocol::HttpBinary => LogExporter::builder()
77                    .with_http()
78                    .with_protocol(opentelemetry_otlp::Protocol::HttpBinary)
79                    .with_endpoint(endpoint)
80                    .with_timeout(cfg.export_timeout)
81                    .build(),
82            }
83            .map_err(|e| AppError::new(ErrorCode::Internal, format!("OTLP log exporter: {e}")))?;
84            let builder = builder.with_batch_exporter(exporter);
85            return Ok(LogsHandle {
86                provider: builder.build(),
87            });
88        }
89    }
90
91    Ok(LogsHandle {
92        provider: builder.build(),
93    })
94}
95
96#[cfg(test)]
97mod tests {
98    use std::time::Duration;
99
100    use super::*;
101
102    fn config(endpoint: Option<&str>) -> LogsConfig {
103        LogsConfig {
104            service_name: "logs-test".to_string(),
105            otlp_endpoint: endpoint.map(str::to_string),
106            protocol: OtlpProtocol::Grpc,
107            export_timeout: Duration::from_millis(10),
108        }
109    }
110
111    #[test]
112    fn no_export_pipeline_builds_and_shuts_down_cleanly() {
113        let handle = init_logs(&config(None)).expect("build no-export logger provider");
114        let _provider = handle.provider();
115
116        handle.shutdown().expect("shutdown no-export provider");
117    }
118
119    #[cfg(not(feature = "otlp"))]
120    #[test]
121    fn otlp_endpoint_requires_feature() {
122        let error = match init_logs(&config(Some("http://127.0.0.1:4317"))) {
123            Ok(_) => panic!("otlp disabled should reject log exporter configuration"),
124            Err(error) => error,
125        };
126
127        assert_eq!(error.code(), ErrorCode::InvalidInput);
128        assert!(error.message().contains("otlp"));
129    }
130
131    #[cfg(feature = "otlp")]
132    #[test]
133    fn otlp_pipeline_builds_for_http_protocol_without_connecting() {
134        let mut cfg = config(Some("http://127.0.0.1:4318/v1/logs"));
135        cfg.protocol = OtlpProtocol::HttpBinary;
136
137        let handle = init_logs(&cfg).expect("build otlp logger provider");
138        let _provider = handle.provider();
139    }
140}