Skip to main content

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/// Returns a clone of the process-wide shared HTTP client.
90///
91/// Configured with:
92/// - `timeout(10 s)` — total request duration.
93/// - `connect_timeout(5 s)` — TCP + TLS handshake budget.
94pub fn http_client() -> reqwest::Client {
95    HTTP_CLIENT
96        .get_or_init(|| {
97            reqwest::Client::builder()
98                .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
99                .connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS))
100                .build()
101                .expect("failed to build the shared analytics HTTP client")
102        })
103        .clone()
104}
105
106// ── AnalyticsClient ───────────────────────────────────────────────────────────
107
108/// A configured PostHog client. Owns an API key + host; reuses the
109/// process-wide [`http_client`] connection pool.
110///
111/// Normally installed as the ambient client via [`AnalyticsPlugin`].
112/// Build one explicitly for testing or for callers that prefer explicit
113/// dependency injection over the ambient pattern.
114#[derive(Clone, Debug)]
115pub struct AnalyticsClient {
116    api_key: String,
117    host: String,
118}
119
120impl AnalyticsClient {
121    /// Build a client with explicit API key and host.
122    pub fn new(api_key: impl Into<String>, host: impl Into<String>) -> Self {
123        Self {
124            api_key: api_key.into(),
125            host: host.into(),
126        }
127    }
128
129    /// Build the PostHog `/capture/` JSON payload.
130    ///
131    /// Shape: `{ "api_key", "event", "distinct_id", "properties", "timestamp" }`.
132    /// The timestamp is RFC 3339 (ISO 8601) UTC.
133    pub fn build_payload(
134        &self,
135        distinct_id: &str,
136        event: &str,
137        properties: Value,
138    ) -> Value {
139        json!({
140            "api_key": self.api_key,
141            "event": event,
142            "distinct_id": distinct_id,
143            "properties": properties,
144            "timestamp": Utc::now().to_rfc3339(),
145        })
146    }
147
148    /// Send one event to PostHog `/capture/`. Fire-and-forget: spawns the
149    /// HTTP send in a background task; the caller returns immediately.
150    /// Analytics send errors are logged at `warn` / `debug` level and
151    /// never propagated.
152    pub fn capture_fire_and_forget(
153        &self,
154        distinct_id: impl Into<String>,
155        event: impl Into<String>,
156        properties: Value,
157    ) {
158        let payload = self.build_payload(&distinct_id.into(), &event.into(), properties);
159        let url = format!("{}/capture/", self.host.trim_end_matches('/'));
160        let client = http_client();
161
162        tokio::spawn(async move {
163            match client.post(&url).json(&payload).send().await {
164                Ok(resp) if resp.status().is_success() => {
165                    debug!(url = %url, "analytics: event captured");
166                }
167                Ok(resp) => {
168                    warn!(
169                        url = %url,
170                        status = %resp.status(),
171                        "analytics: PostHog returned non-success status (swallowed)"
172                    );
173                }
174                Err(e) => {
175                    warn!(
176                        url = %url,
177                        error = %e,
178                        "analytics: PostHog send failed (swallowed)"
179                    );
180                }
181            }
182        });
183    }
184}
185
186// ── Free functions (ambient API) ──────────────────────────────────────────────
187
188/// Fire-and-forget event capture. Sends `event` with `properties` attributed
189/// to `distinct_id` to PostHog. The HTTP send happens in a background task;
190/// this function returns immediately and analytics failures never affect the
191/// caller.
192///
193/// When no API key is configured (no ambient client), this is a clean no-op.
194///
195/// # Example
196///
197/// ```ignore
198/// capture("user_42", "purchase", serde_json::json!({ "amount_cents": 999 })).await;
199/// ```
200pub async fn capture(
201    distinct_id: impl Into<String>,
202    event: impl Into<String>,
203    properties: Value,
204) {
205    if let Some(client) = ambient_client() {
206        client.capture_fire_and_forget(distinct_id, event, properties);
207    } else {
208        debug!("analytics: capture called with no client installed (no-op)");
209    }
210}
211
212/// Fire-and-forget person identification. Sends a PostHog `$identify` event
213/// with person properties under `$set`. Use this to associate a `distinct_id`
214/// with user properties (name, email, plan, etc.).
215///
216/// When no API key is configured, this is a clean no-op.
217///
218/// # Example
219///
220/// ```ignore
221/// identify("user_42", serde_json::json!({ "$set": { "email": "a@b.com", "plan": "pro" } })).await;
222/// ```
223pub async fn identify(distinct_id: impl Into<String>, properties: Value) {
224    if let Some(client) = ambient_client() {
225        client.capture_fire_and_forget(distinct_id, "$identify", properties);
226    } else {
227        debug!("analytics: identify called with no client installed (no-op)");
228    }
229}
230
231// ── Request middleware ────────────────────────────────────────────────────────
232
233/// Axum `from_fn` middleware that fires a `$pageview` event for every
234/// incoming HTTP request. Installed via [`AnalyticsPlugin::capture_requests`].
235///
236/// - `distinct_id`: `"anonymous"` (future: resolved from session/identity).
237/// - Event: `"$pageview"`.
238/// - Properties: `{ "path", "method", "status" }`.
239///
240/// The status is captured after the inner handler responds.
241async fn pageview_middleware(
242    req: axum::extract::Request,
243    next: axum::middleware::Next,
244) -> axum::response::Response {
245    let path = req.uri().path().to_string();
246    let method = req.method().to_string();
247
248    let response = next.run(req).await;
249    let status = response.status().as_u16();
250
251    // Fire-and-forget: send after the response is composed so the status
252    // code is available, but spawn the HTTP call so we never block the
253    // response stream returning to the client.
254    if let Some(client) = ambient_client() {
255        let props = json!({
256            "path": path,
257            "method": method,
258            "status": status,
259            "$current_url": path,
260        });
261        client.capture_fire_and_forget("anonymous", "$pageview", props);
262    }
263
264    response
265}
266
267// ── AnalyticsPlugin ───────────────────────────────────────────────────────────
268
269/// The analytics plugin. Carries no models, no persistent routes — just an
270/// [`AnalyticsClient`] it installs as the ambient handle at boot so
271/// [`capture`] / [`identify`] work anywhere in the process.
272///
273/// ## Registration
274///
275/// ```ignore
276/// App::builder()
277///     .plugin(AnalyticsPlugin::new("phc_your_api_key"))
278///     .build()
279///     .await?;
280/// ```
281///
282/// ## Opt-in per-request pageview capture
283///
284/// ```ignore
285/// AnalyticsPlugin::new("phc_your_api_key")
286///     .capture_requests()   // fires a $pageview event on every request
287/// ```
288///
289/// ## No-op when unconfigured
290///
291/// When the API key is absent (neither builder arg nor env var), the plugin
292/// registers but the ambient client is not installed. Both [`capture`] and
293/// [`identify`] are silent no-ops. A one-time `warn!` fires at boot so the
294/// operator can diagnose misconfiguration without a runtime panic.
295pub struct AnalyticsPlugin {
296    /// API key supplied via builder. Beats the env var.
297    api_key: Option<String>,
298    /// PostHog host. Defaults to [`DEFAULT_POSTHOG_HOST`].
299    host: String,
300    /// When true, mount the [`pageview_middleware`] in `wrap_router`.
301    auto_capture_requests: bool,
302}
303
304impl AnalyticsPlugin {
305    /// Build the plugin with an explicit PostHog project API key.
306    ///
307    /// The key wins over `UMBRAL_POSTHOG_API_KEY` / `posthog_api_key` in
308    /// settings. Use this for apps that keep secrets in code (not recommended
309    /// for production; prefer the env var and call [`AnalyticsPlugin::from_env`]).
310    pub fn new(api_key: impl Into<String>) -> Self {
311        Self {
312            api_key: Some(api_key.into()),
313            host: DEFAULT_POSTHOG_HOST.to_string(),
314            auto_capture_requests: false,
315        }
316    }
317
318    /// Build the plugin reading configuration exclusively from environment
319    /// variables / `umbral.toml` settings. Equivalent to
320    /// `AnalyticsPlugin::default()` with no builder overrides.
321    pub fn from_env() -> Self {
322        Self::default()
323    }
324
325    /// Override the PostHog ingest host. Default: `https://us.i.posthog.com`.
326    /// Override for EU region (`https://eu.i.posthog.com`) or a self-hosted
327    /// instance.
328    pub fn host(mut self, host: impl Into<String>) -> Self {
329        self.host = host.into();
330        self
331    }
332
333    /// Opt in to automatic per-request `$pageview` capture. Mounts a
334    /// `from_fn` middleware that fires one event per request (with path,
335    /// method, and status code in properties) without any handler
336    /// changes. Default OFF.
337    pub fn capture_requests(mut self) -> Self {
338        self.auto_capture_requests = true;
339        self
340    }
341
342    /// Resolve the API key: builder field beats env var beats settings.
343    fn resolve_api_key(&self) -> Option<String> {
344        // Builder arg wins.
345        if let Some(ref key) = self.api_key {
346            if !key.trim().is_empty() {
347                return Some(key.clone());
348            }
349        }
350
351        // Environment variable next.
352        if let Ok(val) = std::env::var("UMBRAL_POSTHOG_API_KEY") {
353            if !val.trim().is_empty() {
354                return Some(val);
355            }
356        }
357
358        // umbral.toml extra key last.
359        if let Ok(settings) = umbral::Settings::from_env() {
360            if let Some(v) = settings.extra.get("posthog_api_key") {
361                if let Some(key) = v.as_str() {
362                    if !key.trim().is_empty() {
363                        return Some(key.to_string());
364                    }
365                }
366            }
367        }
368
369        None
370    }
371
372    /// Resolve the PostHog host: builder field beats env var beats settings,
373    /// with [`DEFAULT_POSTHOG_HOST`] as the final fallback.
374    fn resolve_host(&self) -> String {
375        // Builder field (already defaulted in ::new / ::default).
376        if self.host != DEFAULT_POSTHOG_HOST {
377            return self.host.clone();
378        }
379
380        // Environment variable.
381        if let Ok(val) = std::env::var("UMBRAL_POSTHOG_HOST") {
382            if !val.trim().is_empty() {
383                return val;
384            }
385        }
386
387        // umbral.toml extra key.
388        if let Ok(settings) = umbral::Settings::from_env() {
389            if let Some(v) = settings.extra.get("posthog_host") {
390                if let Some(h) = v.as_str() {
391                    if !h.trim().is_empty() {
392                        return h.to_string();
393                    }
394                }
395            }
396        }
397
398        DEFAULT_POSTHOG_HOST.to_string()
399    }
400}
401
402impl Default for AnalyticsPlugin {
403    fn default() -> Self {
404        Self {
405            api_key: None,
406            host: DEFAULT_POSTHOG_HOST.to_string(),
407            auto_capture_requests: false,
408        }
409    }
410}
411
412impl Plugin for AnalyticsPlugin {
413    fn name(&self) -> &'static str {
414        "analytics"
415    }
416
417    fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {
418        match self.resolve_api_key() {
419            Some(key) => {
420                let host = self.resolve_host();
421                let client = AnalyticsClient::new(key, host.clone());
422                if AMBIENT_CLIENT.set(client).is_err() {
423                    warn!(
424                        "AnalyticsPlugin: an ambient analytics client was already installed; \
425                         ignoring this registration."
426                    );
427                } else {
428                    tracing::info!(host = %host, "analytics: PostHog client installed");
429                }
430            }
431            None => {
432                warn!(
433                    "AnalyticsPlugin registered with no PostHog API key. Set \
434                     UMBRAL_POSTHOG_API_KEY or pass an explicit key via \
435                     AnalyticsPlugin::new(key). Capture calls will be silent no-ops."
436                );
437            }
438        }
439        Ok(())
440    }
441
442    fn wrap_router(&self, router: Router) -> Router {
443        if self.auto_capture_requests {
444            router.layer(axum::middleware::from_fn(pageview_middleware))
445        } else {
446            router
447        }
448    }
449}