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..3fc1927536 --- /dev/null +++ b/examples/env-var-propagation/README.md @@ -0,0 +1,35 @@ +# 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. The child inherits + the rest of the parent's environment through `std::process::Command`. +3. The child process builds an `EnvVarExtractor` from the active propagator's + fields, 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..6cdda2c43f --- /dev/null +++ b/examples/env-var-propagation/src/main.rs @@ -0,0 +1,93 @@ +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 { + // Parent and child both use W3C Trace Context when encoding env vars. + 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() + ); + + // Inject into a fresh environment map. `Command` inherits the parent + // environment by default, and `envs` adds the propagated context variables. + let mut child_env = EnvVarInjector::new(); + global::get_text_map_propagator(|propagator| propagator.inject_context(&cx, &mut child_env)); + + // Re-exec the current binary in child mode to keep the example self-contained. + let status = Command::new(env::current_exe()?) + .arg("--child") + .envs(child_env) + .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 parent_cx = global::get_text_map_propagator(|propagator| { + // Read only the environment variables used by the active propagator. + let extractor = EnvVarExtractor::from_fields(propagator.fields()); + 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(); + // The parent launches the same executable with `--child` to demonstrate + // propagation across a real process boundary. + 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..4d8d5799ed --- /dev/null +++ b/opentelemetry/src/propagation/env.rs @@ -0,0 +1,412 @@ +//! 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` owns caller-provided environment entries and implements +/// [`Extractor`] with the normalization rules from the OpenTelemetry +/// environment-variable carrier specification. Only keys that are already +/// normalized are retained, `get()` normalizes the requested propagation key +/// before lookup, and `keys()` returns the retained names. +/// +/// The extractor stores environment values because [`Extractor::get`] must +/// return `&str`, while [`std::env::var_os`] returns owned values. Storing values +/// gives the carrier stable owned storage to borrow from and makes +/// [`Extractor::keys`] operate over a consistent view instead of repeatedly +/// reading process-global state. +/// +/// Most callers should pass the active propagator's fields to +/// [`EnvVarExtractor::from_fields`] at child-process startup so only known +/// propagation variables are read from the environment. Use +/// [`EnvVarExtractor::from_os_entries`] when a propagator needs +/// [`Extractor::keys`] to see the whole environment, such as legacy propagators +/// that scan carrier keys by prefix. +/// +/// 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() + } + + /// Builds an extractor by reading the normalized form of each provided field + /// from the current process environment. + /// + /// This is the recommended constructor when extracting with a known + /// [`crate::propagation::TextMapPropagator`]. It avoids enumerating the whole + /// environment for propagators that only call [`Extractor::get`] for their + /// advertised fields. Any value that is not valid UTF-8 is ignored. + pub fn from_fields<'a, I>(fields: I) -> Self + where + I: IntoIterator, + { + Self { + env: collect_fields(fields), + } + } + + /// Builds an extractor from the provided UTF-8 environment entries. + /// + /// Only entries whose names are already normalized are stored. `get()` still + /// reads only the normalized form of a propagation key, and `keys()` returns + /// the retained names. + /// + /// Lookup in this snapshot is case-sensitive on all platforms. This can + /// differ from [`EnvVarExtractor::from_fields`] on platforms such as Windows, + /// where reading a normalized name from the process environment may match an + /// environment variable whose name differs only by case. + pub fn from_entries(iter: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + Self { + env: collect_entries(iter), + } + } + + /// Builds an extractor from OS-string environment entries. + /// + /// Any entry whose name or value is not valid UTF-8 is ignored. Entries + /// whose names are not already normalized are also ignored. This scans the + /// provided entries, so prefer [`EnvVarExtractor::from_fields`] unless a + /// propagator needs [`Extractor::keys`] to see the whole environment. + /// + /// Lookup in this snapshot is case-sensitive on all platforms. This can + /// differ from [`EnvVarExtractor::from_fields`] on platforms such as Windows, + /// where reading a normalized name from the process environment may match an + /// environment variable whose name differs only by case. + pub fn from_os_entries(iter: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + Self { + env: collect_os_entries(iter), + } + } +} + +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` owns 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. +#[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() + } +} + +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() + } +} + +fn collect_entries(iter: I) -> HashMap +where + I: IntoIterator, + K: Into, + V: Into, +{ + iter.into_iter() + .filter_map(|(key, value)| { + let key = key.into(); + is_normalized_env_var_name(&key).then(|| (key, value.into())) + }) + .collect() +} + +fn collect_fields<'a, I>(fields: I) -> HashMap +where + I: IntoIterator, +{ + fields + .into_iter() + .filter_map(|field| { + let normalized = normalize_env_var_key(field); + std::env::var_os(normalized.as_ref()).and_then(|value| { + value + .into_string() + .ok() + .map(|value| (normalized.into_owned(), value)) + }) + }) + .collect() +} + +fn collect_os_entries(iter: I) -> HashMap +where + I: IntoIterator, + K: AsRef, + V: AsRef, +{ + iter.into_iter() + .filter_map(|(key, value)| { + let key = key.as_ref().to_str()?; + let value = value.as_ref().to_str()?; + is_normalized_env_var_name(key).then(|| (key.to_string(), value.to_string())) + }) + .collect() +} + +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::{HashMap, 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_from_fields_reads_only_requested_environment_names() { + with_vars( + vec![ + ("TRACEPARENT", Some("normalized")), + ("TRACESTATE", Some("state")), + ("BAGGAGE", Some("not-requested")), + ], + || { + let extractor = + EnvVarExtractor::from_fields(["traceparent", "tracestate", "missing"]); + + assert_eq!( + Extractor::get(&extractor, "traceparent"), + Some("normalized") + ); + assert_eq!(Extractor::get(&extractor, "tracestate"), Some("state")); + assert_eq!(Extractor::get(&extractor, "baggage"), None); + + let keys = Extractor::keys(&extractor) + .into_iter() + .collect::>(); + assert_eq!(keys, HashSet::from(["TRACEPARENT", "TRACESTATE"])); + }, + ); + } + + #[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_os_entries_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_os_entries(std::env::vars_os()); + + assert_eq!( + Extractor::get(&extractor, "OTEL_ENV_VAR_EXTRACTOR_TEST"), + Some("value") + ); + assert_eq!( + Extractor::get(&extractor, "otel.env.var.extractor.other"), + None + ); + + let keys = Extractor::keys(&extractor) + .into_iter() + .collect::>(); + assert!( + keys.contains("OTEL_ENV_VAR_EXTRACTOR_TEST"), + "normalized keys should be visible" + ); + assert!( + !keys.contains("otel.env.var.extractor.other"), + "non-normalized keys should be hidden" + ); + }, + ); + } + + #[test] + fn injector_normalizes_inserted_names() { + let mut injector = EnvVarInjector::new(); + 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: HashMap<_, _> = injector.into_iter().collect(); + + 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_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: HashMap<_, _> = injector.into_iter().collect(); + 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`