Skip to main content

sofie_http/
browser.rs

1//! Nothing a person typed or a page showed is accepted: only a kind, a listed name and a number.
2
3use std::ops::ControlFlow;
4
5use http::{Method, StatusCode};
6use hyper::body::Incoming;
7use serde_json::Value;
8use sofie_core::{Error, Result, Timestamp};
9use sofie_db::Db;
10
11use crate::admission::too_many_requests;
12use crate::answer::{self, Response};
13use crate::cookie::CookieNames;
14use crate::pipeline::{Asked, read_body};
15use crate::routes::Route;
16use crate::throttle::{Limiter, Rate, Requester};
17use crate::{RouteName, csrf};
18
19pub(crate) const PATH: &str = "/_sofie/telemetry";
20const BODY_LIMIT: usize = 8 * 1024;
21const RECORDS_LIMIT: usize = 32;
22/// Past this a value is a broken clock, not a measurement.
23const VALUE_LIMIT: f64 = 3_600_000.0;
24/// Past this an address is flooding the log, not browsing.
25const RATE: Rate = Rate::per_minute(60);
26const OTHER: &str = "other";
27const UNNAMED: &str = "unnamed";
28
29const KINDS: [(&str, &[&str]); 4] = [
30    ("vital", &["CLS", "FCP", "INP", "LCP", "TTFB"]),
31    (
32        "error",
33        &[
34            "Error",
35            "AggregateError",
36            "EvalError",
37            "RangeError",
38            "ReferenceError",
39            "SyntaxError",
40            "TypeError",
41            "URIError",
42            "unhandledrejection",
43        ],
44    ),
45    ("navigation", &["load", "htmx", "client"]),
46    ("long_task", &["longtask"]),
47];
48
49#[cfg(feature = "metrics")]
50static BROWSER_EVENTS: sofie_telemetry::metrics::Instrument =
51    sofie_telemetry::metrics::Instrument {
52        name: "sofie.browser.events",
53        description: "Browser telemetry records, by kind, name and page route.",
54        kind: sofie_telemetry::metrics::Kind::Counter,
55        labels: &["browser.kind", "browser.name", "http.route"],
56    };
57
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct BrowserSample(f64);
60
61impl BrowserSample {
62    pub fn parse(text: &str) -> Result<Self> {
63        let share: f64 = text.trim().parse().map_err(|_| refusal(text))?;
64        if !(0.0..=1.0).contains(&share) {
65            return Err(refusal(text));
66        }
67        Ok(Self(share))
68    }
69}
70
71impl Default for BrowserSample {
72    fn default() -> Self {
73        Self(1.0)
74    }
75}
76
77fn refusal(text: &str) -> Error {
78    Error::internal_message(format!(
79        "browser sample `{text}` is not a share from 0 to 1, such as 0.1"
80    ))
81}
82
83impl crate::Serving {
84    /// `sofie::run` reads it from `SOFIE_BROWSER_SAMPLE`.
85    #[must_use]
86    pub fn browser_sample(mut self, sample: BrowserSample) -> Self {
87        self.browser_sample = sample;
88        self
89    }
90}
91
92pub(crate) struct Browser {
93    routes: Vec<&'static str>,
94    sample: BrowserSample,
95    limiter: Limiter,
96    cookies: CookieNames,
97}
98
99impl Browser {
100    pub(crate) fn new(
101        names: &[RouteName],
102        routes: &[Route],
103        sample: BrowserSample,
104        cookies: CookieNames,
105    ) -> Self {
106        let libraries = routes.iter().filter_map(|route| route.name);
107        Self {
108            routes: names
109                .iter()
110                .map(|name| name.name)
111                .chain(libraries)
112                .collect(),
113            sample,
114            limiter: Limiter::new(RATE),
115            cookies,
116        }
117    }
118
119    /// Every value is the server's own.
120    pub(crate) fn meta(&self, route: Option<&'static str>, traceparent: &str) -> String {
121        format!(
122            "<meta name=\"sofie-telemetry\" content=\"{PATH}\" data-route=\"{}\" \
123             data-traceparent=\"{traceparent}\" data-sample=\"{}\">",
124            route.unwrap_or(UNNAMED),
125            self.sample.0
126        )
127    }
128
129    pub(crate) async fn answer(
130        &self,
131        asked: Asked<'_>,
132        db: &Db,
133        body: &mut Incoming,
134    ) -> ControlFlow<Response> {
135        if asked.request.method != Method::POST || asked.request.uri.path() != PATH {
136            return ControlFlow::Continue(());
137        }
138        let now = db.clock().now().timestamp();
139        ControlFlow::Break(self.receive(asked, body, now).await)
140    }
141
142    async fn receive(&self, asked: Asked<'_>, body: &mut Incoming, now: Timestamp) -> Response {
143        let caller = asked.caller;
144        if csrf::refused_by(asked.request, &self.cookies).is_some() {
145            return answer::refusal(StatusCode::FORBIDDEN, "Refused.", caller);
146        }
147        if let Err(wait) = self.limiter.admit(Requester::of(None, asked.client), now) {
148            return too_many_requests(wait, asked);
149        }
150        let body = match read_body(body, BODY_LIMIT, caller).await {
151            Ok(body) => body,
152            Err(refusal) => return *refusal,
153        };
154        let Ok(posted) = serde_json::from_slice::<Value>(&body) else {
155            return answer::refusal(StatusCode::BAD_REQUEST, "Not telemetry.", caller);
156        };
157        let route = posted["route"]
158            .as_str()
159            .and_then(|named| self.routes.iter().copied().find(|route| *route == named))
160            .unwrap_or(UNNAMED);
161        let records = posted["records"].as_array().map_or(&[][..], Vec::as_slice);
162        for record in records.iter().take(RECORDS_LIMIT) {
163            if let Some((kind, name, value)) = accepted(record) {
164                recorded(route, kind, name, value);
165            }
166        }
167        answer::response(StatusCode::NO_CONTENT, None, bytes::Bytes::new())
168    }
169}
170
171/// Milliseconds, unitless for `CLS`.
172fn accepted(record: &Value) -> Option<(&'static str, &'static str, f64)> {
173    let (kind, names) = KINDS
174        .iter()
175        .find(|(kind, _)| record["kind"].as_str() == Some(*kind))?;
176    let name = names
177        .iter()
178        .copied()
179        .find(|name| record["name"].as_str() == Some(*name))
180        .unwrap_or(OTHER);
181    let value = record["value"]
182        .as_f64()
183        .filter(|value| value.is_finite())
184        .map_or(0.0, |value| value.clamp(0.0, VALUE_LIMIT));
185    Some((kind, name, value))
186}
187
188fn recorded(route: &'static str, kind: &'static str, name: &'static str, value: f64) {
189    tracing::info!(
190        browser.kind = kind,
191        browser.name = name,
192        browser.value = value,
193        http.route = route,
194        "browser telemetry"
195    );
196    #[cfg(feature = "metrics")]
197    sofie_telemetry::metrics::record(
198        &BROWSER_EVENTS,
199        &[kind.into(), name.into(), route.into()],
200        1.0,
201    );
202}