Skip to main content

webserver_base/analytics/
config.rs

1//! What the proxies need to know about their upstreams.
2
3use crate::env::{self, EnvError};
4
5/// The environment variable holding the Plausible script id.
6pub const ENV_ANALYTICS_ID: &str = "WSB_ANALYTICS_ID";
7
8/// The environment variable holding the browser Sentry DSN.
9pub const ENV_SENTRY_BROWSER_DSN: &str = "WSB_SENTRY_BROWSER_DSN";
10
11/// Plausible's script host.
12pub const PLAUSIBLE_HOST: &str = "https://plausible.io";
13
14/// Sentry's loader-script CDN.
15pub const SENTRY_CDN_HOST: &str = "https://js.sentry-cdn.com";
16
17/// Which Plausible site events belong to.
18///
19/// Modern Plausible identifies a site by the id embedded in its script URL
20/// rather than a `data-domain` attribute, so this is the whole configuration.
21/// Deriving it from the request's hostname instead would split `www.` and
22/// apex traffic into two sites Plausible does not both know about, and would
23/// let a staging deploy pour into production's numbers.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct AnalyticsConfig {
26    script_id: String,
27}
28
29impl AnalyticsConfig {
30    /// Events for the site this script id belongs to.
31    #[must_use]
32    pub fn new(script_id: impl Into<String>) -> Self {
33        Self {
34            script_id: script_id.into(),
35        }
36    }
37
38    /// Reads [`ENV_ANALYTICS_ID`].
39    ///
40    /// # Errors
41    ///
42    /// [`EnvError`] if it is unset or blank.
43    pub fn from_env() -> Result<Self, EnvError> {
44        Ok(Self::new(env::required(ENV_ANALYTICS_ID)?))
45    }
46
47    /// The configured script id.
48    #[must_use]
49    pub fn script_id(&self) -> &str {
50        &self.script_id
51    }
52
53    /// The upstream script URL this proxy fetches.
54    #[must_use]
55    pub fn upstream_script_url(&self) -> String {
56        format!("{PLAUSIBLE_HOST}/js/{}.js", self.script_id)
57    }
58
59    /// The upstream event endpoint this proxy forwards to.
60    #[must_use]
61    pub fn upstream_event_url() -> String {
62        format!("{PLAUSIBLE_HOST}/api/event")
63    }
64}
65
66/// A parsed Sentry DSN.
67///
68/// Only the pieces the tunnel needs: which host to relay to, and which project.
69/// Because a frontend declares exactly one browser DSN, the tunnel never has to
70/// parse an envelope to discover where an event belongs — which keeps it a dumb
71/// byte relay and stops it being usable as an open relay to arbitrary projects.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SentryDsn {
74    public_key: String,
75    host: String,
76    project_id: String,
77}
78
79/// Why a DSN could not be understood.
80#[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    /// Parses `https://<public key>@<host>/<project id>`.
88    ///
89    /// # Errors
90    ///
91    /// [`SentryDsnParseError`] if the DSN is not in that shape.
92    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    /// The upstream loader script for this DSN's public key.
114    #[must_use]
115    pub fn upstream_script_url(&self) -> String {
116        format!("{SENTRY_CDN_HOST}/{}.min.js", self.public_key)
117    }
118
119    /// Where envelopes are relayed, verbatim.
120    #[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}