Self-hosted analytics · v0.1.0, early

See the trace.
Never the person.

Athar (أثر — Arabic for trace, impact) is self-hosted web analytics with heatmaps and ecommerce tooling, in one Go binary. No cookie, no third-party collector, no consent banner for the default install — because the beacon never leaves the server you run it on.

0 cookies, ever 1 binary — collector, API, dashboard 1.6 KB tracker, gzipped 2 databases, one schema MIT / Apache-2.0
your-athar-host — Overview · 30 days
Athar dashboard overview in dark theme: five stat cards (visitors, pageviews, sessions, bounce rate, average visit), a revenue row, and a 30-day pageview trend chart with a visible traffic spike. Athar dashboard overview in light theme: five stat cards (visitors, pageviews, sessions, bounce rate, average visit), a revenue row, and a 30-day pageview trend chart with a visible traffic spike.

FIG. 1The reporting overview, as shipped. Pageviews, visitors, bounce rate and revenue per currency, over a 30-day trend with a previous-period ghost. Captured against a seeded demo instance — 1,909 visits and 4,804 pageviews across 30 days, including one deliberate spike day.

PLATE 01The nameDEPTH

A trace, on purpose.

أثر (athar) means both trace and impact — the mark left behind, and the fact that it mattered. Every analytics tool is in the business of recording a trace of what happened on a page. The question that actually matters is who ends up holding it.

Athar's answer: only the operator, on infrastructure they control. There is no third party in the request path to receive a copy of the beacon, and no cookie to leave one on the visitor's device. The trace forms, gets counted, and stops at your server.

Third partiesNone in the request path. The script is served by your instance and beacons back to it.
On the deviceNothing. No cookie set or read, no localStorage, no sessionStorage, no identifier of any kind.
Weight3.3 KB raw, 1.6 KB gzipped. ?source=1 always serves the readable original, so anyone can check what a site is running.
PLATE 02The trace stops at your doorDEPTH

Two days. Same visitor. No thread between them.

Heatmaps and behavioural sampling are only defensible because the operator self-hosts and holds the data plainly — this is not a claim that Athar is content-blind. What it removes is everything that does not need to exist for that: a persistent identifier, a third-party lookup, a raw IP sitting in a table.

identity.go
// 1 · once per instance, generated and stored
const instance_secret = random(32 bytes)

// 2 · recomputed once per UTC day
salt = HMAC(instance_secret, "2026-07-31")

// 3 · per beacon — the IP is discarded right after this line
visitor = HMAC(salt, website_id  ip  user_agent)

Unlinkable across days

The salt rotates at UTC midnight from an HMAC of the long-lived secret — not from the previous day's salt. Today's hash for a visitor cannot be derived from yesterday's. Cross-day tracking is absent from what is stored, not merely unimplemented.

No cross-site profile

The website id sits inside the hash, ahead of a separator. The same person on two sites hosted by one instance produces two unrelated hashes — the operator's own database cannot join them.

The IP never lands

The raw IP is used for exactly two things at ingest — this hash, and a local GeoIP lookup against an .mmdb file on disk — then discarded. Never written to the database, never logged, never in an error message.

Not a compliance certificate. Cookieless and no-PII by construction is a GDPR-friendly default — no cookie banner is needed for the stock configuration — but that describes the software's behaviour, not a guarantee that any particular deployment is compliant. That depends on how an operator configures, discloses and runs their own instance. Full threat model, including what a stolen database can and cannot recover: Privacy docs →
PLATE 03Where people actually goDEPTH

Every click leaves a trace. Enough of them draw a picture.

That is what a heatmap literally is: hundreds of individual traces, each just a position and a selector, accumulated until a pattern is visible. A pattern is only worth reading over the page it was left on — so Athar draws it over the page.

your-athar-host — Heatmap · /pricing · ~1920px
Athar's click heatmap in dark theme for the page /pricing at the 1920-pixel viewport bucket: a red-to-blue click density field drawn directly over a full-page screenshot of the pricing page, with the hottest areas sitting on the three plan buttons, the navigation bar and the footer links. Beside it, a most-clicked-elements list and the page-capture panel with its privacy notice. Athar's click heatmap in light theme for the page /pricing at the 1920-pixel viewport bucket: a red-to-blue click density field drawn directly over a full-page screenshot of the pricing page, with the hottest areas sitting on the three plan buttons, the navigation bar and the footer links. Beside it, a most-clicked-elements list and the page-capture panel with its privacy notice.

