klieo-otel 3.3.0

OpenTelemetry tracing exporter for klieo agent runtimes.
Documentation
//! Builder-style configuration for [`crate::install`].

use std::time::Duration;

/// OTLP wire protocol selector.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Protocol {
    /// gRPC over tonic — the default. Targets the collector's `4317` port.
    #[default]
    Grpc,
    /// HTTP with protobuf bodies. Targets the collector's `4318` port.
    HttpProtobuf,
}

/// Configuration for [`crate::install`].
///
/// Construct with [`OtelConfig::new`] and chain builder methods. All
/// fields are public so callers can introspect a configuration after
/// construction (e.g. for logging the resolved endpoint at startup).
#[derive(Debug, Clone)]
pub struct OtelConfig {
    /// Required: emitted as the `service.name` resource attribute.
    pub service_name: String,
    /// Optional: emitted as `service.version` if set.
    pub service_version: Option<String>,
    /// Required (with a default): `gen_ai.system` resource attribute,
    /// per the OTel GenAI semantic convention. Defaults to
    /// `"unknown"`.
    pub gen_ai_system: String,
    /// OTLP endpoint URL. Overridden by the
    /// `OTEL_EXPORTER_OTLP_ENDPOINT` env var when set.
    pub endpoint: String,
    /// Wire protocol — gRPC by default.
    pub protocol: Protocol,
    /// Per-export timeout. Defaults to `5s`, matching the OTel SDK default.
    pub timeout: Duration,
    /// Static headers attached to every OTLP export (W2.A12). Most
    /// commonly carries `("authorization", "Bearer <token>")` for
    /// authenticated collectors (Grafana Cloud, Honeycomb, etc.).
    /// Spans + log records on the wire contain `gen_ai.prompt` and
    /// `gen_ai.completion` attributes — i.e. PII / customer prompts —
    /// so an unauthenticated remote collector is a data-exfiltration
    /// risk.
    pub headers: Vec<(String, String)>,
}

const OTLP_DEFAULT_EXPORT_TIMEOUT: Duration = Duration::from_secs(5);

impl OtelConfig {
    /// Start a new configuration for the given service name.
    ///
    /// The default endpoint is `http://localhost:4317` (the gRPC OTLP
    /// port a `otelcol-contrib` / Tempo / Jaeger image listens on by
    /// default). Override with [`OtelConfig::endpoint`] or by setting
    /// `OTEL_EXPORTER_OTLP_ENDPOINT` in the environment.
    pub fn new(service_name: impl Into<String>) -> Self {
        Self {
            service_name: service_name.into(),
            service_version: None,
            gen_ai_system: "unknown".to_string(),
            endpoint: "http://localhost:4317".to_string(),
            protocol: Protocol::Grpc,
            timeout: OTLP_DEFAULT_EXPORT_TIMEOUT,
            headers: Vec::new(),
        }
    }

    /// Set the OTLP endpoint URL.
    ///
    /// The endpoint is validated at [`crate::install`] time, not here,
    /// because the standard `OTEL_EXPORTER_OTLP_ENDPOINT` env var can
    /// override this value. `install` refuses to wire up cleartext (any
    /// non-`https://`) export to non-loopback collectors (W8 / W2.A12 /
    /// CWE-319); prompts and completions ride this wire as `gen_ai.*`
    /// attributes and must not transit in cleartext.
    #[must_use]
    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
        self.endpoint = endpoint.into();
        self
    }

    /// Add a header attached to every OTLP export (W2.A12).
    ///
    /// Typical use:
    /// ```rust,ignore
    /// OtelConfig::new("klieo")
    ///     .endpoint("https://collector.honeycomb.io:443")
    ///     .with_header("x-honeycomb-team", env!("HONEYCOMB_KEY"))
    /// ```
    #[must_use]
    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push((name.into(), value.into()));
        self
    }

    /// Convenience for `("authorization", format!("Bearer {token}"))`.
    #[must_use]
    pub fn bearer_token(self, token: impl AsRef<str>) -> Self {
        self.with_header("authorization", format!("Bearer {}", token.as_ref()))
    }

    /// Set the wire protocol (gRPC or HTTP/protobuf).
    #[must_use]
    pub fn protocol(mut self, protocol: Protocol) -> Self {
        self.protocol = protocol;
        self
    }

    /// Set the per-export timeout.
    #[must_use]
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Set the `service.version` resource attribute.
    #[must_use]
    pub fn service_version(mut self, version: impl Into<String>) -> Self {
        self.service_version = Some(version.into());
        self
    }

    /// Set the `gen_ai.system` resource attribute (per OTel GenAI
    /// semantic conventions: `"ollama"`, `"openai"`, `"anthropic"`,
    /// etc.).
    #[must_use]
    pub fn gen_ai_system(mut self, system: impl Into<String>) -> Self {
        self.gen_ai_system = system.into();
        self
    }
}

