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}