FIG. 2Click density over the real page. The backdrop is a full-page capture of /pricing at 1920px, uploaded by the operator; the heat is 166 recorded clicks from the seeded instance. Nothing here is arranged by hand — the screenshot pipeline reads the heat canvas's own pixels at the coordinates of each real button and refuses to save the image unless the field is hot there.

How the page gets under the heat

The tracker still records three things per click and nothing else: a position as a percentage of the document, the viewport size, and a short CSS selector. It captures no DOM, no text, no form values, no screenshot — that boundary has not moved, and it is why the backdrop has to come from somewhere else.

Where the picture comes fromAn operator uploads a full-page capture of their own page, keyed to one path and one viewport width. Athar never takes it and never fetches your site. No DOM recorder ships to visitors, and the server makes no outbound request — the two alternatives, and why each was rejected, are argued out in backend/internal/api/pageimages.go.
Why it lines upBoth sides are proportions of the same document. A full-page capture at viewport width W is exactly one document wide and one document tall, so a sample at (x%, y%) of the rendered image is the pixel that was pressed — at any display size, and at any capture device pixel ratio.
When it would notTwo ways, both checked rather than hoped for. An above-the-fold capture has the viewport's aspect ratio rather than the document's, and the view says so in plain words. And a capture is only ever shown for a single recorded viewport width — averaging a 390px layout and a 1440px one onto one picture is a lie that looks authoritative.
What it costs youA manual step, and honesty about it. The capture lives in your own database and is served only to signed-in users of that website, so it should be the page as a logged-out visitor sees it. The dashboard says exactly that, next to the upload button, every time.
/docs/getting-started · no capture
The same Athar heatmap view in dark theme for a page with no uploaded capture: the density field is drawn over a wireframe of dashed selector boxes on a neutral grid, badged SCHEMATIC, with a caption stating it is not a picture of the page. The same Athar heatmap view in light theme for a page with no uploaded capture: the density field is drawn over a wireframe of dashed selector boxes on a neutral grid, badged SCHEMATIC, with a caption stating it is not a picture of the page.

FIG. 3No capture, and it says so. Where no image exists for the selected page and viewport, the view falls back to a wireframe built only from real recorded coordinates, badges itself SCHEMATIC, and never borrows another page's picture to fill the gap.

What a sample carries

  • Click — a position, and the CSS selector of the element pressed.
  • Scroll — the furthest depth that session reached.
  • Attention — dwell time per tenth-of-page band.

What it never carries

  • No DOM snapshot, no page text, no automatic screenshot.
  • No form values, no keystrokes.
  • No session replay — a different feature, deliberately not this one.

Positions are percentages of the document rather than viewport pixels, and the selector rides alongside, which is what keeps a click map meaningful after a redesign.

PLATE 04What's in the boxDEPTH

Everything a self-hosted analytics stack needs. Not much it doesn't.

Umami and Ackee are genuinely MIT and genuinely light, but stop at pageview counting. PostHog has heatmaps and needs ClickHouse, Kafka and Redis behind it. Matomo's heatmap tooling sits behind a paid plugin on a GPL core. MIT-or-Apache, lightweight, with heatmaps, ecommerce and built-in GeoIP in one binary was the specific combination missing — the full comparison is Plate 09.

