feat(otel): respect standard OTel env vars for exporter selection (#7144)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2026-02-14 09:03:11 +08:00
committed by GitHub
parent f00630ad20
commit ab5407508b
12 changed files with 621 additions and 595 deletions
+3 -20
View File
@@ -6,7 +6,8 @@ use tracing_subscriber::{
Registry,
};
use goose::tracing::{langfuse_layer, otlp_layer};
use goose::otel::otlp;
use goose::tracing::langfuse_layer;
// Used to ensure we only set up tracing once
static INIT: Once = Once::new();
@@ -68,25 +69,7 @@ fn setup_logging_internal(name: Option<&str>, force: bool) -> Result<()> {
];
if !force {
if let Ok((otlp_tracing_layer, otlp_metrics_layer, otlp_logs_layer)) =
otlp_layer::init_otlp()
{
layers.push(
otlp_tracing_layer
.with_filter(otlp_layer::create_otlp_tracing_filter())
.boxed(),
);
layers.push(
otlp_metrics_layer
.with_filter(otlp_layer::create_otlp_metrics_filter())
.boxed(),
);
layers.push(
otlp_logs_layer
.with_filter(otlp_layer::create_otlp_logs_filter())
.boxed(),
);
}
layers.extend(otlp::init_otlp_layers(goose::config::Config::global()));
}
if let Some(langfuse) = langfuse_layer::create_langfuse_observer() {
+3 -22
View File
@@ -9,28 +9,9 @@ async fn main() -> Result<()> {
let result = cli().await;
// Only wait for telemetry flush if OTLP is configured
let should_wait = goose::config::Config::global()
.get_param::<String>("otel_exporter_otlp_endpoint")
.is_ok();
if should_wait {
// Use a shorter, dynamic wait with max timeout
let max_wait = tokio::time::Duration::from_millis(500);
let start = tokio::time::Instant::now();
// Give telemetry a chance to flush, but don't wait too long
while start.elapsed() < max_wait {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
// In future, we could check if there are pending spans/metrics here
// For now, we just do a quick wait to allow batch exports to complete
if start.elapsed() >= tokio::time::Duration::from_millis(200) {
break; // Most exports should complete within 200ms
}
}
goose::tracing::shutdown_otlp();
if goose::otel::otlp::is_otlp_initialized() {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
goose::otel::otlp::shutdown_otlp();
}
result
@@ -58,6 +58,12 @@ pub async fn run() -> Result<()> {
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
if goose::otel::otlp::is_otlp_initialized() {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
goose::otel::otlp::shutdown_otlp();
}
info!("server shutdown complete");
Ok(())
}
+3 -18
View File
@@ -5,7 +5,8 @@ use tracing_subscriber::{
Registry,
};
use goose::tracing::{langfuse_layer, otlp_layer};
use goose::otel::otlp;
use goose::tracing::langfuse_layer;
/// Sets up the logging infrastructure for the application.
/// This includes:
@@ -54,23 +55,7 @@ pub fn setup_logging(name: Option<&str>) -> Result<()> {
console_layer.with_filter(base_env_filter).boxed(),
];
if let Ok((otlp_tracing_layer, otlp_metrics_layer, otlp_logs_layer)) = otlp_layer::init_otlp() {
layers.push(
otlp_tracing_layer
.with_filter(otlp_layer::create_otlp_tracing_filter())
.boxed(),
);
layers.push(
otlp_metrics_layer
.with_filter(otlp_layer::create_otlp_metrics_filter())
.boxed(),
);
layers.push(
otlp_logs_layer
.with_filter(otlp_layer::create_otlp_logs_filter())
.boxed(),
);
}
layers.extend(otlp::init_otlp_layers(goose::config::Config::global()));
if let Some(langfuse) = langfuse_layer::create_langfuse_observer() {
layers.push(langfuse.with_filter(LevelFilter::DEBUG).boxed());
+6 -8
View File
@@ -52,14 +52,12 @@ webbrowser = { workspace = true }
lazy_static = "1.5.0"
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-opentelemetry = "0.28"
opentelemetry = "0.27"
opentelemetry-appender-tracing = "0.27"
opentelemetry_sdk = { version = "0.27", features = ["rt-tokio", "metrics"] }
opentelemetry-otlp = { version = "0.27", features = [
"http-proto",
"reqwest-client",
] }
tracing-opentelemetry = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry-appender-tracing = { workspace = true }
opentelemetry_sdk = { workspace = true }
opentelemetry-otlp = { workspace = true }
opentelemetry-stdout = { workspace = true }
keyring = { version = "3.6.2", features = [
"apple-native",
"windows-native",
+1
View File
@@ -12,6 +12,7 @@ pub mod logging;
pub mod mcp_utils;
pub mod model;
pub mod oauth;
pub mod otel;
pub mod permission;
pub mod posthog;
pub mod prompt_template;
+1
View File
@@ -0,0 +1 @@
pub mod otlp;
+551
View File
@@ -0,0 +1,551 @@
use opentelemetry::trace::TracerProvider;
use opentelemetry::{global, KeyValue};
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_sdk::logs::{SdkLogger, SdkLoggerProvider};
use opentelemetry_sdk::metrics::SdkMeterProvider;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::resource::{EnvResourceDetector, TelemetryResourceDetector};
use opentelemetry_sdk::trace::SdkTracerProvider;
use opentelemetry_sdk::Resource;
use std::env;
use std::sync::Mutex;
use tracing::{Level, Metadata};
use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer};
use tracing_subscriber::filter::FilterFn;
use tracing_subscriber::Layer as _;
pub type OtlpTracingLayer =
OpenTelemetryLayer<tracing_subscriber::Registry, opentelemetry_sdk::trace::Tracer>;
pub type OtlpMetricsLayer = MetricsLayer<tracing_subscriber::Registry, SdkMeterProvider>;
pub type OtlpLogsLayer = OpenTelemetryTracingBridge<SdkLoggerProvider, SdkLogger>;
pub type OtlpResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
static TRACER_PROVIDER: Mutex<Option<SdkTracerProvider>> = Mutex::new(None);
static METER_PROVIDER: Mutex<Option<SdkMeterProvider>> = Mutex::new(None);
static LOGGER_PROVIDER: Mutex<Option<SdkLoggerProvider>> = Mutex::new(None);
#[derive(Debug, Clone, PartialEq)]
pub enum ExporterType {
Otlp,
Console,
None,
}
impl ExporterType {
pub fn from_env_value(value: &str) -> Self {
match value.to_lowercase().as_str() {
"" | "otlp" => ExporterType::Otlp,
"console" | "stdout" => ExporterType::Console,
_ => ExporterType::None,
}
}
}
/// Returns the exporter type for a signal, or None if disabled.
///
/// Checks in order:
/// 1. OTEL_SDK_DISABLED — disables everything
/// 2. OTEL_{SIGNAL}_EXPORTER — explicit exporter selection ("none" disables)
/// 3. OTEL_EXPORTER_OTLP_{SIGNAL}_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT — enables OTLP
pub fn signal_exporter(signal: &str) -> Option<ExporterType> {
if env::var("OTEL_SDK_DISABLED")
.ok()
.is_some_and(|v| v.eq_ignore_ascii_case("true"))
{
return None;
}
let exporter_var = format!("OTEL_{}_EXPORTER", signal.to_uppercase());
if let Ok(val) = env::var(&exporter_var) {
let typ = ExporterType::from_env_value(&val);
return if matches!(typ, ExporterType::None) {
None
} else {
Some(typ)
};
}
let signal_endpoint = format!("OTEL_EXPORTER_OTLP_{}_ENDPOINT", signal.to_uppercase());
let has_endpoint = env::var(&signal_endpoint)
.ok()
.is_some_and(|v| !v.is_empty())
|| env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.ok()
.is_some_and(|v| !v.is_empty());
if has_endpoint {
Some(ExporterType::Otlp)
} else {
None
}
}
/// Promotes goose config-file OTel settings to env vars before exporter build.
pub fn promote_config_to_env(config: &crate::config::Config) {
if env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_err() {
if let Ok(endpoint) = config.get_param::<String>("otel_exporter_otlp_endpoint") {
env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint);
}
}
if env::var("OTEL_EXPORTER_OTLP_TIMEOUT").is_err() {
if let Ok(timeout) = config.get_param::<u64>("otel_exporter_otlp_timeout") {
env::set_var("OTEL_EXPORTER_OTLP_TIMEOUT", timeout.to_string());
}
}
}
fn create_resource() -> Resource {
let mut builder = Resource::builder_empty()
.with_attributes([
KeyValue::new("service.name", "goose"),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
KeyValue::new("service.namespace", "goose"),
])
.with_detector(Box::new(EnvResourceDetector::new()))
.with_detector(Box::new(TelemetryResourceDetector));
// OTEL_SERVICE_NAME takes highest priority (skip SdkProvidedResourceDetector
// which would fall back to "unknown_service" when unset)
if let Ok(name) = std::env::var("OTEL_SERVICE_NAME") {
if !name.is_empty() {
builder = builder.with_service_name(name);
}
}
builder.build()
}
/// Initializes all OTLP signal layers (traces, metrics, logs) and propagation.
/// Returns boxed layers ready to add to a subscriber.
pub fn init_otlp_layers(
config: &crate::config::Config,
) -> Vec<Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync>> {
promote_config_to_env(config);
let mut layers: Vec<
Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync>,
> = Vec::new();
if let Ok(layer) = create_otlp_tracing_layer() {
layers.push(layer.with_filter(create_otlp_tracing_filter()).boxed());
}
if let Ok(layer) = create_otlp_metrics_layer() {
layers.push(layer.with_filter(create_otlp_metrics_filter()).boxed());
}
if let Ok(layer) = create_otlp_logs_layer() {
layers.push(layer.with_filter(create_otlp_logs_filter()).boxed());
}
if !layers.is_empty() {
global::set_text_map_propagator(TraceContextPropagator::new());
}
layers
}
fn create_otlp_tracing_layer() -> OtlpResult<OtlpTracingLayer> {
let exporter = signal_exporter("traces").ok_or("Traces not enabled")?;
let resource = create_resource();
let tracer_provider = match exporter {
ExporterType::Otlp => {
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.build()?;
SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(resource)
.build()
}
ExporterType::Console => {
let exporter = opentelemetry_stdout::SpanExporter::default();
SdkTracerProvider::builder()
.with_simple_exporter(exporter)
.with_resource(resource)
.build()
}
ExporterType::None => return Err("Traces exporter set to none".into()),
};
global::set_tracer_provider(tracer_provider.clone());
let tracer = tracer_provider.tracer("goose");
*TRACER_PROVIDER.lock().unwrap_or_else(|e| e.into_inner()) = Some(tracer_provider);
Ok(tracing_opentelemetry::layer().with_tracer(tracer))
}
fn create_otlp_metrics_layer() -> OtlpResult<OtlpMetricsLayer> {
let exporter = signal_exporter("metrics").ok_or("Metrics not enabled")?;
let resource = create_resource();
let meter_provider = match exporter {
ExporterType::Otlp => {
let exporter = opentelemetry_otlp::MetricExporter::builder()
.with_http()
.build()?;
SdkMeterProvider::builder()
.with_resource(resource)
.with_periodic_exporter(exporter)
.build()
}
ExporterType::Console => {
let exporter = opentelemetry_stdout::MetricExporter::default();
SdkMeterProvider::builder()
.with_resource(resource)
.with_periodic_exporter(exporter)
.build()
}
ExporterType::None => return Err("Metrics exporter set to none".into()),
};
global::set_meter_provider(meter_provider.clone());
*METER_PROVIDER.lock().unwrap_or_else(|e| e.into_inner()) = Some(meter_provider.clone());
Ok(MetricsLayer::new(meter_provider))
}
fn create_otlp_logs_layer() -> OtlpResult<OtlpLogsLayer> {
let exporter = signal_exporter("logs").ok_or("Logs not enabled")?;
let resource = create_resource();
let logger_provider = match exporter {
ExporterType::Otlp => {
let exporter = opentelemetry_otlp::LogExporter::builder()
.with_http()
.build()?;
SdkLoggerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(resource)
.build()
}
ExporterType::Console => {
let exporter = opentelemetry_stdout::LogExporter::default();
SdkLoggerProvider::builder()
.with_simple_exporter(exporter)
.with_resource(resource)
.build()
}
ExporterType::None => return Err("Logs exporter set to none".into()),
};
let bridge = OpenTelemetryTracingBridge::new(&logger_provider);
*LOGGER_PROVIDER.lock().unwrap_or_else(|e| e.into_inner()) = Some(logger_provider);
Ok(bridge)
}
pub fn is_otlp_initialized() -> bool {
TRACER_PROVIDER
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
|| METER_PROVIDER
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
|| LOGGER_PROVIDER
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
}
/// Creates a custom filter for OTLP tracing that captures:
/// - All spans at INFO level and above
/// - Specific spans marked with "otel.trace" field
/// - Events from specific modules related to telemetry
fn create_otlp_tracing_filter() -> FilterFn<impl Fn(&Metadata<'_>) -> bool> {
FilterFn::new(|metadata: &Metadata<'_>| {
if metadata.level() <= &Level::INFO {
return true;
}
if metadata.level() == &Level::DEBUG {
let target = metadata.target();
if target.starts_with("goose::")
|| target.starts_with("opentelemetry")
|| target.starts_with("tracing_opentelemetry")
{
return true;
}
}
false
})
}
/// Creates a custom filter for OTLP metrics that captures:
/// - All events at INFO level and above
/// - Specific events marked with "otel.metric" field
/// - Events that should be converted to metrics
fn create_otlp_metrics_filter() -> FilterFn<impl Fn(&Metadata<'_>) -> bool> {
FilterFn::new(|metadata: &Metadata<'_>| {
if metadata.level() <= &Level::INFO {
return true;
}
if metadata.level() == &Level::DEBUG {
let target = metadata.target();
if target.starts_with("goose::telemetry")
|| target.starts_with("goose::metrics")
|| target.contains("metric")
{
return true;
}
}
false
})
}
/// Creates a custom filter for OTLP logs that captures:
/// - All events at WARN level and above
fn create_otlp_logs_filter() -> FilterFn<impl Fn(&Metadata<'_>) -> bool> {
FilterFn::new(|metadata: &Metadata<'_>| metadata.level() <= &Level::WARN)
}
/// Shutdown OTLP providers gracefully
pub fn shutdown_otlp() {
if let Some(provider) = TRACER_PROVIDER
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
let _ = provider.shutdown();
}
if let Some(provider) = METER_PROVIDER
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
let _ = provider.shutdown();
}
if let Some(provider) = LOGGER_PROVIDER
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
let _ = provider.shutdown();
}
}
#[cfg(test)]
mod tests {
use super::*;
use opentelemetry::metrics::{Meter, MeterProvider};
use opentelemetry::InstrumentationScope;
use std::sync::Arc;
use test_case::test_case;
// set_meter_provider requires P: MeterProvider, not Arc<dyn MeterProvider>
struct SavedMeterProvider(Arc<dyn MeterProvider + Send + Sync>);
impl MeterProvider for SavedMeterProvider {
fn meter_with_scope(&self, scope: InstrumentationScope) -> Meter {
self.0.meter_with_scope(scope)
}
}
struct OtelTestGuard {
_env: env_lock::EnvGuard<'static>,
prev_tracer: global::GlobalTracerProvider,
prev_meter: Arc<dyn MeterProvider + Send + Sync>,
}
impl Drop for OtelTestGuard {
fn drop(&mut self) {
global::set_tracer_provider(self.prev_tracer.clone());
global::set_meter_provider(SavedMeterProvider(self.prev_meter.clone()));
}
}
fn clear_otel_env(overrides: &[(&str, &str)]) -> OtelTestGuard {
let prev_tracer = global::tracer_provider();
let prev_meter = global::meter_provider();
let guard = env_lock::lock_env([
("OTEL_SDK_DISABLED", None::<&str>),
("OTEL_TRACES_EXPORTER", None),
("OTEL_METRICS_EXPORTER", None),
("OTEL_LOGS_EXPORTER", None),
("OTEL_EXPORTER_OTLP_ENDPOINT", None),
("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", None),
("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", None),
("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", None),
("OTEL_EXPORTER_OTLP_TIMEOUT", None),
("OTEL_SERVICE_NAME", None),
("OTEL_RESOURCE_ATTRIBUTES", None),
]);
for &(k, v) in overrides {
env::set_var(k, v);
}
OtelTestGuard {
_env: guard,
prev_tracer,
prev_meter,
}
}
#[test]
fn exporter_type_from_env_value() {
assert_eq!(ExporterType::from_env_value("otlp"), ExporterType::Otlp);
assert_eq!(ExporterType::from_env_value("OTLP"), ExporterType::Otlp);
assert_eq!(ExporterType::from_env_value(""), ExporterType::Otlp);
assert_eq!(
ExporterType::from_env_value("console"),
ExporterType::Console
);
assert_eq!(
ExporterType::from_env_value("stdout"),
ExporterType::Console
);
assert_eq!(ExporterType::from_env_value("none"), ExporterType::None);
assert_eq!(ExporterType::from_env_value("NONE"), ExporterType::None);
assert_eq!(ExporterType::from_env_value("unknown"), ExporterType::None);
}
#[test_case(&[("OTEL_SDK_DISABLED", "true")]; "OTEL_SDK_DISABLED disables all signals")]
#[test_case(&[]; "no env vars returns None")]
fn signal_exporter_disabled(env: &[(&str, &str)]) {
let _guard = clear_otel_env(env);
assert!(signal_exporter("traces").is_none());
assert!(signal_exporter("metrics").is_none());
assert!(signal_exporter("logs").is_none());
}
#[test_case("traces", &[("OTEL_TRACES_EXPORTER", "console")], Some(ExporterType::Console); "OTEL_TRACES_EXPORTER=console")]
#[test_case("traces", &[("OTEL_TRACES_EXPORTER", "none")], None; "OTEL_TRACES_EXPORTER=none")]
#[test_case("traces", &[("OTEL_TRACES_EXPORTER", "otlp")], Some(ExporterType::Otlp); "OTEL_TRACES_EXPORTER=otlp")]
#[test_case("metrics", &[("OTEL_METRICS_EXPORTER", "console")], Some(ExporterType::Console); "OTEL_METRICS_EXPORTER=console")]
#[test_case("logs", &[("OTEL_LOGS_EXPORTER", "none")], None; "OTEL_LOGS_EXPORTER=none")]
fn signal_exporter_by_var(signal: &str, env: &[(&str, &str)], expected: Option<ExporterType>) {
let _guard = clear_otel_env(env);
assert_eq!(signal_exporter(signal), expected);
}
#[test_case("traces", &[("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")], Some(ExporterType::Otlp); "generic endpoint enables traces")]
#[test_case("traces", &[("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://localhost:4318")], Some(ExporterType::Otlp); "signal-specific endpoint enables traces")]
#[test_case("metrics", &[("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://localhost:4318")], Some(ExporterType::Otlp); "signal-specific endpoint enables metrics")]
#[test_case("traces", &[("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://localhost:4318")], None; "metrics endpoint does not enable traces")]
#[test_case("traces", &[("OTEL_TRACES_EXPORTER", "none"), ("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")], None; "OTEL_TRACES_EXPORTER=none overrides endpoint")]
#[test_case("traces", &[("OTEL_EXPORTER_OTLP_ENDPOINT", "")], None; "empty endpoint returns None")]
fn signal_exporter_endpoints(
signal: &str,
env: &[(&str, &str)],
expected: Option<ExporterType>,
) {
let _guard = clear_otel_env(env);
assert_eq!(signal_exporter(signal), expected);
}
#[test_case("console"; "console")]
#[test_case("otlp"; "otlp")]
fn test_all_layers_ok(exporter: &str) {
let rt = tokio::runtime::Runtime::new().unwrap();
let _guard = rt.enter();
let _env = clear_otel_env(&[
("OTEL_TRACES_EXPORTER", exporter),
("OTEL_METRICS_EXPORTER", exporter),
("OTEL_LOGS_EXPORTER", exporter),
("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
]);
assert!(create_otlp_tracing_layer().is_ok());
assert!(create_otlp_metrics_layer().is_ok());
assert!(create_otlp_logs_layer().is_ok());
shutdown_otlp();
}
#[test_case(
&[],
Resource::builder_empty()
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
.with_detector(Box::new(TelemetryResourceDetector))
.build();
"no env vars uses goose defaults"
)]
#[test_case(
&[("OTEL_SERVICE_NAME", "custom")],
Resource::builder_empty()
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
.with_detector(Box::new(TelemetryResourceDetector))
.with_service_name("custom")
.build();
"OTEL_SERVICE_NAME overrides service.name"
)]
#[test_case(
&[("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod")],
Resource::builder_empty()
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
.with_detector(Box::new(TelemetryResourceDetector))
.with_attribute(KeyValue::new("deployment.environment", "prod"))
.build();
"OTEL_RESOURCE_ATTRIBUTES adds custom attributes"
)]
#[test_case(
&[("OTEL_SERVICE_NAME", "custom"), ("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod")],
Resource::builder_empty()
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
.with_detector(Box::new(TelemetryResourceDetector))
.with_service_name("custom")
.with_attribute(KeyValue::new("deployment.environment", "prod"))
.build();
"OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES combine"
)]
fn test_create_resource(env: &[(&str, &str)], expected: Resource) {
let _guard = clear_otel_env(env);
assert_eq!(create_resource(), expected);
}
fn test_config(
params: &[(&str, &str)],
) -> (
crate::config::Config,
tempfile::NamedTempFile,
tempfile::NamedTempFile,
) {
let config_file = tempfile::NamedTempFile::new().unwrap();
let secrets_file = tempfile::NamedTempFile::new().unwrap();
let yaml: String = params.iter().map(|(k, v)| format!("{k}: {v}\n")).collect();
std::fs::write(config_file.path(), yaml).unwrap();
let config =
crate::config::Config::new_with_file_secrets(config_file.path(), secrets_file.path())
.unwrap();
(config, config_file, secrets_file)
}
#[test_case(
&[],
&[("otel_exporter_otlp_endpoint", "http://config:4318"), ("otel_exporter_otlp_timeout", "5000")],
Some("http://config:4318"), Some("5000");
"config promotes to env when unset"
)]
#[test_case(
&[("OTEL_EXPORTER_OTLP_ENDPOINT", "http://env:4318"), ("OTEL_EXPORTER_OTLP_TIMEOUT", "3000")],
&[("otel_exporter_otlp_endpoint", "http://config:4318"), ("otel_exporter_otlp_timeout", "5000")],
Some("http://env:4318"), Some("3000");
"env var takes precedence over config"
)]
#[test_case(
&[],
&[],
None, None;
"no config leaves env unset"
)]
fn test_promote_config_to_env(
env_overrides: &[(&str, &str)],
cfg: &[(&str, &str)],
expect_endpoint: Option<&str>,
expect_timeout: Option<&str>,
) {
let _guard = clear_otel_env(env_overrides);
let (config, _cf, _sf) = test_config(cfg);
promote_config_to_env(&config);
assert_eq!(
env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok().as_deref(),
expect_endpoint
);
assert_eq!(
env::var("OTEL_EXPORTER_OTLP_TIMEOUT").ok().as_deref(),
expect_timeout
);
}
}
-5
View File
@@ -1,16 +1,11 @@
pub mod langfuse_layer;
mod observation_layer;
pub mod otlp_layer;
pub mod rate_limiter;
pub use langfuse_layer::{create_langfuse_observer, LangfuseBatchManager};
pub use observation_layer::{
flatten_metadata, map_level, BatchManager, ObservationLayer, SpanData, SpanTracker,
};
pub use otlp_layer::{
create_otlp_metrics_filter, create_otlp_tracing_filter, create_otlp_tracing_layer,
init_otlp_metrics, init_otlp_tracing, init_otlp_tracing_only, shutdown_otlp, OtlpConfig,
};
pub use rate_limiter::{
MetricData, RateLimitedTelemetrySender, SpanData as RateLimitedSpanData, TelemetryEvent,
};
-337
View File
@@ -1,337 +0,0 @@
use opentelemetry::trace::TracerProvider;
use opentelemetry::{global, KeyValue};
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::logs::{Logger, LoggerProvider};
use opentelemetry_sdk::trace::{self, RandomIdGenerator, Sampler};
use opentelemetry_sdk::{runtime, Resource};
use std::time::Duration;
use tracing::{Level, Metadata};
use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer};
use tracing_subscriber::filter::FilterFn;
pub type OtlpTracingLayer =
OpenTelemetryLayer<tracing_subscriber::Registry, opentelemetry_sdk::trace::Tracer>;
pub type OtlpMetricsLayer = MetricsLayer<tracing_subscriber::Registry>;
pub type OtlpLogsLayer = OpenTelemetryTracingBridge<LoggerProvider, Logger>;
pub type OtlpLayers = (OtlpTracingLayer, OtlpMetricsLayer, OtlpLogsLayer);
pub type OtlpResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
#[derive(Debug, Clone)]
pub struct OtlpConfig {
pub endpoint: String,
pub timeout: Duration,
}
impl Default for OtlpConfig {
fn default() -> Self {
Self {
endpoint: "http://localhost:4318".to_string(),
timeout: Duration::from_secs(10),
}
}
}
impl OtlpConfig {
pub fn from_config() -> Option<Self> {
let config = crate::config::Config::global();
// Try to get the endpoint from config (checks OTEL_EXPORTER_OTLP_ENDPOINT env var first)
let endpoint = config
.get_param::<String>("otel_exporter_otlp_endpoint")
.ok()?;
let mut otlp_config = Self {
endpoint,
timeout: Duration::from_secs(10),
};
// Try to get timeout from config (checks OTEL_EXPORTER_OTLP_TIMEOUT env var first)
if let Ok(timeout_ms) = config.get_param::<u64>("otel_exporter_otlp_timeout") {
otlp_config.timeout = Duration::from_millis(timeout_ms);
}
Some(otlp_config)
}
}
pub fn init_otlp_tracing(config: &OtlpConfig) -> OtlpResult<()> {
let resource = Resource::new(vec![
KeyValue::new("service.name", "goose"),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
KeyValue::new("service.namespace", "goose"),
]);
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_endpoint(&config.endpoint)
.with_timeout(config.timeout)
.build()?;
let tracer_provider = trace::TracerProvider::builder()
.with_batch_exporter(exporter, runtime::Tokio)
.with_resource(resource.clone())
.with_id_generator(RandomIdGenerator::default())
.with_sampler(Sampler::AlwaysOn)
.build();
global::set_tracer_provider(tracer_provider);
Ok(())
}
pub fn init_otlp_metrics(config: &OtlpConfig) -> OtlpResult<()> {
let resource = Resource::new(vec![
KeyValue::new("service.name", "goose"),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
KeyValue::new("service.namespace", "goose"),
]);
let exporter = opentelemetry_otlp::MetricExporter::builder()
.with_http()
.with_endpoint(&config.endpoint)
.with_timeout(config.timeout)
.build()?;
let meter_provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder()
.with_resource(resource)
.with_reader(
opentelemetry_sdk::metrics::PeriodicReader::builder(exporter, runtime::Tokio)
.with_interval(Duration::from_secs(3))
.build(),
)
.build();
global::set_meter_provider(meter_provider);
Ok(())
}
pub fn create_otlp_tracing_layer() -> OtlpResult<OtlpTracingLayer> {
let config = OtlpConfig::from_config().ok_or("OTEL_EXPORTER_OTLP_ENDPOINT not configured")?;
let resource = Resource::new(vec![
KeyValue::new("service.name", "goose"),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
KeyValue::new("service.namespace", "goose"),
]);
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_endpoint(&config.endpoint)
.with_timeout(config.timeout)
.build()?;
let tracer_provider = trace::TracerProvider::builder()
.with_batch_exporter(exporter, runtime::Tokio)
.with_max_events_per_span(2048)
.with_max_attributes_per_span(512)
.with_max_links_per_span(512)
.with_resource(resource)
.with_id_generator(RandomIdGenerator::default())
.with_sampler(Sampler::TraceIdRatioBased(0.1))
.build();
let tracer = tracer_provider.tracer("goose");
Ok(tracing_opentelemetry::layer().with_tracer(tracer))
}
pub fn create_otlp_metrics_layer() -> OtlpResult<OtlpMetricsLayer> {
let config = OtlpConfig::from_config().ok_or("OTEL_EXPORTER_OTLP_ENDPOINT not configured")?;
let resource = Resource::new(vec![
KeyValue::new("service.name", "goose"),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
KeyValue::new("service.namespace", "goose"),
]);
let exporter = opentelemetry_otlp::MetricExporter::builder()
.with_http()
.with_endpoint(&config.endpoint)
.with_timeout(config.timeout)
.build()?;
let meter_provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder()
.with_resource(resource)
.with_reader(
opentelemetry_sdk::metrics::PeriodicReader::builder(exporter, runtime::Tokio)
.with_interval(Duration::from_millis(2000))
.build(),
)
.build();
global::set_meter_provider(meter_provider.clone());
Ok(tracing_opentelemetry::MetricsLayer::new(meter_provider))
}
pub fn create_otlp_logs_layer() -> OtlpResult<OpenTelemetryTracingBridge<LoggerProvider, Logger>> {
let config = OtlpConfig::from_config().ok_or("OTEL_EXPORTER_OTLP_ENDPOINT not configured")?;
let resource = Resource::new(vec![
KeyValue::new("service.name", "goose"),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
KeyValue::new("service.namespace", "goose"),
]);
let exporter = opentelemetry_otlp::LogExporter::builder()
.with_http()
.with_endpoint(&config.endpoint)
.with_timeout(config.timeout)
.build()?;
let logger_provider = LoggerProvider::builder()
.with_batch_exporter(exporter, runtime::Tokio)
.with_resource(resource)
.build();
Ok(OpenTelemetryTracingBridge::new(&logger_provider))
}
pub fn init_otlp() -> OtlpResult<OtlpLayers> {
let tracing_layer = create_otlp_tracing_layer()?;
let metrics_layer = create_otlp_metrics_layer()?;
let logs_layer = create_otlp_logs_layer()?;
Ok((tracing_layer, metrics_layer, logs_layer))
}
pub fn init_otlp_tracing_only() -> OtlpResult<OtlpTracingLayer> {
create_otlp_tracing_layer()
}
/// Creates a custom filter for OTLP tracing that captures:
/// - All spans at INFO level and above
/// - Specific spans marked with "otel.trace" field
/// - Events from specific modules related to telemetry
pub fn create_otlp_tracing_filter() -> FilterFn<impl Fn(&Metadata<'_>) -> bool> {
FilterFn::new(|metadata: &Metadata<'_>| {
if metadata.level() <= &Level::INFO {
return true;
}
if metadata.level() == &Level::DEBUG {
let target = metadata.target();
if target.starts_with("goose::")
|| target.starts_with("opentelemetry")
|| target.starts_with("tracing_opentelemetry")
{
return true;
}
}
false
})
}
/// Creates a custom filter for OTLP metrics that captures:
/// - All events at INFO level and above
/// - Specific events marked with "otel.metric" field
/// - Events that should be converted to metrics
pub fn create_otlp_metrics_filter() -> FilterFn<impl Fn(&Metadata<'_>) -> bool> {
FilterFn::new(|metadata: &Metadata<'_>| {
if metadata.level() <= &Level::INFO {
return true;
}
if metadata.level() == &Level::DEBUG {
let target = metadata.target();
if target.starts_with("goose::telemetry")
|| target.starts_with("goose::metrics")
|| target.contains("metric")
{
return true;
}
}
false
})
}
/// Creates a custom filter for OTLP metrics that captures:
/// - All events at WARN level and above
pub fn create_otlp_logs_filter() -> FilterFn<impl Fn(&Metadata<'_>) -> bool> {
FilterFn::new(|metadata: &Metadata<'_>| {
if metadata.level() <= &Level::WARN {
return true;
}
false
})
}
/// Shutdown OTLP providers gracefully
pub fn shutdown_otlp() {
// Shutdown the tracer provider and flush any pending spans
global::shutdown_tracer_provider();
// Force flush of metrics by waiting a bit
// The meter provider doesn't have a direct shutdown method in the current SDK,
// but we can give it time to export any pending metrics
std::thread::sleep(std::time::Duration::from_millis(500));
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_otlp_config_default() {
let config = OtlpConfig::default();
assert_eq!(config.endpoint, "http://localhost:4318");
assert_eq!(config.timeout, Duration::from_secs(10));
}
#[test]
fn test_otlp_config_from_config() {
use tempfile::NamedTempFile;
// Save original env vars
let original_endpoint = env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok();
let original_timeout = env::var("OTEL_EXPORTER_OTLP_TIMEOUT").ok();
// Clear env vars to ensure we're testing config file
env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
env::remove_var("OTEL_EXPORTER_OTLP_TIMEOUT");
// Create a test config file
let temp_file = NamedTempFile::new().unwrap();
let test_config = crate::config::Config::new(temp_file.path(), "test-otlp").unwrap();
// Set values in config
test_config
.set_param("otel_exporter_otlp_endpoint", "http://config:4318")
.unwrap();
test_config
.set_param("otel_exporter_otlp_timeout", 3000)
.unwrap();
// Test that from_config reads from the config file
// Note: We can't easily test from_config() directly since it uses Config::global()
// But we can test that the config system works with our keys
let endpoint: String = test_config
.get_param("otel_exporter_otlp_endpoint")
.unwrap();
assert_eq!(endpoint, "http://config:4318");
let timeout: u64 = test_config.get_param("otel_exporter_otlp_timeout").unwrap();
assert_eq!(timeout, 3000);
// Test env var override still works
env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "http://env:4317");
let endpoint: String = test_config
.get_param("otel_exporter_otlp_endpoint")
.unwrap();
assert_eq!(endpoint, "http://env:4317");
// Restore original env vars
match original_endpoint {
Some(val) => env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", val),
None => env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT"),
}
match original_timeout {
Some(val) => env::set_var("OTEL_EXPORTER_OTLP_TIMEOUT", val),
None => env::remove_var("OTEL_EXPORTER_OTLP_TIMEOUT"),
}
}
}