use std::env;
use std::io::Write;
use tracing::Level;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::Layer;
use tracing_subscriber::filter::{FilterFn, LevelFilter};
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::Registry;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TelemetryBootstrapState {
Healthy,
Disabled,
Degraded,
}
static BOOTSTRAP_STATE: std::sync::OnceLock<std::sync::RwLock<TelemetryBootstrapState>> =
std::sync::OnceLock::new();
static LOGGER_PROVIDER: std::sync::OnceLock<opentelemetry_sdk::logs::SdkLoggerProvider> =
std::sync::OnceLock::new();
static FILE_LOGGER_GUARD: std::sync::Mutex<Option<tracing_appender::non_blocking::WorkerGuard>> =
std::sync::Mutex::new(None);
fn bootstrap_state_cell() -> &'static std::sync::RwLock<TelemetryBootstrapState> {
BOOTSTRAP_STATE.get_or_init(|| std::sync::RwLock::new(TelemetryBootstrapState::Disabled))
}
pub fn telemetry_bootstrap_state() -> TelemetryBootstrapState {
bootstrap_state_cell()
.read()
.map(|g| *g)
.unwrap_or(TelemetryBootstrapState::Degraded)
}
fn set_bootstrap_state(state: TelemetryBootstrapState) {
if let Ok(mut g) = bootstrap_state_cell().write() {
*g = state;
}
}
pub fn init_logging(cfg: &crate::telemetry::TelemetryConfig) {
let enable_analytics = cfg.enabled && cfg.logs;
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new("warn,jarvy=info,opentelemetry=off,opentelemetry_sdk=off")
});
let stderr_non_error = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_filter(FilterFn::new(|meta| meta.level() < &Level::ERROR));
let stderr_errors = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_filter(LevelFilter::ERROR);
let file_layer = match ensure_log_dir() {
Ok(dir) => {
let appender = RollingFileAppender::new(Rotation::DAILY, dir, "jarvy.log");
let (non_blocking, guard) = tracing_appender::non_blocking(appender);
if let Ok(mut slot) = FILE_LOGGER_GUARD.lock() {
*slot = Some(guard);
}
let layer = tracing_subscriber::fmt::layer()
.json()
.with_writer(non_blocking)
.with_span_events(FmtSpan::CLOSE)
.with_current_span(true)
.with_target(true)
.with_ansi(false)
.with_filter(LevelFilter::INFO);
Some(layer)
}
Err(e) => {
eprintln!("Warning: file logging disabled — could not create log dir: {e}");
None
}
};
let mut bootstrap_error: Option<String> = None;
let otel_layer_opt = if enable_analytics {
match build_otlp_logger_provider(cfg) {
Ok(logger_provider) => {
let layer = opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(
&logger_provider,
)
.with_filter(otlp_level_filter());
let _ = LOGGER_PROVIDER.set(logger_provider);
Some(layer)
}
Err(e) => {
eprintln!("Warning: failed to initialize OTLP telemetry: {e}");
bootstrap_error = Some(e.to_string());
None
}
}
} else {
None
};
let subscriber = Registry::default()
.with(env_filter)
.with(stderr_non_error)
.with(stderr_errors)
.with(file_layer)
.with(otel_layer_opt);
let install_result = tracing::subscriber::set_global_default(subscriber);
if let Err(e) = install_result {
eprintln!("Failed to set tracing default: {e}");
}
if !enable_analytics {
set_bootstrap_state(TelemetryBootstrapState::Disabled);
} else if let Some(reason) = bootstrap_error {
set_bootstrap_state(TelemetryBootstrapState::Degraded);
tracing::error!(
event = "telemetry.bootstrap.degraded",
reason = %reason,
"OTLP exporter failed to initialize; running with console logs only"
);
} else {
set_bootstrap_state(TelemetryBootstrapState::Healthy);
}
}
pub fn shutdown_logging() {
if let Some(provider) = LOGGER_PROVIDER.get() {
let _ = provider.force_flush();
let _ = provider.shutdown();
}
if let Ok(mut slot) = FILE_LOGGER_GUARD.lock() {
slot.take();
}
}
fn otlp_level_filter() -> LevelFilter {
match env::var("JARVY_OTLP_LEVEL")
.map(|v| v.trim().to_ascii_lowercase())
.as_deref()
{
Ok("error") => LevelFilter::ERROR,
Ok("warn") => LevelFilter::WARN,
Ok("info") => LevelFilter::INFO,
Ok("debug") => LevelFilter::DEBUG,
Ok("trace") => LevelFilter::TRACE,
_ => LevelFilter::INFO,
}
}
fn ensure_log_dir() -> std::io::Result<std::path::PathBuf> {
let dir = crate::paths::logs_dir().map_err(|e| std::io::Error::other(e.to_string()))?;
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub(crate) fn resolve_otlp_endpoint(base: &str, signal: &str) -> String {
let signal_env = match signal {
"logs" => "JARVY_OTLP_LOGS_ENDPOINT",
"metrics" => "JARVY_OTLP_METRICS_ENDPOINT",
"traces" => "JARVY_OTLP_TRACES_ENDPOINT",
_ => "",
};
let candidate = env::var(signal_env)
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
env::var("JARVY_OTLP_ENDPOINT")
.ok()
.filter(|v| !v.trim().is_empty())
})
.unwrap_or_else(|| base.to_string());
append_signal_path(&candidate, signal)
}
fn append_signal_path(endpoint: &str, signal: &str) -> String {
let trimmed = endpoint.trim_end_matches('/');
let suffix = format!("/v1/{}", signal);
if trimmed.ends_with(&suffix) || trimmed.contains("/v1/") {
trimmed.to_string()
} else {
format!("{}{}", trimmed, suffix)
}
}
pub fn send_otlp_smoke_probe() {
if env::var("JARVY_TELEMETRY_SMOKE").as_deref() != Ok("1") {
return;
}
let (host, port) = env::var("JARVY_OTLP_LOGS_ENDPOINT")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
env::var("JARVY_OTLP_ENDPOINT")
.ok()
.filter(|v| !v.trim().is_empty())
})
.and_then(|url| parse_host_port(&url))
.unwrap_or_else(|| ("127.0.0.1".to_string(), 4318));
let req = b"POST /v1/logs HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
for attempt in 0..3 {
if let Ok(mut s) = std::net::TcpStream::connect((host.as_str(), port))
&& s.write_all(req).is_ok()
{
let _ = s.flush();
return;
}
if host == "127.0.0.1"
&& let Ok(mut s) = std::net::TcpStream::connect(("::1", port))
&& s.write_all(req).is_ok()
{
let _ = s.flush();
return;
}
if attempt < 2 {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
}
fn parse_host_port(url: &str) -> Option<(String, u16)> {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
let host_port = rest.split('/').next().unwrap_or(rest);
let (host, port_str) = host_port.rsplit_once(':')?;
let port = port_str.parse().ok()?;
Some((host.to_string(), port))
}
pub(crate) fn build_resource() -> opentelemetry_sdk::Resource {
use opentelemetry::KeyValue;
let hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string());
opentelemetry_sdk::Resource::builder()
.with_attributes(vec![
KeyValue::new("service.name", "jarvy"),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
KeyValue::new("host.name", hostname),
KeyValue::new("os.type", env::consts::OS),
KeyValue::new("os.description", env::consts::OS),
])
.build()
}
fn build_otlp_logger_provider(
cfg: &crate::telemetry::TelemetryConfig,
) -> Result<opentelemetry_sdk::logs::SdkLoggerProvider, Box<dyn std::error::Error>> {
use opentelemetry_otlp::{Protocol, WithExportConfig};
let endpoint = resolve_otlp_endpoint(&cfg.endpoint, "logs");
let exporter = opentelemetry_otlp::LogExporter::builder()
.with_http()
.with_protocol(Protocol::HttpBinary)
.with_endpoint(endpoint.as_str())
.build()?;
let mut logger_builder =
opentelemetry_sdk::logs::SdkLoggerProvider::builder().with_resource(build_resource());
if env::var("JARVY_TELEMETRY_SMOKE").as_deref() == Ok("1") {
logger_builder = logger_builder.with_simple_exporter(exporter);
} else {
logger_builder = logger_builder.with_batch_exporter(exporter);
}
Ok(logger_builder.build())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_host_port_handles_canonical_otlp_url() {
assert_eq!(
parse_host_port("http://127.0.0.1:4318"),
Some(("127.0.0.1".to_string(), 4318))
);
assert_eq!(
parse_host_port("https://otel.corp:443"),
Some(("otel.corp".to_string(), 443))
);
}
#[test]
fn parse_host_port_strips_signal_path() {
assert_eq!(
parse_host_port("http://127.0.0.1:4318/v1/logs"),
Some(("127.0.0.1".to_string(), 4318))
);
}
#[test]
fn parse_host_port_handles_bare_host_port() {
assert_eq!(
parse_host_port("127.0.0.1:9999"),
Some(("127.0.0.1".to_string(), 9999))
);
}
#[test]
fn parse_host_port_returns_none_when_port_missing() {
assert_eq!(parse_host_port("http://127.0.0.1"), None);
assert_eq!(parse_host_port("not a url"), None);
}
#[test]
fn parse_host_port_returns_none_when_port_not_numeric() {
assert_eq!(parse_host_port("http://127.0.0.1:abc"), None);
}
}