Skip to main content

sofie_http/
metrics.rs

1use std::fmt;
2use std::ops::ControlFlow;
3use std::time::{Duration, Instant};
4
5use http::header::{AUTHORIZATION, WWW_AUTHENTICATE};
6use http::request::Parts;
7use http::{HeaderValue, Method, StatusCode};
8use sofie_core::{Error, Result};
9use sofie_telemetry::metrics::{self, Instrument, Kind};
10
11use crate::answer::{self, Response, TEXT};
12use crate::trace::RouteLabel;
13
14pub(crate) const PATH: &str = "/metrics";
15const PROMETHEUS_TEXT: &str = "text/plain; version=0.0.4; charset=utf-8";
16/// Generated wiring names every route.
17const UNNAMED: &str = "unnamed";
18const UNMATCHED: &str = "unmatched";
19const OTHER_METHOD: &str = "_OTHER";
20const MIN_TOKEN_LENGTH: usize = 32;
21
22static REQUEST_DURATION: Instrument = Instrument {
23    name: "http.server.request.duration",
24    description: "How long a request took, by route name, method, status and area.",
25    kind: Kind::Duration,
26    labels: &[
27        "http.route",
28        "http.request.method",
29        "http.response.status_code",
30        "area",
31    ],
32};
33static HANDLER_DURATION: Instrument = Instrument {
34    name: "sofie.handler.duration",
35    description: "How long an action or a loader took, commit included, by handler and module.",
36    kind: Kind::Duration,
37    labels: &["code.function.name", "module"],
38};
39
40#[derive(Clone, Copy)]
41pub(crate) struct RequestMeasure {
42    route: &'static str,
43    method: &'static str,
44    area: &'static str,
45}
46
47impl RequestMeasure {
48    pub(crate) fn of(method: &Method, route: Option<RouteLabel<'_>>) -> Self {
49        Self {
50            route: route.map_or(UNMATCHED, |route| route.name.unwrap_or(UNNAMED)),
51            method: method_label(method),
52            area: route.and_then(|route| route.area).unwrap_or_default(),
53        }
54    }
55
56    pub(crate) fn record(self, status: u16, elapsed: Duration) {
57        let labels = [
58            self.route.into(),
59            self.method.into(),
60            status.into(),
61            self.area.into(),
62        ];
63        metrics::record_duration(&REQUEST_DURATION, &labels, elapsed);
64    }
65}
66
67/// A method a client made up is one label, so it cannot add series.
68fn method_label(method: &Method) -> &'static str {
69    match *method {
70        Method::GET => "GET",
71        Method::HEAD => "HEAD",
72        Method::POST => "POST",
73        Method::PUT => "PUT",
74        Method::PATCH => "PATCH",
75        Method::DELETE => "DELETE",
76        Method::OPTIONS => "OPTIONS",
77        _ => OTHER_METHOD,
78    }
79}
80
81pub(crate) async fn timed<T>(
82    (name, module): crate::trace::HandlerName,
83    work: impl Future<Output = T>,
84) -> T {
85    let started = Instant::now();
86    let output = work.await;
87    metrics::record_duration(
88        &HANDLER_DURATION,
89        &[name.into(), module.into()],
90        started.elapsed(),
91    );
92    output
93}
94
95impl crate::Serving {
96    /// Without it `/metrics` does not exist; `sofie::run` reads `SOFIE_METRICS_TOKEN`.
97    #[must_use]
98    pub fn metrics_token(mut self, token: MetricsToken) -> Self {
99        self.metrics_token = Some(token);
100        self
101    }
102}
103
104/// Never printed: `Debug` shows only that there is one.
105pub struct MetricsToken(String);
106
107impl MetricsToken {
108    pub fn parse(text: &str) -> Result<Self> {
109        let text = text.trim();
110        if text.len() < MIN_TOKEN_LENGTH || !text.bytes().all(|byte| byte.is_ascii_graphic()) {
111            return Err(Error::internal_message(format!(
112                "a metrics token needs at least {MIN_TOKEN_LENGTH} visible ASCII characters"
113            )));
114        }
115        Ok(Self(text.to_owned()))
116    }
117
118    /// Constant time, so timing does not tell how much of a guess was right.
119    fn admits(&self, authorization: Option<&HeaderValue>) -> bool {
120        let Some(sent) = authorization.and_then(crate::caller::bearer_credentials) else {
121            return false;
122        };
123        let (sent, own) = (sent.as_bytes(), self.0.as_bytes());
124        sent.len() == own.len()
125            && sent
126                .iter()
127                .zip(own)
128                .fold(0, |difference, (sent, own)| difference | (sent ^ own))
129                == 0
130    }
131}
132
133impl fmt::Debug for MetricsToken {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        formatter.write_str("MetricsToken(..)")
136    }
137}
138
139pub(crate) async fn scrape(request: &Parts, token: Option<&MetricsToken>) -> ControlFlow<Response> {
140    if request.uri.path() != PATH
141        || (request.method != Method::GET && request.method != Method::HEAD)
142    {
143        return ControlFlow::Continue(());
144    }
145    let Some(token) = token else {
146        return ControlFlow::Break(answer::response(
147            StatusCode::NOT_FOUND,
148            Some(TEXT),
149            "Not found.",
150        ));
151    };
152    if !token.admits(request.headers.get(AUTHORIZATION)) {
153        tracing::warn!(
154            http.route = PATH,
155            "refused a scrape without the metrics token"
156        );
157        let mut refused = answer::response(StatusCode::UNAUTHORIZED, Some(TEXT), "Unauthorized.");
158        refused
159            .headers_mut()
160            .insert(WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
161        return ControlFlow::Break(refused);
162    }
163    ControlFlow::Break(match metrics::prometheus().await {
164        Some(text) => answer::response(StatusCode::OK, Some(PROMETHEUS_TEXT), text),
165        None => answer::response(
166            StatusCode::SERVICE_UNAVAILABLE,
167            Some(TEXT),
168            "unavailable: metrics",
169        ),
170    })
171}