1use 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;
22const VALUE_LIMIT: f64 = 3_600_000.0;
24const 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 #[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 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
171fn 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}