rskit-observability 0.2.0-alpha.1

OpenTelemetry tracing, metrics, and context propagation
Documentation
use std::time::Duration;

use serde::Deserialize;

use rskit_errors::{AppError, AppResult, ErrorCode};

use crate::tracer::{OtlpProtocol, SERVICE_NAME};

/// Configuration for an OpenTelemetry log pipeline.
#[derive(Debug, Clone, Deserialize)]
pub struct LogsConfig {
    /// Logical service name attached to every log record.
    pub service_name: String,
    /// OTLP collector endpoint. When `None`, logs are not exported.
    pub otlp_endpoint: Option<String>,
    /// Export transport protocol.
    pub protocol: OtlpProtocol,
    /// Maximum time to wait when flushing logs on shutdown.
    pub export_timeout: Duration,
}

/// Injectable OpenTelemetry logger provider handle.
#[derive(Clone)]
pub struct LogsHandle {
    provider: opentelemetry_sdk::logs::SdkLoggerProvider,
}

impl LogsHandle {
    /// Return the SDK logger provider for constructor injection.
    #[must_use]
    pub fn provider(&self) -> opentelemetry_sdk::logs::SdkLoggerProvider {
        self.provider.clone()
    }

    /// Flush and shut down the provider.
    pub fn shutdown(&self) -> AppResult<()> {
        self.provider
            .shutdown()
            .map_err(|e| AppError::new(ErrorCode::Internal, format!("OTLP log shutdown: {e}")))
    }
}

/// Build an injectable OpenTelemetry log provider without touching global state.
///
/// When `otlp_endpoint` is `None`, the provider is built without exporters and
/// acts as an explicit no-export pipeline.
pub fn init_logs(cfg: &LogsConfig) -> AppResult<LogsHandle> {
    use opentelemetry::KeyValue;
    use opentelemetry_sdk::{Resource, logs::SdkLoggerProvider};

    let resource = Resource::builder_empty()
        .with_attributes([KeyValue::new(SERVICE_NAME, cfg.service_name.clone())])
        .build();
    let builder = SdkLoggerProvider::builder().with_resource(resource);

    if let Some(endpoint) = &cfg.otlp_endpoint {
        #[cfg(not(feature = "otlp"))]
        {
            let _ = endpoint;
            Err(AppError::new(
                ErrorCode::InvalidInput,
                "OTLP log exporter requires the `otlp` feature",
            ))?;
        }

        #[cfg(feature = "otlp")]
        {
            use opentelemetry_otlp::{LogExporter, WithExportConfig};

            let exporter = match cfg.protocol {
                OtlpProtocol::Grpc => LogExporter::builder()
                    .with_tonic()
                    .with_endpoint(endpoint)
                    .with_timeout(cfg.export_timeout)
                    .build(),
                OtlpProtocol::HttpBinary => LogExporter::builder()
                    .with_http()
                    .with_protocol(opentelemetry_otlp::Protocol::HttpBinary)
                    .with_endpoint(endpoint)
                    .with_timeout(cfg.export_timeout)
                    .build(),
            }
            .map_err(|e| AppError::new(ErrorCode::Internal, format!("OTLP log exporter: {e}")))?;
            let builder = builder.with_batch_exporter(exporter);
            return Ok(LogsHandle {
                provider: builder.build(),
            });
        }
    }

    Ok(LogsHandle {
        provider: builder.build(),
    })
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use super::*;

    fn config(endpoint: Option<&str>) -> LogsConfig {
        LogsConfig {
            service_name: "logs-test".to_string(),
            otlp_endpoint: endpoint.map(str::to_string),
            protocol: OtlpProtocol::Grpc,
            export_timeout: Duration::from_millis(10),
        }
    }

    #[test]
    fn no_export_pipeline_builds_and_shuts_down_cleanly() {
        let handle = init_logs(&config(None)).expect("build no-export logger provider");
        let _provider = handle.provider();

        handle.shutdown().expect("shutdown no-export provider");
    }

    #[cfg(not(feature = "otlp"))]
    #[test]
    fn otlp_endpoint_requires_feature() {
        let error = match init_logs(&config(Some("http://127.0.0.1:4317"))) {
            Ok(_) => panic!("otlp disabled should reject log exporter configuration"),
            Err(error) => error,
        };

        assert_eq!(error.code(), ErrorCode::InvalidInput);
        assert!(error.message().contains("otlp"));
    }

    #[cfg(feature = "otlp")]
    #[test]
    fn otlp_pipeline_builds_for_http_protocol_without_connecting() {
        let mut cfg = config(Some("http://127.0.0.1:4318/v1/logs"));
        cfg.protocol = OtlpProtocol::HttpBinary;

        let handle = init_logs(&cfg).expect("build otlp logger provider");
        let _provider = handle.provider();
    }
}