WP Visits Stats
Privacy-friendly, self-hosted analytics: which posts get the most visits, how long readers stay, and where readers exit quickly. No cookies, no PII, data never leaves your site.
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/aristath/wp-visits-stats/archive/refs/heads/main.zipPrivacy-friendly, self-hosted post-level analytics for WordPress.
Three questions, answered per post:
- How much — which posts get the most (and least) visits.
- How deep — how long readers stay, as a per-post dwell-time distribution.
- Where it's lost — quick-exit rates, and the posts nobody reads.
No cookies. No user-specific data. No PII. Two tiny same-origin requests per page view. All data lives in your own database and never leaves your site.
What it is and is not
This is not a tracking suite. There are no visitor profiles, no geolocation, no referrer or campaign tracking, and no "who is online right now". It answers the three questions above with aggregate numbers only, and it says so plainly: the per-install secret shipped in the page source is a junk filter, not authentication — it stops forged noise, it is not a security boundary.
How it works
A hand-written ~2.5 KB frontend script (assets/beacon.js, no build step)
sends two beacons per page view, both via sendBeacon (with a
fetch keep-alive fallback):
- View — on page load.
- Dwell — when the reader leaves (
pagehide), reporting how long the tab was visible, mapped to one of eight fixed buckets.
Dwell is stored as a bucket, not an exact second count:
| Bucket | Time on page |
|---|---|
| b0 | under 5 s |
| b1 | 5–15 s |
| b2 | 15–30 s |
| b3 | 30–60 s |
| b4 | 1–2 min |
| b5 | 2–5 min |
| b6 | 5–15 min |
| b7 | 15 min or more |
Each beacon hits beacon.php, which validates the payload, checks the
per-install secret (current or previous), and writes a single aggregated
row. Nothing is stored that identifies or could be connected to a person.
Storage
Two custom tables per site, prefixed by the site's table prefix:
…_wvs_hourly—(hour, post_id, views, b0 … b7)…_wvs_daily—(day, post_id, views, b0 … b7)
There is no raw event log. Complete hourly days are rolled up into the daily table by the nightly cron, and data older than the retention window is pruned.
The read path merges the two tables on the "last rolled day" cursor, so a late cron run can only lag the numbers — never drop them.
Requirements
- WordPress 6.0+
- PHP 8.0+
- A MySQL or MariaDB database, or the SQLite
integration
drop-in. The data layer uses only
$wpdb, so no second code path is needed.
Installation
- Upload the
wp-visits-statsfolder to/wp-content/plugins/. - Activate the plugin (network-activation works on multisite).
- Open Visits Stats in the admin sidebar.
Settings
- Pause collection — temporarily stop recording; existing data is kept.
- Exclude logged-in users — don't count visits from logged-in accounts (e.g. your own previewing).
- Quick-exit threshold — the bucket under which a stay counts as a quick exit (under 5 s / 15 s / 30 s).
- Data retention — keep data for 90 / 365 / 730 days, or forever.
- Delete all data on uninstall — when the plugin is deleted, drop the statistics tables and settings (otherwise the data is left in place).
Dashboard
Period tabs (7 / 30 / 90 days, all-time, custom range), a KPI strip (views, quick-exit %, read-past-1-minute %, dwell events), a site dwell-time distribution bar, a top-100 posts table with per-post distribution bars, a "Nobody read these" list of zero-view published posts (the 100 most recent), a "Fastest exits" list, and CSV export. The view polls every 30 seconds and pauses when the tab is hidden.
Privacy model
- A "visit" is a page load. Without cookies we cannot distinguish two loads, so refreshing counts again. Counts are honest but not deduplicated to unique humans — that is the deliberate privacy trade-off.
- No PII. No IPs, no user agents, no referrers, no timestamps tied to a person. The hour of an event is derived server-side (the client never sends a timestamp).
- Self-contained. Beacons go only to your own
beacon.php; nothing is sent to any third party. - Accepted, documented limitations. A reader whose tab crashes loses their final dwell beacon; ad-blockers may suppress the beacons (an undercount); a page cached longer than the secret-rotation window may briefly 404 on the beacon and self-heal on the next rotation; the "Nobody read these" list only scans the most recent published posts (bounded, so it stays fast on large sites) and surfaces the 100 most recent of those.
Development
npm install
npm run build # builds dist/admin/ (committed to the repo)
npm run i18n # regenerates languages/wp-visits-stats.pot
The admin bundle externalizes React, ReactDOM, and all @wordpress/* packages
against the globals wp-admin already ships — the plugin never bundles a second
React. The frontend beacon script is hand-written vanilla JS with no build
step.
The plugin is validated by a standalone PHP test suite that runs the data, beacon, REST, and lifecycle layers against both a MySQL scratch database and the real SQLite drop-in:
php tests/harness-mysql.php # data layer, MySQL
php tests/harness-sqlite.php # data layer, SQLite drop-in
php tests/harness-beacon.php # write path
php tests/harness-rest.php # read path
php tests/harness-lifecycle.php # activation / cron / uninstall
File layout
wp-visits-stats.php Main plugin file (hooks, admin, asset enqueues)
beacon.php Thin beacon endpoint
uninstall.php Uninstall handler
includes/
class-wvs-data.php All SQL ($wpdb only): DDL, upsert, reads, rollup, prune
class-wvs-settings.php One options array + secret rotation
class-wvs-beacon.php Beacon validation/record decision path
class-wvs-rest.php Dashboard / export / settings REST routes
class-wvs-lifecycle.php Activation, upgrades, cron, uninstall, multisite
assets/beacon.js Hand-written frontend beacon (no build)
src/admin/ React dashboard + settings source
dist/admin/ Built admin bundle (committed)
tests/ Standalone validation harnesses
tools/generate-pot.js .pot generator
See CHANGELOG.md for release notes and readme.txt for the wp.org listing.