Skip to main content

telemetry_rust/
otlp.rs

1//! OpenTelemetry Protocol (OTLP) configuration and initialization utilities.
2
3// Originally retired from davidB/tracing-opentelemetry-instrumentation-sdk
4// which is licensed under CC0 1.0 Universal
5// https://github.com/davidB/tracing-opentelemetry-instrumentation-sdk/blob/d3609ac2cc699d3a24fbf89754053cc8e938e3bf/LICENSE
6
7use opentelemetry_otlp::{
8    ExporterBuildError, Protocol, SpanExporter, WithExportConfig, WithHttpConfig,
9};
10use opentelemetry_sdk::{
11    Resource,
12    trace::{Sampler, SdkTracerProvider as TracerProvider, TracerProviderBuilder},
13};
14use std::{collections::HashMap, num::ParseIntError, str::FromStr, time::Duration};
15
16pub use crate::filter::read_tracing_level_from_env as read_otel_log_level_from_env;
17use crate::util;
18
19#[derive(Debug)]
20struct InferredExportConfig {
21    endpoint: Option<String>,
22    protocol: Protocol,
23    timeout: Option<Duration>,
24}
25
26trait WithExportConfigExt: WithExportConfig {
27    fn with_export_config(self, cfg: InferredExportConfig) -> Self;
28}
29
30impl<B: WithExportConfig> WithExportConfigExt for B {
31    fn with_export_config(self, cfg: InferredExportConfig) -> Self {
32        let this = self.with_protocol(cfg.protocol);
33        let this = match cfg.endpoint {
34            Some(endpoint) => this.with_endpoint(endpoint),
35            None => this,
36        };
37        match cfg.timeout {
38            Some(timeout) => this.with_timeout(timeout),
39            None => this,
40        }
41    }
42}
43
44/// Error types that can occur during OpenTelemetry tracer initialization.
45///
46/// This enum represents the various failure modes when setting up an OTLP
47/// tracer provider, including configuration errors and exporter build failures.
48#[derive(thiserror::Error, Debug)]
49pub enum InitTracerError {
50    /// An unsupported protocol was specified in environment variables.
51    ///
52    /// This error occurs when the `OTEL_EXPORTER_OTLP_PROTOCOL` or
53    /// `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` environment variable contains
54    /// a protocol that is not supported by this library.
55    #[error("unsupported protocol {0:?} form env")]
56    UnsupportedEnvProtocol(String),
57
58    /// An invalid timeout value was provided in environment variables.
59    ///
60    /// This error occurs when the timeout specified in `OTEL_EXPORTER_OTLP_TIMEOUT`
61    /// or `OTEL_EXPORTER_OTLP_TRACES_TIMEOUT` cannot be parsed as a valid integer.
62    #[error("invalid timeout {0:?} form env: {1}")]
63    InvalidEnvTimeout(String, #[source] ParseIntError),
64
65    /// An error occurred while building the OTLP exporter.
66    ///
67    /// This error wraps underlying exporter build errors that may occur during
68    /// the construction of the OTLP span exporter.
69    #[error(transparent)]
70    ExporterBuildError(#[from] ExporterBuildError),
71}
72
73/// Identity transformation function for tracer provider builders.
74///
75/// This function accepts a [`TracerProviderBuilder`] and returns it unchanged.
76/// It serves as a default transformation function when no custom configuration
77/// is needed during tracer provider initialization.
78///
79/// # Arguments
80///
81/// - `v`: The tracer provider builder to return unchanged
82///
83/// # Returns
84///
85/// The same tracer provider builder that was passed in
86///
87/// # Examples
88///
89/// ```rust
90/// use opentelemetry_sdk::Resource;
91/// use telemetry_rust::otlp::{identity, init_tracer};
92///
93/// let resource = Resource::builder().build();
94/// let tracer_provider = init_tracer(resource, identity).unwrap();
95/// ```
96#[must_use]
97pub fn identity(v: TracerProviderBuilder) -> TracerProviderBuilder {
98    v
99}
100
101/// Initializes an OpenTelemetry tracer provider with OTLP exporter configuration.
102///
103/// This function creates a fully configured tracer provider with an OTLP exporter
104/// that reads configuration from environment variables. It supports both HTTP and
105/// gRPC protocols and allows for custom transformation of the tracer provider builder.
106///
107/// # Environment Variables
108///
109/// The function reads configuration from the following environment variables:
110/// - `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` / `OTEL_EXPORTER_OTLP_ENDPOINT`: Exporter endpoint
111/// - `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` / `OTEL_EXPORTER_OTLP_PROTOCOL`: Protocol (grpc, http, http/protobuf)
112/// - `OTEL_EXPORTER_OTLP_TRACES_TIMEOUT` / `OTEL_EXPORTER_OTLP_TIMEOUT`: Timeout in milliseconds
113/// - `OTEL_EXPORTER_OTLP_HEADERS` / `OTEL_EXPORTER_OTLP_TRACES_HEADERS`: Additional headers
114/// - `OTEL_TRACES_SAMPLER`: Sampling strategy configuration
115/// - `OTEL_TRACES_SAMPLER_ARG`: Sampling rate for ratio-based samplers
116///
117/// # Arguments
118///
119/// - `resource`: OpenTelemetry resource containing service metadata
120/// - `transform`: Function to customize the tracer provider builder before building
121///
122/// # Returns
123///
124/// A configured [`TracerProvider`] on success, or an [`InitTracerError`] on failure
125///
126/// # Examples
127///
128/// ```rust
129/// use opentelemetry_sdk::Resource;
130/// use telemetry_rust::otlp::{identity, init_tracer};
131///
132/// let resource = Resource::builder().build();
133/// let tracer_provider = init_tracer(resource, identity)?;
134/// # Ok::<(), Box<dyn std::error::Error>>(())
135/// ```
136// see https://opentelemetry.io/docs/reference/specification/protocol/exporter/
137pub fn init_tracer<F>(
138    resource: Resource,
139    transform: F,
140) -> Result<TracerProvider, InitTracerError>
141where
142    F: FnOnce(TracerProviderBuilder) -> TracerProviderBuilder,
143{
144    let (maybe_protocol, maybe_endpoint, maybe_timeout) = read_export_config_from_env();
145    let export_config = infer_export_config(
146        maybe_protocol.as_deref(),
147        maybe_endpoint.as_deref(),
148        maybe_timeout.as_deref(),
149    )?;
150    tracing::debug!(target: "otel::setup", ?export_config);
151    let exporter: SpanExporter = match export_config.protocol {
152        Protocol::HttpBinary => SpanExporter::builder()
153            .with_http()
154            .with_headers(read_headers_from_env())
155            .with_export_config(export_config)
156            .build()?,
157        Protocol::Grpc => SpanExporter::builder()
158            .with_tonic()
159            .with_export_config(export_config)
160            .build()?,
161    };
162
163    let tracer_provider_builder = TracerProvider::builder()
164        .with_batch_exporter(exporter)
165        .with_resource(resource)
166        .with_sampler(read_sampler_from_env());
167
168    Ok(transform(tracer_provider_builder).build())
169}
170
171/// turn a string of "k1=v1,k2=v2,..." into an iterator of (key, value) tuples
172fn parse_headers(val: &str) -> impl Iterator<Item = (String, String)> + '_ {
173    val.split(',').filter_map(|kv| {
174        kv.split_once('=')
175            .map(|(k, v)| (k.to_owned(), v.to_owned()))
176    })
177}
178fn read_headers_from_env() -> HashMap<String, String> {
179    let mut headers = HashMap::new();
180    headers.extend(parse_headers(
181        &util::env_var("OTEL_EXPORTER_OTLP_HEADERS").unwrap_or_default(),
182    ));
183    headers.extend(parse_headers(
184        &util::env_var("OTEL_EXPORTER_OTLP_TRACES_HEADERS").unwrap_or_default(),
185    ));
186    headers
187}
188fn read_export_config_from_env() -> (Option<String>, Option<String>, Option<String>) {
189    let maybe_endpoint = util::env_var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
190        .or_else(|| util::env_var("OTEL_EXPORTER_OTLP_ENDPOINT"));
191    let maybe_protocol = util::env_var("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL")
192        .or_else(|| util::env_var("OTEL_EXPORTER_OTLP_PROTOCOL"));
193    let maybe_timeout = util::env_var("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT")
194        .or_else(|| util::env_var("OTEL_EXPORTER_OTLP_TIMEOUT"));
195    (maybe_protocol, maybe_endpoint, maybe_timeout)
196}
197
198/// see <https://opentelemetry.io/docs/reference/specification/sdk-environment-variables/#general-sdk-configuration>
199/// TODO log error and infered sampler
200fn read_sampler_from_env() -> Sampler {
201    let mut name = util::env_var("OTEL_TRACES_SAMPLER")
202        .unwrap_or_default()
203        .to_lowercase();
204    let v = match name.as_str() {
205        "always_on" => Sampler::AlwaysOn,
206        "always_off" => Sampler::AlwaysOff,
207        "traceidratio" => Sampler::TraceIdRatioBased(read_sampler_arg_from_env(1f64)),
208        "parentbased_always_on" => Sampler::ParentBased(Box::new(Sampler::AlwaysOn)),
209        "parentbased_always_off" => Sampler::ParentBased(Box::new(Sampler::AlwaysOff)),
210        "parentbased_traceidratio" => Sampler::ParentBased(Box::new(
211            Sampler::TraceIdRatioBased(read_sampler_arg_from_env(1f64)),
212        )),
213        "jaeger_remote" => todo!("unsupported: OTEL_TRACES_SAMPLER='jaeger_remote'"),
214        "xray" => todo!("unsupported: OTEL_TRACES_SAMPLER='xray'"),
215        _ => {
216            name = "parentbased_always_on".to_string();
217            Sampler::ParentBased(Box::new(Sampler::AlwaysOn))
218        }
219    };
220    tracing::debug!(target: "otel::setup", OTEL_TRACES_SAMPLER = ?name);
221    v
222}
223
224fn read_sampler_arg_from_env<T>(default: T) -> T
225where
226    T: FromStr + Copy + std::fmt::Debug,
227{
228    //TODO Log for invalid value (how to log)
229    let v = util::env_var("OTEL_TRACES_SAMPLER_ARG")
230        .map_or(default, |s| T::from_str(&s).unwrap_or(default));
231    tracing::debug!(target: "otel::setup", OTEL_TRACES_SAMPLER_ARG = ?v);
232    v
233}
234
235fn infer_export_config(
236    maybe_protocol: Option<&str>,
237    maybe_endpoint: Option<&str>,
238    maybe_timeout: Option<&str>,
239) -> Result<InferredExportConfig, InitTracerError> {
240    let protocol = match maybe_protocol {
241        Some("grpc") => Protocol::Grpc,
242        Some("http") | Some("http/protobuf") => Protocol::HttpBinary,
243        Some(other) => {
244            return Err(InitTracerError::UnsupportedEnvProtocol(other.to_owned()));
245        }
246        None => match maybe_endpoint {
247            Some(e) if e.contains(":4317") => Protocol::Grpc,
248            _ => Protocol::HttpBinary,
249        },
250    };
251
252    let timeout = maybe_timeout
253        .map(|millis| {
254            millis
255                .parse::<u64>()
256                .map_err(|err| InitTracerError::InvalidEnvTimeout(millis.to_owned(), err))
257        })
258        .transpose()?
259        .map(Duration::from_millis);
260
261    Ok(InferredExportConfig {
262        endpoint: maybe_endpoint.map(ToOwned::to_owned),
263        protocol,
264        timeout,
265    })
266}
267
268#[cfg(test)]
269mod tests {
270    use assert2::assert;
271    use rstest::rstest;
272
273    use super::*;
274    use Protocol::*;
275
276    #[rstest]
277    #[case(None, None, None, HttpBinary, None, None)]
278    #[case(Some("http/protobuf"), None, None, HttpBinary, None, None)]
279    #[case(Some("http"), None, None, HttpBinary, None, None)]
280    #[case(Some("grpc"), None, None, Grpc, None, None)]
281    #[case(
282        None,
283        Some("http://localhost:4317"),
284        None,
285        Grpc,
286        Some("http://localhost:4317"),
287        None
288    )]
289    #[case(
290        Some("http/protobuf"),
291        Some("http://localhost:4318"),
292        None,
293        HttpBinary,
294        Some("http://localhost:4318"),
295        None
296    )]
297    #[case(
298        Some("http/protobuf"),
299        Some("https://examples.com:4318"),
300        None,
301        HttpBinary,
302        Some("https://examples.com:4318"),
303        None
304    )]
305    #[case(
306        Some("http/protobuf"),
307        Some("https://examples.com:4317"),
308        Some("12345"),
309        HttpBinary,
310        Some("https://examples.com:4317"),
311        Some(Duration::from_millis(12345))
312    )]
313    fn test_infer_export_config(
314        #[case] traces_protocol: Option<&str>,
315        #[case] traces_endpoint: Option<&str>,
316        #[case] traces_timeout: Option<&str>,
317        #[case] expected_protocol: Protocol,
318        #[case] expected_endpoint: Option<&str>,
319        #[case] expected_timeout: Option<Duration>,
320    ) {
321        let InferredExportConfig {
322            protocol,
323            endpoint,
324            timeout,
325        } = infer_export_config(traces_protocol, traces_endpoint, traces_timeout)
326            .unwrap();
327
328        assert!(protocol == expected_protocol);
329        assert!(endpoint.as_deref() == expected_endpoint);
330        assert!(timeout == expected_timeout);
331    }
332
333    #[rstest]
334    #[case(Some("tonic"), None, r#"unsupported protocol "tonic" form env"#)]
335    #[case(
336        Some("http/protobuf"),
337        Some("-1"),
338        r#"invalid timeout "-1" form env: invalid digit found in string"#
339    )]
340    fn test_infer_export_config_error(
341        #[case] traces_protocol: Option<&str>,
342        #[case] traces_timeout: Option<&str>,
343        #[case] expected_error: &str,
344    ) {
345        let result = infer_export_config(traces_protocol, None, traces_timeout);
346
347        assert!(let Err(err) = result);
348
349        assert!(format!("{}", err) == expected_error);
350    }
351}