Skip to main content

courier/config/
observability.rs

1use std::fmt;
2
3use super::redact::{RedactedOptionStr, RedactedStr};
4
5#[derive(Clone, PartialEq)]
6pub struct ObservabilityConfig {
7    pub service_name: String,
8    pub log_format: LogFormat,
9    pub log_level: Option<String>,
10    pub log_keys: bool,
11    pub metrics: MetricsConfig,
12    pub tracing: TracingConfig,
13    pub logs: LogsConfig,
14}
15
16impl fmt::Debug for ObservabilityConfig {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        f.debug_struct("ObservabilityConfig")
19            .field("service_name", &RedactedStr(&self.service_name))
20            .field("log_format", &self.log_format)
21            .field("log_level", &RedactedOptionStr(self.log_level.as_deref()))
22            .field("log_keys", &self.log_keys)
23            .field("metrics", &self.metrics)
24            .field("tracing", &self.tracing)
25            .field("logs", &self.logs)
26            .finish()
27    }
28}
29
30impl Default for ObservabilityConfig {
31    fn default() -> Self {
32        Self {
33            service_name: "courier".to_string(),
34            log_format: LogFormat::Text,
35            log_level: None,
36            log_keys: false,
37            metrics: MetricsConfig::default(),
38            tracing: TracingConfig::default(),
39            logs: LogsConfig::default(),
40        }
41    }
42}
43
44#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
45pub enum LogFormat {
46    #[default]
47    Text,
48    Json,
49}
50
51#[derive(Clone, PartialEq)]
52pub struct MetricsConfig {
53    pub otlp_endpoint: Option<String>,
54    pub export_interval_ms: u64,
55}
56
57impl fmt::Debug for MetricsConfig {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.debug_struct("MetricsConfig")
60            .field(
61                "otlp_endpoint",
62                &RedactedOptionStr(self.otlp_endpoint.as_deref()),
63            )
64            .field("export_interval_ms", &self.export_interval_ms)
65            .finish()
66    }
67}
68
69impl Default for MetricsConfig {
70    fn default() -> Self {
71        Self {
72            otlp_endpoint: None,
73            export_interval_ms: 15_000,
74        }
75    }
76}
77
78#[derive(Clone, PartialEq)]
79pub struct TracingConfig {
80    pub otlp_endpoint: Option<String>,
81    pub sample_ratio: f64,
82}
83
84impl fmt::Debug for TracingConfig {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.debug_struct("TracingConfig")
87            .field(
88                "otlp_endpoint",
89                &RedactedOptionStr(self.otlp_endpoint.as_deref()),
90            )
91            .field("sample_ratio", &self.sample_ratio)
92            .finish()
93    }
94}
95
96impl Default for TracingConfig {
97    fn default() -> Self {
98        Self {
99            otlp_endpoint: None,
100            sample_ratio: 0.1,
101        }
102    }
103}
104
105#[derive(Clone, Default, PartialEq)]
106pub struct LogsConfig {
107    pub otlp_endpoint: Option<String>,
108}
109
110impl fmt::Debug for LogsConfig {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.debug_struct("LogsConfig")
113            .field(
114                "otlp_endpoint",
115                &RedactedOptionStr(self.otlp_endpoint.as_deref()),
116            )
117            .finish()
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use crate::config::{Config, LogFormat, ObservabilityConfig};
124
125    fn minimal_pipeline_block() -> &'static str {
126        r#"
127            [[pipelines]]
128            name = "p"
129            [pipelines.source]
130            type = "noop"
131            [[pipelines.sinks]]
132            type = "noop"
133        "#
134    }
135
136    #[test]
137    fn observability_absent_is_none_and_validates() {
138        let config = Config::from_toml_str(minimal_pipeline_block()).unwrap();
139        assert_eq!(config.observability, None);
140        config.validate().unwrap();
141    }
142
143    #[test]
144    fn observability_empty_block_yields_defaults() {
145        let config = Config::from_toml_str(&format!(
146            r#"
147            [observability]
148            {}"#,
149            minimal_pipeline_block()
150        ))
151        .unwrap();
152        let obs = config
153            .observability
154            .clone()
155            .expect("observability should parse");
156        assert_eq!(obs, ObservabilityConfig::default());
157        config.validate().unwrap();
158    }
159
160    #[test]
161    fn observability_full_block_round_trips() {
162        let config = Config::from_toml_str(&format!(
163            r#"
164            [observability]
165            service_name = "courier-prod"
166            log_format = "json"
167            log_level = "courier=debug,hyper=warn"
168            log_keys = true
169
170            [observability.metrics]
171            otlp_endpoint = "http://collector:4317"
172            export_interval_ms = 5000
173
174            [observability.tracing]
175            otlp_endpoint = "http://collector:4317"
176            sample_ratio = 0.25
177
178            [observability.logs]
179            otlp_endpoint = "http://collector:4317"
180            {}"#,
181            minimal_pipeline_block()
182        ))
183        .unwrap();
184
185        let obs = config.observability.unwrap();
186        assert_eq!(obs.log_format, LogFormat::Json);
187        assert_eq!(obs.log_level.as_deref(), Some("courier=debug,hyper=warn"));
188        assert!(obs.log_keys);
189        assert_eq!(
190            obs.metrics.otlp_endpoint.as_deref(),
191            Some("http://collector:4317")
192        );
193        assert_eq!(obs.metrics.export_interval_ms, 5000);
194        assert_eq!(
195            obs.tracing.otlp_endpoint.as_deref(),
196            Some("http://collector:4317")
197        );
198        assert_eq!(obs.tracing.sample_ratio, 0.25);
199        assert_eq!(obs.service_name, "courier-prod");
200        assert_eq!(
201            obs.logs.otlp_endpoint.as_deref(),
202            Some("http://collector:4317")
203        );
204    }
205
206    #[test]
207    fn json_and_toml_parse_observability_identically() {
208        let toml = Config::from_toml_str(&format!(
209            r#"
210            [observability]
211            service_name = "svc"
212            log_format = "json"
213            log_level = "info"
214
215            [observability.tracing]
216            sample_ratio = 0.5
217            {}"#,
218            minimal_pipeline_block()
219        ))
220        .unwrap();
221
222        let json = Config::from_json_str(
223            r#"{
224              "observability": {
225                "service_name": "svc",
226                "log_format": "json",
227                "log_level": "info",
228                "tracing": { "sample_ratio": 0.5 }
229              },
230              "pipelines": [
231                {
232                  "name": "p",
233                  "source": { "type": "noop" },
234                  "sinks": [{ "type": "noop" }]
235                }
236              ]
237            }"#,
238        )
239        .unwrap();
240
241        assert_eq!(toml, json);
242    }
243
244    #[test]
245    fn tracing_service_name_is_rejected() {
246        let err = Config::from_toml_str(&format!(
247            r#"
248            [observability.tracing]
249            service_name = "legacy-svc"
250            {}"#,
251            minimal_pipeline_block()
252        ))
253        .expect_err("legacy tracing service_name should be rejected");
254
255        let msg = format!("{err:#}");
256        assert!(msg.contains("unknown field `service_name`"), "{msg}");
257    }
258
259    #[test]
260    fn observability_rejects_unknown_top_level_field() {
261        let err = Config::from_toml_str(&format!(
262            r#"
263            [observability]
264            unknown_field = "oops"
265            {}"#,
266            minimal_pipeline_block()
267        ))
268        .unwrap_err();
269        let msg = format!("{err:#}");
270        assert!(msg.contains("unknown_field"), "{msg}");
271    }
272}