rainier_framework/
observability.rs1use std::sync::Arc;
23
24use rainier_config::Config;
25use rainier_metrics::Metrics;
26use rainier_openapi::{OpenApi, Rendered};
27use rainier_routing::CompiledRouter;
28use rainier_support::Result;
29use rainier_telemetry::Trace;
30
31use crate::keys;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct MetricsSettings {
36 pub enabled: bool,
38 pub path: String,
40}
41
42impl MetricsSettings {
43 pub fn from_config(config: &Config) -> Self {
45 Self {
46 enabled: config.get(keys::METRICS_ENABLED).unwrap_or(false),
47 path: config.get(keys::METRICS_PATH).unwrap_or_else(|| "/metrics".to_string()),
48 }
49 }
50
51 pub fn registry(&self) -> Option<Arc<Metrics>> {
57 self.enabled.then(|| Arc::new(Metrics::new()))
58 }
59}
60
61#[derive(Debug, Clone, PartialEq)]
63pub struct TelemetrySettings {
64 pub enabled: bool,
66 pub endpoint: Option<String>,
68 pub service_name: String,
70 pub sample_ratio: f64,
72}
73
74impl TelemetrySettings {
75 pub fn from_config(config: &Config) -> Self {
77 Self {
78 enabled: config.get(keys::TELEMETRY_ENABLED).unwrap_or(false),
79 endpoint: config.get(keys::TELEMETRY_ENDPOINT),
80 service_name: config
81 .get(keys::TELEMETRY_SERVICE_NAME)
82 .or_else(|| config.get(keys::APP_NAME))
83 .unwrap_or_else(|| "rainier".to_string()),
84 sample_ratio: config.get(keys::TELEMETRY_SAMPLE_RATIO).unwrap_or(1.0),
85 }
86 }
87
88 pub fn middleware(&self) -> Option<Trace> {
90 self.enabled.then(|| Trace::new().sampling(self.sample_ratio > 0.0))
91 }
92
93 pub fn exports(&self) -> bool {
99 self.enabled && self.endpoint.is_some()
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct OpenApiSettings {
106 pub enabled: bool,
108 pub path: String,
110 pub title: String,
112 pub version: String,
114 pub server: Option<String>,
116}
117
118impl OpenApiSettings {
119 pub fn from_config(config: &Config) -> Self {
121 Self {
122 enabled: config.get(keys::OPENAPI_ENABLED).unwrap_or(false),
123 path: config.get(keys::OPENAPI_PATH).unwrap_or_else(|| "/openapi.json".to_string()),
124 title: config
125 .get(keys::OPENAPI_TITLE)
126 .or_else(|| config.get(keys::APP_NAME))
127 .unwrap_or_else(|| "API".to_string()),
128 version: config.get(keys::OPENAPI_VERSION).unwrap_or_else(|| "1.0.0".to_string()),
129 server: config.get(keys::OPENAPI_SERVER),
130 }
131 }
132
133 pub fn apply(&self, document: OpenApi) -> OpenApi {
139 let document = document.titled(&self.title, &self.version);
140
141 match &self.server {
142 Some(url) => document.server(url),
143 None => document,
144 }
145 }
146
147 pub fn render(&self, document: OpenApi, router: &CompiledRouter) -> Option<Arc<Rendered>> {
149 self.enabled.then(|| Arc::new(Rendered::new(&self.apply(document), router)))
150 }
151}
152
153pub async fn metrics_endpoint(_request: rainier_routing::Req) -> rainier_http::Response {
159 let Ok(metrics) = rainier_container::facade_application().resolve::<Metrics>() else {
160 return rainier_http::Response::new(rainier_http::StatusCode::NOT_FOUND);
161 };
162
163 rainier_http::Response::ok(metrics.render())
164 .with_header("content-type", "text/plain; version=0.0.4; charset=utf-8")
165}
166
167pub async fn openapi_endpoint(_request: rainier_routing::Req) -> rainier_http::Response {
173 let Ok(document) = rainier_container::facade_application().resolve::<Rendered>() else {
174 return rainier_http::Response::new(rainier_http::StatusCode::NOT_FOUND);
175 };
176
177 rainier_http::Response::ok(document.json().to_string())
178 .with_header("content-type", "application/json; charset=utf-8")
179}
180
181pub fn settings(config: &Config) -> Result<(MetricsSettings, TelemetrySettings, OpenApiSettings)> {
183 Ok((
184 MetricsSettings::from_config(config),
185 TelemetrySettings::from_config(config),
186 OpenApiSettings::from_config(config),
187 ))
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn config() -> Config {
195 Config::new()
196 }
197
198 #[test]
199 fn everything_is_off_when_nothing_is_configured() {
200 let config = config();
202
203 assert!(!MetricsSettings::from_config(&config).enabled);
204 assert!(!TelemetrySettings::from_config(&config).enabled);
205 assert!(!OpenApiSettings::from_config(&config).enabled);
206 }
207
208 #[test]
209 fn metrics_off_means_no_registry_and_so_no_timer() {
210 let settings = MetricsSettings::from_config(&config());
211 assert!(settings.registry().is_none());
212
213 let config = config();
214 config.set(keys::METRICS_ENABLED, true).unwrap();
215 assert!(MetricsSettings::from_config(&config).registry().is_some());
216 }
217
218 #[test]
219 fn the_paths_have_defaults_worth_having() {
220 assert_eq!(MetricsSettings::from_config(&config()).path, "/metrics");
221 assert_eq!(OpenApiSettings::from_config(&config()).path, "/openapi.json");
222 }
223
224 #[test]
225 fn a_configured_path_wins() {
226 let config = config();
227 config.set(keys::METRICS_PATH, "/internal/metrics".to_string()).unwrap();
228
229 assert_eq!(MetricsSettings::from_config(&config).path, "/internal/metrics");
230 }
231
232 #[test]
233 fn tracing_can_propagate_without_exporting() {
234 let config = config();
237 config.set(keys::TELEMETRY_ENABLED, true).unwrap();
238
239 let settings = TelemetrySettings::from_config(&config);
240
241 assert!(settings.enabled);
242 assert!(!settings.exports(), "no endpoint, so nothing is sent");
243 assert!(settings.middleware().is_some());
244 }
245
246 #[test]
247 fn an_endpoint_turns_exporting_on() {
248 let config = config();
249 config.set(keys::TELEMETRY_ENABLED, true).unwrap();
250 config.set(keys::TELEMETRY_ENDPOINT, "http://localhost:4317".to_string()).unwrap();
251
252 assert!(TelemetrySettings::from_config(&config).exports());
253 }
254
255 #[test]
256 fn an_endpoint_alone_does_nothing_while_telemetry_is_off() {
257 let config = config();
260 config.set(keys::TELEMETRY_ENDPOINT, "http://localhost:4317".to_string()).unwrap();
261
262 let settings = TelemetrySettings::from_config(&config);
263 assert!(!settings.exports());
264 assert!(settings.middleware().is_none());
265 }
266
267 #[test]
268 fn the_service_name_falls_back_to_the_application_name() {
269 let config = config();
270 config.set(keys::APP_NAME, "Rainier Sample".to_string()).unwrap();
271
272 assert_eq!(TelemetrySettings::from_config(&config).service_name, "Rainier Sample");
273
274 config.set(keys::TELEMETRY_SERVICE_NAME, "api".to_string()).unwrap();
275 assert_eq!(TelemetrySettings::from_config(&config).service_name, "api");
276 }
277
278 #[test]
279 fn the_document_title_falls_back_to_the_application_name() {
280 let config = config();
281 config.set(keys::APP_NAME, "Rainier Sample".to_string()).unwrap();
282
283 assert_eq!(OpenApiSettings::from_config(&config).title, "Rainier Sample");
284 }
285}