Tracking
Cookieless by construction. Automatic pageviews including SPA route changes via pushState/replaceState/popstate, custom events, revenue events, and opt-in heatmap sampling. One script tag.
Heatmaps
Click density, scroll depth and attention bands, per page and per recorded viewport width, drawn over an operator-supplied capture of the page or over an honest schematic.
Reporting
Pageviews, unique visitors, sessions, bounce rate, average visit time; top, entry and exit pages; referrers and UTM campaigns; browser, OS, device, screen and language; country, region and city; custom events; realtime active visitors.
Ecommerce
athar.revenue(amount, currency, orderId). Integer minor units per currency, never floats, never summed across currencies.
Geography
In-process GeoIP from a local MaxMind-format .mmdb — no lookup service, no network call. Not bundled, for size and licence; unset simply leaves the location fields empty.
Access control
argon2id (64 MiB, t=2), server-side sessions storing only a SHA-256 of the token, httpOnly SameSite=Lax cookies, double-submit CSRF on every state-changing route, login rate limiting keyed on username and address, owner/editor/viewer roles. Fail-closed throughout.
Hygiene
Bot traffic dropped at ingest rather than recorded. Retention deletes whole visitor sessions past a configurable age, cascading to their events, heatmap samples, revenue rows and page captures — so a purge never leaves orphans to skew a later bounce rate.
PLATE 05One binary, either groundDEPTH

SQLite on your laptop. Postgres in the cloud. Same binary either way.

The difference between a box you own and a managed deployment is one config value — database — not a fork. One database/sql implementation serves both engines; a small Dialect rewrites only placeholder syntax and a time-bucket expression. Every migration is written in the subset of SQL both engines accept literally, which is a real constraint on future schema work and the reason the two deployments cannot drift apart.

the store seam
one binaryatharcollector · API · dashboard
SQLitebare path — zero setup, self-host default
Postgrespostgres:// DSN — hosted / managed
Standalone
./athar on a home server, NAS or small VPS. Zero setup, storage is one file, loopback-bound by default.
Managed
The same binary against a postgres:// DSN for a pooled deployment. Nothing above the storage layer changes.
Public
Reached through cloudflared, ngrok, your own Ephor, or nginx/Caddy terminating TLS in front of it.
Embedded
frame_ancestors lets a host shell embed the dashboard as an app tile behind its own routing and auth.
The dashboard is just a client for it. Closing the browser tab does not stop Athar — the collector is the same long-running process that serves the dashboard, and it keeps taking beacons for as long as it is deployed, whether or not anyone has the dashboard open. There is no separate app to quit.
PLATE 06Revenue, honestlyDEPTH

Revenue is just an event with money attached.

No separate ecommerce system, no product catalogue to sync — a purchase is a custom event with a revenue payload. Amounts convert to integer minor units at ingest, so money is never carried as a float, and the API reports amount_minor per currency rather than assuming two decimal places apply everywhere. They do not: JPY has no minor unit and KWD has three.

What this is not, yet: there is no product-level breakdown, no cart or checkout funnel, no per-SKU reporting. Revenue today is a total per currency over a time range, attributed to the visit and event that produced it. See the register.
checkout.js
athar.revenue(49.99, 'USD', 'order_123', 'purchase');
GET /api/websites/{id}/revenue
{ "totals": [ { "currency": "USD", "amount_minor": 483200 } ] }
PLATE 07Run itDEPTH

Running end to end in about a minute.

Requires Go 1.25+ and Node 22+ to build from source — Node only to minify the tracker and drive screenshots; the dashboard itself is hand-written HTML, CSS and JS embedded straight into the binary, with no bundle to go stale. The result is one static binary. Copy it wherever you like.

terminal
# build from source
git clone https://github.com/vul-os/athar.git
cd athar
npm install && npm run build
./athar

# drop one script tag on your site
<script defer src="https://your-athar-host/athar.js"
        data-website-id="YOUR_WEBSITE_ID"
        data-heatmap="true"></script>
  1. 01
    Run itListens on 127.0.0.1:3100, stores to ./athar.db. The first visit creates the one admin account.
  2. 02
    Add a siteCreate a website in the dashboard and paste the script tag it gives you. data-heatmap="true" turns on click, scroll and attention sampling.
  3. 03
    Add a page captureOptional, and per page. Take a full-page screenshot at one viewport width and upload it from the heatmap view — that is what puts your real page under the density field.
  4. 04
    Go publicAthar binds loopback by default. Reach it with a tunnel or a reverse proxy — see Self-hosting.
PLATE 08The registerDEPTH

Built and exercised end to end. Still young. Both are true.

