Self-hosted, lightweight product analytics — Rust backend, tiny JS SDK. PostHog-style feature set: event tracking, autocapture, session replay, funnels, retention.
Used in production by OxaDash, the dashboard of OxalisHeberg.fr.
crates/iris-core— pure domain logic: models, funnel/retention math, session boundary rules, user-agent → device/browser/OS detection (automatic, no client work), feature flag / experiment bucketing, A/B stats engine.crates/iris-server— Axum server. GraphQL API (/graphql,async-graphql) for querying, plus a lightweight REST ingestion path (/capture,/batch,/replay) so high-volume writes skip GraphQL parsing overhead. SQLite storage viasqlx(swap theDATABASE_URLfor Postgres-compatible storage later without touching the query layer's shape).sdk/iris.js— dependency-free browser tracker (~6kb unminified). Autocapture for pageviews/clicks/form submits, session management, session replay (DOM snapshot + incremental mutation/scroll/mouse/input capture), customcapture()API, batched delivery withsendBeaconon unload.
See DOC.md for a full, non-technical feature walkthrough.
cargo run -p iris-server
Server listens on :8791 (override with PORT). Then open http://localhost:8791/graphql
for GraphiQL.
Session IPs are resolved to country/city/coordinates automatically — no MaxMind account
or license key needed. On first boot (and weekly after that) the server downloads a free
GeoLite2-derived City database from
sapics/ip-location-db (both IPv4 and IPv6,
cached under ./data/geoip) and reads it locally; no visitor IP is ever sent to a
third-party geolocation API. If the download fails (no network egress, air-gapped
deployment) the server still starts fine — geo fields just stay empty.
To use your own database instead (e.g. an official combined MaxMind
GeoLite2-City/GeoIP2-City .mmdb, for better accuracy or an air-gapped deploy), set
GEOIP_DB_PATH and auto-download is skipped entirely:
GEOIP_DB_PATH=/path/to/GeoLite2-City.mmdb cargo run -p iris-server
Query the result once data has flowed in:
query {
sessionGeo(projectId: "your-project", sessionId: "some-session-id") {
countryCode
countryName
city
region
latitude
longitude
}
sessionsByCountry(projectId: "your-project") {
countryName
count
}
}<script src="https://your-iris-host/sdk/iris.js"></script>
<script>
Iris.init({
apiHost: "https://your-iris-host",
projectId: "your-project",
autocapture: true,
sessionReplay: true,
});
Iris.capture("custom_event", { foo: "bar" });
Iris.identify("user-123");
</script>Queries: events, eventCounts, sessions, replay, funnel, retention,
featureFlags, isFeatureEnabled, experimentVariant, experiments, experimentResults,
cohorts, groups, surveys, errors, destinations, exportEventsCsv, runQuery,
sessionGeo, sessionsByCountry, project.
Mutations: capture (prefer /capture//batch REST routes for the SDK's own
traffic — GraphQL mutation is for server-side/backend integrations),
upsertFeatureFlag, upsertExperiment, createCohort, groupIdentify,
setPersonProperties, createDestination, createSurvey, upsertProject.
By default a projectId is just a free-form string: any site knowing it can post events
under it. To stop site B from posting events under site A's projectId, register the
origins A is actually embedded on:
mutation {
upsertProject(id: "your-project", name: "My Site", allowedOrigins: ["https://example.com"])
}Once a project has at least one registered origin, /capture, /batch, /replay, and the
capture GraphQL mutation reject requests whose Origin (or Referer, as a fallback) header
doesn't match one of them, with 403. A project that's never been registered (or registered
with an empty allowedOrigins) stays open, so this is opt-in and doesn't break existing
deployments.
This is the same mechanism Plausible/PostHog call "site verification" — it stops a page on
another site from silently posting analytics in a visitor's browser. It is not a secret:
Origin/Referer are just headers, so a non-browser client (curl, a script) that already
knows your projectId and expected origin can still set them to match. Treat it as raising
the bar against browser-context abuse, not as authentication — the GraphQL API has no auth at
all, by design, and is meant to be reached only by trusted/admin callers.
Benchmarked on SQLite with WAL mode + batched transactions:
| Events | Time | Rate |
|---|---|---|
| 100,000 | ~13s | ~7,500/s |
| 1,000,000 | ~120s | ~7,460/s |
DB size: ~190 bytes/event. Server RAM stays under 20MB even at 1M stored events.
MSVC toolchain is required for libsqlite3-sys's C build step. If cc-rs picks up
an unrelated CC env var (e.g. from a Python/Triton install), .cargo/config.toml
in this repo forces it empty so cc-rs falls back to cl.exe.
Event capture, autocapture, pageviews, session identification, session replay with
real incremental DOM diffs (add/remove/attribute/text/input, each targeting a stable
per-element id, replayable with a real-time scrubber), funnels, retention, UA-based
device/browser/OS detection, channel/referrer classification, GeoIP-based session
location (country/city/coordinates, auto-downloaded MaxMind-format mmdb — offline
lookups, no signup, bring your own .mmdb optionally), feature flags
(consistent-hash rollout %), groups/group-identify, person $set properties,
cohorts (event+property filter membership), A/B experiments with weighted variant
assignment and a two-proportion z-test significance engine, webhook destinations
(fire an HTTP POST on matching events), error tracking (window.onerror + unhandled
rejections, grouped by message), web vitals (LCP/CLS), rageclick detection, click
heatmap coordinates, surveys (fetch + inline widget + response capture), CSV event
export, and a sandboxed read-only SQL insights query (runQuery, events table
only, project-scoped, keyword/table denylist).