use crate::env::{self, EnvError};
pub const ENV_ANALYTICS_ID: &str = "WSB_ANALYTICS_ID";
pub const ENV_SENTRY_BROWSER_DSN: &str = "WSB_SENTRY_BROWSER_DSN";
pub const PLAUSIBLE_HOST: &str = "https://plausible.io";
pub const SENTRY_CDN_HOST: &str = "https://js.sentry-cdn.com";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnalyticsConfig {
script_id: String,
}
impl AnalyticsConfig {
#[must_use]
pub fn new(script_id: impl Into<String>) -> Self {
Self {
script_id: script_id.into(),
}
}
pub fn from_env() -> Result<Self, EnvError> {
Ok(Self::new(env::required(ENV_ANALYTICS_ID)?))
}
#[must_use]
pub fn script_id(&self) -> &str {
&self.script_id
}
#[must_use]
pub fn upstream_script_url(&self) -> String {
format!("{PLAUSIBLE_HOST}/js/{}.js", self.script_id)
}
#[must_use]
pub fn upstream_event_url() -> String {
format!("{PLAUSIBLE_HOST}/api/event")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SentryDsn {
public_key: String,
host: String,
project_id: String,
}
#[derive(Debug, thiserror::Error)]
#[error("`{dsn}` is not a Sentry DSN; expected `https://<key>@<host>/<project id>`")]
pub struct SentryDsnParseError {
dsn: String,
}
impl SentryDsn {
pub fn parse(dsn: &str) -> Result<Self, SentryDsnParseError> {
let malformed = || SentryDsnParseError {
dsn: dsn.to_string(),
};
let scheme_end: usize = dsn.find("://").ok_or_else(malformed)? + 3;
let rest: &str = &dsn[scheme_end..];
let (public_key, remainder) = rest.split_once('@').ok_or_else(malformed)?;
let (host, project_id) = remainder.rsplit_once('/').ok_or_else(malformed)?;
if public_key.is_empty() || host.is_empty() || project_id.is_empty() {
return Err(malformed());
}
Ok(Self {
public_key: public_key.to_string(),
host: host.to_string(),
project_id: project_id.to_string(),
})
}
#[must_use]
pub fn upstream_script_url(&self) -> String {
format!("{SENTRY_CDN_HOST}/{}.min.js", self.public_key)
}
#[must_use]
pub fn upstream_envelope_url(&self) -> String {
format!("https://{}/api/{}/envelope/", self.host, self.project_id)
}
}
#[cfg(test)]
mod tests {
use super::{AnalyticsConfig, SentryDsn};
#[test]
fn the_plausible_script_url_embeds_the_site_id() {
let config: AnalyticsConfig = AnalyticsConfig::new("pa-1qi0TQEvpewNxVHboeeOC");
let expected: String = String::from("https://plausible.io/js/pa-1qi0TQEvpewNxVHboeeOC.js");
let actual: String = config.upstream_script_url();
assert_eq!(expected, actual);
}
#[test]
fn a_dsn_yields_its_loader_script_and_envelope_endpoint() {
let dsn: SentryDsn =
SentryDsn::parse("https://abc123@o4504844394627072.ingest.sentry.io/4504862450515968")
.expect("a well-formed DSN");
let expected: String = String::from("https://js.sentry-cdn.com/abc123.min.js");
let actual: String = dsn.upstream_script_url();
assert_eq!(expected, actual);
let expected: String = String::from(
"https://o4504844394627072.ingest.sentry.io/api/4504862450515968/envelope/",
);
let actual: String = dsn.upstream_envelope_url();
assert_eq!(expected, actual);
}
#[test]
fn a_malformed_dsn_is_rejected_at_boot_rather_than_at_the_first_error() {
assert!(SentryDsn::parse("not-a-dsn").is_err());
assert!(SentryDsn::parse("https://o123.ingest.sentry.io/456").is_err());
assert!(SentryDsn::parse("https://key@host/").is_err());
assert!(SentryDsn::parse("https://@host/456").is_err());
}
}