Skip to main content

renox_core/
analytics.rs

1//! Analytics events, with Google Analytics 4 and Tag Manager configured in
2//! `.env` (see `AnalyticsConfig`).
3//!
4//! From a handler, whatever the response is:
5//!
6//! ```
7//! # use renox::prelude::*;
8//! # fn demo(session: &Session) -> Result {
9//! renox::analytics::event(&session, "sign_up", json!({ "method": "email" }))?;
10//! # Ok(()) }
11//! ```
12//!
13//! The event reaches the browser with this response when it's an htmx swap
14//! (an `HX-Trigger`) or a page, or with the next page after a redirect;
15//! renox.js passes it to `gtag('event', …)` and, with Tag Manager, pushes it
16//! to the `dataLayer`. Page views need nothing: GA4 counts the history changes
17//! `hx-boost` makes (enhanced measurement), and GTM has a History Change trigger.
18//!
19//! Events that must not be lost to ad blockers (a purchase) can go from the
20//! server instead, through the Measurement Protocol:
21//!
22//! ```
23//! # use renox::prelude::*;
24//! use renox::analytics::{GaClientId, ServerEvent};
25//!
26//! async fn paid(State(state): State<AppState>, GaClientId(client): GaClientId) -> Result<Redirect> {
27//!     state.dispatch(ServerEvent::new(client, "purchase").param("value", 49.99).param("currency", "USD")).await?;
28//!     Ok(Redirect::to("/thanks"))
29//! }
30//! ```
31
32use std::convert::Infallible;
33
34use axum::extract::FromRequestParts;
35use axum::http::Response;
36use axum::http::header::{COOKIE, LOCATION};
37use axum::http::request::Parts;
38use serde::{Deserialize, Serialize};
39use serde_json::{Value, json};
40
41use crate::{Result, Session};
42
43const SESSION_KEY: &str = "_renox_analytics";
44const TRIGGER: &str = "renox:analytics";
45
46/// One analytics event: a name such as `sign_up` and its parameters.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[non_exhaustive]
49pub struct Event {
50    /// The event name, e.g. `sign_up`.
51    pub name: String,
52    /// The event's parameters, a JSON object (empty when missing).
53    #[serde(default)]
54    pub params: Value,
55}
56
57/// Queues an event for the visitor's browser (see the module docs).
58pub fn event(session: &Session, name: &str, params: impl Serialize) -> Result {
59    let mut events: Vec<Event> = session.get(SESSION_KEY).unwrap_or_default();
60    events.push(Event {
61        name: name.to_owned(),
62        params: serde_json::to_value(params)?,
63    });
64    session.put(SESSION_KEY, events)
65}
66
67/// Takes the events waiting for the browser.
68pub(crate) fn take(session: &Session) -> Vec<Event> {
69    session.pull(SESSION_KEY).unwrap_or_default()
70}
71
72pub(crate) fn has_pending(session: &Session) -> bool {
73    session.has(SESSION_KEY)
74}
75
76/// The `<meta>` renox.js reads on page load.
77pub(crate) fn events_meta(events: &[Event]) -> String {
78    if events.is_empty() {
79        return String::new();
80    }
81    let json = serde_json::to_string(events).unwrap_or_default();
82    format!(
83        "<meta name=\"renox-analytics\" content=\"{}\">\n",
84        crate::seo::escape(&json)
85    )
86}
87
88/// Whether an htmx response can carry the events: a swap, not a redirect.
89pub(crate) fn deliverable_by_htmx<B>(res: &Response<B>) -> bool {
90    res.status().is_success()
91        && !res.headers().contains_key("hx-redirect")
92        && !res.headers().contains_key("hx-location")
93        && !res.headers().contains_key("hx-refresh")
94        && !res.headers().contains_key(LOCATION)
95}
96
97/// Adds the events to the response's `HX-Trigger`, keeping any it has.
98pub(crate) fn add_trigger<B>(res: &mut Response<B>, events: Vec<Event>) {
99    crate::htmx::add_trigger(res, TRIGGER, json!({ "events": events }));
100}
101
102/// The GA4 client id from the `_ga` cookie (`GA1.1.123.456` → `123.456`),
103/// for server-side events. `None` when the visitor has none (no consent, an
104/// ad blocker, or analytics off).
105#[derive(Debug, Clone)]
106pub struct GaClientId(pub Option<String>);
107
108impl<S: Send + Sync> FromRequestParts<S> for GaClientId {
109    type Rejection = Infallible;
110
111    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Infallible> {
112        let id = parts
113            .headers
114            .get_all(COOKIE)
115            .iter()
116            .filter_map(|v| v.to_str().ok())
117            .flat_map(|v| v.split(';'))
118            .filter_map(|pair| pair.trim().strip_prefix("_ga="))
119            .find_map(client_id_from_cookie);
120        Ok(Self(id))
121    }
122}
123
124fn client_id_from_cookie(value: &str) -> Option<String> {
125    let parts: Vec<&str> = value.split('.').collect();
126    (parts.len() >= 4 && parts[0].starts_with("GA"))
127        .then(|| format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]))
128}
129
130#[cfg(feature = "server-events")]
131pub use server_event::ServerEvent;
132
133/// `ServerEvent` needs an HTTP client, so it comes with the
134/// `server-events` feature (on by default).
135#[cfg(feature = "server-events")]
136mod server_event {
137
138    use anyhow::anyhow;
139
140    use super::*;
141    use crate::config::Environment;
142    use crate::queue::{Job, JobContext};
143    use serde_json::Map;
144
145    /// An event sent from the server to GA4 (Measurement Protocol), as a queue
146    /// job so a slow or failing request never holds up the page. Needs
147    /// `GA4_MEASUREMENT_ID` and `GA4_API_SECRET`; outside production it's only logged.
148    #[derive(Debug, Clone, Serialize, Deserialize)]
149    pub struct ServerEvent {
150        client_id: String,
151        user_id: Option<String>,
152        name: String,
153        params: Map<String, Value>,
154    }
155
156    impl ServerEvent {
157        /// `client_id` from [`GaClientId`]; without one a random id is used, so
158        /// the event still counts but isn't linked to the visitor's session.
159        pub fn new(client_id: Option<String>, name: &str) -> Self {
160            let client_id = client_id.unwrap_or_else(|| {
161                let [a, b] = [rand::random::<u32>(), rand::random::<u32>()];
162                format!("{a}.{b}")
163            });
164            Self {
165                client_id,
166                user_id: None,
167                name: name.to_owned(),
168                params: Map::new(),
169            }
170        }
171
172        /// Adds a parameter; a value that can't be serialized becomes `null`.
173        pub fn param(mut self, key: &str, value: impl Serialize) -> Self {
174            self.params.insert(
175                key.to_owned(),
176                serde_json::to_value(value).unwrap_or(Value::Null),
177            );
178            self
179        }
180
181        /// Links the event to your own user id (GA4 User-ID).
182        pub fn user_id(mut self, id: impl ToString) -> Self {
183            self.user_id = Some(id.to_string());
184            self
185        }
186
187        /// The JSON body sent to `/mp/collect`.
188        pub fn payload(&self) -> Value {
189            let mut body = json!({
190                "client_id": self.client_id,
191                "events": [{ "name": self.name, "params": self.params }],
192            });
193            if let Some(user_id) = &self.user_id {
194                body["user_id"] = json!(user_id);
195            }
196            body
197        }
198    }
199
200    impl Job for ServerEvent {
201        const NAME: &'static str = "renox:analytics";
202
203        async fn handle(self, ctx: JobContext) -> Result {
204            let config = &ctx.state.config;
205            let analytics = &config.analytics;
206            let (Some(id), Some(secret)) =
207                (&analytics.ga4_measurement_id, &analytics.ga4_api_secret)
208            else {
209                tracing::debug!(event = %self.name, "analytics event not sent: GA4_MEASUREMENT_ID or GA4_API_SECRET is not set");
210                return Ok(());
211            };
212            if config.env != Environment::Production {
213                tracing::info!(event = %self.name, payload = %self.payload(), "analytics event (not sent outside production)");
214                return Ok(());
215            }
216            let res = ctx
217                .state
218                .http
219                .post("https://www.google-analytics.com/mp/collect")
220                .query(&[("measurement_id", id), ("api_secret", secret)])
221                .json(&self.payload())
222                .timeout(std::time::Duration::from_secs(10))
223                .send()
224                .await?;
225            if !res.ok() {
226                return Err(anyhow!("GA4 answered {}", res.status()).into());
227            }
228            Ok(())
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use axum::http::HeaderValue;
237
238    #[test]
239    fn reads_the_client_id_from_the_ga_cookie() {
240        assert_eq!(
241            client_id_from_cookie("GA1.1.1234567890.1700000000").as_deref(),
242            Some("1234567890.1700000000")
243        );
244        assert_eq!(client_id_from_cookie("junk"), None);
245    }
246
247    #[test]
248    #[cfg(feature = "server-events")]
249    fn builds_measurement_protocol_payloads() {
250        let event = ServerEvent::new(Some("1.2".into()), "purchase")
251            .param("value", 49.99)
252            .param("currency", "USD")
253            .user_id(42);
254        assert_eq!(
255            event.payload(),
256            json!({
257                "client_id": "1.2",
258                "user_id": "42",
259                "events": [{ "name": "purchase", "params": { "value": 49.99, "currency": "USD" } }],
260            })
261        );
262        assert!(
263            ServerEvent::new(None, "x").payload()["client_id"]
264                .as_str()
265                .unwrap()
266                .contains('.')
267        );
268    }
269
270    #[test]
271    fn merges_with_existing_triggers() {
272        let events = vec![Event {
273            name: "sign_up".into(),
274            params: json!({"method": "email"}),
275        }];
276        let mut res = Response::new(());
277        res.headers_mut()
278            .insert("hx-trigger", HeaderValue::from_static("saved, closed"));
279        add_trigger(&mut res, events);
280        let header: Value =
281            serde_json::from_str(res.headers()["hx-trigger"].to_str().unwrap()).unwrap();
282        assert_eq!(header["saved"], Value::Null);
283        assert_eq!(header["closed"], Value::Null);
284        assert_eq!(header["renox:analytics"]["events"][0]["name"], "sign_up");
285    }
286}