Skip to main content

privatus_analytics/
config.rs

1use crate::tag::Tag;
2use crate::value::{Props, Value};
3use std::fmt;
4use std::str::FromStr;
5
6/// Where `/js/pa.js` is served when nothing else is set.
7pub const DEFAULT_SCRIPT_HOST: &str = "https://privatusstats.com";
8
9/// How the tracker follows page changes in a single-page app (the `data-spa`
10/// attribute).
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
12pub enum Spa {
13    /// The tracker default. Prints no attribute.
14    #[default]
15    Auto,
16    /// Follow the History API (`pushState`).
17    History,
18    /// Follow changes to the `#hash` part of the address.
19    Hash,
20    /// Do not follow page changes.
21    Off,
22}
23
24impl Spa {
25    /// The attribute value: `auto`, `history`, `hash` or `off`.
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Spa::Auto => "auto",
29            Spa::History => "history",
30            Spa::Hash => "hash",
31            Spa::Off => "off",
32        }
33    }
34}
35
36impl fmt::Display for Spa {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(self.as_str())
39    }
40}
41
42/// The error for an unknown single-page app mode.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct UnknownSpaMode(pub String);
45
46impl fmt::Display for UnknownSpaMode {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(
49            f,
50            "unknown spa mode {:?}: use auto, history, hash or off",
51            self.0
52        )
53    }
54}
55
56impl std::error::Error for UnknownSpaMode {}
57
58impl FromStr for Spa {
59    type Err = UnknownSpaMode;
60
61    fn from_str(s: &str) -> Result<Self, Self::Err> {
62        match s.trim().to_ascii_lowercase().as_str() {
63            "auto" => Ok(Spa::Auto),
64            "history" => Ok(Spa::History),
65            "hash" => Ok(Spa::Hash),
66            "off" => Ok(Spa::Off),
67            _ => Err(UnknownSpaMode(s.to_owned())),
68        }
69    }
70}
71
72/// A Content Security Policy nonce for the current request.
73///
74/// Your own CSP middleware puts it in the request extensions (or a handler
75/// puts it in the response extensions). The axum and actix-web middleware
76/// read it from there and add it to the scripts they print.
77#[derive(Clone, Debug, PartialEq, Eq, Hash)]
78pub struct CspNonce(pub String);
79
80/// Settings for the tracking script.
81///
82/// Every setting is optional except the site id. Build it once at start up
83/// and share it, for example in your app state.
84///
85/// ```
86/// use privatus_analytics::{props, Config, Spa};
87///
88/// let config = Config::new()
89///     .site_id("pa_YOURSITEID")
90///     .modules(["engage", "auto"])
91///     .excluded_paths(["/admin/*", "/preview/*"])
92///     .spa(Spa::History)
93///     .props(props! { "app_version" => "2.4" });
94/// # let _ = config;
95/// ```
96///
97/// ## When the tag prints
98///
99/// The tag prints nothing without a site id. When [`Config::enabled`] is not
100/// set, the tag is on in release builds and off in debug builds. The check is
101/// `cfg!(not(debug_assertions))`, so `cargo run` and `cargo test` print
102/// nothing, and `cargo build --release` does. Set `enabled(true)` or
103/// `enabled(false)` to choose yourself.
104#[derive(Clone, Debug, Default, PartialEq)]
105pub struct Config {
106    site_id: Option<String>,
107    script_host: Option<String>,
108    modules: Option<Vec<String>>,
109    excluded_paths: Vec<String>,
110    domains: Option<Vec<String>>,
111    spa: Spa,
112    manual: bool,
113    enabled: Option<bool>,
114    auto_inject: Option<bool>,
115    extra_attributes: Vec<(String, String)>,
116    props: Props,
117    queue_stub: bool,
118}
119
120impl Config {
121    /// Settings with every default. The site id then comes from the
122    /// `PRIVATUS_SITE_ID` environment variable.
123    pub fn new() -> Self {
124        Config::default()
125    }
126
127    /// The site id, which starts with `pa_`. You find it under Site settings,
128    /// Tracking. Without it, falls back to `PRIVATUS_SITE_ID`.
129    pub fn site_id(mut self, site_id: impl Into<String>) -> Self {
130        self.site_id = Some(site_id.into());
131        self
132    }
133
134    /// Where `/js/pa.js` is served. Falls back to `PRIVATUS_SCRIPT_HOST`, then
135    /// `https://privatusstats.com`. A trailing slash is removed. Change it
136    /// only if you host your own copy.
137    pub fn script_host(mut self, host: impl Into<String>) -> Self {
138        self.script_host = Some(host.into());
139        self
140    }
141
142    /// Tracker modules to load (`data-modules`), for example
143    /// `["engage", "auto"]`. Setting it replaces the tracker default, so keep
144    /// `engage` for time on page and scroll depth. An empty list loads none.
145    pub fn modules<I, S>(mut self, modules: I) -> Self
146    where
147        I: IntoIterator<Item = S>,
148        S: Into<String>,
149    {
150        self.modules = Some(modules.into_iter().map(Into::into).collect());
151        self
152    }
153
154    /// Goes back to the tracker's default modules.
155    pub fn default_modules(mut self) -> Self {
156        self.modules = None;
157        self
158    }
159
160    /// Path globs the tracker never counts (`data-exclude`), for example
161    /// `["/admin/*"]`.
162    pub fn excluded_paths<I, S>(mut self, paths: I) -> Self
163    where
164        I: IntoIterator<Item = S>,
165        S: Into<String>,
166    {
167        self.excluded_paths = paths.into_iter().map(Into::into).collect();
168        self
169    }
170
171    /// Count only these hostnames (`data-domains`). Not set counts every host.
172    pub fn domains<I, S>(mut self, domains: I) -> Self
173    where
174        I: IntoIterator<Item = S>,
175        S: Into<String>,
176    {
177        self.domains = Some(domains.into_iter().map(Into::into).collect());
178        self
179    }
180
181    /// Single-page app mode (`data-spa`). Only modes other than
182    /// [`Spa::Auto`] are printed.
183    pub fn spa(mut self, spa: Spa) -> Self {
184        self.spa = spa;
185        self
186    }
187
188    /// `true` turns off the automatic pageview (`data-manual="true"`).
189    pub fn manual(mut self, manual: bool) -> Self {
190        self.manual = manual;
191        self
192    }
193
194    /// `true` or `false` forces the tag on or off. Not set means on in release
195    /// builds and off in debug builds.
196    pub fn enabled(mut self, enabled: bool) -> Self {
197        self.enabled = Some(enabled);
198        self
199    }
200
201    /// Whether the axum and actix-web middleware add the tag. Adding the
202    /// middleware turns it on, so this defaults to `true`. Set `false` to
203    /// keep the middleware in place but switched off.
204    pub fn auto_inject(mut self, auto_inject: bool) -> Self {
205        self.auto_inject = Some(auto_inject);
206        self
207    }
208
209    /// Adds any other attribute to the tracker tag, for example
210    /// `("data-mask", "/orders/*->/orders/:id")`. A name set twice keeps the
211    /// later value.
212    pub fn extra_attribute(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
213        set_pair(&mut self.extra_attributes, name.into(), value.into());
214        self
215    }
216
217    /// Page props sent with every pageview and event. For props that change
218    /// per request, see the middleware's `props_fn` or [`Tag::props`].
219    ///
220    /// Never put names, emails, user ids or anything else that points to one
221    /// person here.
222    pub fn props<I, K, V>(mut self, props: I) -> Self
223    where
224        I: IntoIterator<Item = (K, V)>,
225        K: Into<String>,
226        V: Into<Value>,
227    {
228        self.props = props.into_iter().collect();
229        self
230    }
231
232    /// `true` prints the tracker's queue stub first, so `privatus.track`
233    /// calls made before `pa.js` loads are kept and sent later.
234    pub fn queue_stub(mut self, queue_stub: bool) -> Self {
235        self.queue_stub = queue_stub;
236        self
237    }
238
239    /// The tag for these settings. Change any setting for one page on the
240    /// returned [`Tag`].
241    pub fn tag(&self) -> Tag<'_> {
242        Tag::new(self)
243    }
244
245    /// The site id in use: the configured one, else `PRIVATUS_SITE_ID`, else
246    /// `None`.
247    pub fn resolved_site_id(&self) -> Option<String> {
248        let configured = self.site_id.as_deref().unwrap_or("").trim();
249        if !configured.is_empty() {
250            return Some(configured.to_owned());
251        }
252        let env = std::env::var("PRIVATUS_SITE_ID").unwrap_or_default();
253        let env = env.trim();
254        (!env.is_empty()).then(|| env.to_owned())
255    }
256
257    /// The script host in use, without a trailing slash.
258    pub fn resolved_script_host(&self) -> String {
259        let mut host = self.script_host.as_deref().unwrap_or("").trim().to_owned();
260        if host.is_empty() {
261            host = std::env::var("PRIVATUS_SCRIPT_HOST")
262                .unwrap_or_default()
263                .trim()
264                .to_owned();
265        }
266        if host.is_empty() {
267            host = DEFAULT_SCRIPT_HOST.to_owned();
268        }
269        if host.ends_with('/') {
270            host.pop();
271        }
272        host
273    }
274
275    /// Whether the tag is on: the `enabled` setting when set, else
276    /// `cfg!(not(debug_assertions))` (on in release builds).
277    pub fn is_enabled(&self) -> bool {
278        self.enabled.unwrap_or(cfg!(not(debug_assertions)))
279    }
280
281    /// Whether the middleware should add the tag. Defaults to `true`.
282    pub fn is_auto_inject(&self) -> bool {
283        self.auto_inject.unwrap_or(true)
284    }
285
286    pub(crate) fn modules_list(&self) -> Option<&[String]> {
287        self.modules.as_deref()
288    }
289
290    pub(crate) fn excluded_paths_list(&self) -> &[String] {
291        &self.excluded_paths
292    }
293
294    pub(crate) fn domains_list(&self) -> Option<&[String]> {
295        self.domains.as_deref()
296    }
297
298    pub(crate) fn spa_mode(&self) -> Spa {
299        self.spa
300    }
301
302    pub(crate) fn is_manual(&self) -> bool {
303        self.manual
304    }
305
306    pub(crate) fn extra_attributes_list(&self) -> &[(String, String)] {
307        &self.extra_attributes
308    }
309
310    pub(crate) fn configured_props(&self) -> &Props {
311        &self.props
312    }
313
314    pub(crate) fn wants_queue_stub(&self) -> bool {
315        self.queue_stub
316    }
317}
318
319pub(crate) fn set_pair(pairs: &mut Vec<(String, String)>, name: String, value: String) {
320    match pairs.iter_mut().find(|(k, _)| *k == name) {
321        Some(slot) => slot.1 = value,
322        None => pairs.push((name, value)),
323    }
324}