Skip to main content

krabka_telemetry/
config.rs

1//! Resolution of the OTLP settings from the environment.
2//!
3//! The module holds `OtlpProtocol`, `OtlpConfig`, and the environment parsing
4//! that decides whether OTLP export is on at all. It is separate from the
5//! exporter build, so the decision logic stays pure and testable without a
6//! collector.
7
8use {
9    crate::error::TelemetryError,
10    krabka_units::{
11        parse,
12        prelude::{Time, TimeExt, secs},
13    },
14    std::time::Duration,
15};
16
17/// OTLP transport.
18///
19/// The variants mirror the `OTEL_EXPORTER_OTLP_PROTOCOL` spec values.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum OtlpProtocol {
22    /// The OTLP/gRPC transport. Default collector port `4317`.
23    Grpc,
24    /// The OTLP/HTTP transport with protobuf payloads. Default collector port
25    /// `4318`.
26    HttpProtobuf,
27}
28
29impl OtlpProtocol {
30    /// Parse an `OTEL_EXPORTER_OTLP_PROTOCOL`-style value.
31    ///
32    /// A value that this function does not recognize falls back to gRPC, the
33    /// SDK's default transport.
34    #[must_use]
35    pub fn parse(s: &str) -> Self {
36        match s.trim().to_ascii_lowercase().as_str() {
37            "http/protobuf" | "http" | "httpbinary" | "http-protobuf" => Self::HttpProtobuf,
38            _ => Self::Grpc,
39        }
40    }
41
42    #[must_use]
43    pub fn default_endpoint(self) -> &'static str {
44        match self {
45            Self::Grpc => "http://localhost:4317",
46            Self::HttpProtobuf => "http://localhost:4318",
47        }
48    }
49}
50
51/// Resolved OTLP configuration.
52///
53/// [`OtlpConfig::from_env`] builds this configuration. `Ok(None)` from that
54/// constructor means that OTLP is disabled and that this crate builds no
55/// exporter.
56#[derive(Debug, Clone)]
57pub struct OtlpConfig {
58    pub endpoint: String,
59    pub protocol: OtlpProtocol,
60    /// Head sampling ratio in `[0.0, 1.0]`. A parent-based sampler wraps this
61    /// ratio, so child spans keep an upstream sampling decision.
62    pub sample_ratio: f64,
63    pub service_name: String,
64    pub service_version: String,
65    pub service_instance_id: String,
66    pub timeout: Time,
67    pub heartbeat_interval: Option<Time>,
68}
69
70/// Truthy parse of `*_ENABLED` and `*_DISABLED` style environment values.
71fn env_truthy(v: &str) -> bool {
72    matches!(
73        v.trim().to_ascii_lowercase().as_str(),
74        "1" | "true" | "yes" | "on"
75    )
76}
77
78fn parse_time(name: &'static str, value: &str) -> Result<Time, TelemetryError> {
79    let time = parse::non_negative_time(value).map_err(|error| TelemetryError::InvalidConfig {
80        name,
81        message: error.to_string(),
82    })?;
83    Duration::try_from_secs_f64(time.secs_f64()).map_err(|error| {
84        TelemetryError::InvalidConfig {
85            name,
86            message: error.to_string(),
87        }
88    })?;
89    Ok(time)
90}
91
92impl OtlpConfig {
93    /// Resolve OTLP config from the environment.
94    ///
95    /// `get` is the environment lookup. The caller injects it, so this
96    /// function is pure and easy to test. `service_instance_id` is the service
97    /// instance id, for example the broker node id. `service_version` is the
98    /// crate version. `default_service_name` is the fallback name for when
99    /// `OTEL_SERVICE_NAME` is not set.
100    ///
101    /// Returns `Ok(None)` when OTLP is disabled. OTLP is disabled when nothing
102    /// turned it on, or when `OTEL_SDK_DISABLED` turned it off.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error when a custom Krabka duration is malformed.
107    pub fn from_env(
108        get: impl Fn(&str) -> Option<String>,
109        service_instance_id: &str,
110        service_version: &str,
111        default_service_name: &str,
112    ) -> Result<Option<Self>, TelemetryError> {
113        if get("OTEL_SDK_DISABLED").as_deref().is_some_and(env_truthy) {
114            return Ok(None);
115        }
116
117        let endpoint_override = get("KRABKA_OTLP_ENDPOINT")
118            .or_else(|| get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"))
119            .or_else(|| get("OTEL_EXPORTER_OTLP_ENDPOINT"))
120            .map(|s| s.trim().to_owned())
121            .filter(|s| !s.is_empty());
122
123        let explicitly_enabled = get("KRABKA_OTLP_ENABLED")
124            .as_deref()
125            .is_some_and(env_truthy);
126
127        // Off unless something opts in.
128        if endpoint_override.is_none() && !explicitly_enabled {
129            return Ok(None);
130        }
131
132        let protocol = get("KRABKA_OTLP_PROTOCOL")
133            .or_else(|| get("OTEL_EXPORTER_OTLP_PROTOCOL"))
134            .map_or(OtlpProtocol::Grpc, |s| OtlpProtocol::parse(&s));
135
136        let endpoint = endpoint_override.unwrap_or_else(|| protocol.default_endpoint().to_owned());
137
138        let sample_ratio = get("KRABKA_OTLP_SAMPLE_RATIO")
139            .or_else(|| get("OTEL_TRACES_SAMPLER_ARG"))
140            .and_then(|s| s.trim().parse::<f64>().ok())
141            .map_or(1.0, |r| r.clamp(0.0, 1.0));
142
143        let service_name = get("OTEL_SERVICE_NAME")
144            .map(|s| s.trim().to_owned())
145            .filter(|s| !s.is_empty())
146            .unwrap_or_else(|| default_service_name.to_owned());
147
148        let timeout = match get("KRABKA_OTLP_TIMEOUT") {
149            Some(value) => parse_time("KRABKA_OTLP_TIMEOUT", &value)?,
150            None => get("OTEL_EXPORTER_OTLP_TIMEOUT_SECS")
151                .and_then(|s| s.trim().parse::<u64>().ok())
152                .map_or_else(
153                    || secs(10),
154                    |seconds| Time::from_std(Duration::from_secs(seconds)),
155                ),
156        };
157
158        let heartbeat_interval = get("KRABKA_OTLP_HEARTBEAT_INTERVAL")
159            .map(|value| parse_time("KRABKA_OTLP_HEARTBEAT_INTERVAL", &value))
160            .transpose()?
161            .filter(|interval| *interval > Time::ZERO);
162
163        Ok(Some(Self {
164            endpoint,
165            protocol,
166            sample_ratio,
167            service_name,
168            service_version: service_version.to_owned(),
169            service_instance_id: service_instance_id.to_owned(),
170            timeout,
171            heartbeat_interval,
172        }))
173    }
174}
175
176#[cfg(test)]
177mod tests;