diff --git a/examples/env-var-propagation/Cargo.toml b/examples/env-var-propagation/Cargo.toml new file mode 100644 index 0000000000..d6e8104eac --- /dev/null +++ b/examples/env-var-propagation/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "env-var-propagation" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +rust-version = "1.75.0" +publish = false +autobenches = false + +[[bin]] +name = "env-var-propagation" +path = "src/main.rs" +doc = false +bench = false + +[dependencies] +opentelemetry = { workspace = true, features = ["trace", "otel_unstable"] } +opentelemetry_sdk = { workspace = true, features = ["trace"] } +opentelemetry-stdout = { workspace = true, features = ["trace"] } diff --git a/examples/env-var-propagation/README.md b/examples/env-var-propagation/README.md new file mode 100644 index 0000000000..be70492d72 --- /dev/null +++ b/examples/env-var-propagation/README.md @@ -0,0 +1,34 @@ +# Environment Variable Propagation Example + +This example demonstrates distributed tracing across a parent process and a +child process using environment variables as the propagation carrier. + +## What This Example Does + +1. The parent process configures the W3C `TraceContextPropagator` and starts a + root span. +2. The parent injects that span context into an `EnvVarInjector`, then passes + the resulting environment variables to a child process. +3. The child process captures its startup environment with `EnvVarExtractor`, + extracts the propagated context, and starts its own span as a child of the + parent's span. + +The example prints the parent's `TraceId` and `SpanId`, then prints the child's +`TraceId` and remote parent `SpanId` so you can see that the trace was +propagated across the process boundary. + +## Usage + +From `examples/env-var-propagation`, run: + +```shell +cargo run +``` + +The output should show: + +- the parent and child spans sharing the same `TraceId` +- the child's reported `parent_span_id` matching the parent's `span_id` + +Both processes also export their spans to stdout using the OpenTelemetry stdout +exporter. diff --git a/examples/env-var-propagation/src/main.rs b/examples/env-var-propagation/src/main.rs new file mode 100644 index 0000000000..324c606d26 --- /dev/null +++ b/examples/env-var-propagation/src/main.rs @@ -0,0 +1,84 @@ +use opentelemetry::{ + global, + propagation::{EnvVarExtractor, EnvVarInjector}, + trace::{Span, TraceContextExt, Tracer}, + Context, +}; +use opentelemetry_sdk::{propagation::TraceContextPropagator, trace::SdkTracerProvider}; +use opentelemetry_stdout::SpanExporter; +use std::{env, error::Error, io, process::Command}; + +fn init_tracer() -> SdkTracerProvider { + global::set_text_map_propagator(TraceContextPropagator::new()); + + let provider = SdkTracerProvider::builder() + .with_simple_exporter(SpanExporter::default()) + .build(); + global::set_tracer_provider(provider.clone()); + + provider +} + +fn run_parent() -> Result<(), Box> { + let tracer = global::tracer("examples/env-var-propagation/parent"); + let span = tracer.start("parent"); + let span_context = span.span_context().clone(); + let cx = Context::current_with_span(span); + + println!( + "parent trace_id={} span_id={}", + span_context.trace_id(), + span_context.span_id() + ); + + let mut child_env = EnvVarInjector::new(); + global::get_text_map_propagator(|propagator| propagator.inject_context(&cx, &mut child_env)); + + let status = Command::new(env::current_exe()?) + .arg("--child") + .envs(child_env.into_inner()) + .status()?; + + cx.span().end(); + + if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!("child process failed with status {status}")).into()) + } +} + +fn run_child() -> Result<(), Box> { + let extractor = EnvVarExtractor::from_env(); + let parent_cx = global::get_text_map_propagator(|propagator| propagator.extract(&extractor)); + let remote_parent = parent_cx.span().span_context().clone(); + + if !remote_parent.is_valid() { + return Err(io::Error::other("missing propagated span context").into()); + } + + let tracer = global::tracer("examples/env-var-propagation/child"); + let mut span = tracer.start_with_context("child", &parent_cx); + let span_context = span.span_context().clone(); + + println!( + "child trace_id={} parent_span_id={}", + span_context.trace_id(), + remote_parent.span_id() + ); + + span.end(); + + Ok(()) +} + +fn main() -> Result<(), Box> { + let provider = init_tracer(); + let result = match env::args().nth(1).as_deref() { + Some("--child") => run_child(), + _ => run_parent(), + }; + + provider.shutdown()?; + result +} diff --git a/opentelemetry/CHANGELOG.md b/opentelemetry/CHANGELOG.md index 05511dac4d..3c71dc443e 100644 --- a/opentelemetry/CHANGELOG.md +++ b/opentelemetry/CHANGELOG.md @@ -2,6 +2,9 @@ ## vNext +- **Added** experimental `EnvVarExtractor` and `EnvVarInjector` helpers for + propagating OpenTelemetry context through environment variables. This API is + gated behind the `otel_unstable` feature flag. - `otel_info!`, `otel_warn!`, `otel_debug!`, and `otel_error!` macros now accept quoted-key fields (e.g. `"otel.component.type" = "value"`) for dotted attribute names. - **Added** `BoundGauge` and `BoundUpDownCounter` types (and the diff --git a/opentelemetry/Cargo.toml b/opentelemetry/Cargo.toml index 5efba0cf2a..edb80c7bcf 100644 --- a/opentelemetry/Cargo.toml +++ b/opentelemetry/Cargo.toml @@ -39,12 +39,14 @@ metrics = [] testing = ["trace"] logs = [] internal-logs = ["tracing"] +otel_unstable = [] experimental_metrics_bound_instruments = ["metrics"] [dev-dependencies] opentelemetry_sdk = { path = "../opentelemetry-sdk"} # for documentation tests criterion = { workspace = true } rand = { workspace = true, features = ["os_rng", "thread_rng"] } +temp-env = { workspace = true } tokio = { version = "1.0", features = ["full"] } [[bench]] diff --git a/opentelemetry/README.md b/opentelemetry/README.md index ad0912b19e..4da9054cd6 100644 --- a/opentelemetry/README.md +++ b/opentelemetry/README.md @@ -129,6 +129,13 @@ additional exporters and other related components as well. See [docs](https://docs.rs/opentelemetry). +Experimental environment-variable propagation helpers are available behind the +`otel_unstable` feature flag via +`opentelemetry::propagation::EnvVarExtractor` and +`opentelemetry::propagation::EnvVarInjector`. See +[`examples/env-var-propagation`](https://github.com/open-telemetry/opentelemetry-rust/tree/main/examples/env-var-propagation) +for an end-to-end parent/child process example using `TraceContextPropagator`. + ## Release Notes You can find the release notes (changelog) [here](https://github.com/open-telemetry/opentelemetry-rust/blob/main/opentelemetry/CHANGELOG.md). diff --git a/opentelemetry/src/lib.rs b/opentelemetry/src/lib.rs index 0b77c9d373..7e3d9bb080 100644 --- a/opentelemetry/src/lib.rs +++ b/opentelemetry/src/lib.rs @@ -39,7 +39,9 @@ //! which demonstrates creating spans and propagating trace context across a //! gRPC client and server. See the //! [examples](https://github.com/open-telemetry/opentelemetry-rust/tree/main/examples) -//! directory for additional integration patterns. +//! directory for additional integration patterns, including +//! [`examples/env-var-propagation`](https://github.com/open-telemetry/opentelemetry-rust/tree/main/examples/env-var-propagation) +//! for parent/child process propagation via environment variables. //! //! See the [`trace`] module docs for more information on creating and managing //! spans. @@ -173,7 +175,9 @@ //! //! //! The following feature flags enable APIs defined in OpenTelemetry specification that is in experimental phase: -//! * `otel_unstable`: Includes unstable APIs. There are no features behind this flag at the moment. +//! * `otel_unstable`: Includes unstable APIs such as +//! `opentelemetry::propagation::EnvVarExtractor` and +//! `opentelemetry::propagation::EnvVarInjector` for environment-variable context propagation. //! //! # Related Crates //! diff --git a/opentelemetry/src/propagation/env.rs b/opentelemetry/src/propagation/env.rs new file mode 100644 index 0000000000..d44924990b --- /dev/null +++ b/opentelemetry/src/propagation/env.rs @@ -0,0 +1,385 @@ +//! Experimental environment-variable propagation carriers. + +use crate::propagation::{Extractor, Injector}; +use std::{borrow::Cow, collections::HashMap, ffi::OsStr}; + +/// Experimental extractor for propagated context stored in environment variables. +/// +/// `EnvVarExtractor` captures a UTF-8 snapshot of the process environment and +/// implements [`Extractor`] with the normalization rules from the +/// OpenTelemetry environment-variable carrier specification. Keys are stored as +/// they appear in the source environment, `get()` normalizes the requested +/// propagation key before lookup, and `keys()` returns only names that are +/// already normalized. +/// +/// `from_env()` is intended for initialization-time extraction, such as child +/// process startup. Any environment variable name or value that is not valid +/// UTF-8 is ignored when building the snapshot. +/// +/// Environment variables are visible to other code running in the process and +/// may be visible to other users or processes with sufficient permissions, so +/// they are not suitable for sensitive data. +#[cfg_attr(docsrs, doc(cfg(feature = "otel_unstable")))] +#[derive(Clone, Debug, Default)] +pub struct EnvVarExtractor { + env: HashMap, +} + +impl EnvVarExtractor { + /// Creates an empty environment-variable extractor. + pub fn new() -> Self { + Self::default() + } + + /// Captures a UTF-8 snapshot of the current process environment. + /// + /// Non-UTF-8 environment variable names or values are ignored. + pub fn from_env() -> Self { + Self::from_os_iter(std::env::vars_os()) + } + + /// Builds an extractor from the provided UTF-8 environment entries. + /// + /// Entries are stored exactly as provided. `get()` still reads only the + /// normalized form of a propagation key, and `keys()` still returns only + /// already-normalized names. + pub fn from_entries(iter: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + Self { + env: iter + .into_iter() + .map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string())) + .collect(), + } + } + + fn from_os_iter(iter: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + Self { + env: iter + .into_iter() + .filter_map(|(key, value)| { + Some(( + key.as_ref().to_str()?.to_string(), + value.as_ref().to_str()?.to_string(), + )) + }) + .collect(), + } + } +} + +impl Extractor for EnvVarExtractor { + fn get(&self, key: &str) -> Option<&str> { + let normalized = normalize_env_var_key(key); + self.env.get(normalized.as_ref()).map(String::as_str) + } + + fn keys(&self) -> Vec<&str> { + self.env + .keys() + .filter(|key| is_normalized_env_var_name(key)) + .map(String::as_str) + .collect() + } +} + +/// Experimental injector for child-process environment-variable propagation. +/// +/// `EnvVarInjector` collects UTF-8 environment entries and implements +/// [`Injector`] by normalizing each propagation key before storing it. This +/// makes it suitable for passing to process-spawning APIs such as +/// [`std::process::Command::envs`], while leaving the parent process +/// environment untouched. +/// +/// Most callers can start with [`EnvVarInjector::new`] and let +/// [`std::process::Command`] inherit the rest of the parent environment by +/// default. Use [`EnvVarInjector::from_env`] when you need an explicit UTF-8 +/// copy of the current process environment. +#[cfg_attr(docsrs, doc(cfg(feature = "otel_unstable")))] +#[derive(Clone, Debug, Default)] +pub struct EnvVarInjector { + env: HashMap, +} + +impl EnvVarInjector { + /// Creates an empty environment-variable injector. + pub fn new() -> Self { + Self::default() + } + + /// Captures a UTF-8 copy of the current process environment. + /// + /// Non-UTF-8 environment variable names or values are ignored. + pub fn from_env() -> Self { + Self::from_os_iter(std::env::vars_os()) + } + + /// Builds an injector from the provided UTF-8 environment entries. + /// + /// Existing entries are copied exactly as provided. Calls to [`Injector::set`] + /// normalize only the propagation keys added through this injector API. + pub fn from_entries(iter: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + Self { + env: iter + .into_iter() + .map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string())) + .collect(), + } + } + + /// Consumes the injector and returns the underlying environment-variable map. + pub fn into_inner(self) -> HashMap { + self.env + } + + fn from_os_iter(iter: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + Self { + env: iter + .into_iter() + .filter_map(|(key, value)| { + Some(( + key.as_ref().to_str()?.to_string(), + value.as_ref().to_str()?.to_string(), + )) + }) + .collect(), + } + } +} + +impl Injector for EnvVarInjector { + fn set(&mut self, key: &str, value: String) { + self.env + .insert(normalize_env_var_key(key).into_owned(), value); + } + + fn reserve(&mut self, additional: usize) { + self.env.reserve(additional); + } +} + +impl IntoIterator for EnvVarInjector { + type Item = (String, String); + type IntoIter = std::collections::hash_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.env.into_iter() + } +} + +impl<'a> IntoIterator for &'a EnvVarInjector { + type Item = (&'a String, &'a String); + type IntoIter = std::collections::hash_map::Iter<'a, String, String>; + + fn into_iter(self) -> Self::IntoIter { + self.env.iter() + } +} + +fn normalize_env_var_key(key: &str) -> Cow<'_, str> { + if is_normalized_env_var_name(key) { + return Cow::Borrowed(key); + } + + if key.is_empty() { + return Cow::Borrowed("_"); + } + + let mut normalized = String::with_capacity( + key.len() + usize::from(key.as_bytes().first().is_some_and(u8::is_ascii_digit)), + ); + + if key.as_bytes().first().is_some_and(u8::is_ascii_digit) { + normalized.push('_'); + } + + for ch in key.chars() { + normalized.push(match ch { + 'a'..='z' => ch.to_ascii_uppercase(), + 'A'..='Z' | '0'..='9' | '_' => ch, + _ => '_', + }); + } + + Cow::Owned(normalized) +} + +fn is_normalized_env_var_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + + if !(first.is_ascii_uppercase() || first == '_') { + return false; + } + + chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::propagation::{Extractor, Injector}; + use std::collections::HashSet; + use temp_env::with_vars; + + #[test] + fn normalize_env_var_key_matches_spec() { + assert_eq!(normalize_env_var_key("traceparent"), "TRACEPARENT"); + assert_eq!(normalize_env_var_key("tracestate"), "TRACESTATE"); + assert_eq!(normalize_env_var_key("x-b3-traceid"), "X_B3_TRACEID"); + assert_eq!(normalize_env_var_key("3trace"), "_3TRACE"); + assert_eq!(normalize_env_var_key(""), "_"); + assert_eq!(normalize_env_var_key("héllo.world"), "H_LLO_WORLD"); + } + + #[test] + fn normalized_env_var_detection_matches_spec() { + assert!(is_normalized_env_var_name("TRACEPARENT")); + assert!(is_normalized_env_var_name("_3TRACE")); + assert!(is_normalized_env_var_name("TRACE_STATE_2")); + assert!(!is_normalized_env_var_name("")); + assert!(!is_normalized_env_var_name("traceparent")); + assert!(!is_normalized_env_var_name("3TRACE")); + assert!(!is_normalized_env_var_name("TRACE-STATE")); + } + + #[test] + fn extractor_reads_only_normalized_names() { + let extractor = EnvVarExtractor::from_entries([ + ("TRACEPARENT", "normalized"), + ("traceparent", "ignored"), + ("x-b3-traceid", "ignored"), + ("X_B3_TRACEID", "normalized-b3"), + ]); + + assert_eq!( + Extractor::get(&extractor, "traceparent"), + Some("normalized") + ); + assert_eq!( + Extractor::get(&extractor, "x-b3-traceid"), + Some("normalized-b3") + ); + } + + #[test] + fn extractor_keys_return_only_normalized_names() { + let extractor = EnvVarExtractor::from_entries([ + ("TRACEPARENT", "value"), + ("traceparent", "ignored"), + ("TRACESTATE", "value"), + ("baggage", "ignored"), + ]); + + let keys = Extractor::keys(&extractor) + .into_iter() + .collect::>(); + + assert_eq!(keys, HashSet::from(["TRACEPARENT", "TRACESTATE"])); + } + + #[test] + fn extractor_from_env_ignores_non_normalized_entries() { + with_vars( + vec![ + ("OTEL_ENV_VAR_EXTRACTOR_TEST", Some("value")), + ("otel.env.var.extractor.other", Some("ignored")), + ], + || { + let extractor = EnvVarExtractor::from_env(); + + assert_eq!( + Extractor::get(&extractor, "OTEL_ENV_VAR_EXTRACTOR_TEST"), + Some("value") + ); + assert_eq!( + Extractor::get(&extractor, "otel.env.var.extractor.other"), + None + ); + assert!( + Extractor::keys(&extractor).contains(&"OTEL_ENV_VAR_EXTRACTOR_TEST"), + "normalized keys should be visible" + ); + assert!( + !Extractor::keys(&extractor).contains(&"otel.env.var.extractor.other"), + "non-normalized keys should be hidden" + ); + }, + ); + } + + #[test] + fn injector_normalizes_inserted_names() { + let mut injector = EnvVarInjector::from_entries([("PATH", "/bin"), ("traceparent", "old")]); + Injector::reserve(&mut injector, 2); + Injector::set(&mut injector, "x-b3-traceid", "trace-id".to_string()); + Injector::set(&mut injector, "3trace", "prefixed".to_string()); + + let env = injector.into_inner(); + + assert_eq!(env.get("PATH").map(String::as_str), Some("/bin")); + assert_eq!(env.get("traceparent").map(String::as_str), Some("old")); + assert_eq!( + env.get("X_B3_TRACEID").map(String::as_str), + Some("trace-id") + ); + assert_eq!(env.get("_3TRACE").map(String::as_str), Some("prefixed")); + } + + #[test] + fn injector_from_env_preserves_utf8_entries() { + with_vars(vec![("OTEL_ENV_VAR_INJECTOR_TEST", Some("value"))], || { + let env = EnvVarInjector::from_env().into_inner(); + + assert_eq!( + env.get("OTEL_ENV_VAR_INJECTOR_TEST").map(String::as_str), + Some("value") + ); + }); + } + + #[test] + fn injector_and_extractor_round_trip_propagation_keys() { + let mut injector = EnvVarInjector::new(); + + Injector::set( + &mut injector, + "traceparent", + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(), + ); + Injector::set(&mut injector, "tracestate", "foo=bar".to_string()); + + let env = injector.into_inner(); + assert!(env.contains_key("TRACEPARENT")); + assert!(env.contains_key("TRACESTATE")); + + let extractor = EnvVarExtractor::from_entries(env); + + assert_eq!( + Extractor::get(&extractor, "traceparent"), + Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + ); + assert_eq!(Extractor::get(&extractor, "tracestate"), Some("foo=bar")); + } +} diff --git a/opentelemetry/src/propagation/mod.rs b/opentelemetry/src/propagation/mod.rs index 7848ffe2a8..fcd09268d1 100644 --- a/opentelemetry/src/propagation/mod.rs +++ b/opentelemetry/src/propagation/mod.rs @@ -9,6 +9,7 @@ //! //! Currently, the following `Propagator` types are supported: //! - [`TextMapPropagator`], inject values into and extracts values from carriers as string key/value pairs +//! - Experimental environment-variable carriers behind the `otel_unstable` feature flag //! //! A binary Propagator type will be added in //! the future, See [tracking issues](https://github.com/open-telemetry/opentelemetry-specification/issues/437)). @@ -22,9 +23,15 @@ use std::collections::HashMap; pub mod composite; +#[cfg(feature = "otel_unstable")] +#[cfg_attr(docsrs, doc(cfg(feature = "otel_unstable")))] +pub mod env; pub mod text_map_propagator; pub use composite::TextMapCompositePropagator; +#[cfg(feature = "otel_unstable")] +#[cfg_attr(docsrs, doc(cfg(feature = "otel_unstable")))] +pub use env::{EnvVarExtractor, EnvVarInjector}; pub use text_map_propagator::TextMapPropagator; /// Injector provides an interface for adding fields from an underlying struct like `HashMap`