#[cfg(not(any(test, feature = "test-utils")))]
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::level_filters::LevelFilter;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::{SubscriberInitExt, TryInitError};
use tracing_subscriber::{EnvFilter, Layer, Registry};
#[derive(Debug, Error)]
pub enum TelemetryError {
#[error("OXEN_LOG_DIR set but is empty, cannot enable JSON file logging.")]
EmptyLogDir,
#[error("Requested JSON file logging cannot be enabled because OXEN_LOG_DIR is a file: {0}")]
LogDirIsFile(PathBuf),
#[error("Failed to create log directory ({0}): {1}")]
CreateLogDir(PathBuf, std::io::Error),
#[error("Failed to initialize tracing: {0}")]
InitFail(#[from] TryInitError),
#[cfg(feature = "otel")]
#[error(
"Unknown OXEN_OTEL_PROTOCOL / OTEL_EXPORTER_OTLP_PROTOCOL value: {0} (expected grpc, http, http/protobuf, or http/json)"
)]
UnknownProtocol(String),
#[cfg(feature = "otel")]
#[error(
"OXEN_OTEL_ENDPOINT must be an http:// or https:// URL, or a bare host:port (got: {0})"
)]
InvalidEndpoint(String),
}
pub type BoxedLayer = Box<dyn Layer<Registry> + Send + Sync>;
pub struct TracingGuard {
_file_guard: Option<WorkerGuard>,
#[cfg(feature = "otel")]
_tracer_provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>,
}
impl TracingGuard {
pub async fn shutdown(&self) {
#[cfg(feature = "otel")]
if let Some(provider) = self._tracer_provider.clone() {
match tokio::task::spawn_blocking(move || shutdown_provider(&provider)).await {
Ok(()) => {}
Err(e) => eprintln!("warning: OTel tracer provider shutdown task failed: {e}"),
}
}
}
}
#[cfg(feature = "otel")]
fn shutdown_provider(provider: &opentelemetry_sdk::trace::SdkTracerProvider) {
match provider.shutdown() {
Ok(()) | Err(opentelemetry_sdk::error::OTelSdkError::AlreadyShutdown) => {}
Err(e) => eprintln!("warning: OTel tracer provider shutdown failed: {e}"),
}
}
impl Drop for TracingGuard {
fn drop(&mut self) {
#[cfg(feature = "otel")]
if let Some(ref provider) = self._tracer_provider {
shutdown_provider(provider);
}
}
}
#[cfg(feature = "otel")]
mod atexit_flush {
use std::sync::OnceLock;
static PROVIDER: OnceLock<opentelemetry_sdk::trace::SdkTracerProvider> = OnceLock::new();
extern "C" fn on_exit() {
if let Some(provider) = PROVIDER.get() {
let _ = provider.shutdown();
}
}
pub(super) fn register(provider: opentelemetry_sdk::trace::SdkTracerProvider) -> bool {
if PROVIDER.set(provider).is_err() {
return false;
}
unsafe extern "C" {
safe fn atexit(f: extern "C" fn()) -> core::ffi::c_int;
}
let registered = atexit(on_exit) == 0;
if !registered {
eprintln!("warning: failed to register OTel atexit flush handler");
}
registered
}
}
pub fn init_tracing(app_name: &str, default: LevelFilter) -> Result<TracingGuard, TelemetryError> {
init_tracing_with_layer(app_name, default, None)
}
pub fn init_tracing_with_layer(
app_name: &str,
default: LevelFilter,
extra: Option<BoxedLayer>,
) -> Result<TracingGuard, TelemetryError> {
let log_directives = log_filter_directives(default);
let span_events = std::env::var("OXEN_FMT_SPAN")
.ok()
.map(|v| parse_fmt_span(&v))
.unwrap_or(FmtSpan::NONE);
#[cfg(any(test, feature = "test-utils"))]
let stderr_layer = tracing_subscriber::fmt::layer()
.with_writer(tracing_subscriber::fmt::TestWriter::default())
.with_target(true)
.with_ansi(false)
.with_span_events(span_events);
#[cfg(not(any(test, feature = "test-utils")))]
let stderr_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_target(true)
.with_ansi(std::io::stderr().is_terminal())
.with_span_events(span_events);
let maybe_log_dir = match std::env::var("OXEN_LOG_DIR").ok() {
Some(log_dir) => {
let created_log_dir = create_log_dir(&log_dir)?;
Some(created_log_dir)
}
None => None,
};
let (m_json_layer, m_worker_guard) = if let Some(ref log_dir) = maybe_log_dir {
let (jl, wg) = json_file_logging(app_name, log_dir);
(Some(jl), Some(wg))
} else {
(None, None)
};
let log_filter = env_filter(&log_directives, default);
let registry = tracing_subscriber::registry()
.with(extra.map(|layer| layer.with_filter(log_filter.clone())))
.with(m_json_layer.map(|layer| layer.with_filter(log_filter.clone())))
.with(stderr_layer.with_filter(log_filter));
#[cfg(feature = "otel")]
let (m_otel_layer, m_tracer_provider, m_endpoint_p) = match otel_endpoint() {
Some(mut endpoint) => {
endpoint.url = normalize_otel_endpoint(&endpoint.url)?;
let protocol = otel_protocol()?;
match build_otel_layer(app_name, &protocol, &endpoint) {
(Some(layer), Some(provider)) => {
atexit_flush::register(provider.clone());
let url = &endpoint.url;
(
Some(layer),
Some(provider),
Some(format!("{protocol} (protobuf) -> {url}")),
)
}
_ => (None, None, None),
}
}
None => (None, None, None),
};
#[cfg(feature = "otel")]
{
let otel_directives = otel_filter_directives();
let otel_layer = m_otel_layer
.map(|layer| layer.with_filter(env_filter(&otel_directives, OTEL_DEFAULT_FILTER)));
registry.with(otel_layer).try_init()?;
if let Some(protocol_and_endpoint) = m_endpoint_p {
log::info!(
"OpenTelemetry tracing enabled (endpoint: {protocol_and_endpoint}, span filter: {otel_directives})"
);
}
}
#[cfg(not(feature = "otel"))]
{
registry.try_init()?;
if otel_env(
"OXEN_OTEL_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_ENDPOINT",
)
.is_some()
{
log::error!(
"An OTLP endpoint is configured but the otel feature is not enabled! (Ignoring)"
)
}
}
if let Some(ref log_dir) = maybe_log_dir {
log::info!(
"JSON file logging enabled (log directory: {})",
log_dir.display()
);
}
Ok(TracingGuard {
_file_guard: m_worker_guard,
#[cfg(feature = "otel")]
_tracer_provider: m_tracer_provider,
})
}
#[cfg(feature = "otel")]
const OTEL_FILTER_ENV: &str = "OXEN_OTEL_FILTER";
#[cfg(feature = "otel")]
const OTEL_DEFAULT_FILTER: LevelFilter = LevelFilter::INFO;
fn log_filter_directives(default: LevelFilter) -> String {
non_empty_env(EnvFilter::DEFAULT_ENV).unwrap_or_else(|| default.to_string())
}
#[cfg(feature = "otel")]
fn otel_filter_directives() -> String {
non_empty_env(OTEL_FILTER_ENV).unwrap_or_else(|| OTEL_DEFAULT_FILTER.to_string())
}
#[cfg(feature = "otel")]
fn env_names_a_service(
service_name_var: Option<&str>,
attributes_service_name: Option<&str>,
) -> bool {
[service_name_var, attributes_service_name]
.into_iter()
.flatten()
.any(|name| !name.trim().is_empty())
}
#[cfg(feature = "otel")]
struct OtlpEndpoint {
url: String,
is_base: bool,
}
#[cfg(feature = "otel")]
fn otel_endpoint() -> Option<OtlpEndpoint> {
if let Some(url) = non_empty_env("OXEN_OTEL_ENDPOINT") {
return Some(OtlpEndpoint { url, is_base: true });
}
if let Some(url) = non_empty_env("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") {
return Some(OtlpEndpoint {
url,
is_base: false,
});
}
non_empty_env("OTEL_EXPORTER_OTLP_ENDPOINT").map(|url| OtlpEndpoint { url, is_base: true })
}
fn otel_env(oxen: &str, traces: &str, general: &str) -> Option<String> {
non_empty_env(oxen)
.or_else(|| non_empty_env(traces))
.or_else(|| non_empty_env(general))
}
fn non_empty_env(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
}
fn env_filter(directives: &str, fallback: LevelFilter) -> EnvFilter {
EnvFilter::builder()
.with_default_directive(fallback.into())
.parse_lossy(directives)
}
fn create_log_dir(oxen_log_dir: &str) -> Result<PathBuf, TelemetryError> {
let oxen_log_dir = oxen_log_dir.trim();
if oxen_log_dir.is_empty() {
Err(TelemetryError::EmptyLogDir)
} else {
let log_dir = PathBuf::from(oxen_log_dir);
if log_dir.is_file() {
Err(TelemetryError::LogDirIsFile(log_dir))
} else {
match std::fs::create_dir_all(&log_dir) {
Ok(()) => Ok(log_dir),
Err(e) => Err(TelemetryError::CreateLogDir(log_dir, e)),
}
}
}
}
fn json_file_logging<S>(app_name: &str, log_dir: &Path) -> (impl Layer<S>, WorkerGuard)
where
S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>,
{
let file_appender = tracing_appender::rolling::daily(log_dir, app_name);
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let layer = tracing_subscriber::fmt::layer()
.json()
.with_writer(non_blocking)
.with_target(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true);
(layer, guard)
}
fn parse_fmt_span(value: &str) -> FmtSpan {
let upper = value.to_uppercase();
if !upper.contains('|') {
return parse_fmt_span_token(&upper);
}
let mut span = FmtSpan::NONE;
for part in upper.split('|') {
span |= parse_fmt_span_token(part.trim());
}
span
}
fn parse_fmt_span_token(token: &str) -> FmtSpan {
match token {
"1" | "TRUE" | "CLOSE" => FmtSpan::CLOSE,
"NEW" => FmtSpan::NEW,
"ENTER" => FmtSpan::ENTER,
"EXIT" => FmtSpan::EXIT,
"ACTIVE" => FmtSpan::ACTIVE,
"FULL" => FmtSpan::FULL,
"NONE" => FmtSpan::NONE,
other => {
eprintln!("warning: unknown OXEN_FMT_SPAN component: {other:?}, ignoring");
FmtSpan::NONE
}
}
}
#[cfg(feature = "otel")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Protocol {
Grpc,
Http,
}
#[cfg(feature = "otel")]
impl std::fmt::Display for Protocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Protocol::Grpc => write!(f, "grpc"),
Protocol::Http => write!(f, "http"),
}
}
}
#[cfg(feature = "otel")]
fn otel_protocol() -> Result<Protocol, TelemetryError> {
let configured = otel_env(
"OXEN_OTEL_PROTOCOL",
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
"OTEL_EXPORTER_OTLP_PROTOCOL",
);
parse_otel_protocol(configured.as_deref())
}
#[cfg(feature = "otel")]
fn parse_otel_protocol(configured: Option<&str>) -> Result<Protocol, TelemetryError> {
match configured
.map(|value| value.trim().to_lowercase())
.as_deref()
{
None | Some("grpc") => Ok(Protocol::Grpc),
Some("http" | "http/protobuf" | "http/json") => Ok(Protocol::Http),
Some(unknown) => Err(TelemetryError::UnknownProtocol(unknown.to_string())),
}
}
#[cfg(feature = "otel")]
fn normalize_otel_endpoint(endpoint: &str) -> Result<String, TelemetryError> {
let endpoint = endpoint.trim();
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
require_host(endpoint, endpoint)?;
return Ok(endpoint.to_string());
}
if endpoint.is_empty() || endpoint.contains("://") || endpoint.starts_with('/') {
return Err(TelemetryError::InvalidEndpoint(endpoint.to_string()));
}
let with_scheme = format!("http://{endpoint}");
require_host(&with_scheme, endpoint)?;
Ok(with_scheme)
}
#[cfg(feature = "otel")]
fn require_host(url: &str, configured: &str) -> Result<(), TelemetryError> {
let invalid = || TelemetryError::InvalidEndpoint(configured.to_string());
let parsed = url::Url::parse(url).map_err(|_| invalid())?;
match parsed.host_str() {
Some(host) if !host.is_empty() => Ok(()),
_ => Err(invalid()),
}
}
#[cfg(feature = "otel")]
const OTLP_HTTP_TRACES_PATH: &str = "/v1/traces";
#[cfg(feature = "otel")]
fn http_endpoint_url(endpoint: &OtlpEndpoint) -> String {
if endpoint.is_base {
http_traces_endpoint(&endpoint.url)
} else {
endpoint.url.clone()
}
}
#[cfg(feature = "otel")]
fn http_traces_endpoint(endpoint: &str) -> String {
let Ok(mut url) = url::Url::parse(endpoint) else {
return endpoint.to_string();
};
let base_path = url.path().trim_end_matches('/');
if base_path.ends_with(OTLP_HTTP_TRACES_PATH) {
return endpoint.to_string();
}
url.set_path(&format!("{base_path}{OTLP_HTTP_TRACES_PATH}"));
url.to_string()
}
#[cfg(feature = "otel")]
fn build_otel_layer<S>(
app_name: &str,
protocol: &Protocol,
endpoint: &OtlpEndpoint,
) -> (
Option<tracing_opentelemetry::OpenTelemetryLayer<S, opentelemetry_sdk::trace::SdkTracer>>,
Option<opentelemetry_sdk::trace::SdkTracerProvider>,
)
where
S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>,
{
use opentelemetry::trace::TracerProvider;
use opentelemetry::{Key, KeyValue};
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_otlp::WithTonicConfig;
use opentelemetry_otlp::tonic_types::transport::ClientTlsConfig;
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::resource::{EnvResourceDetector, ResourceDetector};
use opentelemetry_sdk::trace::{BatchConfigBuilder, BatchSpanProcessor, SdkTracerProvider};
let exporter = match protocol {
Protocol::Http => {
match opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_protocol(opentelemetry_otlp::Protocol::HttpBinary)
.with_endpoint(http_endpoint_url(endpoint))
.build()
{
Ok(e) => e,
Err(err) => {
eprintln!("[ERROR] failed to build OTel HTTP exporter: {err}");
return (None, None);
}
}
}
Protocol::Grpc => {
match opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&endpoint.url)
.with_tls_config(ClientTlsConfig::new().with_native_roots())
.build()
{
Ok(e) => e,
Err(err) => {
eprintln!("[ERROR] failed to build OTel gRPC exporter: {err}");
return (None, None);
}
}
}
};
let attributes_service_name = EnvResourceDetector::new()
.detect()
.get(&Key::from_static_str("service.name"))
.map(|name| name.to_string());
let mut builder = Resource::builder().with_attributes([KeyValue::new(
"service.version",
crate::constants::OXEN_VERSION,
)]);
if !env_names_a_service(
std::env::var("OTEL_SERVICE_NAME").ok().as_deref(),
attributes_service_name.as_deref(),
) {
builder = builder.with_service_name(app_name.to_string());
}
let resource = builder.build();
let mut batch_config = BatchConfigBuilder::default();
if std::env::var_os("OTEL_BSP_MAX_QUEUE_SIZE").is_none() {
batch_config = batch_config.with_max_queue_size(4096);
}
if std::env::var_os("OTEL_BSP_SCHEDULE_DELAY").is_none() {
batch_config = batch_config.with_scheduled_delay(std::time::Duration::from_secs(2));
}
let processor = BatchSpanProcessor::builder(exporter)
.with_batch_config(batch_config.build())
.build();
let provider = SdkTracerProvider::builder()
.with_span_processor(processor)
.with_resource(resource)
.build();
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
let tracer = provider.tracer("oxen");
let layer = tracing_opentelemetry::layer().with_tracer(tracer);
(Some(layer), Some(provider))
}
#[cfg(test)]
mod tests {
use super::*;
use tracing_subscriber::fmt::format::FmtSpan;
#[test]
fn a_filter_with_no_usable_directive_falls_back_to_the_given_level() {
for directives in ["liboxen=verbose", "warn=oops=bad", "="] {
assert_eq!(
env_filter(directives, LevelFilter::WARN).max_level_hint(),
Some(LevelFilter::WARN),
"{directives} should fall back to the given level"
);
}
}
#[test]
fn usable_directives_win_over_the_fallback() {
assert_eq!(
env_filter("debug", LevelFilter::WARN).max_level_hint(),
Some(LevelFilter::DEBUG)
);
assert_eq!(
env_filter("off", LevelFilter::WARN).max_level_hint(),
Some(LevelFilter::OFF)
);
assert_eq!(
env_filter("liboxen=verbose,oxen_server=debug", LevelFilter::WARN).max_level_hint(),
Some(LevelFilter::DEBUG)
);
}
#[test]
fn token_close() {
assert_eq!(parse_fmt_span_token("CLOSE"), FmtSpan::CLOSE);
assert_eq!(parse_fmt_span_token("1"), FmtSpan::CLOSE);
assert_eq!(parse_fmt_span_token("TRUE"), FmtSpan::CLOSE);
assert_eq!(parse_fmt_span("cLosE"), FmtSpan::CLOSE);
assert_eq!(parse_fmt_span("tRuE"), FmtSpan::CLOSE);
}
#[test]
fn token_new() {
assert_eq!(parse_fmt_span_token("NEW"), FmtSpan::NEW);
assert_eq!(parse_fmt_span("NeW"), FmtSpan::NEW);
}
#[test]
fn token_enter() {
assert_eq!(parse_fmt_span_token("ENTER"), FmtSpan::ENTER);
assert_eq!(parse_fmt_span("eNteR"), FmtSpan::ENTER);
}
#[test]
fn token_exit() {
assert_eq!(parse_fmt_span_token("EXIT"), FmtSpan::EXIT);
assert_eq!(parse_fmt_span("exIT"), FmtSpan::EXIT);
}
#[test]
fn token_active() {
assert_eq!(parse_fmt_span_token("ACTIVE"), FmtSpan::ACTIVE);
assert_eq!(parse_fmt_span("aCtIvE"), FmtSpan::ACTIVE);
}
#[test]
fn token_full() {
assert_eq!(parse_fmt_span_token("FULL"), FmtSpan::FULL);
assert_eq!(parse_fmt_span("FUll"), FmtSpan::FULL);
}
#[test]
fn token_none() {
assert_eq!(parse_fmt_span_token("NONE"), FmtSpan::NONE);
assert_eq!(parse_fmt_span("NonE"), FmtSpan::NONE);
}
#[test]
fn token_unknown_returns_none() {
assert_eq!(parse_fmt_span_token("BOGUS"), FmtSpan::NONE);
assert_eq!(parse_fmt_span("bogus"), FmtSpan::NONE);
}
#[test]
fn combined_new_close() {
assert_eq!(parse_fmt_span("NEW|CLOSE"), FmtSpan::NEW | FmtSpan::CLOSE);
assert_eq!(parse_fmt_span("new|close"), FmtSpan::NEW | FmtSpan::CLOSE);
assert_eq!(parse_fmt_span("NEW | CLOSE"), FmtSpan::NEW | FmtSpan::CLOSE);
}
#[test]
fn combined_active_close() {
assert_eq!(
parse_fmt_span("ACTIVE|CLOSE"),
FmtSpan::ACTIVE | FmtSpan::CLOSE
);
}
#[test]
fn combined_full_new() {
assert_eq!(parse_fmt_span("FULL|NEW"), FmtSpan::FULL | FmtSpan::NEW);
}
#[test]
fn combined_unknown_component_ignored() {
assert_eq!(parse_fmt_span("NEW|BOGUS"), FmtSpan::NEW | FmtSpan::NONE);
}
#[test]
fn combined_all_four_lifecycle() {
assert_eq!(
parse_fmt_span("NEW|ENTER|EXIT|CLOSE"),
FmtSpan::NEW | FmtSpan::ENTER | FmtSpan::EXIT | FmtSpan::CLOSE
);
}
#[cfg(feature = "otel")]
mod otel_tests {
use super::super::{
OtlpEndpoint, Protocol, TelemetryError, env_names_a_service, http_endpoint_url,
http_traces_endpoint, normalize_otel_endpoint, otel_filter_directives,
parse_otel_protocol,
};
#[test]
fn no_configured_protocol_is_grpc() {
assert_eq!(parse_otel_protocol(None).unwrap(), Protocol::Grpc);
}
#[test]
fn standard_protocol_values_select_a_transport() {
assert_eq!(parse_otel_protocol(Some("grpc")).unwrap(), Protocol::Grpc);
for value in [
"http",
"http/protobuf",
"http/json",
"HTTP/protobuf",
" http ",
] {
assert_eq!(
parse_otel_protocol(Some(value)).unwrap(),
Protocol::Http,
"{value} should select HTTP"
);
}
}
#[test]
fn rejects_an_unknown_protocol() {
for value in ["https", "http/proto", "tcp"] {
let result = parse_otel_protocol(Some(value));
assert!(
matches!(result, Err(TelemetryError::UnknownProtocol(_))),
"{value} should be rejected, got {result:?}"
);
}
}
#[test]
fn the_environment_names_the_service_when_it_says_so() {
assert!(env_names_a_service(Some("checkout"), None));
assert!(env_names_a_service(None, Some("checkout")));
assert!(env_names_a_service(Some("checkout"), Some("billing")));
}
#[test]
fn a_blank_service_name_names_nothing() {
for blank in [None, Some(""), Some(" ")] {
assert!(
!env_names_a_service(blank, None),
"OTEL_SERVICE_NAME {blank:?} should not count as naming a service"
);
assert!(
!env_names_a_service(None, blank),
"service.name {blank:?} should not count as naming a service"
);
assert!(
!env_names_a_service(blank, blank),
"both blank at {blank:?} should not count as naming a service"
);
}
}
#[test]
fn keeps_http_endpoint() {
assert_eq!(
normalize_otel_endpoint("http://localhost:4317").unwrap(),
"http://localhost:4317"
);
}
#[test]
fn keeps_https_endpoint() {
assert_eq!(
normalize_otel_endpoint("https://otlp.vendor.example:443").unwrap(),
"https://otlp.vendor.example:443"
);
}
#[test]
fn adds_scheme_to_bare_host_port() {
assert_eq!(
normalize_otel_endpoint("localhost:4317").unwrap(),
"http://localhost:4317"
);
}
#[test]
fn adds_scheme_to_bare_host() {
assert_eq!(
normalize_otel_endpoint("collector").unwrap(),
"http://collector"
);
}
#[test]
fn trims_surrounding_whitespace() {
assert_eq!(
normalize_otel_endpoint(" http://localhost:4317 ").unwrap(),
"http://localhost:4317"
);
}
#[test]
fn rejects_other_schemes() {
for endpoint in ["grpc://localhost:4317", "unix:///var/run/otel.sock"] {
let err = normalize_otel_endpoint(endpoint).unwrap_err();
assert!(matches!(err, TelemetryError::InvalidEndpoint(_)));
}
}
#[test]
fn rejects_empty_endpoint() {
let err = normalize_otel_endpoint(" ").unwrap_err();
assert!(matches!(err, TelemetryError::InvalidEndpoint(_)));
}
#[test]
fn rejects_endpoints_with_no_host() {
for endpoint in ["http://", "https://", "http://:4317", ":4317"] {
let result = normalize_otel_endpoint(endpoint);
assert!(
matches!(result, Err(TelemetryError::InvalidEndpoint(_))),
"{endpoint} should be rejected, got {result:?}"
);
}
}
#[test]
fn http_signal_path_is_appended_to_a_base_endpoint() {
assert_eq!(
http_traces_endpoint("http://localhost:4318"),
"http://localhost:4318/v1/traces"
);
assert_eq!(
http_traces_endpoint("http://localhost:4318/"),
"http://localhost:4318/v1/traces"
);
assert_eq!(
http_traces_endpoint("https://vendor.example/otlp"),
"https://vendor.example/otlp/v1/traces"
);
}
#[test]
fn http_signal_path_is_not_doubled() {
assert_eq!(
http_traces_endpoint("http://localhost:4318/v1/traces"),
"http://localhost:4318/v1/traces"
);
}
#[test]
fn http_signal_path_keeps_a_query_string_intact() {
assert_eq!(
http_traces_endpoint("http://localhost:4318?token=abc"),
"http://localhost:4318/v1/traces?token=abc"
);
assert_eq!(
http_traces_endpoint("https://vendor.example/otlp?token=abc"),
"https://vendor.example/otlp/v1/traces?token=abc"
);
}
#[test]
fn only_a_base_endpoint_carries_the_signal_path() {
let url = "https://vendor.example/otlp";
let base = OtlpEndpoint {
url: url.to_string(),
is_base: true,
};
let traces = OtlpEndpoint {
url: url.to_string(),
is_base: false,
};
assert_eq!(
http_endpoint_url(&base),
"https://vendor.example/otlp/v1/traces"
);
assert_eq!(http_endpoint_url(&traces), url);
}
#[test]
fn span_export_defaults_to_info() {
if std::env::var_os("OXEN_OTEL_FILTER").is_none() {
assert_eq!(otel_filter_directives(), "info");
}
}
}
}