Skip to main content

toolkit/telemetry/
config.rs

1//! OpenTelemetry tracing and metrics configuration types
2//!
3//! These types define the configuration structure for OpenTelemetry distributed
4//! tracing and metrics.
5
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, HashMap};
8
9/// Top-level OpenTelemetry configuration grouping resource identity,
10/// a shared default exporter, tracing settings and metrics settings.
11#[derive(Debug, Clone, Deserialize, Serialize, Default)]
12#[serde(deny_unknown_fields)]
13pub struct OpenTelemetryConfig {
14    #[serde(default)]
15    pub resource: OpenTelemetryResource,
16    /// Default exporter shared by tracing and metrics. Per-signal `exporter`
17    /// fields override this when present.
18    pub exporter: Option<Exporter>,
19    #[serde(default)]
20    pub tracing: TracingConfig,
21    #[serde(default)]
22    pub metrics: MetricsConfig,
23}
24
25impl OpenTelemetryConfig {
26    /// Resolve the effective exporter for tracing (per-signal or shared fallback).
27    #[must_use]
28    pub fn tracing_exporter(&self) -> Option<&Exporter> {
29        self.tracing.exporter.as_ref().or(self.exporter.as_ref())
30    }
31    /// Resolve the effective exporter for metrics (per-signal or shared fallback).
32    #[must_use]
33    pub fn metrics_exporter(&self) -> Option<&Exporter> {
34        self.metrics.exporter.as_ref().or(self.exporter.as_ref())
35    }
36    /// Whether JSON log records should carry top-level `trace_id` / `span_id`.
37    ///
38    /// On by default: a log line an operator cannot join to the `trace_id` an
39    /// error response handed the caller is a broken incident trail, and that
40    /// join is the whole point of the correlation. The cost is a span-context
41    /// lookup per event; set this to `false` explicitly to opt out.
42    #[must_use]
43    pub fn inject_trace_ids_into_logs(&self) -> bool {
44        self.tracing
45            .logs_correlation
46            .as_ref()
47            .and_then(|c| c.inject_trace_ids_into_logs)
48            .unwrap_or(true)
49    }
50}
51
52/// OpenTelemetry resource identity — attached to all traces and metrics.
53#[derive(Debug, Clone, Deserialize, Serialize)]
54#[serde(deny_unknown_fields)]
55pub struct OpenTelemetryResource {
56    /// Logical service name.
57    #[serde(default = "default_service_name")]
58    pub service_name: String,
59    /// Extra resource attributes added to every span and metric data point.
60    #[serde(default)]
61    pub attributes: BTreeMap<String, String>,
62}
63
64/// Return the default OpenTelemetry service name used when none is configured.
65fn default_service_name() -> String {
66    "cf-gears".to_owned()
67}
68
69impl Default for OpenTelemetryResource {
70    fn default() -> Self {
71        Self {
72            service_name: default_service_name(),
73            attributes: BTreeMap::default(),
74        }
75    }
76}
77
78/// Tracing configuration for OpenTelemetry distributed tracing
79#[derive(Debug, Clone, Deserialize, Serialize, Default)]
80#[serde(deny_unknown_fields)]
81pub struct TracingConfig {
82    pub enabled: bool,
83    /// Per-signal exporter override. When `None`, the shared
84    /// [`OpenTelemetryConfig::exporter`] is used instead.
85    pub exporter: Option<Exporter>,
86    pub sampler: Option<Sampler>,
87    pub propagation: Option<Propagation>,
88    pub http: Option<HttpOpts>,
89    pub logs_correlation: Option<LogsCorrelation>,
90}
91
92/// Metrics configuration for OpenTelemetry metrics collection
93#[derive(Debug, Clone, Deserialize, Serialize, Default)]
94#[serde(deny_unknown_fields)]
95pub struct MetricsConfig {
96    pub enabled: bool,
97    /// Per-signal exporter override. When `None`, the shared
98    /// [`OpenTelemetryConfig::exporter`] is used instead.
99    pub exporter: Option<Exporter>,
100    /// Maximum number of distinct attribute combinations per instrument.
101    /// When the limit is reached, new combinations are folded into an
102    /// overflow data point.  `None` means the SDK default is used.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub cardinality_limit: Option<usize>,
105}
106
107#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq, Copy)]
108#[serde(rename_all = "snake_case")]
109pub enum ExporterKind {
110    #[default]
111    OtlpGrpc,
112    OtlpHttp,
113}
114
115#[derive(Debug, Clone, Deserialize, Serialize)]
116pub struct Exporter {
117    /// Defaults to `otlp_grpc`, matching `extract_exporter_config`. Without a
118    /// default, overriding only the endpoint (e.g. via
119    /// `APP__OPENTELEMETRY__EXPORTER__ENDPOINT`) would fail to load because
120    /// `kind` would be missing from the partially-built map.
121    #[serde(default)]
122    pub kind: ExporterKind,
123    pub endpoint: Option<String>,
124    pub headers: Option<HashMap<String, String>>,
125    pub timeout_ms: Option<u64>,
126}
127
128#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
129#[serde(rename_all = "snake_case")]
130pub enum Sampler {
131    ParentBasedAlwaysOn {},
132    ParentBasedRatio {
133        #[serde(skip_serializing_if = "Option::is_none")]
134        ratio: Option<f64>,
135    },
136    AlwaysOn {},
137    AlwaysOff {},
138}
139
140#[derive(Debug, Clone, Deserialize, Serialize)]
141pub struct Propagation {
142    pub w3c_trace_context: Option<bool>,
143}
144
145#[derive(Debug, Clone, Deserialize, Serialize)]
146pub struct HttpOpts {
147    pub inject_request_id_header: Option<String>,
148    pub record_headers: Option<Vec<String>>,
149}
150
151#[derive(Debug, Clone, Deserialize, Serialize)]
152pub struct LogsCorrelation {
153    pub inject_trace_ids_into_logs: Option<bool>,
154}
155
156#[cfg(test)]
157#[cfg_attr(coverage_nightly, coverage(off))]
158mod tests {
159    use super::{LogsCorrelation, OpenTelemetryConfig, TracingConfig};
160
161    /// A config whose `logs_correlation` section is exactly `lc`.
162    fn with_logs_correlation(lc: Option<LogsCorrelation>) -> OpenTelemetryConfig {
163        OpenTelemetryConfig {
164            tracing: TracingConfig {
165                logs_correlation: lc,
166                ..TracingConfig::default()
167            },
168            ..OpenTelemetryConfig::default()
169        }
170    }
171
172    fn flag(inject: Option<bool>) -> LogsCorrelation {
173        LogsCorrelation {
174            inject_trace_ids_into_logs: inject,
175        }
176    }
177
178    /// The default is ON: a default config, an absent `logs_correlation`
179    /// section, and a section that omits the flag all resolve to `true`; only an
180    /// explicit `false` opts out. No prior test pinned this.
181    #[test]
182    fn inject_trace_ids_default_is_on_unless_explicitly_disabled() {
183        assert!(
184            OpenTelemetryConfig::default().inject_trace_ids_into_logs(),
185            "the default config must splice ids into logs"
186        );
187        assert!(
188            with_logs_correlation(None).inject_trace_ids_into_logs(),
189            "an absent logs_correlation section must default ON"
190        );
191        assert!(
192            with_logs_correlation(Some(flag(None))).inject_trace_ids_into_logs(),
193            "a logs_correlation section without the flag must default ON"
194        );
195        assert!(
196            with_logs_correlation(Some(flag(Some(true)))).inject_trace_ids_into_logs(),
197            "an explicit true stays ON"
198        );
199        assert!(
200            !with_logs_correlation(Some(flag(Some(false)))).inject_trace_ids_into_logs(),
201            "an explicit false is the only way to opt out"
202        );
203    }
204
205    /// The bootstrap layers resolve the flag over an `Option<&OpenTelemetryConfig>`,
206    /// absent when a gear ships no `[opentelemetry]` section. That absent case must
207    /// land ON, matching the in-process default — `is_none_or`, not `is_some_and`.
208    #[test]
209    fn absent_opentelemetry_section_resolves_the_splice_on() {
210        let absent: Option<&OpenTelemetryConfig> = None;
211        assert!(
212            absent.is_none_or(OpenTelemetryConfig::inject_trace_ids_into_logs),
213            "no [opentelemetry] section must resolve the splice ON"
214        );
215
216        let disabled = with_logs_correlation(Some(flag(Some(false))));
217        assert!(
218            !Some(&disabled).is_none_or(OpenTelemetryConfig::inject_trace_ids_into_logs),
219            "an explicit false must still opt out when a section is present"
220        );
221    }
222}