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/// 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        // Excluding a whole route family is the operator's call; the scrubber
162        // (gaps4 #22) is the universal default that makes even a captured path
163        // safe to ship.
164        !self
165            .exclude_prefixes
166            .iter()
167            .any(|p| path.starts_with(p.as_str()))
168    }
169
170    /// Scrub identifying / secret segments out of a request path before it
171    /// ships to a third-party analytics host (gaps4 #22).
172    ///
173    /// URLs routinely carry data that must not leave the trust boundary: a
174    /// password-reset token, a per-user email, a private object id. Raw paths
175    /// also make analytics worse — `/orders/8412` and `/orders/8830` are two
176    /// rows where you wanted one route. This replaces each dynamic-looking
177    /// segment with a typed placeholder, so `/users/ada@x.com/orders/8412`
178    /// becomes `/users/:email/orders/:id`.
179    ///
180    /// Applied to EVERY auto-captured pageview, so it's safe by default rather
181    /// than opt-in.
182    pub fn scrub_path(path: &str) -> String {
183        // Map each segment through the scrubber and rejoin. `split('/')`
184        // preserves empties for leading/trailing/internal slashes, so a rejoin
185        // reproduces the exact slash structure — an empty segment scrubs to
186        // empty and the join re-inserts its slash.
187        path.split('/')
188            .map(scrub_segment)
189            .collect::<Vec<_>>()
190            .join("/")
191    }
192
193    /// Build the PostHog `/capture/` JSON payload.
194    ///
195    /// Shape: `{ "api_key", "event", "distinct_id", "properties", "timestamp" }`.
196    /// The timestamp is RFC 3339 (ISO 8601) UTC.
197    pub fn build_payload(&self, distinct_id: &str, event: &str, properties: Value) -> Value {
198        json!({
199            "api_key": self.api_key,
200            "event": event,
201            "distinct_id": distinct_id,
202            "properties": properties,
203            "timestamp": Utc::now().to_rfc3339(),
204        })
205    }
206
207    /// Send one event to PostHog `/capture/`. Fire-and-forget: spawns the
208    /// HTTP send in a background task; the caller returns immediately.
209    /// Analytics send errors are logged at `warn` / `debug` level and
210    /// never propagated.
211    pub fn capture_fire_and_forget(
212        &self,
213        distinct_id: impl Into<String>,
214        event: impl Into<String>,
215        properties: Value,
216    ) {
217        // audit_2 #5: acquire a send slot BEFORE spawning so a burst can't fan
218        // out unbounded outbound tasks. At capacity we drop the event (analytics
219        // is best-effort) instead of queueing without bound. The permit is moved
220        // into the task and released when the send finishes.
221        let permit = match send_slots().clone().try_acquire_owned() {
222            Ok(p) => p,
223            Err(_) => {
224                debug!("analytics: concurrent-send limit reached; dropping event");
225                return;
226            }
227        };
228        let payload = self.build_payload(&distinct_id.into(), &event.into(), properties);
229        let url = format!("{}/capture/", self.host.trim_end_matches('/'));
230        let client = http_client();
231
232        tokio::spawn(async move {
233            let _permit = permit; // released when the send completes
234            match client.post(&url).json(&payload).send().await {
235                Ok(resp) if resp.status().is_success() => {
236                    debug!(url = %url, "analytics: event captured");
237                }
238                Ok(resp) => {
239                    warn!(
240                        url = %url,
241                        status = %resp.status(),
242                        "analytics: PostHog returned non-success status (swallowed)"
243                    );
244                }
245                Err(e) => {
246                    warn!(
247                        url = %url,
248                        error = %e,
249                        "analytics: PostHog send failed (swallowed)"
250                    );
251                }
252            }
253        });
254    }
255}
256
257/// Classify one path segment and return either it unchanged or a typed
258/// placeholder. gaps4 #22 — the per-segment worker behind
259/// [`AnalyticsClient::scrub_path`].
260fn scrub_segment(seg: &str) -> &str {
261    if seg.is_empty() {
262        return seg;
263    }
264    // Email — anything with an `@` and a dot after it.
265    if seg.contains('@') {
266        return ":email";
267    }
268    // UUID — 8-4-4-4-12 hex.
269    if seg.len() == 36
270        && seg.as_bytes().iter().enumerate().all(|(i, &b)| match i {
271            8 | 13 | 18 | 23 => b == b'-',
272            _ => b.is_ascii_hexdigit(),
273        })
274    {
275        return ":uuid";
276    }
277    // Pure integer id.
278    if seg.bytes().all(|b| b.is_ascii_digit()) {
279        return ":id";
280    }
281    // Token-like: long and high-entropy-ish (mixed alnum, or contains the
282    // base64url/`.`/`_`/`-` alphabet). Catches reset tokens, API keys, JWT-ish
283    // blobs. A short slug like `my-post-title` stays put.
284    if seg.len() >= 24
285        && seg
286            .bytes()
287            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'='))
288        && seg.bytes().any(|b| b.is_ascii_digit())
289        && seg.bytes().any(|b| b.is_ascii_alphabetic())
290    {
291        return ":token";
292    }
293    seg
294}
295
296// ── Free functions (ambient API) ──────────────────────────────────────────────
297
298/// Fire-and-forget event capture. Sends `event` with `properties` attributed
299/// to `distinct_id` to PostHog. The HTTP send happens in a background task;
300/// this function returns immediately and analytics failures never affect the
301/// caller.
302///
303/// When no API key is configured (no ambient client), this is a clean no-op.
304///
305/// # Example
306///
307/// ```ignore
308/// capture("user_42", "purchase", serde_json::json!({ "amount_cents": 999 })).await;
309/// ```
310pub async fn capture(distinct_id: impl Into<String>, event: impl Into<String>, properties: Value) {
311    if let Some(client) = ambient_client() {
312        client.capture_fire_and_forget(distinct_id, event, properties);
313    } else {
314        debug!("analytics: capture called with no client installed (no-op)");
315    }
316}
317
318/// Fire-and-forget person identification. Sends a PostHog `$identify` event
319/// with person properties under `$set`. Use this to associate a `distinct_id`
320/// with user properties (name, email, plan, etc.).
321///
322/// When no API key is configured, this is a clean no-op.
323///
324/// # Example
325///
326/// ```ignore
327/// identify("user_42", serde_json::json!({ "$set": { "email": "a@b.com", "plan": "pro" } })).await;
328/// ```
329pub async fn identify(distinct_id: impl Into<String>, properties: Value) {
330    if let Some(client) = ambient_client() {
331        client.capture_fire_and_forget(distinct_id, "$identify", properties);
332    } else {
333        debug!("analytics: identify called with no client installed (no-op)");
334    }
335}
336
337// ── Request middleware ────────────────────────────────────────────────────────
338
339/// Axum `from_fn` middleware that fires a `$pageview` event for every
340/// incoming HTTP request. Installed via [`AnalyticsPlugin::capture_requests`].
341///
342/// - `distinct_id`: `"anonymous"` (future: resolved from session/identity).
343/// - Event: `"$pageview"`.
344/// - Properties: `{ "path", "method", "status" }`.
345///
346/// The status is captured after the inner handler responds.
347async fn pageview_middleware(
348    req: axum::extract::Request,
349    next: axum::middleware::Next,
350) -> axum::response::Response {
351    let path = req.uri().path().to_string();
352    let method = req.method().to_string();
353
354    let response = next.run(req).await;
355    let status = response.status().as_u16();
356
357    // Fire-and-forget: send after the response is composed so the status
358    // code is available, but spawn the HTTP call so we never block the
359    // response stream returning to the client.
360    if let Some(client) = ambient_client() {
361        // Don't ship the path of a sensitive route (reset tokens, per-user
362        // paths) to the third-party analytics host (audit_2 #4).
363        if client.should_capture_path(&path) {
364            // gaps4 #22: scrub identifying / secret segments (reset tokens,
365            // emails, object ids) before the path leaves the trust boundary,
366            // and so route analytics aggregate `/orders/:id` instead of one
367            // row per order.
368            let scrubbed = AnalyticsClient::scrub_path(&path);
369            let props = json!({
370                "path": scrubbed,
371                "method": method,
372                "status": status,
373                "$current_url": scrubbed,
374            });
375            client.capture_fire_and_forget("anonymous", "$pageview", props);
376        }
377    }
378
379    response
380}
381
382// ── AnalyticsPlugin ───────────────────────────────────────────────────────────
383
384/// The analytics plugin. Carries no models, no persistent routes — just an
385/// [`AnalyticsClient`] it installs as the ambient handle at boot so
386/// [`capture`] / [`identify`] work anywhere in the process.
387///
388/// ## Registration
389///
390/// ```ignore
391/// App::builder()
392///     .plugin(AnalyticsPlugin::new("phc_your_api_key"))
393///     .build()
394///     .await?;
395/// ```
396///
397/// ## Opt-in per-request pageview capture
398///
399/// ```ignore
400/// AnalyticsPlugin::new("phc_your_api_key")
401///     .capture_requests()   // fires a $pageview event on every request
402/// ```
403///
404/// ## No-op when unconfigured
405///
406/// When the API key is absent (neither builder arg nor env var), the plugin
407/// registers but the ambient client is not installed. Both [`capture`] and
408/// [`identify`] are silent no-ops. A one-time `warn!` fires at boot so the
409/// operator can diagnose misconfiguration without a runtime panic.
410pub struct AnalyticsPlugin {
411    /// API key supplied via builder. Beats the env var.
412    api_key: Option<String>,
413    /// PostHog host. Defaults to [`DEFAULT_POSTHOG_HOST`].
414    host: String,
415    /// When true, mount the [`pageview_middleware`] in `wrap_router`.
416    auto_capture_requests: bool,
417    /// Request-path prefixes excluded from auto pageview capture (#4).
418    exclude_prefixes: Vec<String>,
419}
420
421impl AnalyticsPlugin {
422    /// Build the plugin with an explicit PostHog project API key.
423    ///
424    /// The key wins over `UMBRAL_POSTHOG_API_KEY` / `posthog_api_key` in
425    /// settings. Use this for apps that keep secrets in code (not recommended
426    /// for production; prefer the env var and call [`AnalyticsPlugin::from_env`]).
427    pub fn new(api_key: impl Into<String>) -> Self {
428        Self {
429            api_key: Some(api_key.into()),
430            host: DEFAULT_POSTHOG_HOST.to_string(),
431            auto_capture_requests: false,
432            exclude_prefixes: Vec::new(),
433        }
434    }
435
436    /// Build the plugin reading configuration exclusively from environment
437    /// variables / `umbral.toml` settings. Equivalent to
438    /// `AnalyticsPlugin::default()` with no builder overrides.
439    pub fn from_env() -> Self {
440        Self::default()
441    }
442
443    /// Override the PostHog ingest host. Default: `https://us.i.posthog.com`.
444    /// Override for EU region (`https://eu.i.posthog.com`) or a self-hosted
445    /// instance.
446    pub fn host(mut self, host: impl Into<String>) -> Self {
447        self.host = host.into();
448        self
449    }
450
451    /// Opt in to automatic per-request `$pageview` capture. Mounts a
452    /// `from_fn` middleware that fires one event per request (with path,
453    /// method, and status code in properties) without any handler
454    /// changes. Default OFF.
455    pub fn capture_requests(mut self) -> Self {
456        self.auto_capture_requests = true;
457        self
458    }
459
460    /// Exclude a request-path prefix from auto `$pageview` capture so its path
461    /// never ships to the analytics host (audit_2 plugin-observability #4).
462    /// Add every route whose path can carry a secret or PII — password-reset
463    /// and email-verification links, per-user resource paths, signed URLs, etc.
464    /// Call more than once to exclude several prefixes.
465    pub fn exclude_path_prefix(mut self, prefix: impl Into<String>) -> Self {
466        self.exclude_prefixes.push(prefix.into());
467        self
468    }
469
470    /// Resolve the API key: builder field beats env var beats settings.
471    fn resolve_api_key(&self) -> Option<String> {
472        // Builder arg wins.
473        if let Some(ref key) = self.api_key {
474            if !key.trim().is_empty() {
475                return Some(key.clone());
476            }
477        }
478
479        // Environment variable next.
480        if let Ok(val) = std::env::var("UMBRAL_POSTHOG_API_KEY") {
481            if !val.trim().is_empty() {
482                return Some(val);
483            }
484        }
485
486        // umbral.toml extra key last.
487        if let Ok(settings) = umbral::Settings::from_env() {
488            if let Some(v) = settings.extra.get("posthog_api_key") {
489                if let Some(key) = v.as_str() {
490                    if !key.trim().is_empty() {
491                        return Some(key.to_string());
492                    }
493                }
494            }
495        }
496
497        None
498    }
499
500    /// Resolve the PostHog host: builder field beats env var beats settings,
501    /// with [`DEFAULT_POSTHOG_HOST`] as the final fallback.
502    fn resolve_host(&self) -> String {
503        // Builder field (already defaulted in ::new / ::default).
504        if self.host != DEFAULT_POSTHOG_HOST {
505            return self.host.clone();
506        }
507
508        // Environment variable.
509        if let Ok(val) = std::env::var("UMBRAL_POSTHOG_HOST") {
510            if !val.trim().is_empty() {
511                return val;
512            }
513        }
514
515        // umbral.toml extra key.
516        if let Ok(settings) = umbral::Settings::from_env() {
517            if let Some(v) = settings.extra.get("posthog_host") {
518                if let Some(h) = v.as_str() {
519                    if !h.trim().is_empty() {
520                        return h.to_string();
521                    }
522                }
523            }
524        }
525
526        DEFAULT_POSTHOG_HOST.to_string()
527    }
528}
529
530impl Default for AnalyticsPlugin {
531    fn default() -> Self {
532        Self {
533            api_key: None,
534            host: DEFAULT_POSTHOG_HOST.to_string(),
535            auto_capture_requests: false,
536            exclude_prefixes: Vec::new(),
537        }
538    }
539}
540
541impl Plugin for AnalyticsPlugin {
542    fn name(&self) -> &'static str {
543        "analytics"
544    }
545
546    fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {
547        match self.resolve_api_key() {
548            Some(key) => {
549                let host = self.resolve_host();
550                let client = AnalyticsClient::new(key, host.clone())
551                    .with_exclude_prefixes(self.exclude_prefixes.clone());
552                if AMBIENT_CLIENT.set(client).is_err() {
553                    warn!(
554                        "AnalyticsPlugin: an ambient analytics client was already installed; \
555                         ignoring this registration."
556                    );
557                } else {
558                    tracing::info!(host = %host, "analytics: PostHog client installed");
559                }
560            }
561            None => {
562                warn!(
563                    "AnalyticsPlugin registered with no PostHog API key. Set \
564                     UMBRAL_POSTHOG_API_KEY or pass an explicit key via \
565                     AnalyticsPlugin::new(key). Capture calls will be silent no-ops."
566                );
567            }
568        }
569        Ok(())
570    }
571
572    fn wrap_router(&self, router: Router) -> Router {
573        if self.auto_capture_requests {
574            router.layer(axum::middleware::from_fn(pageview_middleware))
575        } else {
576            router
577        }
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::AnalyticsClient;
584
585    // audit_2 plugin-observability #5: outbound sends are bounded so a burst
586    // can't fan out unbounded tasks. The semaphore starts sized to the ceiling,
587    // and once exhausted, further acquisitions fail (→ the event is dropped).
588    #[test]
589    fn outbound_sends_are_concurrency_bounded() {
590        let sem = super::send_slots();
591        assert_eq!(
592            sem.available_permits(),
593            super::MAX_CONCURRENT_ANALYTICS_SENDS
594        );
595        // Exhaust a private clone's worth to prove the drop path: hold every
596        // permit, then the next acquire fails (what `capture` treats as "drop").
597        let mut held = Vec::new();
598        for _ in 0..super::MAX_CONCURRENT_ANALYTICS_SENDS {
599            held.push(sem.clone().try_acquire_owned().expect("permit"));
600        }
601        assert!(
602            sem.clone().try_acquire_owned().is_err(),
603            "at capacity, a further send must be refused (dropped)"
604        );
605        // Permits released here (held dropped) so sibling tests aren't starved.
606    }
607
608    #[test]
609    fn excluded_prefixes_are_not_captured() {
610        let client = AnalyticsClient::new("k", "https://h")
611            .with_exclude_prefixes(vec!["/reset-password".to_string(), "/verify".to_string()]);
612        // Sensitive paths (incl. a token segment) are excluded.
613        assert!(!client.should_capture_path("/reset-password/abc123token"));
614        assert!(!client.should_capture_path("/verify/xyz"));
615        // Ordinary paths are still captured.
616        assert!(client.should_capture_path("/"));
617        assert!(client.should_capture_path("/pricing"));
618        // With no exclusions everything is captured.
619        let open = AnalyticsClient::new("k", "https://h");
620        assert!(open.should_capture_path("/reset-password/abc"));
621    }
622
623    #[test]
624    fn scrub_path_replaces_identifying_segments() {
625        use super::AnalyticsClient;
626        // ids, uuids, emails, tokens -> typed placeholders; static segments stay.
627        assert_eq!(AnalyticsClient::scrub_path("/orders/8412"), "/orders/:id");
628        assert_eq!(
629            AnalyticsClient::scrub_path("/users/ada@example.com/orders/9"),
630            "/users/:email/orders/:id"
631        );
632        assert_eq!(
633            AnalyticsClient::scrub_path("/t/550e8400-e29b-41d4-a716-446655440000"),
634            "/t/:uuid"
635        );
636        assert_eq!(
637            AnalyticsClient::scrub_path("/reset-password/aB3xK9zLmQ7pR2tV5wY8nC1dE4fG6hJ0"),
638            "/reset-password/:token"
639        );
640        // A human slug survives — not every segment is dynamic.
641        assert_eq!(
642            AnalyticsClient::scrub_path("/blog/my-first-post"),
643            "/blog/my-first-post"
644        );
645        // Root and trailing slash structure is preserved.
646        assert_eq!(AnalyticsClient::scrub_path("/"), "/");
647        assert_eq!(AnalyticsClient::scrub_path("/orders/8412/"), "/orders/:id/");
648    }
649}