links/stats/
mod.rs

1//! Links statistics
2//!
3//! Statistics can be collected by the redirector server after every redirect
4//! and have a numeric value indicating the number of requests performed by
5//! someone/something that matches a particular [`Statistic`]. The value of the
6//! statistic is simply incremented for every matching request.
7//!
8//! Statistics are represented as a key-value pair, where the key is [this
9//! struct][`Statistic`] and the value is [a number][`StatisticValue`] that gets
10//! incremented each time a statistic is collected. The actual internal
11//! representation depends on the store backend, but could for example be a
12//! string like `id-or-vanity:statistic-type:statistic-time:statistic-data`,
13//! i.e. `example:user_agent_platform:2022-10-02T14:30:00Z:Windows`. It is also
14//! important to note that statistics are collected individually, not in a
15//! combined per-request object - they are simple counters, incremented per
16//! request. This helps in preserving the users' privacy, because multiple
17//! pieces of data can not reliably be correlated with each other, e.g. the
18//! server may know that there were 22 requests from Firefox users and 19
19//! requests using HTTP/2, but it can not know if any of these describe the same
20//! request.
21//!
22//! Not all statistics are necessarily always collected. A store backend may not
23//! support statistics, statistics may not be enabled in the configuration,
24//! there may not be enough data to collect a specific statistic, or statistic
25//! collection may fail. None of these situations are considered critical
26//! errors; statistics are not an integral part of links.
27
28mod internals;
29mod misc;
30
31use std::num::NonZeroU64;
32
33use hyper::{http::HeaderValue, Request, StatusCode};
34use serde::{Deserialize, Serialize};
35
36pub use self::{internals::*, misc::*};
37
38/// A links statistic
39///
40/// Internally, a [`Statistic`] is made up of its [link][`IdOrVanity`] (e.g.
41/// `07Qdzc9W` or `my-cool-link`), [type][`StatisticType`] (e.g. `HostRequest`
42/// or `StatusCode`), [time][`StatisticTime`] (e.g. `2022-10-22T19:15:00Z`), and
43/// [data][`StatisticData`] (e.g. `example.com` or `308`).
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45pub struct Statistic {
46	/// The ID or vanity path of the link that this statistic is about
47	pub link: IdOrVanity,
48	/// The type of this statistic
49	#[serde(rename = "type")]
50	pub stat_type: StatisticType,
51	/// The data for this statistic
52	pub data: StatisticData,
53	/// The approximate time this statistic was collected at
54	pub time: StatisticTime,
55}
56
57impl Statistic {
58	/// Create a new [`Statistic`] from the provided information and the current
59	/// time
60	pub fn new(
61		link: impl Into<IdOrVanity>,
62		stat_type: StatisticType,
63		data: impl Into<StatisticData>,
64	) -> Self {
65		Self {
66			link: link.into(),
67			stat_type,
68			data: data.into(),
69			time: StatisticTime::now(),
70		}
71	}
72
73	/// Get all statistics from the provided [`ExtraStatisticInfo`] and other
74	/// miscellaneous data. Only statistics specified by `categories` are
75	/// returned.
76	///
77	/// The returned value is an iterator over statistics with some or all of
78	/// the following types:
79	/// - [`StatisticType::Request`]
80	/// - [`StatisticType::StatusCode`]
81	/// - [`StatisticType::SniRequest`]
82	/// - [`StatisticType::TlsVersion`]
83	/// - [`StatisticType::TlsCipherSuite`]
84	pub fn get_misc(
85		link: Option<&IdOrVanity>,
86		stat_info: ExtraStatisticInfo,
87		status_code: StatusCode,
88		categories: StatisticCategories,
89	) -> impl Iterator<Item = Statistic> {
90		link.map_or_else(
91			|| Vec::new().into_iter(),
92			|link| {
93				let mut stats = Vec::with_capacity(5);
94
95				if categories.specifies(StatisticType::Request) {
96					stats.push(Self::new(
97						link,
98						StatisticType::Request,
99						StatisticData::default(),
100					));
101				}
102
103				if categories.specifies(StatisticType::StatusCode) {
104					stats.push(Self::new(
105						link,
106						StatisticType::StatusCode,
107						status_code.as_str(),
108					));
109				}
110
111				if categories.specifies(StatisticType::SniRequest) {
112					if let Some(sni) = stat_info.tls_sni {
113						stats.push(Self::new(link, StatisticType::SniRequest, sni.to_string()));
114					}
115				}
116
117				if categories.specifies(StatisticType::TlsVersion) {
118					if let Some(Some(version)) = stat_info.tls_version.map(|v| v.as_str()) {
119						stats.push(Self::new(link, StatisticType::TlsVersion, version));
120					}
121				}
122
123				if categories.specifies(StatisticType::TlsCipherSuite) {
124					if let Some(Some(suite)) =
125						stat_info.tls_cipher_suite.map(|s| s.suite().as_str())
126					{
127						stats.push(Self::new(link, StatisticType::TlsCipherSuite, suite));
128					}
129				}
130
131				stats.into_iter()
132			},
133		)
134	}
135
136	/// Get all possible statistics from the provided HTTP request info. Only
137	/// statistics specified by `categories` are returned.
138	///
139	/// The returned value is an iterator over statistics with some or all of
140	/// the following types:
141	/// - [`StatisticType::HostRequest`]
142	/// - [`StatisticType::HttpVersion`]
143	/// - [`StatisticType::UserAgent`]
144	/// - [`StatisticType::UserAgentMobile`]
145	/// - [`StatisticType::UserAgentPlatform`]
146	pub fn from_req<T>(
147		link: Option<&IdOrVanity>,
148		req: &Request<T>,
149		categories: StatisticCategories,
150	) -> impl Iterator<Item = Statistic> {
151		link.map_or_else(
152			|| Vec::new().into_iter(),
153			|link| {
154				let mut stats = Vec::with_capacity(5);
155
156				if categories.specifies(StatisticType::HttpVersion) {
157					stats.push(Self::new(
158						link,
159						StatisticType::HttpVersion,
160						HttpVersion::from(req.version()).as_str(),
161					));
162				}
163
164				let headers = req.headers();
165
166				if categories.specifies(StatisticType::HostRequest) {
167					if let Some(Ok(host)) = req
168						.uri()
169						.host()
170						.map(Ok)
171						.or_else(|| headers.get("host").map(HeaderValue::to_str))
172					{
173						stats.push(Self::new(link, StatisticType::HostRequest, host));
174					}
175				}
176
177				if categories.user_agent {
178					if let Some(Ok(val)) = headers.get("sec-ch-ua").map(HeaderValue::to_str) {
179						stats.push(Self::new(link, StatisticType::UserAgent, val));
180					} else if let Some(Ok(val)) = headers.get("user-agent").map(HeaderValue::to_str)
181					{
182						stats.push(Self::new(link, StatisticType::UserAgent, val));
183					}
184				}
185
186				if categories.specifies(StatisticType::UserAgentMobile) {
187					if let Some(Ok(val)) = headers.get("sec-ch-ua-mobile").map(HeaderValue::to_str)
188					{
189						stats.push(Self::new(link, StatisticType::UserAgentMobile, val));
190					}
191				}
192
193				if categories.specifies(StatisticType::UserAgentPlatform) {
194					if let Some(Ok(val)) =
195						headers.get("sec-ch-ua-platform").map(HeaderValue::to_str)
196					{
197						stats.push(Self::new(link, StatisticType::UserAgentPlatform, val));
198					}
199				}
200
201				stats.into_iter()
202			},
203		)
204	}
205}
206
207/// A description of one or more [`Statistic`]s, where some fields may be
208/// omitted so that they act as a wildcard
209///
210/// This struct is intended for use with the links store and RPC API, and can
211/// describe multiple statistics by specifying only some of a statistic's
212/// fields. When a field is omitted, all values for it are accepted. Therefore,
213/// for example to get all statistics for a given link regardless of type, data,
214/// or time, only the `link` field of the [`StatisticDescription`] would be
215/// `Some(...)`, while all others are `None`. If all fields are `None`, then all
216/// statistics for all links are matched.
217#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
218pub struct StatisticDescription {
219	/// The ID or vanity path of the link that this statistic is about
220	pub link: Option<IdOrVanity>,
221	/// The type of this statistic, see [`StatisticType`]
222	#[serde(rename = "type")]
223	pub stat_type: Option<StatisticType>,
224	/// The data for this statistic
225	pub data: Option<StatisticData>,
226	/// The approximate time this statistic was collected at
227	pub time: Option<StatisticTime>,
228}
229
230impl StatisticDescription {
231	/// Check whether the provided [`Statistic`] matches this description
232	#[must_use]
233	pub fn matches(&self, stat: &Statistic) -> bool {
234		(self.link.is_none() || self.link.as_ref() == Some(&stat.link))
235			&& (self.stat_type.is_none() || self.stat_type.as_ref() == Some(&stat.stat_type))
236			&& (self.data.is_none() || self.data.as_ref() == Some(&stat.data))
237			&& (self.time.is_none() || self.time.as_ref() == Some(&stat.time))
238	}
239}
240
241/// The value of a links statistic
242///
243/// A [`StatisticValue`] represents the number of requests matching a particular
244/// [`Statistic`]. This gets incremented (inside of the store) every time a
245/// statistic is collected.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
247pub struct StatisticValue {
248	count: NonZeroU64,
249}
250
251impl StatisticValue {
252	/// Create a new [`StatisticValue`] with the provided count
253	///
254	/// This function returns `None` if the count is 0
255	#[must_use]
256	pub fn new(count: u64) -> Option<Self> {
257		Some(Self {
258			count: NonZeroU64::new(count)?,
259		})
260	}
261
262	/// Get the numeric count of this statistic value
263	///
264	/// The returned value is never 0. If the returned value is used to
265	/// construct a [`NonZeroU64`], you can instead use `get_nonzero` to get one
266	/// directly.
267	#[must_use]
268	pub const fn get(self) -> u64 {
269		self.count.get()
270	}
271
272	/// Get the numeric count of this statistic value as a [`NonZeroU64`]
273	#[must_use]
274	pub const fn get_nonzero(self) -> NonZeroU64 {
275		self.count
276	}
277
278	/// Increment this [`StatisticValue`], returning the next value up
279	#[must_use]
280	pub const fn increment(self) -> Self {
281		Self {
282			count: self.count.saturating_add(1),
283		}
284	}
285}
286
287impl Default for StatisticValue {
288	fn default() -> Self {
289		Self::new(1).expect("1 is not 0")
290	}
291}
292
293#[cfg(test)]
294mod tests {
295	use links_id::Id;
296	use links_normalized::Normalized;
297	use tokio_rustls::rustls::{crypto::ring::ALL_CIPHER_SUITES, ProtocolVersion};
298
299	use super::*;
300
301	#[test]
302	fn statistic() {
303		assert_eq!(
304			Statistic::new(Id::new(), StatisticType::Request, "").time,
305			StatisticTime::now()
306		);
307
308		let id = Id::new();
309		assert_eq!(
310			Statistic::new(id, StatisticType::Request, "").link,
311			id.into()
312		);
313
314		assert_eq!(
315			Statistic::new(Id::new(), StatisticType::Request, "").stat_type,
316			StatisticType::Request
317		);
318
319		assert_eq!(
320			Statistic::new(Id::new(), StatisticType::Request, "this is a test").data,
321			"this is a test".into()
322		);
323
324		assert_eq!(
325			Statistic::new(Normalized::new("a link"), StatisticType::StatusCode, "501"),
326			Statistic {
327				link: IdOrVanity::Vanity(Normalized::new("a link")),
328				stat_type: StatisticType::StatusCode,
329				time: StatisticTime::now(),
330				data: StatisticData::from("501")
331			}
332		);
333	}
334
335	#[test]
336	fn statistic_collection() {
337		let stats = Statistic::get_misc(
338			Some(&Normalized::new("test").into()),
339			ExtraStatisticInfo {
340				tls_sni: Some("example.com".into()),
341				tls_version: Some(ProtocolVersion::TLSv1_3),
342				tls_cipher_suite: Some(ALL_CIPHER_SUITES[0]),
343			},
344			StatusCode::TEMPORARY_REDIRECT,
345			StatisticCategories::ALL,
346		)
347		.map(|s| s.stat_type)
348		.collect::<Vec<_>>();
349
350		assert!(stats.contains(&StatisticType::Request));
351		assert!(stats.contains(&StatisticType::StatusCode));
352		assert!(stats.contains(&StatisticType::SniRequest));
353		assert!(stats.contains(&StatisticType::TlsVersion));
354		assert!(stats.contains(&StatisticType::TlsCipherSuite));
355
356		let stats = Statistic::from_req(
357			Some(&Normalized::new("test").into()),
358			&Request::builder()
359				.header("Host", "example.com")
360				.header(
361					"Sec-CH-UA",
362					r#"" Not A;Brand";v="99", "Chromium";v="96", "Google Chrome";v="96""#,
363				)
364				.header("Sec-CH-UA-Mobile", "?0")
365				.header("Sec-CH-UA-Platform", "Windows")
366				.body(Vec::<u8>::new())
367				.unwrap(),
368			StatisticCategories::ALL,
369		)
370		.map(|s| s.stat_type)
371		.collect::<Vec<_>>();
372
373		assert!(stats.contains(&StatisticType::HostRequest));
374		assert!(stats.contains(&StatisticType::HttpVersion));
375		assert!(stats.contains(&StatisticType::UserAgent));
376		assert!(stats.contains(&StatisticType::UserAgentMobile));
377		assert!(stats.contains(&StatisticType::UserAgentPlatform));
378
379		let mut stats = Statistic::from_req(
380			Some(&Normalized::new("test").into()),
381			&Request::builder()
382				.header("Host", "example.com")
383				.header(
384					"Sec-CH-UA",
385					r#"" Not A;Brand";v="99", "Chromium";v="96", "Google Chrome";v="96""#,
386				)
387				.header("Sec-CH-UA-Mobile", "?0")
388				.header("Sec-CH-UA-Platform", "Windows")
389				.body(Vec::<u8>::new())
390				.unwrap(),
391			StatisticCategories::NONE,
392		);
393
394		// Nothing collected
395		assert!(stats.next().is_none());
396
397		let stats = Statistic::from_req(
398			Some(&Normalized::new("test").into()),
399			&Request::builder()
400				.header("Host", "example.com")
401				.header(
402					"Sec-CH-UA",
403					r#"" Not A;Brand";v="99", "Chromium";v="96", "Google Chrome";v="96""#,
404				)
405				.header("Sec-CH-UA-Mobile", "?0")
406				.header("Sec-CH-UA-Platform", "Windows")
407				.body(Vec::<u8>::new())
408				.unwrap(),
409			StatisticCategories::default(),
410		)
411		.map(|s| s.stat_type)
412		.collect::<Vec<_>>();
413
414		assert!(stats.contains(&StatisticType::HostRequest));
415		assert!(stats.contains(&StatisticType::HttpVersion));
416		assert!(!stats.contains(&StatisticType::UserAgent));
417		assert!(!stats.contains(&StatisticType::UserAgentMobile));
418		assert!(!stats.contains(&StatisticType::UserAgentPlatform));
419	}
420
421	#[test]
422	fn statistic_serde() {
423		let vanity = Normalized::new("test-vanity");
424
425		let stats = vec![
426			Statistic::new(vanity.clone(), StatisticType::Request, ""),
427			Statistic::new(
428				vanity.clone(),
429				StatisticType::UserAgent,
430				r#"" Not A;Brand";v="99", "Chromium";v="96", "Google Chrome";v="96""#,
431			),
432			Statistic::new(vanity, StatisticType::HttpVersion, HttpVersion::V2.as_str()),
433		];
434
435		let json = serde_json::to_string(&stats).unwrap();
436
437		let parsed_stats = serde_json::from_str::<Vec<Statistic>>(&json).unwrap();
438
439		assert_eq!(stats, parsed_stats);
440
441		let id = Id::from([1, 2, 3, 4, 5]);
442
443		let stats = vec![
444			Statistic::new(id, StatisticType::Request, ""),
445			Statistic::new(
446				id,
447				StatisticType::UserAgent,
448				r#"" Not A;Brand";v="99", "Chromium";v="96", "Google Chrome";v="96""#,
449			),
450			Statistic::new(id, StatisticType::HttpVersion, HttpVersion::V2.as_str()),
451		];
452
453		let json = serde_json::to_string_pretty(&stats).unwrap();
454
455		let parsed_stats = serde_json::from_str::<Vec<Statistic>>(&json).unwrap();
456
457		assert_eq!(stats, parsed_stats);
458	}
459
460	#[test]
461	fn statistic_description() {
462		let desc = StatisticDescription {
463			link: None,
464			stat_type: None,
465			data: None,
466			time: None,
467		};
468
469		assert!(desc.matches(&Statistic::new(Id::new(), StatisticType::Request, "")));
470		assert!(desc.matches(&Statistic::new(
471			Normalized::new("a test"),
472			StatisticType::StatusCode,
473			"400"
474		)));
475
476		let desc = StatisticDescription {
477			link: Some(Normalized::new("a test").into()),
478			stat_type: None,
479			data: None,
480			time: None,
481		};
482
483		assert!(!desc.matches(&Statistic::new(Id::new(), StatisticType::Request, "")));
484		assert!(desc.matches(&Statistic::new(
485			Normalized::new("a test"),
486			StatisticType::StatusCode,
487			"400"
488		)));
489
490		let desc = StatisticDescription {
491			link: Some(Normalized::new("a test").into()),
492			stat_type: Some(StatisticType::Request),
493			data: None,
494			time: None,
495		};
496
497		assert!(!desc.matches(&Statistic::new(Id::new(), StatisticType::Request, "")));
498		assert!(!desc.matches(&Statistic::new(
499			Normalized::new("a test"),
500			StatisticType::StatusCode,
501			"400"
502		)));
503
504		let desc = StatisticDescription {
505			link: None,
506			stat_type: Some(StatisticType::Request),
507			data: None,
508			time: None,
509		};
510
511		assert!(desc.matches(&Statistic::new(Id::new(), StatisticType::Request, "")));
512		assert!(!desc.matches(&Statistic::new(
513			Normalized::new("a test"),
514			StatisticType::StatusCode,
515			"400"
516		)));
517
518		let desc = StatisticDescription {
519			link: None,
520			stat_type: None,
521			data: None,
522			time: Some(StatisticTime::try_from("2020-01-01T12:34:56.789Z").unwrap()),
523		};
524
525		assert!(!desc.matches(&Statistic::new(Id::new(), StatisticType::Request, "")));
526		assert!(!desc.matches(&Statistic::new(
527			Normalized::new("a test"),
528			StatisticType::StatusCode,
529			"400"
530		)));
531	}
532
533	#[test]
534	fn statistic_value() {
535		assert_eq!(StatisticValue::new(1), Some(StatisticValue::default()));
536		assert!(StatisticValue::new(0).is_none());
537		assert!(StatisticValue::new(1).is_some());
538		assert!(StatisticValue::new(1_000_000).is_some());
539
540		let stat_val = StatisticValue::default();
541		assert_eq!(stat_val.get(), 1);
542		let stat_val = stat_val.increment();
543		assert_eq!(stat_val.get(), 2);
544
545		let stat_val = StatisticValue::default();
546		assert_eq!(stat_val.get_nonzero(), NonZeroU64::new(1).unwrap());
547		let stat_val = stat_val.increment();
548		assert_eq!(stat_val.get_nonzero(), NonZeroU64::new(2).unwrap());
549
550		let stat_val = StatisticValue::new(u64::MAX - 1).unwrap();
551		assert_eq!(stat_val.get(), u64::MAX - 1);
552		let stat_val = stat_val.increment();
553		assert_eq!(stat_val.get(), u64::MAX);
554		let stat_val = stat_val.increment();
555		assert_eq!(stat_val.get(), u64::MAX);
556	}
557}