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")]
162pub enum OtelProtocol {
163    #[serde(alias = "grpc", alias = "Grpc")]
164    Grpc,
165    #[serde(alias = "http", alias = "Http")]
166    Http,
167}
168
169// Represents https://opentelemetry.io/docs/concepts/signals/
170enum OtelSignal {
171    Traces,
172    Metrics,
173    Logs,
174}
175
176impl std::fmt::Display for OtelSignal {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        write!(
179            f,
180            "/v1/{}",
181            match self {
182                OtelSignal::Traces => "traces",
183                OtelSignal::Metrics => "metrics",
184                OtelSignal::Logs => "logs",
185            }
186        )
187    }
188}
189
190impl Default for OtelProtocol {
191    fn default() -> Self {
192        Self::Http
193    }
194}
195
196impl FromStr for OtelProtocol {
197    type Err = anyhow::Error;
198
199    fn from_str(s: &str) -> Result<Self, Self::Err> {
200        match s {
201            "http" => Ok(Self::Http),
202            "grpc" => Ok(Self::Grpc),
203            protocol => {
204                bail!("unsupported protocol: {protocol:?}, did you mean 'http' or 'grpc'?")
205            }
206        }
207    }
208}
209
210/// Environment settings for initializing a capability provider
211pub type TraceContext = WitMap<String>;
212
213#[cfg(test)]
214mod tests {
215    use super::{OtelConfig, OtelProtocol};
216
217    #[test]
218    fn test_grpc_resolves_to_defaults_without_overrides() {
219        let config = OtelConfig {
220            protocol: OtelProtocol::Grpc,
221            ..Default::default()
222        };
223
224        let expected = String::from("http://127.0.0.1:4317");
225
226        assert_eq!(expected, config.traces_endpoint());
227        assert_eq!(expected, config.metrics_endpoint());
228        assert_eq!(expected, config.logs_endpoint());
229    }
230
231    #[test]
232    fn test_grpc_resolves_to_base_url_without_path_components() {
233        let config = OtelConfig {
234            protocol: OtelProtocol::Grpc,
235            observability_endpoint: Some(String::from(
236                "https://example.com:4318/path/does/not/exist",
237            )),
238            ..Default::default()
239        };
240
241        let expected = String::from("https://example.com:4318");
242
243        assert_eq!(expected, config.traces_endpoint());
244        assert_eq!(expected, config.metrics_endpoint());
245        assert_eq!(expected, config.logs_endpoint());
246    }
247
248    #[test]
249    fn test_grpc_resolves_to_signal_specific_overrides_as_provided() {
250        let config = OtelConfig {
251            protocol: OtelProtocol::Grpc,
252            traces_endpoint: Some(String::from("https://example.com:4318/path/does/not/exist")),
253            ..Default::default()
254        };
255
256        let expected_traces = String::from("https://example.com:4318/path/does/not/exist");
257        let expected_others = String::from("http://127.0.0.1:4317");
258
259        assert_eq!(expected_traces, config.traces_endpoint());
260        assert_eq!(expected_others, config.metrics_endpoint());
261        assert_eq!(expected_others, config.logs_endpoint());
262    }
263
264    #[test]
265    fn test_http_resolves_to_defaults_without_overrides() {
266        let config = OtelConfig {
267            protocol: OtelProtocol::Http,
268            ..Default::default()
269        };
270
271        let expected_traces = String::from("http://127.0.0.1:4318/v1/traces");
272        let expected_metrics = String::from("http://127.0.0.1:4318/v1/metrics");
273        let expected_logs = String::from("http://127.0.0.1:4318/v1/logs");
274
275        assert_eq!(expected_traces, config.traces_endpoint());
276        assert_eq!(expected_metrics, config.metrics_endpoint());
277        assert_eq!(expected_logs, config.logs_endpoint());
278    }
279
280    #[test]
281    fn test_http_configuration_for_specific_signal_should_not_affect_other_signals() {
282        let config = OtelConfig {
283            protocol: OtelProtocol::Http,
284            traces_endpoint: Some(String::from(
285                "https://example.com:4318/v1/traces/or/something",
286            )),
287            ..Default::default()
288        };
289
290        let expected_traces = String::from("https://example.com:4318/v1/traces/or/something");
291        let expected_metrics = String::from("http://127.0.0.1:4318/v1/metrics");
292        let expected_logs = String::from("http://127.0.0.1:4318/v1/logs");
293
294        assert_eq!(expected_traces, config.traces_endpoint());
295        assert_eq!(expected_metrics, config.metrics_endpoint());
296        assert_eq!(expected_logs, config.logs_endpoint());
297    }
298
299    #[test]
300    fn test_http_should_be_configurable_across_all_signals_via_observability_endpoint() {
301        let config = OtelConfig {
302            protocol: OtelProtocol::Http,
303            observability_endpoint: Some(String::from("https://example.com:4318")),
304            ..Default::default()
305        };
306
307        let expected_traces = String::from("https://example.com:4318/v1/traces");
308        let expected_metrics = String::from("https://example.com:4318/v1/metrics");
309        let expected_logs = String::from("https://example.com:4318/v1/logs");
310
311        assert_eq!(expected_traces, config.traces_endpoint());
312        assert_eq!(expected_metrics, config.metrics_endpoint());
313        assert_eq!(expected_logs, config.logs_endpoint());
314    }
315}