wasmcloud_core/
otel.rs

1//! Reusable logic around [OpenTelemetry ("OTEL")][otel] support
2//!
3//! [otel]: https://opentelemetry.io
4
5use std::{path::PathBuf, str::FromStr};
6
7use anyhow::bail;
8use serde::{Deserialize, Serialize};
9use url::Url;
10
11use crate::{logging::Level, wit::WitMap};
12
13/// Configuration values for OpenTelemetry
14#[derive(Clone, Debug, Default, Deserialize, Serialize)]
15pub struct OtelConfig {
16    /// Determine whether observability should be enabled.
17    #[serde(default)]
18    pub enable_observability: bool,
19    /// Determine whether traces should be enabled.
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub enable_traces: Option<bool>,
22    /// Determine whether metrics should be enabled.
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub enable_metrics: Option<bool>,
25    /// Determine whether logs should be enabled.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub enable_logs: Option<bool>,
28    /// Overrides the OpenTelemetry endpoint for all signals.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub observability_endpoint: Option<String>,
31    /// Overrides the OpenTelemetry endpoint for traces.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub traces_endpoint: Option<String>,
34    /// Overrides the OpenTelemetry endpoint for metrics.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub metrics_endpoint: Option<String>,
37    /// Overrides the OpenTelemetry endpoint for logs.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub logs_endpoint: Option<String>,
40    /// Determines whether http or grpc will be used for exporting the telemetry.
41    #[serde(default)]
42    pub protocol: OtelProtocol,
43    /// Additional CAs to include in the OpenTelemetry client configuration
44    #[serde(default)]
45    pub additional_ca_paths: Vec<PathBuf>,
46    /// The level of tracing to enable.
47    #[serde(default)]
48    pub trace_level: Level,
49    /// Configures type of sampler to use for tracing. This will override any sampler set via
50    /// the standard environment variables
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub traces_sampler: Option<String>,
53    /// An additional argument to pass to the sampler. Used for cases such as the
54    /// trace_id_ratio_based sampler.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub traces_sampler_arg: Option<String>,
57    /// The maximum number of tracing events that can be buffered in memory before being exported.
58    /// If the queue is full, events will be dropped. If not set, the default for the underlying
59    /// exporter will be used. This will override any value set via the standard environment
60    /// variables.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub max_batch_queue_size: Option<usize>,
63    /// The maximum number of concurrent export threads that can be used to export tracing data to
64    /// collectors. By default, this number is set to 1, which means that export batches will be
65    /// exported synchronously. This setting has a direct impact on memory usage and performance.
66    /// Setting to > 1 can improve the performance of the exporter, but it can also increase memory
67    /// usage (and possibly CPU). This will override any value set via the standard environment
68    /// variables.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub concurrent_exports: Option<usize>,
71}
72
73impl OtelConfig {
74    pub fn logs_endpoint(&self) -> String {
75        self.resolve_endpoint(OtelSignal::Logs, self.logs_endpoint.clone())
76    }
77
78    pub fn metrics_endpoint(&self) -> String {
79        self.resolve_endpoint(OtelSignal::Metrics, self.metrics_endpoint.clone())
80    }
81
82    pub fn traces_endpoint(&self) -> String {
83        self.resolve_endpoint(OtelSignal::Traces, self.traces_endpoint.clone())
84    }
85
86    pub fn logs_enabled(&self) -> bool {
87        self.enable_logs.unwrap_or(self.enable_observability)
88    }
89
90    pub fn metrics_enabled(&self) -> bool {
91        self.enable_metrics.unwrap_or(self.enable_observability)
92    }
93
94    pub fn traces_enabled(&self) -> bool {
95        self.enable_traces.unwrap_or(self.enable_observability)
96    }
97
98    // We have 3 potential outcomes depending on the provided configuration:
99    // 1. We are given a signal-specific endpoint to use, which we'll use as-is.
100    // 2. We are given an endpoint that each of the signal paths should added to
101    // 3. We are given nothing, and we should simply default to an empty string,
102    //    which lets the opentelemetry-otlp library handle defaults appropriately.
103    fn resolve_endpoint(
104        &self,
105        signal: OtelSignal,
106        signal_endpoint_override: Option<String>,
107    ) -> String {
108        // If we have a signal specific endpoint override, use it as provided.
109        if let Some(endpoint) = signal_endpoint_override {
110            return endpoint;
111        }
112        if let Some(endpoint) = self.observability_endpoint.clone() {
113            return match self.protocol {
114                OtelProtocol::Grpc => self.resolve_grpc_endpoint(endpoint),
115                OtelProtocol::Http => self.resolve_http_endpoint(signal, endpoint),
116            };
117        }
118        // Set sensible defaults if nothing is provided
119        match self.protocol {
120            OtelProtocol::Grpc => "http://127.0.0.1:4317".to_string(),
121            OtelProtocol::Http => format!("http://127.0.0.1:4318{signal}"),
122        }
123    }
124
125    // opentelemetry-otlp expects the gRPC endpoint to not have path components
126    // configured, so we're just clearing them out and returning the base url.
127    fn resolve_grpc_endpoint(&self, endpoint: String) -> String {
128        match Url::parse(&endpoint) {
129            Ok(mut url) => {
130                if let Ok(mut path) = url.path_segments_mut() {
131                    path.clear();
132                }
133                url.as_str().trim_end_matches('/').to_string()
134            }
135            Err(_) => endpoint,
136        }
137    }
138
139    // opentelemetry-otlp expects the http endpoint to be fully configured
140    // including the path, so we check whether there's a path already configured
141    // and use the url as configured, or append the signal-specific path to the
142    // provided endpoint.
143    fn resolve_http_endpoint(&self, signal: OtelSignal, endpoint: String) -> String {
144        match Url::parse(&endpoint) {
145            Ok(url) => {
146                if url.path() == "/" {
147                    format!("{}{}", url.as_str().trim_end_matches('/'), signal)
148                } else {
149                    endpoint
150                }
151            }
152            Err(_) => endpoint,
153        }
154    }
155}
156
157#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
158// TODO(joonas): In a future release we should enable this renaming once we
159// are comfortable with the fact there are no providers being used that have
160// the case sensitive handling still in place.
161// #[serde(rename_all = "lowercase")]
162#[derive(Default)]
163pub enum OtelProtocol {
164    #[serde(alias = "grpc", alias = "Grpc")]
165    Grpc,
166    #[serde(alias = "http", alias = "Http")]
167    #[default]
168    Http,
169}
170
171// Represents https://opentelemetry.io/docs/concepts/signals/
172enum OtelSignal {
173    Traces,
174    Metrics,
175    Logs,
176}
177
178impl std::fmt::Display for OtelSignal {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        write!(
181            f,
182            "/v1/{}",
183            match self {
184                OtelSignal::Traces => "traces",
185                OtelSignal::Metrics => "metrics",
186                OtelSignal::Logs => "logs",
187            }
188        )
189    }
190}
191
192impl FromStr for OtelProtocol {
193    type Err = anyhow::Error;
194
195    fn from_str(s: &str) -> Result<Self, Self::Err> {
196        match s {
197            "http" => Ok(Self::Http),
198            "grpc" => Ok(Self::Grpc),
199            protocol => {
200                bail!("unsupported protocol: {protocol:?}, did you mean 'http' or 'grpc'?")
201            }
202        }
203    }
204}
205
206/// Environment settings for initializing a capability provider
207pub type TraceContext = WitMap<String>;
208
209#[cfg(test)]
210mod tests {
211    use super::{OtelConfig, OtelProtocol};
212
213    #[test]
214    fn test_grpc_resolves_to_defaults_without_overrides() {
215        let config = OtelConfig {
216            protocol: OtelProtocol::Grpc,
217            ..Default::default()
218        };
219
220        let expected = String::from("http://127.0.0.1:4317");
221
222        assert_eq!(expected, config.traces_endpoint());
223        assert_eq!(expected, config.metrics_endpoint());
224        assert_eq!(expected, config.logs_endpoint());
225    }
226
227    #[test]
228    fn test_grpc_resolves_to_base_url_without_path_components() {
229        let config = OtelConfig {
230            protocol: OtelProtocol::Grpc,
231            observability_endpoint: Some(String::from(
232                "https://example.com:4318/path/does/not/exist",
233            )),
234            ..Default::default()
235        };
236
237        let expected = String::from("https://example.com:4318");
238
239        assert_eq!(expected, config.traces_endpoint());
240        assert_eq!(expected, config.metrics_endpoint());
241        assert_eq!(expected, config.logs_endpoint());
242    }
243
244    #[test]
245    fn test_grpc_resolves_to_signal_specific_overrides_as_provided() {
246        let config = OtelConfig {
247            protocol: OtelProtocol::Grpc,
248            traces_endpoint: Some(String::from("https://example.com:4318/path/does/not/exist")),
249            ..Default::default()
250        };
251
252        let expected_traces = String::from("https://example.com:4318/path/does/not/exist");
253        let expected_others = String::from("http://127.0.0.1:4317");
254
255        assert_eq!(expected_traces, config.traces_endpoint());
256        assert_eq!(expected_others, config.metrics_endpoint());
257        assert_eq!(expected_others, config.logs_endpoint());
258    }
259
260    #[test]
261    fn test_http_resolves_to_defaults_without_overrides() {
262        let config = OtelConfig {
263            protocol: OtelProtocol::Http,
264            ..Default::default()
265        };
266
267        let expected_traces = String::from("http://127.0.0.1:4318/v1/traces");
268        let expected_metrics = String::from("http://127.0.0.1:4318/v1/metrics");
269        let expected_logs = String::from("http://127.0.0.1:4318/v1/logs");
270
271        assert_eq!(expected_traces, config.traces_endpoint());
272        assert_eq!(expected_metrics, config.metrics_endpoint());
273        assert_eq!(expected_logs, config.logs_endpoint());
274    }
275
276    #[test]
277    fn test_http_configuration_for_specific_signal_should_not_affect_other_signals() {
278        let config = OtelConfig {
279            protocol: OtelProtocol::Http,
280            traces_endpoint: Some(String::from(
281                "https://example.com:4318/v1/traces/or/something",
282            )),
283            ..Default::default()
284        };
285
286        let expected_traces = String::from("https://example.com:4318/v1/traces/or/something");
287        let expected_metrics = String::from("http://127.0.0.1:4318/v1/metrics");
288        let expected_logs = String::from("http://127.0.0.1:4318/v1/logs");
289
290        assert_eq!(expected_traces, config.traces_endpoint());
291        assert_eq!(expected_metrics, config.metrics_endpoint());
292        assert_eq!(expected_logs, config.logs_endpoint());
293    }
294
295    #[test]
296    fn test_http_should_be_configurable_across_all_signals_via_observability_endpoint() {
297        let config = OtelConfig {
298            protocol: OtelProtocol::Http,
299            observability_endpoint: Some(String::from("https://example.com:4318")),
300            ..Default::default()
301        };
302
303        let expected_traces = String::from("https://example.com:4318/v1/traces");
304        let expected_metrics = String::from("https://example.com:4318/v1/metrics");
305        let expected_logs = String::from("https://example.com:4318/v1/logs");
306
307        assert_eq!(expected_traces, config.traces_endpoint());
308        assert_eq!(expected_metrics, config.metrics_endpoint());
309        assert_eq!(expected_logs, config.logs_endpoint());
310    }
311}