umbral_analytics/lib.rs
1//! umbral-analytics — product-analytics event capture for umbral.
2//!
3//! Analytics instrumentation, the umbral way: declare the plugin, call
4//! [`capture`] / [`identify`] from any handler or service, and analytics
5//! failures never break a request. The PostHog backend is fire-and-forget;
6//! every send is spawned on a background task so the caller returns
7//! immediately.
8//!
9//! ## Quick start
10//!
11//! ```ignore
12//! // Wire in main
13//! App::builder()
14//! .plugin(
15//! AnalyticsPlugin::new("phc_your_api_key")
16//! .capture_requests(), // optional: auto pageview per request
17//! )
18//! .build()
19//! .await?;
20//!
21//! // In a handler
22//! use umbral_analytics::{capture, identify};
23//!
24//! async fn signup(/* ... */) -> impl IntoResponse {
25//! identify("user_42", serde_json::json!({ "$set": { "email": "a@b.com" } })).await;
26//! capture("user_42", "signup", serde_json::json!({ "plan": "pro" })).await;
27//! StatusCode::CREATED
28//! }
29//! ```
30//!
31//! ## Settings keys
32//!
33//! Read from `UMBRAL_POSTHOG_API_KEY` / `UMBRAL_POSTHOG_HOST` env vars or
34//! `umbral.toml` extra keys `posthog_api_key` / `posthog_host`. Builder
35//! overrides win over environment.
36//!
37//! - `posthog_api_key` / `UMBRAL_POSTHOG_API_KEY`. Your project API key.
38//! When absent the plugin is a **no-op**: captures are dropped with a
39//! one-time warning. Never panics, never blocks.
40//! - `posthog_host` / `UMBRAL_POSTHOG_HOST`. Ingest host
41//! (default `https://us.i.posthog.com`).
42//!
43//! ## Surface
44//!
45//! - [`AnalyticsPlugin`]. The plugin; registers the ambient client at boot.
46//! - [`capture`]. Fire-and-forget event capture (free function, ambient).
47//! - [`identify`]. Fire-and-forget `$identify` person update (free function, ambient).
48//! - [`AnalyticsClient`]. The typed PostHog client. Public so callers can
49//! build one directly for testing or send with an explicit client.
50
51use std::sync::OnceLock;
52use std::time::Duration;
53
54use chrono::Utc;
55use serde_json::{Value, json};
56use tracing::{debug, warn};
57use umbral::plugin::PluginError;
58use umbral::prelude::*;
59
60// ── Constants ─────────────────────────────────────────────────────────────────
61
62/// Default PostHog ingest host (US region).
63pub const DEFAULT_POSTHOG_HOST: &str = "https://us.i.posthog.com";
64
65/// HTTP request timeout for PostHog API calls (seconds).
66const HTTP_TIMEOUT_SECS: u64 = 10;
67
68/// TCP + TLS connect timeout for PostHog API calls (seconds).
69const HTTP_CONNECT_TIMEOUT_SECS: u64 = 5;
70
71// ── Ambient client ────────────────────────────────────────────────────────────
72
73/// Process-wide analytics client, installed once during `on_ready`.
74/// `capture` / `identify` read it ambiently. When absent, both are no-ops.
75static AMBIENT_CLIENT: OnceLock<AnalyticsClient> = OnceLock::new();
76
77/// Return the ambient client, or `None` if the plugin isn't registered / no
78/// API key was configured.
79pub fn ambient_client() -> Option<&'static AnalyticsClient> {
80 AMBIENT_CLIENT.get()
81}
82
83// ── HTTP client ───────────────────────────────────────────────────────────────
84
85/// Process-wide shared reqwest client. Built once; cloning is `O(1)` (Arc).
86/// Mirrors the `umbral-oauth` `http_client()` pattern exactly.
87static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
88
89/// Ceiling on concurrent in-flight analytics sends (audit_2
90/// plugin-observability #5). Each `capture_fire_and_forget` spawned an outbound
91/// HTTPS POST with no bound, so a request burst at scale fanned out unbounded
92/// tasks/connections — resource amplification / self-DoS. A permit is acquired
93/// BEFORE spawning; when all are in use the event is dropped (analytics is
94/// best-effort) rather than piling up.
95const MAX_CONCURRENT_ANALYTICS_SENDS: usize = 64;
96
97static SEND_SLOTS: OnceLock<std::sync::Arc<tokio::sync::Semaphore>> = OnceLock::new();
98
99fn send_slots() -> &'static std::sync::Arc<tokio::sync::Semaphore> {
100 SEND_SLOTS.get_or_init(|| {
101 std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYTICS_SENDS))
102 })
103}
104
105/// Returns a clone of the process-wide shared HTTP client.
106///
107/// Configured with:
108/// - `timeout(10 s)` — total request duration.
109/// - `connect_timeout(5 s)` — TCP + TLS handshake budget.
110pub fn http_client() -> reqwest::Client {
111 HTTP_CLIENT
112 .get_or_init(|| {
113 reqwest::Client::builder()
114 .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
115 .connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS))
116 .build()
117 .expect("failed to build the shared analytics HTTP client")
118 })
119 .clone()
120}
121
122// ── AnalyticsClient ───────────────────────────────────────────────────────────
123
124/// A configured PostHog client. Owns an API key + host; reuses the
125/// process-wide [`http_client`] connection pool.
126///
127/// Normally installed as the ambient client via [`AnalyticsPlugin`].
128/// Build one explicitly for testing or for callers that prefer explicit
129/// dependency injection over the ambient pattern.
130#[derive(Clone, Debug)]
131pub struct AnalyticsClient {
132 api_key: String,
133 host: String,
134 /// Request-path prefixes NOT to auto-capture as `$pageview` (audit_2
135 /// plugin-observability #4). Paths under these prefixes carry secrets/PII
136 /// (`/reset-password/<token>`, `/users/<email>/…`) that must not leave the
137 /// trust boundary for a third-party analytics host.
138 exclude_prefixes: Vec<String>,
139}
140
141impl AnalyticsClient {
142 /// Build a client with explicit API key and host.
143 pub fn new(api_key: impl Into<String>, host: impl Into<String>) -> Self {
144 Self {
145 api_key: api_key.into(),
146 host: host.into(),
147 exclude_prefixes: Vec::new(),
148 }
149 }
150
151 /// Set the pageview-exclusion prefixes (see [`Self::exclude_prefixes`]).
152 pub fn with_exclude_prefixes(mut self, prefixes: Vec<String>) -> Self {
153 self.exclude_prefixes = prefixes;
154 self
155 }
156
157 /// Whether an auto-`$pageview` should be captured for `path`. `false` when
158 /// the path starts with any configured exclusion prefix, so sensitive
159 /// routes never ship their path to the analytics host.
160 pub fn should_capture_path(&self, path: &str) -> bool {
161 !self
162 .exclude_prefixes
163 .iter()
164 .any(|p| path.starts_with(p.as_str()))
165 }
166
167 /// Build the PostHog `/capture/` JSON payload.
168 ///
169 /// Shape: `{ "api_key", "event", "distinct_id", "properties", "timestamp" }`.
170 /// The timestamp is RFC 3339 (ISO 8601) UTC.
171 pub fn build_payload(&self, distinct_id: &str, event: &str, properties: Value) -> Value {
172 json!({
173 "api_key": self.api_key,
174 "event": event,
175 "distinct_id": distinct_id,
176 "properties": properties,
177 "timestamp": Utc::now().to_rfc3339(),
178 })
179 }
180
181 /// Send one event to PostHog `/capture/`. Fire-and-forget: spawns the
182 /// HTTP send in a background task; the caller returns immediately.
183 /// Analytics send errors are logged at `warn` / `debug` level and
184 /// never propagated.
185 pub fn capture_fire_and_forget(
186 &self,
187 distinct_id: impl Into<String>,
188 event: impl Into<String>,
189 properties: Value,
190 ) {
191 // audit_2 #5: acquire a send slot BEFORE spawning so a burst can't fan
192 // out unbounded outbound tasks. At capacity we drop the event (analytics
193 // is best-effort) instead of queueing without bound. The permit is moved
194 // into the task and released when the send finishes.
195 let permit = match send_slots().clone().try_acquire_owned() {
196 Ok(p) => p,
197 Err(_) => {
198 debug!("analytics: concurrent-send limit reached; dropping event");
199 return;
200 }
201 };
202 let payload = self.build_payload(&distinct_id.into(), &event.into(), properties);
203 let url = format!("{}/capture/", self.host.trim_end_matches('/'));
204 let client = http_client();
205
206 tokio::spawn(async move {
207 let _permit = permit; // released when the send completes
208 match client.post(&url).json(&payload).send().await {
209 Ok(resp) if resp.status().is_success() => {
210 debug!(url = %url, "analytics: event captured");
211 }
212 Ok(resp) => {
213 warn!(
214 url = %url,
215 status = %resp.status(),
216 "analytics: PostHog returned non-success status (swallowed)"
217 );
218 }
219 Err(e) => {
220 warn!(
221 url = %url,
222 error = %e,
223 "analytics: PostHog send failed (swallowed)"
224 );
225 }
226 }
227 });
228 }
229}
230
231// ── Free functions (ambient API) ──────────────────────────────────────────────
232
233/// Fire-and-forget event capture. Sends `event` with `properties` attributed
234/// to `distinct_id` to PostHog. The HTTP send happens in a background task;
235/// this function returns immediately and analytics failures never affect the
236/// caller.
237///
238/// When no API key is configured (no ambient client), this is a clean no-op.
239///
240/// # Example
241///
242/// ```ignore
243/// capture("user_42", "purchase", serde_json::json!({ "amount_cents": 999 })).await;
244/// ```
245pub async fn capture(distinct_id: impl Into<String>, event: impl Into<String>, properties: Value) {
246 if let Some(client) = ambient_client() {
247 client.capture_fire_and_forget(distinct_id, event, properties);
248 } else {
249 debug!("analytics: capture called with no client installed (no-op)");
250 }
251}
252
253/// Fire-and-forget person identification. Sends a PostHog `$identify` event
254/// with person properties under `$set`. Use this to associate a `distinct_id`
255/// with user properties (name, email, plan, etc.).
256///
257/// When no API key is configured, this is a clean no-op.
258///
259/// # Example
260///
261/// ```ignore
262/// identify("user_42", serde_json::json!({ "$set": { "email": "a@b.com", "plan": "pro" } })).await;
263/// ```
264pub async fn identify(distinct_id: impl Into<String>, properties: Value) {
265 if let Some(client) = ambient_client() {
266 client.capture_fire_and_forget(distinct_id, "$identify", properties);
267 } else {
268 debug!("analytics: identify called with no client installed (no-op)");
269 }
270}
271
272// ── Request middleware ────────────────────────────────────────────────────────
273
274/// Axum `from_fn` middleware that fires a `$pageview` event for every
275/// incoming HTTP request. Installed via [`AnalyticsPlugin::capture_requests`].
276///
277/// - `distinct_id`: `"anonymous"` (future: resolved from session/identity).
278/// - Event: `"$pageview"`.
279/// - Properties: `{ "path", "method", "status" }`.
280///
281/// The status is captured after the inner handler responds.
282async fn pageview_middleware(
283 req: axum::extract::Request,
284 next: axum::middleware::Next,
285) -> axum::response::Response {
286 let path = req.uri().path().to_string();
287 let method = req.method().to_string();
288
289 let response = next.run(req).await;
290 let status = response.status().as_u16();
291
292 // Fire-and-forget: send after the response is composed so the status
293 // code is available, but spawn the HTTP call so we never block the
294 // response stream returning to the client.
295 if let Some(client) = ambient_client() {
296 // Don't ship the path of a sensitive route (reset tokens, per-user
297 // paths) to the third-party analytics host (audit_2 #4).
298 if client.should_capture_path(&path) {
299 let props = json!({
300 "path": path,
301 "method": method,
302 "status": status,
303 "$current_url": path,
304 });
305 client.capture_fire_and_forget("anonymous", "$pageview", props);
306 }
307 }
308
309 response
310}
311
312// ── AnalyticsPlugin ───────────────────────────────────────────────────────────
313
314/// The analytics plugin. Carries no models, no persistent routes — just an
315/// [`AnalyticsClient`] it installs as the ambient handle at boot so
316/// [`capture`] / [`identify`] work anywhere in the process.
317///
318/// ## Registration
319///
320/// ```ignore
321/// App::builder()
322/// .plugin(AnalyticsPlugin::new("phc_your_api_key"))
323/// .build()
324/// .await?;
325/// ```
326///
327/// ## Opt-in per-request pageview capture
328///
329/// ```ignore
330/// AnalyticsPlugin::new("phc_your_api_key")
331/// .capture_requests() // fires a $pageview event on every request
332/// ```
333///
334/// ## No-op when unconfigured
335///
336/// When the API key is absent (neither builder arg nor env var), the plugin
337/// registers but the ambient client is not installed. Both [`capture`] and
338/// [`identify`] are silent no-ops. A one-time `warn!` fires at boot so the
339/// operator can diagnose misconfiguration without a runtime panic.
340pub struct AnalyticsPlugin {
341 /// API key supplied via builder. Beats the env var.
342 api_key: Option<String>,
343 /// PostHog host. Defaults to [`DEFAULT_POSTHOG_HOST`].
344 host: String,
345 /// When true, mount the [`pageview_middleware`] in `wrap_router`.
346 auto_capture_requests: bool,
347 /// Request-path prefixes excluded from auto pageview capture (#4).
348 exclude_prefixes: Vec<String>,
349}
350
351impl AnalyticsPlugin {
352 /// Build the plugin with an explicit PostHog project API key.
353 ///
354 /// The key wins over `UMBRAL_POSTHOG_API_KEY` / `posthog_api_key` in
355 /// settings. Use this for apps that keep secrets in code (not recommended
356 /// for production; prefer the env var and call [`AnalyticsPlugin::from_env`]).
357 pub fn new(api_key: impl Into<String>) -> Self {
358 Self {
359 api_key: Some(api_key.into()),
360 host: DEFAULT_POSTHOG_HOST.to_string(),
361 auto_capture_requests: false,
362 exclude_prefixes: Vec::new(),
363 }
364 }
365
366 /// Build the plugin reading configuration exclusively from environment
367 /// variables / `umbral.toml` settings. Equivalent to
368 /// `AnalyticsPlugin::default()` with no builder overrides.
369 pub fn from_env() -> Self {
370 Self::default()
371 }
372
373 /// Override the PostHog ingest host. Default: `https://us.i.posthog.com`.
374 /// Override for EU region (`https://eu.i.posthog.com`) or a self-hosted
375 /// instance.
376 pub fn host(mut self, host: impl Into<String>) -> Self {
377 self.host = host.into();
378 self
379 }
380
381 /// Opt in to automatic per-request `$pageview` capture. Mounts a
382 /// `from_fn` middleware that fires one event per request (with path,
383 /// method, and status code in properties) without any handler
384 /// changes. Default OFF.
385 pub fn capture_requests(mut self) -> Self {
386 self.auto_capture_requests = true;
387 self
388 }
389
390 /// Exclude a request-path prefix from auto `$pageview` capture so its path
391 /// never ships to the analytics host (audit_2 plugin-observability #4).
392 /// Add every route whose path can carry a secret or PII — password-reset
393 /// and email-verification links, per-user resource paths, signed URLs, etc.
394 /// Call more than once to exclude several prefixes.
395 pub fn exclude_path_prefix(mut self, prefix: impl Into<String>) -> Self {
396 self.exclude_prefixes.push(prefix.into());
397 self
398 }
399
400 /// Resolve the API key: builder field beats env var beats settings.
401 fn resolve_api_key(&self) -> Option<String> {
402 // Builder arg wins.
403 if let Some(ref key) = self.api_key {
404 if !key.trim().is_empty() {
405 return Some(key.clone());
406 }
407 }
408
409 // Environment variable next.
410 if let Ok(val) = std::env::var("UMBRAL_POSTHOG_API_KEY") {
411 if !val.trim().is_empty() {
412 return Some(val);
413 }
414 }
415
416 // umbral.toml extra key last.
417 if let Ok(settings) = umbral::Settings::from_env() {
418 if let Some(v) = settings.extra.get("posthog_api_key") {
419 if let Some(key) = v.as_str() {
420 if !key.trim().is_empty() {
421 return Some(key.to_string());
422 }
423 }
424 }
425 }
426
427 None
428 }
429
430 /// Resolve the PostHog host: builder field beats env var beats settings,
431 /// with [`DEFAULT_POSTHOG_HOST`] as the final fallback.
432 fn resolve_host(&self) -> String {
433 // Builder field (already defaulted in ::new / ::default).
434 if self.host != DEFAULT_POSTHOG_HOST {
435 return self.host.clone();
436 }
437
438 // Environment variable.
439 if let Ok(val) = std::env::var("UMBRAL_POSTHOG_HOST") {
440 if !val.trim().is_empty() {
441 return val;
442 }
443 }
444
445 // umbral.toml extra key.
446 if let Ok(settings) = umbral::Settings::from_env() {
447 if let Some(v) = settings.extra.get("posthog_host") {
448 if let Some(h) = v.as_str() {
449 if !h.trim().is_empty() {
450 return h.to_string();
451 }
452 }
453 }
454 }
455
456 DEFAULT_POSTHOG_HOST.to_string()
457 }
458}
459
460impl Default for AnalyticsPlugin {
461 fn default() -> Self {
462 Self {
463 api_key: None,
464 host: DEFAULT_POSTHOG_HOST.to_string(),
465 auto_capture_requests: false,
466 exclude_prefixes: Vec::new(),
467 }
468 }
469}
470
471impl Plugin for AnalyticsPlugin {
472 fn name(&self) -> &'static str {
473 "analytics"
474 }
475
476 fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {
477 match self.resolve_api_key() {
478 Some(key) => {
479 let host = self.resolve_host();
480 let client = AnalyticsClient::new(key, host.clone())
481 .with_exclude_prefixes(self.exclude_prefixes.clone());
482 if AMBIENT_CLIENT.set(client).is_err() {
483 warn!(
484 "AnalyticsPlugin: an ambient analytics client was already installed; \
485 ignoring this registration."
486 );
487 } else {
488 tracing::info!(host = %host, "analytics: PostHog client installed");
489 }
490 }
491 None => {
492 warn!(
493 "AnalyticsPlugin registered with no PostHog API key. Set \
494 UMBRAL_POSTHOG_API_KEY or pass an explicit key via \
495 AnalyticsPlugin::new(key). Capture calls will be silent no-ops."
496 );
497 }
498 }
499 Ok(())
500 }
501
502 fn wrap_router(&self, router: Router) -> Router {
503 if self.auto_capture_requests {
504 router.layer(axum::middleware::from_fn(pageview_middleware))
505 } else {
506 router
507 }
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::AnalyticsClient;
514
515 // audit_2 plugin-observability #5: outbound sends are bounded so a burst
516 // can't fan out unbounded tasks. The semaphore starts sized to the ceiling,
517 // and once exhausted, further acquisitions fail (→ the event is dropped).
518 #[test]
519 fn outbound_sends_are_concurrency_bounded() {
520 let sem = super::send_slots();
521 assert_eq!(
522 sem.available_permits(),
523 super::MAX_CONCURRENT_ANALYTICS_SENDS
524 );
525 // Exhaust a private clone's worth to prove the drop path: hold every
526 // permit, then the next acquire fails (what `capture` treats as "drop").
527 let mut held = Vec::new();
528 for _ in 0..super::MAX_CONCURRENT_ANALYTICS_SENDS {
529 held.push(sem.clone().try_acquire_owned().expect("permit"));
530 }
531 assert!(
532 sem.clone().try_acquire_owned().is_err(),
533 "at capacity, a further send must be refused (dropped)"
534 );
535 // Permits released here (held dropped) so sibling tests aren't starved.
536 }
537
538 #[test]
539 fn excluded_prefixes_are_not_captured() {
540 let client = AnalyticsClient::new("k", "https://h")
541 .with_exclude_prefixes(vec!["/reset-password".to_string(), "/verify".to_string()]);
542 // Sensitive paths (incl. a token segment) are excluded.
543 assert!(!client.should_capture_path("/reset-password/abc123token"));
544 assert!(!client.should_capture_path("/verify/xyz"));
545 // Ordinary paths are still captured.
546 assert!(client.should_capture_path("/"));
547 assert!(client.should_capture_path("/pricing"));
548 // With no exclusions everything is captured.
549 let open = AnalyticsClient::new("k", "https://h");
550 assert!(open.should_capture_path("/reset-password/abc"));
551 }
552}