Skip to main content

rainier_framework/
observability.rs

1//! Metrics, tracing and the OpenAPI document, built from
2//! [configuration](crate::keys).
3//!
4//! The three share a shape: **off unless configured on**, each reading one
5//! section of `config/`. Nothing here decides for the application; it reads
6//! what the application decided and builds it.
7//!
8//! ```ignore
9//! // config/metrics.rs
10//! config.set(keys::METRICS_ENABLED, env.bool("METRICS_ENABLED", false))?;
11//! config.set(keys::METRICS_PATH, env.string("METRICS_PATH", "/metrics"))?;
12//! ```
13//!
14//! # Why off by default
15//!
16//! Each costs something a request pays for — a lock and a timer, a span, a
17//! document rendered at boot — and more importantly each **exposes
18//! something**. A metrics endpoint tells a reader your traffic shape and every
19//! route you serve; an OpenAPI document is a map of your API. Both are things
20//! to turn on deliberately, on a path you have thought about.
21
22use 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/// What the configuration says about metrics.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct MetricsSettings {
36    /// Whether to record anything at all.
37    pub enabled: bool,
38    /// Where the scrape endpoint lives.
39    pub path: String,
40}
41
42impl MetricsSettings {
43    /// Read them from `config`.
44    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    /// A registry, or `None` when metrics are off.
52    ///
53    /// `None` rather than a registry nobody scrapes, so
54    /// [`RecordMetrics`](rainier_metrics::RecordMetrics) is not installed
55    /// either — a timer on every request that nothing reads is pure cost.
56    pub fn registry(&self) -> Option<Arc<Metrics>> {
57        self.enabled.then(|| Arc::new(Metrics::new()))
58    }
59}
60
61/// What the configuration says about tracing.
62#[derive(Debug, Clone, PartialEq)]
63pub struct TelemetrySettings {
64    /// Whether to join and propagate trace context.
65    pub enabled: bool,
66    /// The OTLP collector, if spans should be exported.
67    pub endpoint: Option<String>,
68    /// What this service calls itself.
69    pub service_name: String,
70    /// What fraction of locally started traces to record.
71    pub sample_ratio: f64,
72}
73
74impl TelemetrySettings {
75    /// Read them from `config`.
76    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    /// The middleware, or `None` when tracing is off.
89    pub fn middleware(&self) -> Option<Trace> {
90        self.enabled.then(|| Trace::new().sampling(self.sample_ratio > 0.0))
91    }
92
93    /// Whether spans should be exported, as opposed to only propagated.
94    ///
95    /// Propagating without exporting is a perfectly good configuration — the
96    /// trace id reaches every log line, which is most of the value — so an
97    /// endpoint is what distinguishes the two.
98    pub fn exports(&self) -> bool {
99        self.enabled && self.endpoint.is_some()
100    }
101}
102
103/// What the configuration says about the OpenAPI document.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct OpenApiSettings {
106    /// Whether to serve it.
107    pub enabled: bool,
108    /// Where.
109    pub path: String,
110    /// The document's title.
111    pub title: String,
112    /// The API's version.
113    pub version: String,
114    /// The base URL to advertise, if any.
115    pub server: Option<String>,
116}
117
118impl OpenApiSettings {
119    /// Read them from `config`.
120    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    /// Apply the configured title, version and server to `document`.
134    ///
135    /// The application declares the endpoints; the configuration decides what
136    /// the document calls itself, because a title and a version are deployment
137    /// facts rather than code.
138    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    /// Render it against `router`, or `None` when it is off.
148    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
153/// `GET /metrics` — the scrape endpoint, reading the bound registry.
154///
155/// The crate-level [`rainier_metrics::endpoint`] takes the registry off the
156/// request; this one resolves it from the container, which is what an
157/// application wants because that is where boot put it.
158pub 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
167/// `GET /openapi.json` — the document, reading what boot rendered.
168///
169/// A `404` when nothing is bound, so an application with the feature off looks
170/// like one that has no such endpoint rather than one serving an empty
171/// document a client would try to use.
172pub 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
181/// Read all three, for a boot that wants to report what it turned on.
182pub 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        // The default an application gets by not having thought about it.
201        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        // The configuration worth having by default: trace ids on every log
235        // line, and no collector to run.
236        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        // Otherwise setting a collector's address in an env file would turn
258        // tracing on for a deployment that never asked.
259        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}