feat: add OpenTelemetry tracing and metrics observer

* feat: add OpenTelemetry tracing and metrics observer

Add OtelObserver that exports traces and metrics via OTLP HTTP/protobuf
to any OpenTelemetry-compatible collector (Jaeger, Grafana Tempo, etc.).

- ObserverEvents map to OTel spans (AgentEnd, ToolCall, Error) and
  metric counters (AgentStart, ChannelMessage, HeartbeatTick)
- ObserverMetrics map to OTel histograms and gauges
- Spans include proper timing via SpanBuilder.with_start_time
- Config: backend="otel", otel_endpoint, otel_service_name
- Accepts "otel", "opentelemetry", "otlp" as backend aliases
- Graceful fallback to NoopObserver on init failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve unused variable warning and update Cargo.lock

Prefix unused `resolved_key` with underscore to suppress clippy
warning introduced by upstream changes. Regenerate Cargo.lock
after rebase on main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review comments on OTel observer

- Fix metric types: use Gauge for ActiveSessions/QueueDepth (absolute
  readings, not deltas), Counter<u64> for TokensUsed (monotonic)
- Remove duplicate token recording from AgentEnd event handler
  (TokensUsed metric via record_metric is the canonical path)
- Store meter_provider in struct so flush() exports both traces
  and metrics (was silently dropping metrics on shutdown)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: argenis de la rosa <theonlyhennygod@gmail.com>
This commit is contained in:
Edvard Schøyen 2026-02-15 14:46:49 -05:00 committed by GitHub
parent 89b1ec6fa2
commit 0f6648ceb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 632 additions and 3 deletions

View file

@ -1,10 +1,12 @@
pub mod log;
pub mod multi;
pub mod noop;
pub mod otel;
pub mod traits;
pub use self::log::LogObserver;
pub use noop::NoopObserver;
pub use otel::OtelObserver;
pub use traits::{Observer, ObserverEvent};
use crate::config::ObservabilityConfig;
@ -13,6 +15,24 @@ use crate::config::ObservabilityConfig;
pub fn create_observer(config: &ObservabilityConfig) -> Box<dyn Observer> {
match config.backend.as_str() {
"log" => Box::new(LogObserver::new()),
"otel" | "opentelemetry" | "otlp" => {
match OtelObserver::new(
config.otel_endpoint.as_deref(),
config.otel_service_name.as_deref(),
) {
Ok(obs) => {
tracing::info!(
endpoint = config.otel_endpoint.as_deref().unwrap_or("http://localhost:4318"),
"OpenTelemetry observer initialized"
);
Box::new(obs)
}
Err(e) => {
tracing::error!("Failed to create OTel observer: {e}. Falling back to noop.");
Box::new(NoopObserver)
}
}
}
"none" | "noop" => Box::new(NoopObserver),
_ => {
tracing::warn!(
@ -32,6 +52,7 @@ mod tests {
fn factory_none_returns_noop() {
let cfg = ObservabilityConfig {
backend: "none".into(),
..ObservabilityConfig::default()
};
assert_eq!(create_observer(&cfg).name(), "noop");
}
@ -40,6 +61,7 @@ mod tests {
fn factory_noop_returns_noop() {
let cfg = ObservabilityConfig {
backend: "noop".into(),
..ObservabilityConfig::default()
};
assert_eq!(create_observer(&cfg).name(), "noop");
}
@ -48,14 +70,46 @@ mod tests {
fn factory_log_returns_log() {
let cfg = ObservabilityConfig {
backend: "log".into(),
..ObservabilityConfig::default()
};
assert_eq!(create_observer(&cfg).name(), "log");
}
#[test]
fn factory_otel_returns_otel() {
let cfg = ObservabilityConfig {
backend: "otel".into(),
otel_endpoint: Some("http://127.0.0.1:19999".into()),
otel_service_name: Some("test".into()),
};
assert_eq!(create_observer(&cfg).name(), "otel");
}
#[test]
fn factory_opentelemetry_alias() {
let cfg = ObservabilityConfig {
backend: "opentelemetry".into(),
otel_endpoint: Some("http://127.0.0.1:19999".into()),
otel_service_name: Some("test".into()),
};
assert_eq!(create_observer(&cfg).name(), "otel");
}
#[test]
fn factory_otlp_alias() {
let cfg = ObservabilityConfig {
backend: "otlp".into(),
otel_endpoint: Some("http://127.0.0.1:19999".into()),
otel_service_name: Some("test".into()),
};
assert_eq!(create_observer(&cfg).name(), "otel");
}
#[test]
fn factory_unknown_falls_back_to_noop() {
let cfg = ObservabilityConfig {
backend: "prometheus".into(),
backend: "xyzzy_unknown".into(),
..ObservabilityConfig::default()
};
assert_eq!(create_observer(&cfg).name(), "noop");
}
@ -64,6 +118,7 @@ mod tests {
fn factory_empty_string_falls_back_to_noop() {
let cfg = ObservabilityConfig {
backend: String::new(),
..ObservabilityConfig::default()
};
assert_eq!(create_observer(&cfg).name(), "noop");
}
@ -72,6 +127,7 @@ mod tests {
fn factory_garbage_falls_back_to_noop() {
let cfg = ObservabilityConfig {
backend: "xyzzy_garbage_123".into(),
..ObservabilityConfig::default()
};
assert_eq!(create_observer(&cfg).name(), "noop");
}