Skip to main content

klieo_otel/
lib.rs

1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `klieo-otel` — OpenTelemetry OTLP exporter wiring for klieo agent
4//! runtimes.
5//!
6//! The klieo runtime is instrumented with `tracing` spans on every
7//! [`Agent::run_steps`](https://docs.rs/klieo-core) call, every LLM
8//! invocation, and every tool dispatch. Field names follow the
9//! [OpenTelemetry GenAI semantic
10//! conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/),
11//! so any OTel-aware backend (Tempo, Jaeger, Honeycomb, Datadog, …)
12//! ingests them once you wire up an exporter.
13//!
14//! This crate is that exporter. One function call:
15//!
16//! ```rust,no_run
17//! use klieo_otel::{install, OtelConfig, Protocol};
18//! use std::time::Duration;
19//!
20//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
21//! let _guard = install(
22//!     OtelConfig::new("my-agent")
23//!         .service_version(env!("CARGO_PKG_VERSION"))
24//!         .gen_ai_system("ollama")
25//!         .endpoint("http://localhost:4317")
26//!         .protocol(Protocol::Grpc)
27//!         .timeout(Duration::from_secs(5)),
28//! )?;
29//! // run agents here — spans flow to the configured collector
30//! // until `_guard` is dropped.
31//! # Ok(()) }
32//! ```
33//!
34//! ## Defaults
35//!
36//! - **Endpoint** — `OTEL_EXPORTER_OTLP_ENDPOINT` env var (the
37//!   standard OTel SDK convention) is honoured if set; otherwise the
38//!   value passed to [`OtelConfig::endpoint`] is used; otherwise
39//!   `http://localhost:4317`.
40//! - **Protocol** — gRPC (`Protocol::Grpc`). Switch with
41//!   [`OtelConfig::protocol`].
42//! - **Resource attributes** — `service.name`, `service.version`,
43//!   `gen_ai.system` are emitted on every span, per OTel semantic
44//!   conventions.
45//!
46//! ## Guard semantics
47//!
48//! [`OtelGuard`]'s `Drop` impl force-flushes any in-flight batch and
49//! shuts the provider down cleanly. Bind it to a binding that lives
50//! for the lifetime of your process (e.g. in `main`):
51//!
52//! ```rust,no_run
53//! # use klieo_otel::{install, OtelConfig};
54//! # #[tokio::main]
55//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
56//! let _otel = install(OtelConfig::new("my-agent"))?;
57//! // … run server … the guard drops at end of main, flushing spans.
58//! # Ok(()) }
59//! ```
60//!
61//! Prefix the binding with `_` (not `_otel = …` then unused) to
62//! suppress the unused-variable warning while keeping the binding
63//! alive — `let _ = …` would drop the guard immediately and discard
64//! every export.
65//!
66//! ## Composition with existing subscribers
67//!
68//! [`install`] sets a global `tracing` subscriber that combines:
69//!
70//! - an `env-filter` layer respecting `RUST_LOG`,
71//! - the OpenTelemetry layer that exports spans over OTLP,
72//! - a human-readable `fmt` layer on stderr.
73//!
74//! If you already call `tracing_subscriber::fmt().init()` (or any
75//! other subscriber installer) earlier in `main`, [`install`] will
76//! return [`OtelError::AlreadyInitialized`]. Call this crate **first**
77//! (or skip the other call) to avoid a double-install.
78
79mod config;
80mod error;
81mod guard;
82
83#[cfg(any(test, feature = "test-utils"))]
84pub mod test_utils;
85
86pub use config::{OtelConfig, Protocol};
87pub use error::OtelError;
88pub use guard::OtelGuard;
89
90pub use tracing_opentelemetry::OpenTelemetrySpanExt;
91
92use opentelemetry::trace::TracerProvider as _;
93use opentelemetry::KeyValue;
94use opentelemetry_otlp::{SpanExporter, WithExportConfig, WithHttpConfig, WithTonicConfig};
95use opentelemetry_sdk::{trace::TracerProvider as SdkTracerProvider, Resource};
96use tonic::metadata::{MetadataKey, MetadataMap, MetadataValue};
97use tracing_subscriber::layer::SubscriberExt;
98use tracing_subscriber::util::SubscriberInitExt;
99use tracing_subscriber::EnvFilter;
100
101/// Standard OTel SDK environment variable: when set, overrides
102/// [`OtelConfig::endpoint`].
103pub const OTEL_EXPORTER_OTLP_ENDPOINT_ENV: &str = "OTEL_EXPORTER_OTLP_ENDPOINT";
104
105/// Install a global OpenTelemetry tracing subscriber.
106///
107/// Builds an OTLP exporter, attaches it to a Tokio-batched
108/// [`SdkTracerProvider`], wires it through `tracing-opentelemetry`,
109/// and installs the result as the global `tracing` subscriber.
110///
111/// Returns an [`OtelGuard`] that flushes and shuts the provider down
112/// when dropped. Bind it to a `_guard` (or longer-lived) binding so
113/// it survives until process shutdown.
114///
115/// # Errors
116///
117/// - [`OtelError::Build`] — the OTLP exporter could not be constructed
118///   (typically a malformed endpoint or unsupported protocol/feature
119///   combination).
120/// - [`OtelError::AlreadyInitialized`] — a global `tracing`
121///   subscriber is already in place. Call this crate before any other
122///   subscriber initializer.
123///
124/// # Panics
125///
126/// Does not panic. All failure paths return an [`OtelError`].
127pub fn install(config: OtelConfig) -> Result<OtelGuard, OtelError> {
128    let endpoint = resolve_endpoint(&config);
129    // W8 / W2.A12 / CWE-319: refuse cleartext (any non-https) export to
130    // non-loopback collectors. Spans carry `gen_ai.prompt` /
131    // `gen_ai.completion` attributes — PII / customer prompts — and must not
132    // transit in cleartext. Loopback collectors stay allowed so local dev works.
133    if config::is_plaintext_remote_otlp(&endpoint) {
134        return Err(OtelError::PlaintextRemote(endpoint));
135    }
136    let provider = build_provider(&config, &endpoint)?;
137    let tracer = provider.tracer(config.service_name.clone());
138
139    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
140    let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
141    let fmt_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stderr);
142
143    tracing_subscriber::registry()
144        .with(env_filter)
145        .with(otel_layer)
146        .with(fmt_layer)
147        .try_init()
148        .map_err(|e| OtelError::AlreadyInitialized(e.to_string()))?;
149
150    // Promote our provider to the global so that components reading
151    // `opentelemetry::global::tracer(...)` see the same exporter.
152    opentelemetry::global::set_tracer_provider(provider.clone());
153
154    Ok(OtelGuard::new(provider))
155}
156
157/// Install klieo's OpenTelemetry exporter, tolerating an already-installed
158/// `tracing` subscriber.
159///
160/// Same as [`install`] when no global `tracing` subscriber has been set
161/// yet — installs klieo's full registry (env-filter + OTel layer + fmt).
162///
163/// When a global subscriber is already set, the subscriber install step
164/// is skipped silently (Rust's `tracing` API exposes no way to uninstall
165/// a previously-set global default). The OTel tracer provider is still
166/// promoted to the OpenTelemetry global so that any third-party
167/// subscriber wired up with `tracing-opentelemetry::layer().with_tracer(
168/// opentelemetry::global::tracer(...))` will route klieo's spans through
169/// the OTLP exporter. Callers get a working [`OtelGuard`] either way and
170/// the flush-on-drop semantics are preserved.
171///
172/// Use cases:
173/// - Integration tests where another fixture pre-installs `tracing_subscriber::fmt()`.
174/// - Plug-in loaders or libraries where klieo runs after host
175///   initialisation.
176/// - Test harnesses with mixed `klieo-otel::install` / `install_or_replace`
177///   invocations across test cases.
178///
179/// Prefer [`install`] when you control the binary's startup order so
180/// the env-filter + fmt + OTel layer all reach `tracing` events.
181///
182/// # Errors
183///
184/// - [`OtelError::Build`] — the OTLP exporter could not be constructed.
185/// - [`OtelError::PlaintextRemote`] — endpoint is plaintext-HTTP to a
186///   non-loopback collector (same redaction-defence policy as [`install`]).
187///
188/// Never returns [`OtelError::AlreadyInitialized`].
189pub fn install_or_replace(config: OtelConfig) -> Result<OtelGuard, OtelError> {
190    let endpoint = resolve_endpoint(&config);
191    if config::is_plaintext_remote_otlp(&endpoint) {
192        return Err(OtelError::PlaintextRemote(endpoint));
193    }
194    let provider = build_provider(&config, &endpoint)?;
195    let tracer = provider.tracer(config.service_name.clone());
196
197    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
198    let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
199    let fmt_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stderr);
200
201    // Best-effort subscriber install. If a subscriber is already set,
202    // `try_init` errors and we degrade to provider-only mode.
203    let subscriber_init_result = tracing_subscriber::registry()
204        .with(env_filter)
205        .with(otel_layer)
206        .with(fmt_layer)
207        .try_init();
208    if let Err(err) = subscriber_init_result {
209        tracing::debug!(error = %err, "klieo-otel install_or_replace: subscriber already set; routing via OTel global tracer provider only");
210    }
211
212    opentelemetry::global::set_tracer_provider(provider.clone());
213
214    Ok(OtelGuard::new(provider))
215}
216
217/// Resolve the OTLP endpoint, preferring the standard env var.
218pub(crate) fn resolve_endpoint(config: &OtelConfig) -> String {
219    if let Ok(env) = std::env::var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV) {
220        if !env.trim().is_empty() {
221            return env;
222        }
223    }
224    config.endpoint.clone()
225}
226
227/// Convert configured `(name, value)` headers into a gRPC [`MetadataMap`].
228///
229/// Header names are lowercased (gRPC metadata keys are case-insensitive ASCII).
230/// A name or value that cannot be represented as gRPC metadata returns an error
231/// so the exporter build fails closed — an auth header is never silently
232/// dropped, which would ship spans unauthenticated.
233fn headers_to_metadata(headers: &[(String, String)]) -> Result<MetadataMap, OtelError> {
234    let mut metadata = MetadataMap::with_capacity(headers.len());
235    for (name, value) in headers {
236        let key = MetadataKey::from_bytes(name.to_ascii_lowercase().as_bytes())
237            .map_err(|e| OtelError::Build(format!("invalid OTLP header name {name:?}: {e}")))?;
238        let value = MetadataValue::try_from(value.as_str()).map_err(|e| {
239            OtelError::Build(format!("invalid OTLP header value for {name:?}: {e}"))
240        })?;
241        metadata.insert(key, value);
242    }
243    Ok(metadata)
244}
245
246/// Build the [`SdkTracerProvider`] from the resolved endpoint.
247pub(crate) fn build_provider(
248    config: &OtelConfig,
249    endpoint: &str,
250) -> Result<SdkTracerProvider, OtelError> {
251    // `OtelConfig.headers` carries auth tokens (e.g. `authorization: Bearer …`,
252    // `x-honeycomb-team: …`) for remote collectors. They MUST reach the
253    // exporter — a dropped header means a 401 / silent telemetry loss, and any
254    // `gen_ai.*` PII on the span would have shipped unauthenticated. Wire them
255    // per transport (gRPC metadata / HTTP headers); an unrepresentable header
256    // fails the build closed rather than silently dropping it.
257    let exporter = match config.protocol {
258        Protocol::Grpc => {
259            let mut builder = SpanExporter::builder()
260                .with_tonic()
261                .with_endpoint(endpoint)
262                .with_timeout(config.timeout);
263            if !config.headers.is_empty() {
264                builder = builder.with_metadata(headers_to_metadata(&config.headers)?);
265            }
266            builder
267                .build()
268                .map_err(|e| OtelError::Build(e.to_string()))?
269        }
270        Protocol::HttpProtobuf => {
271            let mut builder = SpanExporter::builder()
272                .with_http()
273                .with_endpoint(endpoint)
274                .with_timeout(config.timeout);
275            if !config.headers.is_empty() {
276                builder = builder.with_headers(config.headers.iter().cloned().collect());
277            }
278            builder
279                .build()
280                .map_err(|e| OtelError::Build(e.to_string()))?
281        }
282    };
283
284    let mut attrs = vec![
285        KeyValue::new("service.name", config.service_name.clone()),
286        KeyValue::new("gen_ai.system", config.gen_ai_system.clone()),
287    ];
288    if let Some(version) = &config.service_version {
289        attrs.push(KeyValue::new("service.version", version.clone()));
290    }
291
292    let provider = SdkTracerProvider::builder()
293        .with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio)
294        .with_resource(Resource::new(attrs))
295        .build();
296
297    Ok(provider)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::time::Duration;
304
305    #[test]
306    fn headers_to_metadata_carries_valid_entries() {
307        let md = headers_to_metadata(&[
308            ("authorization".into(), "Bearer abc".into()),
309            ("x-honeycomb-team".into(), "team-1".into()),
310        ])
311        .expect("valid headers");
312        assert_eq!(
313            md.get("authorization").unwrap().to_str().unwrap(),
314            "Bearer abc"
315        );
316        assert_eq!(
317            md.get("x-honeycomb-team").unwrap().to_str().unwrap(),
318            "team-1"
319        );
320    }
321
322    #[test]
323    fn headers_to_metadata_fails_closed_on_invalid_name() {
324        // A space is not a valid metadata key; the build must fail rather than
325        // silently drop the (possibly auth) header.
326        let err = headers_to_metadata(&[("bad name".into(), "v".into())]).unwrap_err();
327        assert!(matches!(err, OtelError::Build(_)), "got {err:?}");
328    }
329
330    #[test]
331    fn headers_to_metadata_fails_closed_on_invalid_value() {
332        // A newline in a metadata value is rejected.
333        let err =
334            headers_to_metadata(&[("authorization".into(), "Bearer \nx".into())]).unwrap_err();
335        assert!(matches!(err, OtelError::Build(_)), "got {err:?}");
336    }
337
338    #[test]
339    fn config_builder_round_trip() {
340        let cfg = OtelConfig::new("svc")
341            .endpoint("http://collector:4317")
342            .protocol(Protocol::Grpc)
343            .timeout(Duration::from_secs(7))
344            .service_version("1.2.3")
345            .gen_ai_system("ollama");
346        assert_eq!(cfg.service_name, "svc");
347        assert_eq!(cfg.endpoint, "http://collector:4317");
348        assert!(matches!(cfg.protocol, Protocol::Grpc));
349        assert_eq!(cfg.timeout, Duration::from_secs(7));
350        assert_eq!(cfg.service_version.as_deref(), Some("1.2.3"));
351        assert_eq!(cfg.gen_ai_system, "ollama");
352    }
353
354    /// `OTEL_EXPORTER_OTLP_ENDPOINT` is process-global, so any test
355    /// that mutates it must hold this lock to avoid racing with
356    /// sibling tests that read or mutate it. Cargo runs tests in
357    /// parallel by default.
358    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
359
360    #[test]
361    fn env_var_overrides_endpoint_default() {
362        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
363        let cfg = OtelConfig::new("svc").endpoint("http://default:4317");
364
365        let prior = std::env::var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV).ok();
366        std::env::set_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV, "http://override:4317");
367        let resolved = resolve_endpoint(&cfg);
368        match prior {
369            Some(v) => std::env::set_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV, v),
370            None => std::env::remove_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV),
371        }
372
373        assert_eq!(resolved, "http://override:4317");
374    }
375
376    #[test]
377    fn empty_env_var_falls_back_to_config() {
378        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
379        let cfg = OtelConfig::new("svc").endpoint("http://default:4317");
380        let prior = std::env::var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV).ok();
381        std::env::set_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV, "   ");
382        let resolved = resolve_endpoint(&cfg);
383        match prior {
384            Some(v) => std::env::set_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV, v),
385            None => std::env::remove_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV),
386        }
387        assert_eq!(resolved, "http://default:4317");
388    }
389
390    #[test]
391    fn unset_env_var_falls_back_to_config() {
392        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
393        let cfg = OtelConfig::new("svc").endpoint("http://default:4317");
394        let prior = std::env::var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV).ok();
395        std::env::remove_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV);
396        let resolved = resolve_endpoint(&cfg);
397        if let Some(v) = prior {
398            std::env::set_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV, v);
399        }
400        assert_eq!(resolved, "http://default:4317");
401    }
402
403    /// `install_or_replace` shares the redaction-defence policy of
404    /// `install`: plaintext http:// to a non-loopback collector still
405    /// errors with [`OtelError::PlaintextRemote`].
406    #[test]
407    fn install_or_replace_refuses_plaintext_remote_endpoint() {
408        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
409        let prior = std::env::var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV).ok();
410        std::env::remove_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV);
411        let cfg = OtelConfig::new("svc").endpoint("http://collector.example.com:4317");
412        let result = install_or_replace(cfg);
413        if let Some(v) = prior {
414            std::env::set_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV, v);
415        }
416        assert!(matches!(result, Err(OtelError::PlaintextRemote(_))));
417    }
418
419    /// `install_or_replace` returns `Ok` even when a global tracing
420    /// subscriber is already set — installing one before the call
421    /// proves we do not return [`OtelError::AlreadyInitialized`].
422    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
423    async fn install_or_replace_succeeds_after_global_subscriber_set() {
424        let result = {
425            let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
426            let prior = std::env::var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV).ok();
427            std::env::remove_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV);
428
429            // Pre-install a basic subscriber. We don't care whether THIS
430            // call wins or loses (parallel tests may have set one first)
431            // — we just need a default to be in place when
432            // install_or_replace runs.
433            let _ = tracing_subscriber::fmt().with_test_writer().try_init();
434
435            // Loopback endpoint passes the plaintext-redaction check.
436            let cfg = OtelConfig::new("svc")
437                .endpoint("http://127.0.0.1:1")
438                .timeout(Duration::from_millis(50));
439            let r = install_or_replace(cfg);
440
441            if let Some(v) = prior {
442                std::env::set_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV, v);
443            }
444            r
445        };
446
447        let guard = result.expect("install_or_replace must not return AlreadyInitialized");
448        // Drop the guard on a blocking thread (mirrors guard_drop test).
449        tokio::task::spawn_blocking(move || drop(guard))
450            .await
451            .expect("drop must not panic");
452    }
453
454    /// W8 / W2.A12 / CWE-319: install must refuse plaintext http:// to
455    /// non-loopback collectors. The check fires before any subscriber
456    /// install side-effects, so re-running this test in a process that
457    /// has already installed a subscriber is safe.
458    #[test]
459    fn install_refuses_plaintext_remote_endpoint() {
460        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
461        let prior = std::env::var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV).ok();
462        std::env::remove_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV);
463        let cfg = OtelConfig::new("svc").endpoint("http://collector.example.com:4317");
464        let result = install(cfg);
465        if let Some(v) = prior {
466            std::env::set_var(OTEL_EXPORTER_OTLP_ENDPOINT_ENV, v);
467        }
468        assert!(matches!(result, Err(OtelError::PlaintextRemote(_))));
469    }
470
471    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
472    async fn guard_drop_invokes_flush_and_shutdown() {
473        // Build a provider pointing at a deliberately unreachable
474        // endpoint with a tiny timeout. The provider construction
475        // itself must succeed; flush/shutdown on drop must not panic,
476        // even if no spans were ever exported.
477        //
478        // We need >=2 worker threads so the batch processor task can
479        // make progress while the synchronous `force_flush` blocks.
480        let cfg = OtelConfig::new("svc")
481            .endpoint("http://127.0.0.1:1") // unreachable
482            .timeout(Duration::from_millis(50));
483        let provider = build_provider(&cfg, &cfg.endpoint).expect("build provider");
484        let guard = OtelGuard::new(provider);
485        let marker = guard.shutdown_marker();
486        assert!(!marker.load(std::sync::atomic::Ordering::SeqCst));
487        // Dropping the guard must not panic and must mark itself flushed.
488        // Run drop on a blocking thread so the synchronous force_flush
489        // can wait on the batch worker without starving the runtime.
490        tokio::task::spawn_blocking(move || drop(guard))
491            .await
492            .expect("drop must not panic");
493        assert!(marker.load(std::sync::atomic::Ordering::SeqCst));
494    }
495}