From b2b27573e5cd7f97b023ccc81a2114cca9c42ca7 Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Sun, 22 Mar 2026 14:48:51 +1000 Subject: [PATCH 1/2] Support multiple otlp providers --- src/cli/server.rs | 20 ++++++++++++-------- src/config/observability.rs | 26 ++++++++++++++++++++++---- src/usage_sink.rs | 31 +++++++++++++++++++++++-------- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/cli/server.rs b/src/cli/server.rs index 93888c6..c5cf23c 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -296,15 +296,21 @@ pub(crate) async fn run_server(explicit_config_path: Option<&str>, no_browser: b tracing::info!("Usage logging to database enabled"); } - // Add OTLP sink if configured + // Add OTLP sinks if configured #[cfg(feature = "otlp")] - if let Some(otlp_config) = &config.observability.usage.otlp - && otlp_config.enabled - { + for otlp_config in &config.observability.usage.otlp { + if !otlp_config.enabled { + continue; + } + let sink_name = otlp_config + .name + .clone() + .or_else(|| otlp_config.endpoint.clone()) + .unwrap_or_else(|| "otlp".to_string()); match usage_sink::OtlpSink::new(otlp_config, &config.observability.tracing) { Ok(otlp_sink) => { + tracing::info!(name = sink_name, "Usage logging to OTLP enabled"); sinks.push(Arc::new(otlp_sink)); - tracing::info!("Usage logging to OTLP enabled"); } Err(e) => { tracing::error!(error = %e, "Failed to initialize OTLP usage sink"); @@ -312,9 +318,7 @@ pub(crate) async fn run_server(explicit_config_path: Option<&str>, no_browser: b } } #[cfg(not(feature = "otlp"))] - if let Some(otlp_config) = &config.observability.usage.otlp - && otlp_config.enabled - { + if config.observability.usage.otlp.iter().any(|c| c.enabled) { tracing::warn!( "OTLP usage sink is enabled in config but the 'otlp' feature is not compiled. \ Rebuild with: cargo build --features otlp" diff --git a/src/config/observability.rs b/src/config/observability.rs index 3deb01e..7dc1bdc 100644 --- a/src/config/observability.rs +++ b/src/config/observability.rs @@ -763,10 +763,23 @@ pub struct UsageConfig { #[serde(default = "default_true")] pub database: bool, - /// OTLP exporter for usage data. - /// Sends usage records as OTLP log records to any OpenTelemetry-compatible backend. + /// OTLP exporters for usage data. + /// Sends usage records as OTLP log records to one or more OpenTelemetry-compatible backends. + /// Each entry creates an independent exporter, allowing fan-out to multiple destinations. + /// + /// ```toml + /// [[observability.usage.otlp]] + /// name = "grafana" + /// endpoint = "https://otlp-gateway.grafana.net/otlp" + /// headers = { Authorization = "Basic xxx" } + /// + /// [[observability.usage.otlp]] + /// name = "datadog" + /// endpoint = "https://otel.datadoghq.com" + /// headers = { "DD-API-KEY" = "xxx" } + /// ``` #[serde(default)] - pub otlp: Option, + pub otlp: Vec, /// Buffer configuration for batched writes. #[serde(default)] @@ -777,7 +790,7 @@ impl Default for UsageConfig { fn default() -> Self { Self { database: true, - otlp: None, + otlp: Vec::new(), buffer: UsageBufferConfig::default(), } } @@ -792,6 +805,11 @@ pub struct UsageOtlpConfig { #[serde(default = "default_true")] pub enabled: bool, + /// Human-readable name for this endpoint (used in logs/metrics). + /// Defaults to the endpoint URL if not specified. + #[serde(default)] + pub name: Option, + /// OTLP endpoint URL. /// If not specified, uses the tracing OTLP endpoint. #[serde(default)] diff --git a/src/usage_sink.rs b/src/usage_sink.rs index 7512e66..928b598 100644 --- a/src/usage_sink.rs +++ b/src/usage_sink.rs @@ -14,9 +14,16 @@ //! [observability.usage] //! database = true # Enable database logging (default) //! -//! [observability.usage.otlp] -//! enabled = true -//! endpoint = "http://localhost:4317" # or inherit from tracing.otlp +//! # Fan out to multiple OTLP endpoints: +//! [[observability.usage.otlp]] +//! name = "grafana" +//! endpoint = "https://otlp-gateway.grafana.net/otlp" +//! headers = { Authorization = "Basic xxx" } +//! +//! [[observability.usage.otlp]] +//! name = "datadog" +//! endpoint = "https://otel.datadoghq.com" +//! headers = { "DD-API-KEY" = "xxx" } //! ``` use std::sync::Arc; @@ -48,7 +55,7 @@ pub trait UsageSink: Send + Sync { async fn write_batch(&self, entries: &[UsageLogEntry]) -> Result; /// Get the sink name for logging/metrics. - fn name(&self) -> &'static str; + fn name(&self) -> &str; } /// Errors from usage sinks. @@ -138,7 +145,7 @@ impl UsageSink for DatabaseSink { } } - fn name(&self) -> &'static str { + fn name(&self) -> &str { "database" } } @@ -160,6 +167,7 @@ impl UsageSink for DatabaseSink { /// Requires the `otlp` feature. #[cfg(feature = "otlp")] pub struct OtlpSink { + name: String, logger_provider: opentelemetry_sdk::logs::SdkLoggerProvider, logger: opentelemetry_sdk::logs::SdkLogger, } @@ -203,7 +211,14 @@ impl OtlpSink { let logger = provider.logger("hadrian.usage"); + let name = config + .name + .clone() + .or_else(|| config.endpoint.clone()) + .unwrap_or_else(|| "otlp".to_string()); + Ok(Self { + name, logger_provider: provider, logger, }) @@ -484,8 +499,8 @@ impl UsageSink for OtlpSink { Ok(success_count) } - fn name(&self) -> &'static str { - "otlp" + fn name(&self) -> &str { + &self.name } } @@ -554,7 +569,7 @@ impl UsageSink for CompositeSink { } } - fn name(&self) -> &'static str { + fn name(&self) -> &str { "composite" } } From e5a186fc80a4ae2a65853cff12828241e3f8012e Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Sun, 22 Mar 2026 15:05:15 +1000 Subject: [PATCH 2/2] Review fix --- src/cli/server.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/cli/server.rs b/src/cli/server.rs index c5cf23c..50859df 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -298,18 +298,15 @@ pub(crate) async fn run_server(explicit_config_path: Option<&str>, no_browser: b // Add OTLP sinks if configured #[cfg(feature = "otlp")] + use usage_sink::UsageSink as _; + #[cfg(feature = "otlp")] for otlp_config in &config.observability.usage.otlp { if !otlp_config.enabled { continue; } - let sink_name = otlp_config - .name - .clone() - .or_else(|| otlp_config.endpoint.clone()) - .unwrap_or_else(|| "otlp".to_string()); match usage_sink::OtlpSink::new(otlp_config, &config.observability.tracing) { Ok(otlp_sink) => { - tracing::info!(name = sink_name, "Usage logging to OTLP enabled"); + tracing::info!(name = otlp_sink.name(), "Usage logging to OTLP enabled"); sinks.push(Arc::new(otlp_sink)); } Err(e) => {