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::{LogFormat, 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    /// The shape of a log line.
73    pub log_format: LogFormat,
74}
75
76impl TelemetrySettings {
77    /// Read them from `config`.
78    pub fn from_config(config: &Config) -> Self {
79        Self {
80            enabled: config.get(keys::TELEMETRY_ENABLED).unwrap_or(false),
81            endpoint: config.get(keys::TELEMETRY_ENDPOINT),
82            service_name: config
83                .get(keys::TELEMETRY_SERVICE_NAME)
84                .or_else(|| config.get(keys::APP_NAME))
85                .unwrap_or_else(|| "rainier".to_string()),
86            sample_ratio: config.get(keys::TELEMETRY_SAMPLE_RATIO).unwrap_or(1.0),
87            log_format: config.setting(keys::LOG_FORMAT).unwrap_or_default(),
88        }
89    }
90
91    /// The middleware, or `None` when tracing is off.
92    pub fn middleware(&self) -> Option<Trace> {
93        self.enabled.then(|| Trace::new().sampling(self.sample_ratio > 0.0))
94    }
95
96    /// Whether spans should be exported, as opposed to only propagated.
97    ///
98    /// Propagating without exporting is a perfectly good configuration — the
99    /// trace id reaches every log line, which is most of the value — so an
100    /// endpoint is what distinguishes the two.
101    pub fn exports(&self) -> bool {
102        self.enabled && self.endpoint.is_some()
103    }
104}
105
106impl TelemetrySettings {
107    /// Install a global subscriber in this format.
108    ///
109    /// Returns whether it installed one. `false` means a subscriber was
110    /// already set — by the application, or by an earlier boot in the same
111    /// test binary — and that one is left alone, because replacing somebody
112    /// else's logging configuration is worse than not installing yours.
113    ///
114    /// `filter` is an `EnvFilter` directive, usually `RUST_LOG`. One that does
115    /// not parse falls back to `info` rather than refusing to log at all.
116    pub fn install_logging(&self, environment: &str, filter: &str) -> bool {
117        use tracing_subscriber::{fmt, EnvFilter};
118
119        let filter =
120            EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER));
121        let builder = fmt().with_env_filter(filter);
122
123        match self.log_format.resolve(environment) {
124            // `flatten_event` puts the message and its fields at the top
125            // level, where every aggregator's default parser looks for them;
126            // nested under `fields` they are one query deeper forever.
127            LogFormat::Json => {
128                builder.json().flatten_event(true).with_current_span(true).try_init().is_ok()
129            }
130            LogFormat::Compact => builder.compact().try_init().is_ok(),
131            // `Auto` cannot reach here — `resolve` has answered it.
132            LogFormat::Pretty | LogFormat::Auto => builder.try_init().is_ok(),
133        }
134    }
135}
136
137/// What to log when `RUST_LOG` is unset or unparseable.
138const DEFAULT_LOG_FILTER: &str = "info";
139
140/// What the configuration says about the OpenAPI document.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct OpenApiSettings {
143    /// Whether to serve it.
144    pub enabled: bool,
145    /// Where.
146    pub path: String,
147    /// The document's title.
148    pub title: String,
149    /// The API's version.
150    pub version: String,
151    /// The base URL to advertise, if any.
152    pub server: Option<String>,
153}
154
155impl OpenApiSettings {
156    /// Read them from `config`.
157    pub fn from_config(config: &Config) -> Self {
158        Self {
159            enabled: config.get(keys::OPENAPI_ENABLED).unwrap_or(false),
160            path: config.get(keys::OPENAPI_PATH).unwrap_or_else(|| "/openapi.json".to_string()),
161            title: config
162                .get(keys::OPENAPI_TITLE)
163                .or_else(|| config.get(keys::APP_NAME))
164                .unwrap_or_else(|| "API".to_string()),
165            version: config.get(keys::OPENAPI_VERSION).unwrap_or_else(|| "1.0.0".to_string()),
166            server: config.get(keys::OPENAPI_SERVER),
167        }
168    }
169
170    /// Apply the configured title, version and server to `document`.
171    ///
172    /// The application declares the endpoints; the configuration decides what
173    /// the document calls itself, because a title and a version are deployment
174    /// facts rather than code.
175    pub fn apply(&self, document: OpenApi) -> OpenApi {
176        let document = document.titled(&self.title, &self.version);
177
178        match &self.server {
179            Some(url) => document.server(url),
180            None => document,
181        }
182    }
183
184    /// Render it against `router`, or `None` when it is off.
185    pub fn render(&self, document: OpenApi, router: &CompiledRouter) -> Option<Arc<Rendered>> {
186        self.enabled.then(|| Arc::new(Rendered::new(&self.apply(document), router)))
187    }
188}
189
190/// `GET /metrics` — the scrape endpoint, reading the bound registry.
191///
192/// The crate-level [`rainier_metrics::endpoint`] takes the registry off the
193/// request; this one resolves it from the container, which is what an
194/// application wants because that is where boot put it.
195pub async fn metrics_endpoint(_request: rainier_routing::Req) -> rainier_http::Response {
196    let Ok(metrics) = rainier_container::facade_application().resolve::<Metrics>() else {
197        return rainier_http::Response::new(rainier_http::StatusCode::NOT_FOUND);
198    };
199
200    rainier_http::Response::ok(metrics.render())
201        .with_header("content-type", "text/plain; version=0.0.4; charset=utf-8")
202}
203
204/// `GET /openapi.json` — the document, reading what boot rendered.
205///
206/// A `404` when nothing is bound, so an application with the feature off looks
207/// like one that has no such endpoint rather than one serving an empty
208/// document a client would try to use.
209pub async fn openapi_endpoint(_request: rainier_routing::Req) -> rainier_http::Response {
210    let Ok(document) = rainier_container::facade_application().resolve::<Rendered>() else {
211        return rainier_http::Response::new(rainier_http::StatusCode::NOT_FOUND);
212    };
213
214    rainier_http::Response::ok(document.json().to_string())
215        .with_header("content-type", "application/json; charset=utf-8")
216}
217
218/// Read all three, for a boot that wants to report what it turned on.
219pub fn settings(config: &Config) -> Result<(MetricsSettings, TelemetrySettings, OpenApiSettings)> {
220    Ok((
221        MetricsSettings::from_config(config),
222        TelemetrySettings::from_config(config),
223        OpenApiSettings::from_config(config),
224    ))
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn config() -> Config {
232        Config::new()
233    }
234
235    #[test]
236    fn everything_is_off_when_nothing_is_configured() {
237        // The default an application gets by not having thought about it.
238        let config = config();
239
240        assert!(!MetricsSettings::from_config(&config).enabled);
241        assert!(!TelemetrySettings::from_config(&config).enabled);
242        assert!(!OpenApiSettings::from_config(&config).enabled);
243    }
244
245    #[test]
246    fn metrics_off_means_no_registry_and_so_no_timer() {
247        let settings = MetricsSettings::from_config(&config());
248        assert!(settings.registry().is_none());
249
250        let config = config();
251        config.set(keys::METRICS_ENABLED, true).unwrap();
252        assert!(MetricsSettings::from_config(&config).registry().is_some());
253    }
254
255    #[test]
256    fn the_paths_have_defaults_worth_having() {
257        assert_eq!(MetricsSettings::from_config(&config()).path, "/metrics");
258        assert_eq!(OpenApiSettings::from_config(&config()).path, "/openapi.json");
259    }
260
261    #[test]
262    fn a_configured_path_wins() {
263        let config = config();
264        config.set(keys::METRICS_PATH, "/internal/metrics".to_string()).unwrap();
265
266        assert_eq!(MetricsSettings::from_config(&config).path, "/internal/metrics");
267    }
268
269    #[test]
270    fn tracing_can_propagate_without_exporting() {
271        // The configuration worth having by default: trace ids on every log
272        // line, and no collector to run.
273        let config = config();
274        config.set(keys::TELEMETRY_ENABLED, true).unwrap();
275
276        let settings = TelemetrySettings::from_config(&config);
277
278        assert!(settings.enabled);
279        assert!(!settings.exports(), "no endpoint, so nothing is sent");
280        assert!(settings.middleware().is_some());
281    }
282
283    #[test]
284    fn an_endpoint_turns_exporting_on() {
285        let config = config();
286        config.set(keys::TELEMETRY_ENABLED, true).unwrap();
287        config.set(keys::TELEMETRY_ENDPOINT, "http://localhost:4317".to_string()).unwrap();
288
289        assert!(TelemetrySettings::from_config(&config).exports());
290    }
291
292    #[test]
293    fn an_endpoint_alone_does_nothing_while_telemetry_is_off() {
294        // Otherwise setting a collector's address in an env file would turn
295        // tracing on for a deployment that never asked.
296        let config = config();
297        config.set(keys::TELEMETRY_ENDPOINT, "http://localhost:4317".to_string()).unwrap();
298
299        let settings = TelemetrySettings::from_config(&config);
300        assert!(!settings.exports());
301        assert!(settings.middleware().is_none());
302    }
303
304    #[test]
305    fn the_service_name_falls_back_to_the_application_name() {
306        let config = config();
307        config.set(keys::APP_NAME, "Rainier Sample".to_string()).unwrap();
308
309        assert_eq!(TelemetrySettings::from_config(&config).service_name, "Rainier Sample");
310
311        config.set(keys::TELEMETRY_SERVICE_NAME, "api".to_string()).unwrap();
312        assert_eq!(TelemetrySettings::from_config(&config).service_name, "api");
313    }
314
315    #[test]
316    fn the_document_title_falls_back_to_the_application_name() {
317        let config = config();
318        config.set(keys::APP_NAME, "Rainier Sample".to_string()).unwrap();
319
320        assert_eq!(OpenApiSettings::from_config(&config).title, "Rainier Sample");
321    }
322
323    #[test]
324    fn the_log_format_defaults_to_auto_and_follows_the_environment() {
325        let config = Config::new();
326        assert_eq!(TelemetrySettings::from_config(&config).log_format, LogFormat::Auto);
327
328        // Which is what makes it useful: nothing to set, and production still
329        // gets machine-readable lines.
330        assert_eq!(LogFormat::Auto.resolve("production"), LogFormat::Json);
331    }
332
333    #[test]
334    fn the_log_format_is_read_from_configuration() {
335        let config = Config::new();
336        config.set(keys::LOG_FORMAT.path(), "compact").unwrap();
337
338        assert_eq!(TelemetrySettings::from_config(&config).log_format, LogFormat::Compact);
339    }
340
341    #[test]
342    fn a_log_format_nobody_can_spell_falls_back_rather_than_silencing_the_logs() {
343        // `LOG_FORMAT=jsonn` in the environment is refused at boot, where the
344        // error can be read. A tree built by hand cannot be, so this reader
345        // falls back — logging is the thing that would report the problem, and
346        // must not be the thing that breaks over it.
347        let config = Config::new();
348        config.set(keys::LOG_FORMAT.path(), "jsonn").unwrap();
349
350        assert_eq!(TelemetrySettings::from_config(&config).log_format, LogFormat::Auto);
351    }
352}