1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[non_exhaustive]
49pub struct Event {
50 pub name: String,
52 #[serde(default)]
54 pub params: Value,
55}
56
57pub 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
67pub(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
76pub(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
88pub(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
97pub(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#[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#[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 #[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 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 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 pub fn user_id(mut self, id: impl ToString) -> Self {
183 self.user_id = Some(id.to_string());
184 self
185 }
186
187 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}