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