Vitrus
Officialby Vitrus-Dev
README.md
<p align="center">
<a href="https://vitrus.dev"><img src=".github/assets/banner.png" alt="Vitrus — open-source analytics for every app you ship. One panel you can check." width="100%"></a>
</p>
<p align="center">
<b>Open-source analytics for every app you ship. One panel you can check.</b><br>
Cookie-free web and product analytics for websites, iOS and Android apps — traffic, users, retention and<br>
revenue side by side, without storing who your users are. Every number carries the SQL that produced it,<br>
and your AI agent can ask about all of them over MCP. One process, on your own server.
</p>
<p align="center">
<a href="https://vitrus.dev/demo"><b>Live demo</b></a> ·
<a href="#ask-your-ai-about-your-analytics"><b>Use with ChatGPT / Claude</b></a> ·
<a href="https://vitrus.dev"><b>Website</b></a> ·
<a href="https://app.vitrus.dev"><b>Cloud</b></a> ·
<a href="https://vitrus.dev/docs"><b>Documentation</b></a> ·
<a href="#quickstart"><b>Quickstart</b></a> ·
<a href="https://vitrus.dev/compare">Compare</a> ·
<a href="https://vitrus.dev/changelog">Changelog</a> ·
<a href="https://github.com/Vitrus-Dev/vitrus/discussions">Discussions</a>
</p>
<p align="center">
<a href="LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-5bc8af"></a>
<img alt="Runtime dependencies: 0" src="https://img.shields.io/badge/runtime%20deps-0-5bc8af">
<img alt="Tracker: 3.0 KB gzipped" src="https://img.shields.io/badge/tracker-3.0%20KB%20gzip-5bc8af">
<img alt="Services to install: 0" src="https://img.shields.io/badge/services%20to%20install-0-5bc8af">
<a href="https://github.com/Vitrus-Dev/vitrus/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Vitrus-Dev/vitrus/actions/workflows/ci.yml/badge.svg"></a>
<a href="https://github.com/Vitrus-Dev/vitrus/releases"><img alt="Release" src="https://img.shields.io/github/v/release/Vitrus-Dev/vitrus?color=5bc8af"></a>
</p>
<p align="center">
<img src=".github/assets/hero-split.png" alt="The Vitrus dashboard in light and dark, on a demo workspace with sample data: KPIs with evidence badges, a trend against the previous period, and the deterministic digest" width="100%">
</p>
## Quickstart
You need [Bun](https://bun.sh) 1.3 or newer. That is the whole list.
```bash
bun install -g @vitrus-dev/cli
vitrus init # creates ./vitrus.db and a secret salt
vitrus site add "My site" example.com # prints your script tag
VITRUS_PASSWORD="a long secret" vitrus start # ingest + dashboard on http://localhost:3000
```
From source instead: `git clone https://github.com/Vitrus-Dev/vitrus && cd vitrus && bun install && bun run build:tracker`,
then run `bun packages/cli/src/cli.ts` wherever this says `vitrus`.
Then one line in your site's `<head>`:
```html
<script defer data-site="SITE_ID" src="https://YOUR-HOST/v.js"></script>
```
Mobile apps: [Vitrus for Swift](https://github.com/Vitrus-Dev/vitrus-swift) (iOS, iPadOS, macOS — Swift Package Manager)
and [Vitrus for Flutter & Dart](https://github.com/Vitrus-Dev/vitrus-dart). Same panel, user ids hashed on the device,
no device identifier.
No Docker Compose file, no database server, no migration step. Rather not run it? **[app.vitrus.dev](https://app.vitrus.dev)**
is the same engine, hosted, with a free plan — and **[the live demo](https://vitrus.dev/demo)** opens the full hosted
dashboard on sample data, no account needed.
## Every number opens the query that made it
Click a figure and you get the SQL that ran, its parameters, the window and the result — not a
description of the number, the number's origin. It is the same evidence the AI summary is checked against.
<p align="center">
<img src=".github/assets/shot-main-evidence.webp" alt="The unique-visitors figure opened in the evidence panel: the SQL query, its parameters, the result and the time window (demo workspace, sample data)" width="100%">
</p>
That matters more now that models write the reports. When an AI-written sentence contains a number
that is not in the evidence, Vitrus **drops the sentence** before it reaches you:
```
Visitors are down 18% against the previous period. [e1]
The drop coincides with the last deploy. [e1, e7]
✗ dropped: "The new theme caused a 41.7% fall in conversion."
→ 41.7 is in no evidence record, and "caused" is a claim nobody can make from this data.
```
## Every app you ship, one panel
Shipping several apps — web and mobile — usually means one admin page per app just to see who signed up.
Add one line where your app knows the user is signed in, and Vitrus counts **who is new, who came back, who you lost
and who started paying**, per app and for all of them side by side:
```js
vitrus.identify(user.id) // required: hashed in the browser, never sent raw
vitrus("signup") // optional
vitrus("plan", { plan: "pro" }) // optional: your own plan labels
```
<p align="center">
<img src=".github/assets/shot-portfolio.webp" alt="The Portfolio page: one card per app with its active users, a trend line, Web/iOS/Android badges, active, new and lost users, paying users and revenue (demo workspace, sample data)" width="100%">
</p>
- **Every user, under a made-up name.** Open an app and each signed-in user is a row — returning, one-time or signed
up only, actions, active days, visits, joined, last active; opened, actions by day and recent visits — shown as
"Amber Falcon · 3f9a2c", because no email or name is stored to show.
- **Lifecycle per app.** New, active, came back, lost, net, quick ratio, one-and-done, current plans, became and
stopped paying — each with its query.
- **Ask your AI.** "Which of my apps lost the most users this week?" — `get_portfolio` and `get_lifecycle` over MCP
answer with counts and the queries behind them.
- **Hashed before it leaves the browser.** The script sends `sha256(site + "|" + id)`; the server hashes that again with
a secret before storing it. Your users' ids — often emails — never reach the server. No WebCrypto, no id sent.
- **Separate per app, by design.** The same person in two of your apps is two unrelated hashes; nobody can join them.
Portfolio totals say so instead of pretending to deduplicate.
- **Personal fields dropped twice.** Email, phone, name, address, password, token and similar properties — and any value
shaped like an email or phone number — are removed in the browser and again on the server, and the removal is counted.
- **No auth provider to connect.** Works the same with Supabase, Clerk, Firebase, Better Auth or your own login.
<p align="center">
<img src=".github/assets/shot-users.webp" alt="The Users page: returning, one-time and signed-up-only users, and a row per user under a made-up name with actions, active days and visits; one row opened to actions by day and recent visits (demo workspace, sample data)" width="100%">
</p>
Self-hosted: `GET /api/people?site=…`, `GET /api/lifecycle?site=…` and `GET /api/portfolio` on your server; over MCP, `get_lifecycle` and
`get_portfolio`. [How it is counted →](https://vitrus.dev/docs/lifecycle)
## A whole analytics product
<table>
<tr>
<td width="50%"><img src=".github/assets/tile-globe.webp" alt="The 3D globe with sessions at their cities"><br><b>Globe.</b> Sessions at their city, on a canvas drawn from our own geometry — no map provider, no GeoIP database.</td>
<td width="50%"><img src=".github/assets/tile-realtime.webp" alt="Real-time visitors and the live event feed"><br><b>Real-time.</b> Who is on the site now, and the latest events, streamed as they arrive.</td>
</tr>
<tr>
<td><img src=".github/assets/tile-ai.webp" alt="AI assistant referrals separated from AI crawler reads"><br><b>AI traffic.</b> Visitors ChatGPT and Claude sent you, kept apart from the crawlers that read your pages.</td>
<td><img src=".github/assets/tile-funnel.webp" alt="An ordered funnel with drop-off"><br><b>Funnels.</b> Ordered — a step counts only after the one before it, so the conversion is not inflated.</td>
</tr>
<tr>
<td><img src=".github/assets/tile-journeys.webp" alt="A Sankey of page journeys"><br><b>Journeys.</b> The paths people take, as a Sankey you can filter by start and end.</td>
<td><img src=".github/assets/tile-revenue.webp" alt="Revenue by channel"><br><b>Revenue.</b> Per currency, never converted, attributed to where the visit began.</td>
</tr>
<tr>
<td><img src=".github/assets/tile-performance.webp" alt="Core Web Vitals at p75"><br><b>Web Vitals.</b> LCP, INP, CLS, FCP and TTFB at the percentile you choose, per page.</td>
<td><img src=".github/assets/tile-retention.webp" alt="A retention cohort matrix"><br><b>Retention.</b> Cohorts of identified users — and an honest blank for days that have not happened yet.</td>
</tr>
<tr>
<td><img src=".github/assets/tile-heatmap.webp" alt="A click heatmap over a recorded page"><br><b>Heatmaps.</b> Clicks and scroll depth per device class, drawn over the page from your own recordings — nothing extra collected.</td>
<td><img src=".github/assets/tile-users.webp" alt="The Users page"><br><b>Users.</b> Every signed-in user under a made-up name — returning, one-time, signed up only — with actions by day.</td>
</tr>
</table>
<sub>Screenshots: the hosted dashboard on a demo workspace; every figure is sample data — <a href="https://vitrus.dev/demo">open it yourself</a>. The self-hosted
dashboard in this repository is simpler today — see <a href="#architecture">Architecture</a> for exactly which views it renders.</sub>
## Ask your AI about your analytics
Vitrus speaks MCP. Your assistant reads your analytics — read-only — and every number it gets back comes
with the query that produced it, so it can cite instead of assert. Listed in the official
[MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=dev.vitrus) as `dev.vitrus/analytics`.
| Where | How |
|---|---|
| **ChatGPT** · **Claude** (web & desktop) | Add a custom connector with `https://app.vitrus.dev/mcp`, sign in, pick a workspace, Allow. |
| **Claude Code** — plugin with 4 skills | `claude plugin marketplace add Vitrus-Dev/vitrus` then `claude plugin install vitrus@vitrus` |
| **Claude Code** — server only | `claude mcp add vitrus --transport http https://app.vitrus.dev/mcp` |
| **Cursor** | <a href="https://cursor.com/en/install-mcp?name=vitrus&config=eyJ1cmwiOiJodHRwczovL2FwcC52aXRydXMuZGV2L21jcCJ9"><img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Add Vitrus to Cursor" height="28"></a> |
| **Gemini CLI** | `gemini extensions install https://github.com/Vitrus-Dev/vitrus` |
| Anything else | `{"type": "http", "url": "https://app.vitrus.dev/mcp"}` — OAuth, or `Authorization: Bearer vk_…` (a workspace API key) |
The plugin's skills, under [`plugins/vitrus/skills`](plugins/vitrus/skills):
- **`/vitrus:weekly-report`** — last week against the week before, channels, AI assistants, goals; every figure cited.
- **`/vitrus:traffic-drop`** — finds the day a number broke and the segment it lives in, then checks errors and Web Vitals.
- **`/vitrus:measurement-plan`** — reads your code and proposes the events, goals, funnels and revenue calls to add.
- **`/vitrus:ai-search-audit`** — which assistants send people, which pages the AI crawlers read, and where the two disagree.
17 tools, all read-only: `query_stats` (any metric by any of 20 dimensions or by day, with filters),
`get_portfolio` (all your apps side by side), `get_lifecycle`, `get_realtime`, `get_ai_traffic`, `get_revenue`,
`goal_report`, `analyze_funnel`, `get_journeys`, `get_retention`, `get_web_vitals`, `get_errors`, `get_digest` and more — [docs](https://vitrus.dev/docs/mcp).
Connections are scoped to one workspace and can be disconnected any time under **Account → Connected apps**.
## What it is, in one paragraph
Vitrus tells you how many people visit your website, where they come from, what they do and where
they leave. It sets no cookies, so you do not need a consent banner. Use the hosted version at
[vitrus.dev](https://vitrus.dev) or run it yourself as a single program — there is no database
server to install.
## Features
- **The query behind every number.** Every metric travels as `{ id, sql, params, window, value }`,
and the dashboard opens that exact SQL when you click the badge beside a figure.
- **AI summaries that cannot invent.** Numbers come from deterministic SQL first. The optional model
(local Ollama, or your own key — neither is on by default) may rephrase them and may not produce
one; a numeric guard drops any sentence it cannot support, and a golden-set eval holds leaks at **0**.
- **AI referrals ≠ AI crawlers.** `chatgpt.com` sending a person and `GPTBot` reading a page are two
different events, classified separately and never summed — by a versioned rule table held at
**100%** precision and recall by its own eval.
- **Which pages the crawlers read**, per page — the feedback loop GEO/AEO work actually needs.
- **Verified agent identity.** A request signed with Web Bot Auth (RFC 9421 HTTP Message Signatures)
is checked against the Ed25519 key its operator publishes, so the signer can be *named*. A
user-agent is a sentence; a signature is proof.
- **Agent sessions as their own class** — kept out of your visitor numbers, reported separately,
never silently dropped or counted as people.
- **Two-layer bot detection, in the open-source core.** A user-agent table plus header rules that
check whether a request agrees with the browser it claims to be. Recorded and shown, never
self-applying: *a suspicion is not a verdict*.
- **Everything else you expect** — pageviews, sessions, unique and live visitors, bounce rate, visit
duration, top / entry / exit pages, referrers, channels, UTM campaigns, devices, browsers, OS,
screens, languages, country, custom events with properties, outbound clicks, form submissions,
field-level form abandonment, rage and dead clicks, scroll depth, **ordered funnels**,
**retention cohorts**, **Core Web Vitals at p75** and **JavaScript errors**.
- **Session replay, opt-in and masked.** Off until you switch it on for a site, loaded as a
separate script only on pages that carry `data-replay`, and every text node and input value
masked by default. Password, card and one-time-code fields are always blocked. Recordings play in
a sandboxed player in the self-hosted dashboard.
- **City, region and coordinates without a GeoIP database** — read from the headers your proxy
already sets (Cloudflare, Vercel, CloudFront), coordinates rounded to 0.1°.
- **Digests: what happened → why → what to do.** `vitrus digest` writes one in your terminal, every
line tagged with the evidence ids behind it. The Slack / email / webhook channels and the
never-sent-twice scheduler ship in the core.
- **A read-only MCP server** with 17 tools — any metric by any dimension, every app side by side, user lifecycle,
real-time, AI traffic, revenue, goals, funnels, journeys, retention, vitals, errors — that returns the evidence with every number, so
an agent can cite instead of assert. On the cloud, ChatGPT and Claude connect with OAuth (add
`https://app.vitrus.dev/mcp`, sign in, pick a workspace); Claude Code is one line:
`claude mcp add vitrus --transport http https://app.vitrus.dev/mcp`.
- **Private by construction.** No cookies, no stored identifier, no fingerprinting, no consent
banner. Do Not Track honoured by default. A **3.0 KB** tracker. **Zero** runtime dependencies,
enforced by CI.
Deliberately absent: ad-platform attribution (ROAS/CPA) and multi-touch attribution models, because
a model's opinion is not something we can prove.
## Self-hosting, in more detail
The [Quickstart](#quickstart) is the whole install. **Set `VITRUS_PASSWORD` before the port is reachable
from outside:** the dashboard and the read API then ask for it (HTTP Basic, any user name), while the
tracker and ingest stay public because your visitors' browsers call them. Without it, `vitrus start`
prints a warning and anyone who can reach the port can read your analytics.
Want data on screen straight away? `vitrus demo <site-id>` generates a realistic week of sample traffic.
```bash
vitrus site ls # list your sites
vitrus digest <site-id> --days 7 # deterministic summary, in your terminal
vitrus digest <site-id> --llm ollama:gemma3:4b # phrased by a local model; data never leaves the box
vitrus start --port 8080
```
The database path is `VITRUS_DB` (default `./vitrus.db`). Put Cloudflare, Vercel, Fly, CloudFront or
Netlify in front and country (and, from Cloudflare, Vercel or CloudFront, region and city) is read from
the headers they already set — there is no GeoIP database
to download, and without a proxy the dashboard says the data is missing rather than guessing.
## How it compares
The open-source alternatives are good, and several do things we do not. This table lists only
differences we can state without qualification. *Checked 24 September 2026 against each product's own
documentation — if a cell is out of date, please [open an issue](https://github.com/Vitrus-Dev/vitrus/issues/new).*
| | GA4 | Plausible | Umami | Rybbit | **Vitrus** |
|---|:---:|:---:|:---:|:---:|:---:|
| Open source | ✗ | ✓ AGPL-3.0 | ✓ MIT | ✓ AGPL-3.0 | **✓ Apache-2.0** |
| Cookie-free, no consent banner | ✗ | ✓ | ✓ | ✓ | **✓** |
| Services to install | — | Postgres + ClickHouse | Postgres or MySQL | Postgres + ClickHouse | **none** (one SQLite file) |
| Tracker size (gzipped) | ~28 KB | ~1 KB | ~2 KB | ~18 KB | **3.0 KB** |
| AI assistants as a channel | ✗ | ✓ | ✓ | ✓ | **✓** |
| Crawler reads separated from the humans they send | ✗ | ✗ | ✗ | ✓ | **✓** |
| **The query behind every number, in the UI** | ✗ | ✗ | ✗ | ✗ | **✓** |
| **AI sentences dropped when the number is unprovable** | ✗ | ✗ | ✗ | ✗ | **✓** |
| Verified agent identity (Web Bot Auth) | ✗ | ✗ | ✗ | ✗ | **✓** |
| Bot detection beyond the user-agent, in the open-source build | ✗ | ✗ | ✗ | ✗ | **✓** |
| Funnels, journeys and goals | ✓ | ✓ (funnels on paid plans) | ✓ | ✓ | **✓** |
| Session replay | ✗ | ✗ | ✓ | ✓ | opt-in, masked by default |
| Public API with keys | ✓ | ✓ | ✓ | ✓ | **✓ read-only** |
| City-level geography | ✓ | ✓ | ✓ | ✓ | from proxy headers |
| Hundreds of millions of events a month | ✓ | ✓ | ✓ | ✓ | ~1M/month per box |
### Where Vitrus is ahead
- **You can check every number.** Click it and you get the query, its parameters and the rows. No other
tool in this table does that.
- **AI summaries cannot make numbers up.** A sentence whose number is not in the evidence is removed.
- **AI traffic, told apart properly.** Visitors that ChatGPT or Perplexity sent you are one thing; the AI
crawlers reading your pages are another; agents that sign their requests are verified and named.
- **Nothing to operate.** One process and one SQLite file (built into the Bun runtime). The others need a database server —
Postgres or MySQL, and for two of them ClickHouse as well.
- **The privacy default is the strict one.** The visitor id is re-salted every day, always.
### Where others are ahead
- **Heatmaps** — Umami has them; we do not.
- **Importing your history** — we import from Umami only; Plausible imports Google Analytics history, and Rybbit imports Plausible exports too.
- **Maturity and scale.** Replay, autocapture of every button click and hundreds of millions of events
a month are all further along in Rybbit and Umami, which run on ClickHouse or Postgres. Our embedded
database is what makes the zero-service install possible, and it is also its ceiling (about a million
events a month per box).
- **Ad attribution.** If your reporting is tied to Google or Meta ad spend, GA4 closes that loop and we
never will.
- **Cities without a proxy.** Everyone else bundles a GeoIP database; we read city and region from
Cloudflare, Vercel or CloudFront headers, and show only the country otherwise.
One-page write-ups per tool: **[vitrus.dev/compare](https://vitrus.dev/compare)**.
## Privacy
The visitor id is a one-way hash that cannot follow anyone across days:
```
sha256(secret_salt | day | site | ip_prefix | user_agent)
```
The salt rotates daily, the IP is never stored (IPv6 is reduced to its `/64` first), and nothing is
written to the browser. That is also why the retention page tells you when it *cannot* compute a
cohort instead of showing zeros — call `vitrus.identify(user.id)` for signed-in users and retention
becomes real, with the raw id never touching disk.
## Architecture
```
packages/
core ingest · classifiers · MetricBundle · numeric guard · digest · agent verifier ·
explore queries (sessions, users, journeys, goals, geo) · replay ingest + player
tracker the browser script, 3.0 KB gzipped (+ an opt-in replay recorder, under 5 KB)
server one process: ingest + query API + tracker + evidence dashboard
cli init · site · start · digest · demo
mcp read-only MCP module (JSON-RPC over HTTP) for AI agents
```
Every package has **zero** runtime dependencies. The SQL string *is* the evidence — `Store.select`
takes raw SQL on purpose, because hiding it behind an ORM would remove the one thing this project is
for.
A few pieces ship in the core as libraries before the self-hosted server exposes them. Today the
server does **not** yet mount the MCP module, run the scheduled digest delivery, accept server-side
forwarded requests (which is how signed agents and non-rendering crawlers are seen — a browser script
never sees them), take row filters, or render the sessions, users, journeys, goals and globe views; the building blocks are here and wiring them into `vitrus start` is on the
[roadmap](https://github.com/Vitrus-Dev/vitrus/issues).
## Running the gates
```bash
bun install && bun run build:tracker
bun run gates
```
Type checks, the test suites, two golden-set evals and the build gates:
| Gate | What it prevents |
|---|---|
| `eval:referrer` | the AI-source table going stale unnoticed — 100% required |
| `eval:insight` | an unprovable number surviving into prose — zero leaks tolerated |
| `gate:core-purity` | the metric layer ever importing the LLM layer |
| `gate:no-deps` | a runtime dependency appearing without a deliberate decision |
| `gate:tracker-size` | the tracker quietly growing past 3 KB |
| `gate:inline-js` | a bad escape killing the dashboard's script while the page still renders |
## Cloud
**[app.vitrus.dev](https://app.vitrus.dev)** is the hosted version: teams and roles, quotas, EU
hosting and digest delivery without running your own mail provider, plus the sessions, users,
journeys, goals, real-time and globe views. The analysis engine is the same: every query those views
run lives in `packages/core` in this repository.
## Community
- **Questions and ideas** — [GitHub Discussions](https://github.com/Vitrus-Dev/vitrus/discussions)
- **Bugs** — [open an issue](https://github.com/Vitrus-Dev/vitrus/issues/new). A failing case beats a
description; the AI referrer table in particular is tested by example.
- **Security** — see [SECURITY.md](SECURITY.md). Please do not open a public issue for a vulnerability.
- **Contributing** — see [CONTRIBUTING.md](CONTRIBUTING.md). The most useful first contribution is a
new AI-referrer rule with the test case that pins it.
## Star history
<a href="https://star-history.com/#Vitrus-Dev/vitrus&Date">
<img src="https://api.star-history.com/svg?repos=Vitrus-Dev/vitrus&type=Date" alt="Star history chart" width="600">
</a>
## Contributors
<a href="https://github.com/Vitrus-Dev/vitrus/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Vitrus-Dev/vitrus" alt="Contributors">
</a>
## License
[Apache-2.0](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues