courier/config/
observability.rs1use 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}