# `ev_lib::analytics` — cookbook
End-to-end Rust recipes for every surface of the `analytics` feature. For the
API summary and the parity table, see [`README.md`](./README.md). The TS mirror
is [`@evinvest/analytics`](../../../ts/analytics).
- [The model](#the-model)
- [The event taxonomy + PII rules](#the-event-taxonomy--pii-rules)
- [Reading the key and host from the environment](#reading-the-key-and-host-from-the-environment)
- [Backend: capture from an Axum handler](#backend-capture-from-an-axum-handler)
- [Frontend: capture from a Dioxus `onclick`](#frontend-capture-from-a-dioxus-onclick)
- [The payload builder](#the-payload-builder)
- [Testing](#testing)
- [Gotchas](#gotchas)
## The model
There is one event type and two transports:
- [`Event`] — a snake_case name plus a `BTreeMap<String, PropValue>` of
primitive properties. Built fluently: `Event::new(name).prop(k, v)`.
- [`Analytics`] (native, non-wasm) — an async `reqwest` client that POSTs to
`<host>/capture/`.
- [`AnalyticsProvider`] + [`use_analytics`] (Dioxus) — context-provided config
plus a `Copy` handle; `capture(Event)` POSTs via pure-Rust `reqwest` fetch on
`wasm32`.
Both transports share [`capture_body`], so the JSON sent to PostHog is identical
on the server and in the browser.
## The event taxonomy + PII rules
- **Name:** `snake_case`, scoped `<surface>_<thing>_<action>`, e.g.
`hero_cta_clicked`, `calculator_submitted`, `checkout_order_placed`.
- **Names are the contract.** Dashboards key off them — renaming breaks
analysis.
- **Props are primitives only.** `PropValue` is `Bool | Int | Num | Str` by
construction — there is no variant for nested objects or arrays. `i32`/`i64`,
`f64`, `bool`, `&str`/`String` all `Into<PropValue>`.
- **Never PII.** No names, emails, or free text the user typed. Pass enums and
flags (`tier`, `variant`, `count`), not raw input.
```rust
use ev_lib::analytics::Event;
// good — enum-like labels and counts
Event::new("calculator_submitted").prop("tier", "pro").prop("amount", 1200).prop("recurring", true);
// NEVER — free text / PII
Event::new("calculator_submitted").prop("email", user_email);
```
## Reading the key and host from the environment
The library never reads the environment for you — pass the values in. On the
server, read them at startup:
```rust
use ev_lib::analytics::Analytics;
let analytics = Analytics::new(
std::env::var("POSTHOG_KEY").ok(), // None → capture is a silent no-op
std::env::var("POSTHOG_HOST").ok(), // None → DEFAULT_HOST (https://us.i.posthog.com)
);
```
In a Dioxus/wasm build the env is fixed at compile time, so use `option_env!`:
```rust
AnalyticsProvider { api_key: option_env!("POSTHOG_KEY").map(str::to_string), /* … */ }
```
A PostHog *project* key is publishable, so inlining it into the client bundle is
fine.
## Backend: capture from an Axum handler
Build one `Analytics` at startup, store it in the router state (it is `Clone`
and holds a pooled client), and capture from handlers. Capture is **best-effort
analytics, not request-critical** — log a failure, don't fail the request:
```rust
use axum::{Json, extract::State, http::StatusCode};
use ev_lib::analytics::{Analytics, Event};
#[derive(Clone)]
struct AppState {
analytics: Analytics,
}
async fn place_order(State(state): State<AppState>, Json(req): Json<OrderRequest>) -> StatusCode {
// … do the work …
if let Err(error) = state
.analytics
.capture(&req.user_id, &Event::new("checkout_order_placed").prop("amount", req.amount))
.await
{
tracing::warn!(%error, "analytics capture failed");
}
StatusCode::CREATED
}
```
`distinct_id` names the subject; for system events with no user, pass a
constant such as `"server"`. `capture` returns `reqwest::Result<()>` — network
and non-2xx responses surface as `reqwest::Error`; a missing key returns `Ok`
without a network call.
## Frontend: capture from a Dioxus `onclick`
Mount [`AnalyticsProvider`] once near the root, then read the handle with
[`use_analytics`] in any descendant:
```rust
use dioxus::prelude::*;
use ev_lib::analytics::{AnalyticsProvider, Event, use_analytics};
#[component]
fn App() -> Element {
rsx! {
AnalyticsProvider {
api_key: option_env!("POSTHOG_KEY").map(str::to_string),
host: option_env!("POSTHOG_HOST").map(str::to_string),
Hero {}
}
}
}
#[component]
fn Hero() -> Element {
let analytics = use_analytics();
rsx! {
button {
onclick: move |_| analytics.capture(Event::new("hero_cta_clicked").prop("variant", "b")),
"Invest"
}
// gate optional work on whether analytics is configured
if analytics.is_enabled() {
span { "tracking on" }
}
}
}
```
`AnalyticsHandle` is `Copy`, so it moves cleanly into closures. `capture` takes
the `Event` by value, POSTs it fire-and-forget via
`wasm_bindgen_futures::spawn_local`, and no-ops on non-wasm targets (SSR) and
when no key is set. The browser identity is an anonymous id persisted in the
`ev_did` cookie — no `identify` call is needed.
## The payload builder
[`capture_body`] is pure and network-free, so the wire shape is unit-testable
without a server. It is what both transports send:
```rust
use ev_lib::analytics::{Event, capture_body};
let body = capture_body("phc_key", "anon-1", &Event::new("hero_cta_clicked").prop("variant", "b"));
assert_eq!(body["event"], "hero_cta_clicked");
assert_eq!(body["distinct_id"], "anon-1");
assert_eq!(body["properties"]["variant"], "b");
assert_eq!(body["properties"]["$lib"], "ev-analytics"); // appended marker
```
The timestamp is left to PostHog's receive time.
## Testing
- **The event model and payload are pure** — assert against `Event` and
`capture_body` directly, no client or server needed (the tests in `event.rs`).
- **`Analytics` no-ops without a key**, so unit tests don't need a PostHog
endpoint:
```rust
# use ev_lib::analytics::{Analytics, Event};
#[tokio::test]
async fn capture_is_noop_when_disabled() {
let analytics = Analytics::new(None, None);
assert!(!analytics.is_enabled());
assert!(analytics.capture("anon", &Event::new("noop_event")).await.is_ok());
}
```
- **The Dioxus surface renders under `dioxus-ssr`** — the components are
renderer-agnostic and the capture POST is gated to `wasm32`, so you can assert
that `AnalyticsProvider` exposes the right `is_enabled()` state without a
browser.
## Gotchas
- **No batching / retry.** Each `capture` is one POST. On the browser it is
fire-and-forget (a failed fetch is dropped silently); on the server, check the
`Result`.
- **Native vs wasm capture differ in error handling.** The native client
returns `reqwest::Result<()>`; the Dioxus handle returns `()` and swallows
errors (analytics must never break the UI).
- **Keep props primitive and PII-free.** The type system stops you from sending
nested objects, but it cannot stop you from putting an email into a `Str` —
that discipline is on you.
- **One provider.** Mount `AnalyticsProvider` once near the root; nesting it
creates multiple configs.
- **`use_analytics` panics outside a provider** (it reads context). Mount the
provider above any component that calls it.