diff --git a/src/bridges/tracing.rs b/src/bridges/tracing.rs index a3ad744..79448df 100644 --- a/src/bridges/tracing.rs +++ b/src/bridges/tracing.rs @@ -2335,6 +2335,36 @@ mod tests { ), ), }, + KeyValue { + key: Static( + "telemetry.sdk.language", + ), + value: String( + Static( + "rust", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.name", + ), + value: String( + Static( + "opentelemetry", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.version", + ), + value: String( + Static( + "0.0.0", + ), + ), + }, ], scope_metrics: [ DeterministicScopeMetrics { @@ -2381,6 +2411,36 @@ mod tests { ), ), }, + KeyValue { + key: Static( + "telemetry.sdk.language", + ), + value: String( + Static( + "rust", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.name", + ), + value: String( + Static( + "opentelemetry", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.version", + ), + value: String( + Static( + "0.0.0", + ), + ), + }, ], scope_metrics: [ DeterministicScopeMetrics { diff --git a/src/config.rs b/src/config.rs index 291e072..e5d8dcd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,7 +3,10 @@ //! See [`LogfireConfigBuilder`] for documentation of all these options. use std::{ + collections::HashMap, + convert::Infallible, fmt::Display, + marker::PhantomData, path::PathBuf, str::FromStr, sync::{Arc, Mutex}, @@ -17,7 +20,7 @@ use opentelemetry_sdk::{ use regex::Regex; use tracing::{Level, level_filters::LevelFilter}; -use crate::{ConfigureError, logfire::Logfire}; +use crate::{ConfigureError, internal::env::get_optional_env, logfire::Logfire}; /// Builder for logfire configuration, returned from [`logfire::configure()`][crate::configure]. #[must_use = "call `.finish()` to complete logfire configuration."] @@ -624,6 +627,112 @@ impl LogProcessor for BoxedLogProcessor { } } +pub(crate) trait ParseConfigValue: Sized { + fn parse_config_value(s: &str) -> Result; +} + +impl ParseConfigValue for T +where + T: FromStr, + ConfigureError: From, +{ + fn parse_config_value(s: &str) -> Result { + Ok(s.parse()?) + } +} + +pub(crate) struct ConfigValue { + env_vars: &'static [&'static str], + default_value: fn() -> T, +} + +impl ConfigValue { + const fn new(env_vars: &'static [&'static str], default_value: fn() -> T) -> Self { + Self { + env_vars, + default_value, + } + } +} +impl ConfigValue { + /// Resolves a config value, using the provided value if present, otherwise falling back to the environment variable or the default. + pub(crate) fn resolve( + &self, + value: Option, + env: Option<&HashMap>, + ) -> Result { + if let Some(v) = try_resolve_from_env(value, self.env_vars, env)? { + return Ok(v); + } + + Ok((self.default_value)()) + } +} + +pub(crate) struct OptionalConfigValue { + env_vars: &'static [&'static str], + default_value: PhantomData>, +} + +impl OptionalConfigValue { + const fn new(env_vars: &'static [&'static str]) -> Self { + Self { + env_vars, + default_value: PhantomData, + } + } +} + +impl OptionalConfigValue { + /// Resolves an optional config value, using the provided value if present, otherwise falling back to the environment variable or `None`. + pub(crate) fn resolve( + &self, + value: Option, + env: Option<&HashMap>, + ) -> Result, ConfigureError> { + try_resolve_from_env(value, self.env_vars, env) + } +} + +fn try_resolve_from_env( + value: Option, + env_vars: &[&str], + env: Option<&HashMap>, +) -> Result, ConfigureError> +where + T: ParseConfigValue, +{ + if let Some(v) = value { + return Ok(Some(v)); + } + + for var in env_vars { + if let Some(s) = get_optional_env(var, env)? { + return T::parse_config_value(&s).map(Some); + } + } + + Ok(None) +} + +impl From for ConfigureError { + fn from(_: Infallible) -> Self { + unreachable!("Infallible cannot be constructed") + } +} + +pub(crate) static LOGFIRE_SEND_TO_LOGFIRE: ConfigValue = + ConfigValue::new(&["LOGFIRE_SEND_TO_LOGFIRE"], || SendToLogfire::Yes); + +pub(crate) static LOGFIRE_SERVICE_NAME: OptionalConfigValue = + OptionalConfigValue::new(&["LOGFIRE_SERVICE_NAME", "OTEL_SERVICE_NAME"]); + +pub(crate) static LOGFIRE_SERVICE_VERSION: OptionalConfigValue = + OptionalConfigValue::new(&["LOGFIRE_SERVICE_VERSION", "OTEL_SERVICE_VERSION"]); + +pub(crate) static LOGFIRE_ENVIRONMENT: OptionalConfigValue = + OptionalConfigValue::new(&["LOGFIRE_ENVIRONMENT"]); + #[cfg(test)] mod tests { use crate::config::SendToLogfire; diff --git a/src/logfire.rs b/src/logfire.rs index e9b827a..e0d97ab 100644 --- a/src/logfire.rs +++ b/src/logfire.rs @@ -36,7 +36,10 @@ use crate::{ __macros_impl::LogfireValue, ConfigureError, LogfireConfigBuilder, ShutdownError, bridges::tracing::LogfireTracingLayer, - config::{SendToLogfire, get_base_url_from_token}, + config::{ + LOGFIRE_ENVIRONMENT, LOGFIRE_SEND_TO_LOGFIRE, LOGFIRE_SERVICE_NAME, + LOGFIRE_SERVICE_VERSION, SendToLogfire, get_base_url_from_token, + }, internal::{ env::get_optional_env, exporters::console::{ConsoleWriter, create_console_processors}, @@ -158,6 +161,13 @@ impl Logfire { /// Called by `LogfireConfigBuilder::finish()`. pub(crate) fn from_config_builder( config: LogfireConfigBuilder, + ) -> Result { + Self::from_config_builder_and_env(config, None) + } + + fn from_config_builder_and_env( + config: LogfireConfigBuilder, + env: Option<&HashMap>, ) -> Result { let LogfireParts { local, @@ -170,7 +180,7 @@ impl Logfire { enable_tracing_metrics, shutdown_sender, .. - } = Self::build_parts(config, None)?; + } = Self::build_parts(config, env)?; if !local { // avoid otel logs firing as these messages are sent regarding "global meter provider" @@ -276,13 +286,7 @@ impl Logfire { } } - let send_to_logfire = match config.send_to_logfire { - Some(send_to_logfire) => send_to_logfire, - None => match get_optional_env("LOGFIRE_SEND_TO_LOGFIRE", env)? { - Some(value) => value.parse()?, - None => SendToLogfire::Yes, - }, - }; + let send_to_logfire = LOGFIRE_SEND_TO_LOGFIRE.resolve(config.send_to_logfire, env)?; let send_to_logfire = match send_to_logfire { SendToLogfire::Yes => true, @@ -302,34 +306,32 @@ impl Logfire { } // Add service-specific resources from config - let mut service_resource_builder = opentelemetry_sdk::Resource::builder_empty(); - let mut has_service_attributes = false; + let mut service_resource_builder = opentelemetry_sdk::Resource::builder(); - if let Some(service_name) = config.service_name { + if let Some(service_name) = LOGFIRE_SERVICE_NAME.resolve(config.service_name, env)? { service_resource_builder = service_resource_builder.with_service_name(service_name); - has_service_attributes = true; } - if let Some(service_version) = config.service_version { + if let Some(service_version) = + LOGFIRE_SERVICE_VERSION.resolve(config.service_version, env)? + { service_resource_builder = service_resource_builder.with_attribute( opentelemetry::KeyValue::new("service.version", service_version), ); - has_service_attributes = true; } - if let Some(environment) = config.environment { + if let Some(environment) = LOGFIRE_ENVIRONMENT.resolve(config.environment, env)? { service_resource_builder = service_resource_builder.with_attribute( opentelemetry::KeyValue::new("deployment.environment.name", environment), ); - has_service_attributes = true; } - if has_service_attributes { - let service_resource = service_resource_builder.build(); - advanced_options.resources.push(service_resource); - } - - for resource in advanced_options.resources { + // Use "default" resource first so that user-provided resources can override it + let service_resource = service_resource_builder.build(); + for resource in [service_resource] + .into_iter() + .chain(advanced_options.resources) + { tracer_provider_builder = tracer_provider_builder.with_resource(resource.clone()); logger_provider_builder = logger_provider_builder.with_resource(resource.clone()); meter_provider_builder = meter_provider_builder.with_resource(resource); diff --git a/src/test_utils.rs b/src/test_utils.rs index a3d0a58..3fc03aa 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -191,12 +191,18 @@ pub fn remap_timestamps_in_console_output(output: &str) -> Cow<'_, str> { } /// `Resource` contains a hashmap, so deterministic tests need to convert to an ordered container. -fn make_deterministic_resource(resource: &Resource) -> Vec { +pub fn make_deterministic_resource(resource: &Resource) -> Vec { let mut attrs: Vec<_> = resource .iter() .map(|(k, v)| KeyValue::new(k.clone(), v.clone())) .collect(); attrs.sort_by_key(|kv| kv.key.clone()); + for attr in &mut attrs { + // don't care about opentelemetry sdk version for tests + if attr.key.as_str() == "telemetry.sdk.version" { + attr.value = "0.0.0".into(); + } + } attrs } diff --git a/tests/test_basic_exports.rs b/tests/test_basic_exports.rs index f1fcfe5..a4afb09 100644 --- a/tests/test_basic_exports.rs +++ b/tests/test_basic_exports.rs @@ -1650,6 +1650,36 @@ async fn test_basic_metrics() { ), ), }, + KeyValue { + key: Static( + "telemetry.sdk.language", + ), + value: String( + Static( + "rust", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.name", + ), + value: String( + Static( + "opentelemetry", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.version", + ), + value: String( + Static( + "0.0.0", + ), + ), + }, ], scope_metrics: [ DeterministicScopeMetrics { @@ -1702,6 +1732,36 @@ async fn test_basic_metrics() { ), ), }, + KeyValue { + key: Static( + "telemetry.sdk.language", + ), + value: String( + Static( + "rust", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.name", + ), + value: String( + Static( + "opentelemetry", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.version", + ), + value: String( + Static( + "0.0.0", + ), + ), + }, ], scope_metrics: [ DeterministicScopeMetrics { diff --git a/tests/test_resource_attributes.rs b/tests/test_resource_attributes.rs new file mode 100644 index 0000000..99c47b3 --- /dev/null +++ b/tests/test_resource_attributes.rs @@ -0,0 +1,398 @@ +//! Tests for setting resource attributes. +//! +//! In separate tests because they modify environment variables so we can test the +//! interaction with the OTEL sdk. + +use insta::assert_debug_snapshot; +use logfire::{ + config::{AdvancedOptions, LogfireConfigBuilder}, + configure, +}; +use opentelemetry::KeyValue; +use opentelemetry_sdk::logs::{InMemoryLogExporter, SimpleLogProcessor}; + +use crate::test_utils::make_deterministic_resource; + +#[path = "../src/test_utils.rs"] +mod test_utils; + +/// Mutext to ensure tests that modify env vars are not run in parallel. +static TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn try_get_resource_attrs(config: LogfireConfigBuilder, env: &[(&str, &str)]) -> Vec { + let _lock = TEST_MUTEX.lock().unwrap(); + + for env in env { + // SAFETY: running in separate test with mutex lock + unsafe { + std::env::set_var(env.0, env.1); + } + } + + let exporter = InMemoryLogExporter::default(); + + let logfire = config + .local() + .send_to_logfire(false) + .with_advanced_options( + AdvancedOptions::default() + .with_log_processor(SimpleLogProcessor::new(exporter.clone())), + ) + .finish() + .expect("failed to configure logfire"); + + let guard = logfire::set_local_logfire(logfire); + + logfire::info!("test span"); + + guard.shutdown().expect("shutdown should succeed"); + + let mut logs = exporter.get_emitted_logs().unwrap(); + + assert_eq!(logs.len(), 1); + let log = logs.pop().unwrap(); + + for env in env { + // SAFETY: running in separate test with mutex lock + unsafe { + std::env::remove_var(env.0); + } + } + + make_deterministic_resource(&log.resource) +} + +#[test] +fn test_no_service_resource_attributes() { + let attrs = try_get_resource_attrs(configure(), &[]); + + assert_debug_snapshot!(attrs, @r#" + [ + KeyValue { + key: Static( + "service.name", + ), + value: String( + Static( + "unknown_service", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.language", + ), + value: String( + Static( + "rust", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.name", + ), + value: String( + Static( + "opentelemetry", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.version", + ), + value: String( + Static( + "0.0.0", + ), + ), + }, + ] + "#); +} + +#[test] +fn test_service_resource_attributes() { + let attrs = try_get_resource_attrs( + configure() + .with_service_name("test-service") + .with_service_version("1.2.3") + .with_environment("testing"), + &[("OTEL_RESOURCE_ATTRIBUTES", "key1=val1,key2=val2")], + ); + + assert_debug_snapshot!(attrs, @r#" + [ + KeyValue { + key: Static( + "deployment.environment.name", + ), + value: String( + Owned( + "testing", + ), + ), + }, + KeyValue { + key: Owned( + "key1", + ), + value: String( + Owned( + "val1", + ), + ), + }, + KeyValue { + key: Owned( + "key2", + ), + value: String( + Owned( + "val2", + ), + ), + }, + KeyValue { + key: Static( + "service.name", + ), + value: String( + Owned( + "test-service", + ), + ), + }, + KeyValue { + key: Static( + "service.version", + ), + value: String( + Owned( + "1.2.3", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.language", + ), + value: String( + Static( + "rust", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.name", + ), + value: String( + Static( + "opentelemetry", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.version", + ), + value: String( + Static( + "0.0.0", + ), + ), + }, + ] + "#); +} + +#[test] +fn test_service_resource_attributes_from_env() { + let attrs = try_get_resource_attrs( + configure(), + &[ + ("LOGFIRE_SERVICE_NAME", "env-service"), + ("LOGFIRE_SERVICE_VERSION", "4.5.6"), + ("LOGFIRE_ENVIRONMENT", "env-testing"), + // otel service vars should be ignored if logfire vars are present + ("OTEL_SERVICE_NAME", "otel-service"), + ("OTEL_SERVICE_VERSION", "7.8.9"), + // these should still apply + ("OTEL_RESOURCE_ATTRIBUTES", "key1=val1,key2=val2"), + ], + ); + + assert_debug_snapshot!(attrs, @r#" + [ + KeyValue { + key: Static( + "deployment.environment.name", + ), + value: String( + Owned( + "env-testing", + ), + ), + }, + KeyValue { + key: Owned( + "key1", + ), + value: String( + Owned( + "val1", + ), + ), + }, + KeyValue { + key: Owned( + "key2", + ), + value: String( + Owned( + "val2", + ), + ), + }, + KeyValue { + key: Static( + "service.name", + ), + value: String( + Owned( + "env-service", + ), + ), + }, + KeyValue { + key: Static( + "service.version", + ), + value: String( + Owned( + "4.5.6", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.language", + ), + value: String( + Static( + "rust", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.name", + ), + value: String( + Static( + "opentelemetry", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.version", + ), + value: String( + Static( + "0.0.0", + ), + ), + }, + ] + "#); +} + +#[test] +fn test_service_resource_attributes_from_otel_env() { + let attrs = try_get_resource_attrs( + configure(), + &[ + ("OTEL_SERVICE_NAME", "otel-service"), + ("OTEL_SERVICE_VERSION", "7.8.9"), + ("OTEL_RESOURCE_ATTRIBUTES", "key1=val1,key2=val2"), + ], + ); + + assert_debug_snapshot!(attrs, @r#" + [ + KeyValue { + key: Owned( + "key1", + ), + value: String( + Owned( + "val1", + ), + ), + }, + KeyValue { + key: Owned( + "key2", + ), + value: String( + Owned( + "val2", + ), + ), + }, + KeyValue { + key: Static( + "service.name", + ), + value: String( + Owned( + "otel-service", + ), + ), + }, + KeyValue { + key: Static( + "service.version", + ), + value: String( + Owned( + "7.8.9", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.language", + ), + value: String( + Static( + "rust", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.name", + ), + value: String( + Static( + "opentelemetry", + ), + ), + }, + KeyValue { + key: Static( + "telemetry.sdk.version", + ), + value: String( + Static( + "0.0.0", + ), + ), + }, + ] + "#); +}