Status: v0.1.0, early. The core collector, storage, auth and reporting paths are implemented and have been exercised end to end, but this is a young project — expect rough edges, and expect the API surface to move before 1.0.

Three columns rather than two, because there is a real difference between what a released version does, what is on main and not yet cut into a release, and what nobody has written. Collapsing the middle one into either neighbour would be a small lie in one direction or the other.

Shipped in 0.1.0
Cookieless, no-PII tracking — daily-salted hash, unlinkable across days and sites.
Heatmap view — click density, scroll depth, attention bands, selector list.
In-process GeoIP, bot filtering at ingest.
Full reporting API — the same data the dashboard renders.
Auth — argon2id, server-side sessions, CSRF, rate limiting, per-website roles.
Public share links — server-side, API-only; no dashboard toggle yet.
Retention, multiple websites, REST API.
The Store seam — SQLite or Postgres, one binary.
On main, unreleased
Heatmaps over the real page — an operator-uploaded full-page capture per page and per viewport width, stored in your own database, composited under the click field.
Honest fallback — the wireframe schematic, badged as one, wherever no capture exists for the selected page and viewport.
Capture management in the dashboard — upload, replace and remove, editor-only, with the privacy consequence stated at the point of upload.
Everything here is on main and covered by tests, but is not in the 0.1.0 you can download. It lands in the next release.
Not built yet
Website settings UI — share-link toggle and delete are API-only today.
Funnels, including a cart/checkout funnel for revenue events.
Segmentation / filtering UI in the dashboard.
TOTP two-factor — the column exists; nothing generates or verifies a code.
Teams UI, account and user administration UI — API-only today.
Installability — a web manifest and app icons; the offline service worker stays out.
Data export / import, a maintained Docker image.
Browser end-to-end tests on the production bundle.

Kept current release by release: Roadmap → and Changelog →

PLATE 09Why not just use XDEPTH

The specific gap this fills.

In fairness to the table: Umami and Ackee are genuinely light and genuinely MIT, they just stop at pageviews. PostHog is the most feature-complete of the group but is a product-analytics platform first, on a multi-service stack. Matomo's ecommerce is mature and its heatmaps are a paid plugin. Plausible, GoatCounter and OpenReplay are copyleft licences many businesses avoid embedding.

ProjectLicenceWeightHeatmapsEcommerceBuilt-in GeoIP
AtharMIT OR Apache-2.0Single Go binaryClick / scroll / attentionRevenue, multi-currencyLocal .mmdb, no network call
UmamiMITNode/Next.js + Postgres or MySQL
Plausible CEAGPL-3.0Elixir + ClickHouseOptional, self-configured
MatomoGPL-3.0+Heavy — PHP + MySQLPaid pluginYesPlugin
PostHogMostly MIT, some proprietaryHeavy — ClickHouse + Kafka + RedisToolbar heatmapsNot the focus
PLATE 10The readingDEPTH

This is the whole trace. All of it.

A heatmap sample in Athar carries three things and no more. Below are those three fields, for you, on this page, computed in your browser in the last second. They are the entire shape of what a heatmap knows about a reader.

heat_samples — this session, computed locally, never transmitted

scroll_pct

The furthest point down this document you have reached, as a percentage of its full height.

dwell_ms

Time with this page in view, which Athar buckets into tenths of the page rather than storing whole.

viewport_w × viewport_h

Your viewport, so a map recorded on a phone is never averaged into one recorded on a desktop.

None of this was sent anywhere. This page carries no tracker, no analytics and no network call of any kind — the three numbers above were computed by twenty lines of JavaScript you can read in the source of this file, and they will be gone when you close the tab. On a site running Athar they would go to one place: a file on the operator's own disk.

On a real install there would be a little more than this alongside — the page path, the referrer, a coarse country from a local database file, your browser and operating system, and one salted hash that cannot be recomputed after midnight. That is the complete list. It is short on purpose, and it is the whole argument.

Set in Fraunces, Schibsted Grotesk and IBM Plex Mono — all three self-hosted from ./assets/fonts, with their licences alongside. Figures are unretouched captures of the shipped dashboard, regenerated by scripts/site-screenshots.mjs. No CDN, no remote font, no remote image, no analytics, no network call of any kind.