webserver_base/analytics/
config.rs1use crate::env::{self, EnvError};
4
5pub const ENV_ANALYTICS_ID: &str = "WSB_ANALYTICS_ID";
7
8pub const ENV_SENTRY_BROWSER_DSN: &str = "WSB_SENTRY_BROWSER_DSN";
10
11pub const PLAUSIBLE_HOST: &str = "https://plausible.io";
13
14pub const SENTRY_CDN_HOST: &str = "https://js.sentry-cdn.com";
16
17#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct AnalyticsConfig {
26 script_id: String,
27}
28
29impl AnalyticsConfig {
30 #[must_use]
32 pub fn new(script_id: impl Into<String>) -> Self {
33 Self {
34 script_id: script_id.into(),
35 }
36 }
37
38 pub fn from_env() -> Result<Self, EnvError> {
44 Ok(Self::new(env::required(ENV_ANALYTICS_ID)?))
45 }
46
47 #[must_use]
49 pub fn script_id(&self) -> &str {
50 &self.script_id
51 }
52
53 #[must_use]
55 pub fn upstream_script_url(&self) -> String {
56 format!("{PLAUSIBLE_HOST}/js/{}.js", self.script_id)
57 }
58
59 #[must_use]
61 pub fn upstream_event_url() -> String {
62 format!("{PLAUSIBLE_HOST}/api/event")
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SentryDsn {
74 public_key: String,
75 host: String,
76 project_id: String,
77}
78
79#[derive(Debug, thiserror::Error)]
81#[error("`{dsn}` is not a Sentry DSN; expected `https://<key>@<host>/<project id>`")]
82pub struct SentryDsnParseError {
83 dsn: String,
84}
85
86impl SentryDsn {
87 pub fn parse(dsn: &str) -> Result<Self, SentryDsnParseError> {
93 let malformed = || SentryDsnParseError {
94 dsn: dsn.to_string(),
95 };
96
97 let scheme_end: usize = dsn.find("://").ok_or_else(malformed)? + 3;
98 let rest: &str = &dsn[scheme_end..];
99 let (public_key, remainder) = rest.split_once('@').ok_or_else(malformed)?;
100 let (host, project_id) = remainder.rsplit_once('/').ok_or_else(malformed)?;
101
102 if public_key.is_empty() || host.is_empty() || project_id.is_empty() {
103 return Err(malformed());
104 }
105
106 Ok(Self {
107 public_key: public_key.to_string(),
108 host: host.to_string(),
109 project_id: project_id.to_string(),
110 })
111 }
112
113 #[must_use]
115 pub fn upstream_script_url(&self) -> String {
116 format!("{SENTRY_CDN_HOST}/{}.min.js", self.public_key)
117 }
118
119 #[must_use]
121 pub fn upstream_envelope_url(&self) -> String {
122 format!("https://{}/api/{}/envelope/", self.host, self.project_id)
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::{AnalyticsConfig, SentryDsn};
129
130 #[test]
131 fn the_plausible_script_url_embeds_the_site_id() {
132 let config: AnalyticsConfig = AnalyticsConfig::new("pa-1qi0TQEvpewNxVHboeeOC");
133
134 let expected: String = String::from("https://plausible.io/js/pa-1qi0TQEvpewNxVHboeeOC.js");
135 let actual: String = config.upstream_script_url();
136 assert_eq!(expected, actual);
137 }
138
139 #[test]
140 fn a_dsn_yields_its_loader_script_and_envelope_endpoint() {
141 let dsn: SentryDsn =
142 SentryDsn::parse("https://abc123@o4504844394627072.ingest.sentry.io/4504862450515968")
143 .expect("a well-formed DSN");
144
145 let expected: String = String::from("https://js.sentry-cdn.com/abc123.min.js");
146 let actual: String = dsn.upstream_script_url();
147 assert_eq!(expected, actual);
148
149 let expected: String = String::from(
150 "https://o4504844394627072.ingest.sentry.io/api/4504862450515968/envelope/",
151 );
152 let actual: String = dsn.upstream_envelope_url();
153 assert_eq!(expected, actual);
154 }
155
156 #[test]
157 fn a_malformed_dsn_is_rejected_at_boot_rather_than_at_the_first_error() {
158 assert!(SentryDsn::parse("not-a-dsn").is_err());
159 assert!(SentryDsn::parse("https://o123.ingest.sentry.io/456").is_err());
160 assert!(SentryDsn::parse("https://key@host/").is_err());
161 assert!(SentryDsn::parse("https://@host/456").is_err());
162 }
163}