/// Returns `true` when `endpoint` ships telemetry in cleartext to a
/// non-loopback collector. Only `https://` is treated as encrypted; plaintext
/// `http://`, `grpc://`, and a schemeless `host:port` (tonic's plaintext h2c)
/// to a remote host all return `true`. Loopback targets return `false`.
/// W8 / W2.A12 / CWE-319.
pub(crate) fn is_plaintext_remote_otlp(endpoint: &str) -> bool {
    if endpoint.starts_with("https://") {
        return false;
    }
    let after_scheme = endpoint
        .strip_prefix("http://")
        .or_else(|| endpoint.strip_prefix("grpc://"))
        .unwrap_or(endpoint);
    let authority = after_scheme.split('/').next().unwrap_or("");
    let host = if let Some(end) = authority.find(']') {
        &authority[..=end]
    } else {
        authority.split(':').next().unwrap_or("")
    };
    !matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn defaults_match_otel_conventions() {
        let cfg = OtelConfig::new("svc");
        assert_eq!(cfg.endpoint, "http://localhost:4317");
        assert!(matches!(cfg.protocol, Protocol::Grpc));
        assert_eq!(cfg.timeout, Duration::from_secs(5));
        assert_eq!(cfg.gen_ai_system, "unknown");
        assert!(cfg.service_version.is_none());
        assert!(cfg.headers.is_empty());
    }

    #[test]
    fn protocol_default_is_grpc() {
        assert_eq!(Protocol::default(), Protocol::Grpc);
    }

    /// W2.A12: bearer_token canonicalises to an Authorization header
    /// using the standard Bearer scheme.
    #[test]
    fn bearer_token_emits_authorization_header() {
        let cfg = OtelConfig::new("svc").bearer_token("abc");
        assert_eq!(
            cfg.headers,
            vec![("authorization".to_string(), "Bearer abc".to_string())]
        );
    }

    #[test]
    fn with_header_accumulates() {
        let cfg = OtelConfig::new("svc")
            .with_header("x-team", "team-1")
            .with_header("x-env", "prod");
        assert_eq!(cfg.headers.len(), 2);
    }

    /// W8 / W2.A12 / CWE-319: plaintext http:// to non-loopback is unsafe.
    #[test]
    fn is_plaintext_remote_otlp_flags_remote_http() {
        assert!(is_plaintext_remote_otlp(
            "http://collector.example.com:4317"
        ));
        assert!(is_plaintext_remote_otlp("http://10.0.0.1:4317"));
    }

    #[test]
    fn is_plaintext_remote_otlp_allows_loopback() {
        assert!(!is_plaintext_remote_otlp("http://localhost:4317"));
        assert!(!is_plaintext_remote_otlp("http://127.0.0.1:4317"));
        assert!(!is_plaintext_remote_otlp("http://[::1]:4317"));
    }

    #[test]
    fn is_plaintext_remote_otlp_allows_https() {
        assert!(!is_plaintext_remote_otlp(
            "https://collector.example.com:4317"
        ));
    }

    #[test]
    fn is_plaintext_remote_otlp_flags_schemeless_and_grpc_remote() {
        // tonic accepts a schemeless `host:port` and `grpc://` — both ship
        // plaintext h2c, so a remote target must be flagged just like http://.
        assert!(is_plaintext_remote_otlp("collector.example.com:4317"));
        assert!(is_plaintext_remote_otlp(
            "grpc://collector.example.com:4317"
        ));
        assert!(is_plaintext_remote_otlp("10.0.0.1:4317"));
    }

    #[test]
    fn is_plaintext_remote_otlp_allows_schemeless_loopback() {
        assert!(!is_plaintext_remote_otlp("localhost:4317"));
        assert!(!is_plaintext_remote_otlp("127.0.0.1:4317"));
    }
}