Lightweight, privacy-preserving analytics for your websites and applications.
A self-hosted analytics service written in Rust. It collects useful product analytics without compromising your users' privacy — no cookies, no IP addresses, no personally identifiable information. Multiple sources (a marketing site, its docs, a paired application) can be grouped into a project and viewed in aggregate, alongside a global overview across every project.
It also doubles as a lightweight, privacy-preserving error tracker, grouping client-side exceptions much like Sentry.
Inspired by medama, the service deliberately collects only broad, non-identifying signals:
- No cookies, no IP storage, no PII. Client IPs are used transiently only as rate-limit keys and are never logged or persisted.
- Daily unique visitors are counted with the HTTP conditional-request cache
trick (
If-Modified-Sincevs UTC midnight), so uniqueness resets every day without any client-side identifier. - The User-Agent and Accept-Language headers are parsed into broad classes (browser / OS / device, primary language) at the edge — the raw values are never stored.
- Country is derived from the browser's reported timezone, not IP geolocation.
DNT/Sec-GPCsignals are honored.
- Projects & sources — group multiple hostnames (and applications) into a project; filter to subsets; auto-register new reporting hostnames.
- Metrics — visitors, page views, bounce rate, median time on page, time series, and breakdowns by page, referrer, browser, OS, device, country, language, and source.
- Tracking pixels — admin-created, project-bound tracking GIFs (e.g. for email opens) with attached metadata. Unknown pixel ids are rejected — there is no open pixel endpoint.
- Exception tracking — capture unhandled errors and rejections, grouped by a Sentry-style fingerprint, with triage state (unresolved / resolved / ignored).
- OIDC authentication — the dashboard and management API are gated by a server-driven OIDC flow with a configurable filter-expression ACL. The public tracking endpoints need no authentication.
- Rate limiting — per-IP token-bucket limits on both the public tracking endpoints and unauthenticated hits to protected endpoints.
- Append-only, write-optimized storage — events are appended to an redb hot store, compacted into date-partitioned Parquet, and queried with polars.
A Cargo workspace, mirroring grey and automate:
api/— framework-free serde DTOs shared by the server and the WebAssembly frontend.agent/— theactix-webserver: clap CLI, YAML config, OIDC auth, the ingest pipeline, storage, and the polars query layer. The compiled frontend is embedded into the binary viainclude_dir!.ui/— a client-side-rendered Yew dashboard, built with Trunk.tracker/— the tracking beacon: a dependency-free, pure-JavaScript snippet built into a single heavily-minified artifact with esbuild and served at/tracker.js. One build, no variants — behaviour is toggled bydata-*attributes at runtime. Unit-tested with Vitest.
# 1. Build the tracking beacon (embedded into the server binary).
cd tracker && npm install && npm run build && cd ..
# 2. Build the frontend bundle (embedded into the server binary).
cd ui && trunk build --release && cd ..
# 3. Build and run the server.
cargo build --release -p analytics
cp config.example.yaml config.yaml # then edit to taste
./target/release/analytics --config config.yamlThe dashboard is served at the configured address (default http://127.0.0.1:8080).
Add the tracker script to your pages, pointing data-api at your server:
<script
async
src="https://analytics.example.com/tracker.js"
data-api="https://analytics.example.com"
data-auto-capture-exceptions="true"
></script>The script reports page views (and, with data-auto-capture-exceptions, unhandled
errors and promise rejections). It follows SPA navigations automatically by
intercepting the History API; add data-hash if your app routes with the URL hash
instead. It also exposes window.analytics.event(name, data) and
window.analytics.captureException(error, meta) for manual reporting. Sources are
identified purely by their hostname — no per-site key to embed.
Create a pixel in the dashboard (under a project) to get an embeddable URL such as
https://analytics.example.com/track/gif/<id>.gif for contexts where JavaScript
can't run (email opens, RSS, docs).
All configuration lives in a YAML file (see
config.example.yaml). Secrets can be injected from the
environment with ${{ env.VAR_NAME }} placeholders.
Omitting the web.admin.oidc block disables the sign-in flow, but the dashboard is
still gated by the web.admin.acl filter expression — which defaults to "false"
(deny all). To run locally without authentication, omit OIDC and set an
allow-all ACL so the API is reachable:
web:
admin:
acl: "true" # local development only — grants everyone full accessWith the default deny-all ACL and no OIDC, the dashboard cannot be signed into (the sign-in page explains this rather than looping).
- Public (no auth):
GET /tracker.js,GET /track/ping,POST /track/hit,POST /track/exception,GET /track/gif/{id}.gif,GET /api/v1/health. - Protected (OIDC + ACL): everything else under
/api/v1— projects, sources, pixels, overview, per-project stats, and exception groups/triage.
MIT — see LICENSE.