A comprehensive Node.js/Express analytics server for tracking website views with MySQL storage, featuring auto-database creation, advanced tracking, and rich analytics.
Visit our Interactive Documentation for detailed API specifications, debugging tips, and integration guides.
100% GDPR Compliant By Design. This project is built from the ground up to respect user privacy and adhere to modern ethical standards:
- Zero Cookies: No cookies, no local storage, and no consent banners required for visitors. (The optional admin UI signs its operator in with a session cookie; tracking never sets one.)
- Data Sovereignty: You own your data. Analytics never leave your private infrastructure.
- Minimal Collection: Tracks only what is necessary (Country, Browser, OS, Page Path).
graph LR
A[Visitor Request] --> B{Privacy Filter}
B -->|Transient| C[Geo-lookup]
B -->|Transient| D[Keyed with server secret]
C --> E[Masked IP: 1.2.3.0]
D --> F[HMAC-SHA256, rotating]
E --> G[(MySQL Database)]
F --> G
B -.->|Discarded| H[Raw IP Address]
style H fill:#f96,stroke:#333,stroke-width:2px
We believe in total transparency regarding your visitors' data:
- Transient Use Only: The raw IP address is used only in memory, for the country lookup and for deriving the visitor hash. It is never written to the database, and log lines record the masked address.
- Immediate Masking: Before being saved, the IP is masked (IPv4 last octet zeroed; IPv6 interface identifier zeroed).
- Keyed, Not Just Hashed: The visitor identifier is an HMAC-SHA-256 keyed with a 32-byte server secret generated on first run and stored at mode
0600. This matters: an unkeyed hash of an IP is reversible by exhausting the 2^32 IPv4 space, which takes about an hour on one CPU core. Without the secret, that search is infeasible. - Rotating: The hash also mixes in a time window (
UNIQUE_VISITOR_WINDOW_HOURS), so the same visitor hashes differently after each window and their visits cannot be linked over time. - Automated Guards:
tests/privacyFailSafe.test.jsasserts that no raw IP or User-Agent reaches either the bound parameters or the SQL text of any statement, and that the hash is genuinely keyed. CI runs it on every push, so a change that started storing raw IPs would fail the build.
- 🔒 Security: Prepared statements, rate limiting, Helmet.js, input validation
- ⚡ Performance: Connection pooling with mysql2, duplicate prevention
- 🗄️ Flexible Database: Connect to existing DB or auto-create schema
- 🛠️ Easy Setup: Interactive CLI wizard with config detection
- 🏥 Production-Ready: Health checks, graceful shutdown, structured logging
- 🧑💼 Admin UI: Browse, search, edit, annotate, and soft-delete recorded views, with batch actions, a trash, and audit logs (details)
- 📍 Page Tracking: Track specific pages/paths, not just app-level
- 🔗 Referrer Analysis: Automatic source categorization (search, social, email, campaign, referral, direct)
- 🖥️ User Agent Parsing: Browser, OS, and device type detection
- 👤 Session Tracking: Group views by user session
- 🎯 Custom Events: Track button clicks, form submissions, etc.
- 📊 Time-Based Analytics: Hourly, daily, and weekly trends
Requires Node 24 or newer and a reachable MySQL 8 (or MariaDB 11) instance. Only the current Node LTS is supported — no matrix of older runtimes to maintain.
npm installnpm run setupThe wizard will:
- Detect existing configuration (if any)
- Guide you through database setup (connect vs. create mode)
- Configure allowed app IDs and device sizes
- Optionally create
.envfile
npm startConnect Mode (default): Use existing database
// dbInfo.json
{
"mode": "connect",
"host": "127.0.0.1",
"database": "viewcounterdb",
"user": "root",
"password": "your_password"
}On every start, in either mode, the server brings each app table up to the
current schema and creates the log tables it needs, so the user needs CREATE,
ALTER, and INDEX on the database as well as SELECT, INSERT, UPDATE,
and DELETE. A user limited to reads and writes, which was enough before 3.1,
fails startup with MIGRATION_FAILED. Grant these before upgrading:
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON viewcounterdb.* TO 'vcuser'@'%';Create Mode: Auto-create database and tables
// dbInfo.json
{
"mode": "create",
"host": "127.0.0.1",
"database": "viewcounterdb",
"user": "root",
"password": "your_password"
}// allowed.json
{
"appId": ["blog", "portfolio"],
"deviceSize": ["small", "medium", "large"]
}See .env.example for the full surface with prose on each one.
Required in production — the server refuses to start without these rather than running on a guessable default:
DB_USER/DB_PASSWORD: refuses to boot while stillrootwith an empty passwordDB_NAME: database to write intoALLOWED_APP_IDS(orallowed.json): the placeholderexample_appis rejectedCORS_ORIGINS: browser origins allowed to call the write endpoints
Recommended:
READ_API_KEYS: comma-separated keys for the analytics read endpoints. Unset means the read API is disabled.TRUST_PROXY: hop count or CIDR list. Never set this totrue— trusting every hop lets any caller forge their own IP viaX-Forwarded-For, which fakes geolocation, inflates unique-visitor counts, and bypasses rate limiting.trueand*are downgraded to one hop with a warning. Your proxy must setX-Forwarded-For;X-Real-IPalone is not read.VISITOR_SECRET_PATH/VISITOR_SECRET: where the visitor-hash secret lives, or the value itself.ADMIN_PASSWORD: turns on the admin UI at/admin. At least 16 characters. Unset means the admin UI does not exist.
Optional: DB_MODE, PORT, LOG_LEVEL, RATE_LIMIT_WINDOW_MS,
RATE_LIMIT_MAX, UNIQUE_VISITOR_WINDOW_HOURS, ALLOWED_DEVICE_SIZES,
TRASH_RETENTION_DAYS, VIEW_LOG_RETENTION_DAYS.
A web interface for the data ViewCounter has recorded, served by the same
server at /admin. It is built into the package: set a password and it is
there, with no separate deployment and no build step.
# .env (or the environment of your container)
ADMIN_PASSWORD=<at least 16 characters, e.g. from: openssl rand -base64 24>
TRASH_RETENTION_DAYS=30 # optional; 0 keeps trash until emptied by hand
VIEW_LOG_RETENTION_DAYS=90 # optional; 0 keeps the view log foreverThen open https://<your-server>/admin/ and sign in.
To look around without a database, npm run admin:demo serves the admin UI
at http://localhost:4173/admin/ over several thousand fake views (password
playwright-admin-password). Nothing in it is real traffic.
- Browse one app's views, or every app's together under All apps: filter by date range (7, 30, or 90 days, a year, or all time), event type, and whether an admin changed them; search by page, title, source, note, event, or session; sort by any column; page through them. Under All apps each row names its app. On narrower screens the table drops its least useful columns first, and on a phone each view becomes a card.
- See the insights above the table, for exactly the rows its filters select: views, visitors, unique share, countries, and admin edits; views over time (with a table view); a world map of where views come from, with the split by event type on hover and a ranked country list beside it; and breakdowns by source, device, browser, OS, event type, and app.
- Select several views, across pages and across apps, and act on all of them at once.
- Edit content fields: page path, page title, referrer (the source is recalculated from it), device size, event type, and event data. What was observed about the visitor (time, masked IP, country, browser, OS, device type) is never editable, so an edit can correct what was viewed but never fabricate who viewed it or when.
- Add a note to any view, as a private annotation.
- Move views to the trash. Trashed views stop counting in every statistic at once and come back if restored.
- Erase views permanently from the trash.
- Read two logs: the admin log of every sign-in and every change, and the view log of every view the server accepted.
| Column | Meaning |
|---|---|
public_id |
Random UUID that identifies a view in the UI and API. The auto-increment row number never leaves the server. |
admin_modified_at |
Empty when the row is exactly as recorded; otherwise when an admin last changed its content. Notes do not set it. |
note |
The admin's annotation, if any. |
deleted_at |
Empty for live rows; set when the row went to the trash. |
These columns, and the _admin_log and _view_log tables, are added
automatically when the server starts, in both database modes, whether or not
the admin UI is enabled. The upgrade is additive: nothing is dropped, and
existing rows get their public_id on the first start, in batches that each
resume where the last stopped, so a large table is read once. The database user
therefore needs CREATE, ALTER, and INDEX as well as the usual privileges
(see Database Modes).
The view log gains one row per accepted view, so entries older than
VIEW_LOG_RETENTION_DAYS (default 90) are removed hourly, in batches, and each
run that removes anything is recorded in the admin log. It holds no personal
data, so this only bounds its size; the views themselves are untouched. The
admin log is never pruned: it is the record of who changed or erased what, and
it grows only with admin activity.
Deleting is always a soft delete first. Views in the trash are erased for good
after TRASH_RETENTION_DAYS (default 30), or straight away with Erase
permanently, which exists so a data subject's erasure request (GDPR
Art. 17) can be honoured completely. Each erasure is recorded in the admin
log.
Neither log copies personal data, so erasing a row really erases it:
- the admin log records who acted (a session ID and a masked IP), what they did, when, to which view IDs, and which fields changed, but never the values;
- the view log records that a view was accepted, when, for which app, and through which endpoint, with no IP, visitor hash, or user agent.
ADMIN_PASSWORDis its own credential tier. It is independent ofREAD_API_KEYSandADMIN_API_KEYS, so leaking one never unlocks another, and the server warns if you reuse an API key as the password.- Signing in issues an
HttpOnly,SameSite=Strictsession cookie scoped to the admin path (/admin, or wherever an embedding app mounts it), markedSecurewhenever the request arrived over HTTPS (throughTRUST_PROXYbehind a proxy). Sessions end after 30 idle minutes or 12 hours, and on restart. - Every change also needs a per-session CSRF token and a matching
Origin. - Wrong passwords are rate limited per IP (5 per 15 minutes) and recorded in
the admin log. Requests refused before the password is checked, such as a
foreign
Originor a malformed body, do not count toward the limit. - Behind a TLS-terminating proxy, set
TRUST_PROXYand have the proxy passX-Forwarded-Protoand the originalHost. Otherwise the server believes it is servinghttp://, the browser'shttps://Origindoes not match, and every sign-in is refused as cross-origin. The server logsADMIN_ORIGIN_REJECTEDwith both origins when that happens. - The UI runs under a strict Content Security Policy (no inline script or style, no third-party origins, not frameable) and renders everything as text: page titles and referrers come from anonymous visitors and can never execute.
- Serve it over HTTPS only. The server logs a warning when a sign-in arrives over plain HTTP in production.
# Basic (backward compatible)
GET /registerView?appId=blog&deviceSize=medium
# Enhanced with page tracking
GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&title=My%20Post
# With referrer and session
GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&referrer=https://google.com&sessionId=abc123Automatic tracking:
- ✅ IP address and geolocation
- ✅ Browser, OS, device type (from User-Agent)
- ✅ Referrer domain and source type
- ✅ Duplicate prevention (configurable window)
Response:
{"message": "Success!", "duplicate": false}POST /event
Content-Type: application/json
{
"appId": "blog",
"eventType": "button_click",
"eventData": {"button": "subscribe", "location": "header"},
"sessionId": "abc123",
"page": "/blog/my-post"
}These endpoints require authentication. They return your analytics data, so every one of them expects a valid key in the
x-api-keyheader. Configure keys viaREAD_API_KEYS(comma-separated, minimum 32 characters each). With none configured the read API returns503— it fails closed rather than serving your data to anyone who asks.curl -H "x-api-key: $VIEWCOUNTER_KEY" https://your-server.com/stats/blogThe tracking endpoints above stay public by design: a browser on your site has to be able to reach them. They are bounded by validation, rate limiting, and per-app origin binding instead.
GET /stats/:appIdResponse:
{
"appId": "blog",
"stats": {
"total": 1523,
"uniqueVisitors": 892,
"last24Hours": 47,
"byCountry": [{"country": "US", "count": 423}],
"byDevice": [{"devicesize": "medium", "count": 789}]
}
}# Daily trends for last 30 days
GET /trends/:appId?period=daily&days=30
# Hourly trends for last 7 days
GET /trends/:appId?period=hourly&days=7
# Weekly trends for last 12 weeks
GET /trends/:appId?period=weekly&days=84Response:
{
"appId": "blog",
"period": "daily",
"days": 30,
"trends": [
{"period": "2026-01-01", "count": 45},
{"period": "2026-01-02", "count": 52}
]
}GET /referrers/:appId?limit=20Response:
{
"appId": "blog",
"bySource": [
{"source_type": "search", "count": 450},
{"source_type": "social", "count": 230},
{"source_type": "direct", "count": 180}
],
"byDomain": [
{"referrer_domain": "google.com", "count": 320},
{"referrer_domain": "twitter.com", "count": 150}
]
}GET /browsers/:appIdResponse:
{
"appId": "blog",
"byBrowser": [
{"browser": "Chrome", "count": 650},
{"browser": "Safari", "count": 320}
],
"byOS": [
{"os": "Windows", "count": 550},
{"os": "Mac OS", "count": 380}
],
"byDeviceType": [
{"device_type": "desktop", "count": 890},
{"device_type": "mobile", "count": 450}
]
}GET /pages/:appId?limit=20Response:
{
"appId": "blog",
"pages": [
{"page_path": "/blog/post-1", "page_title": "My First Post", "views": 234},
{"page_path": "/blog/post-2", "page_title": "Second Post", "views": 189}
]
}GET /sessions/:appId/:sessionIdResponse:
{
"appId": "blog",
"sessionId": "abc123",
"events": [
{
"id": 1,
"event_type": "pageview",
"page_path": "/blog/post-1",
"timestamp": "2026-01-09T21:30:00.000Z"
},
{
"id": 2,
"event_type": "button_click",
"event_data": {"button": "subscribe"},
"timestamp": "2026-01-09T21:31:15.000Z"
}
],
"count": 2
}GET /views/:appId?limit=10&offset=0GET /apps # requires x-api-key; filtered to the key's scopePOST /apps # requires an ADMIN x-api-key
Content-Type: application/json
{ "appId": "newcustomer", "origins": ["https://newcustomer.example"] }Creates the table and adds the app to the live allowlist without a restart.
GET /health # public; reports liveness onlynohup node index.js > stdout.log &
# Kill with: kill <pid>Set NODE_ENV=production to hide error details in API responses.
For each view/event, the system automatically captures:
| Field | Source | Description |
|---|---|---|
| IP Address | Request | Visitor IP |
| Country | GeoIP lookup | 2-letter country code |
| Timestamp | Server | When the event occurred |
| Device Size | Query param | small, medium, large |
| Page Path | Query param (optional) | e.g., /blog/my-post |
| Page Title | Query param (optional) | e.g., "My Blog Post" |
| Referrer | Query param (optional) | Full referrer URL, normally document.referrer; absent or empty means direct |
| Referrer Domain | Parsed | e.g., google.com |
| Source Type | Parsed | search, social, email, campaign, referral, direct |
| Browser | User-Agent | e.g., Chrome, Safari, Firefox |
| Browser Version | User-Agent | e.g., 120.0 |
| OS | User-Agent | e.g., Windows, Mac OS, Linux |
| OS Version | User-Agent | e.g., 10, 14.2 |
| Device Type | User-Agent | desktop, mobile, tablet, tv, console |
| Session ID | Query param (optional) | Group events by session |
| Event Type | Query param/body | pageview, click, submit, etc. |
| Event Data | Body (optional) | Custom JSON data |
This setting prevents counting the same visitor multiple times within a time window.
How it works:
- When a view is registered, the system checks if the same IP has visited within the last X hours
- If yes: Returns
{duplicate: true}(doesn't count again) - If no: Inserts new view
Examples:
24(default): Same IP counts as 1 view per day0: Disable duplicate prevention (count every request)168: Same IP counts as 1 view per week
Note: Only applies to pageview events, not custom events.
To guarantee that raw IPs never leak into the database, we've implemented an automated Privacy Guard suite (privacyFailSafe.test.js):
- Query Interception: Every single SQL
INSERTis intercepted during tests. - Regex Scanning: We scan all query parameters against raw IP patterns (IPv4 and IPv6).
- Hard Enforcement: If the system ever attempts to save an unmasked IP, the test suite immediately fails, preventing accidental privacy regressions.
This makes ViewCounter not just "Privacy-First" by design, but Privacy-Guaranteed by automation.
- Implement IP masking utility
- Implement transient hashing for uniqueness
- Update
DatabaseManagerto use hashes/masked IPs - Update
db/schema.sql(column renaming/clarification) - Remove "IP Address" references from docs/README
- Update documentation with "How it works" privacy section
- Update and verify tests
Trust model. The two write endpoints (/registerView, /event) are public
because a browser on your site must be able to reach them. Everything that
reads analytics is authenticated.
- ✅ Authenticated, scoped read API — every analytics endpoint requires
x-api-key, compared in constant time, and each key is authorized against the specificappIdrequested. Fails closed when unconfigured. - ✅ Separate admin tier — provisioning apps uses its own credential; a read key cannot provision and an admin key cannot read.
- ✅ Per-tenant rate limits — an
appId-keyed budget alongside the per-IP limit. - ✅ Keyed visitor hashing — HMAC-SHA-256 with a persisted 32-byte server secret, rotating per window, so stored hashes are not reversible to an IP.
- ✅ SQL injection prevention — every value is a bound parameter; the only interpolated identifier is
appId, gated by the allowlist. - ✅ Explicit CORS allowlist — no wildcard, and writes can be bound to registered origins per app.
- ✅ Proxy-aware IP derivation — client-supplied forwarding headers are not trusted unless
TRUST_PROXYsays so. - ✅ Bounded input — length caps matching every column width, integer ranges on
limit/days/offset, a 16 kB body cap and a 4 kBeventDatacap. - ✅ Resource guards — finite pool queue, per-statement timeout, rate limiting.
- ✅ No error leakage — failures return a request id; the detail goes only to the server log.
- ✅ Security headers (Helmet.js) and
Cache-Control: no-storeon all analytics responses. - ✅ Fail-fast config validation — insecure defaults stop the boot rather than being silently accepted.
- ✅ Adversarial regression suite —
tests/security.test.jscovers header spoofing, auth bypass, injection-shaped input, oversized payloads, and prototype pollution.
Report a vulnerability through private advisory reporting, not a public issue. See SECURITY.md.
This determines whether an integration works or silently produces garbage.
The browser calls ViewCounter directly. It sees the real visitor IP, so masking, geolocation, and the visitor hash all work. This is the intended path.
Your server calls on the visitor's behalf (SSR, a proxy route, a backend hook). ViewCounter sees your server's address, so every visitor hashes identically: unique visitors collapses to 1 and geo reports your datacenter forever. Total views still count. If you must do this, forward the real address and tell ViewCounter to believe you:
X-Forwarded-For: <real visitor IP> # set by your code
TRUST_PROXY=1 # otherwise the header is ignored
The same goes for the referrer: pass the visitor's own Referer (from the
request your server received) as the referrer query parameter. ViewCounter
never reads the Referer header on the request it receives, because from a
browser that header names the tracked page, not where the visitor came from.
A process with no visitor at all (cron, CLI, worker, webhook) should use
POST /event. Custom events are never deduplicated, so "unique visitors" is
simply not a meaningful column for those rows.
One snippet in the layout every page includes.
| Platform | Where it goes |
|---|---|
| Hugo, Jekyll, Eleventy, Astro | baseof.html / _layouts/default.html / base layout |
| Ghost | Settings → Code injection → Site Footer |
| WordPress | wp_footer hook in the theme, or a small plugin |
| Docusaurus, MkDocs | theme footer partial |
| Next.js, Nuxt, SvelteKit | root layout, plus a router hook |
Static generators are the easy case: every navigation is a real page load, so one fetch in the layout is complete coverage.
SPAs are the trap. Client-side routing fires no page load, so you record the entry page and nothing else. Track again on route change:
router.afterEach(() => track()); // Vue / Nuxt
useEffect(() => track(), [pathname]); // Next.js app routerRun one instance for all your sites — one appId each, each with its own table
and its own origin list.
Each appId is a tenant: its own table, its own origin allowlist, its own
request budget, and its own read credentials.
appId is not a secret. It travels in a URL the browser fetches, so anyone
can read it from your page source. What stops a stranger writing into your table
is the origins list, not the ID being unguessable. Configure origins per app.
A key maps to the apps it may read. Keys in READ_API_KEYS are unscoped (they
read everything, which is what you want when all the apps are yours). Scoped
keys live in allowed.json:
{
"appId": ["acme", "globex"],
"origins": {
"acme": ["https://acme.example"],
"globex": ["https://globex.example"]
},
"apiKeys": {
"<32+ char key for acme>": ["acme"],
"<32+ char key for globex>": ["globex"],
"<32+ char key for you>": "*"
}
}Acme's key on GET /stats/globex returns 403. GET /apps returns only the
apps in the presented key's scope, so the listing cannot be used to discover
which other tenants exist. A nonexistent app returns the same 403 as an
out-of-scope one, for the same reason.
POST /apps creates the app's table, records it, and adds it to the live
allowlist — no restart, no config edit. It requires an admin key
(ADMIN_API_KEYS), which is a separate tier: a read key cannot provision, and
an admin key cannot read analytics.
curl -X POST https://your-server.com/apps \
-H "x-api-key: $VIEWCOUNTER_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"appId": "newcustomer", "origins": ["https://newcustomer.example"]}'Registered apps live in an _apps table and are reloaded on every boot, so they
survive restarts. Re-registering is idempotent.
The appId becomes a MySQL table name, so it is restricted to 1–64 characters
of letters, digits, underscore, and hyphen, and may not start with an underscore
(reserved for internal tables). Anything else is rejected with 422.
Two independent limits apply to writes:
RATE_LIMIT_MAX— per client IP. The single-abuser backstop.APP_RATE_LIMIT_MAX— perappId. Stops one tenant consuming the budget everyone else on the instance depends on. Keyed onappIdalone, so it cannot be bypassed by rotating addresses. Set0to disable for single-tenant use.
Tenancy here is data isolation and quota, not a billing system. There is no
usage metering, no plan enforcement, and no self-serve signup flow — POST /apps
is an admin action you would call from your own onboarding code.
ViewCounter can run three ways. All three share the same route layer
(routes/analytics.js), so behaviour is identical.
The default. Runs its own Express app on its own port.
npm start # listens on PORT (default 3030)Mount the router into an application you already have, under any path prefix. Useful when you would rather not run and reverse-proxy a second service.
const express = require('express');
const { createAnalyticsRouter, DatabaseManager } = require('@harshankur/viewcounter');
const app = express();
app.use(express.json({ limit: '16kb' }));
const dbManager = new DatabaseManager({
mode: 'connect',
host: '127.0.0.1',
port: 3306,
database: 'viewcounterdb',
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
});
// Connects, and brings these apps' tables up to the current schema.
await dbManager.initialize(['blog']);
app.use('/analytics', createAnalyticsRouter({
dbManager,
config: {
allowed: { appId: ['blog'], deviceSize: ['small', 'medium', 'large'], origins: {} },
auth: {
// key -> the apps it may read, or '*' for all
readKeyScopes: { [process.env.VIEWCOUNTER_KEY]: '*' },
adminApiKeys: [],
},
privacy: { visitorSecret: process.env.VISITOR_SECRET },
server: {
uniqueVisitorWindowHours: 24,
// omit to disable the per-app write budget
rateLimit: { windowMs: 60000, perAppMax: 1000 },
},
},
}));Endpoints then live under the prefix — POST /analytics/event,
GET /analytics/stats/blog, and so on.
Two things the host application owns in this mode, because the router does not
install them itself: helmet() and the CORS allowlist, and trust proxy. Set
app.set('trust proxy', <hop count>) — never true, or callers can forge
their own IP through X-Forwarded-For.
The admin UI mounts the same way, at any path. Mount it before your CORS middleware: it is same-origin only and must never carry the CORS headers your tracked sites need. Its session cookie is scoped to the path you choose, and it refuses a password shorter than 16 characters.
const { createAdminRouter, startRetention } = require('@harshankur/viewcounter');
const allowed = { appId: ['blog'], deviceSize: ['small', 'medium', 'large'], origins: {} };
app.use('/admin', createAdminRouter({
adminRepo: dbManager.admin,
logRepo: dbManager.logs,
config: {
allowed,
// The retention periods are shown in the UI; pass the ones you schedule below.
admin: { password: process.env.ADMIN_PASSWORD, trashRetentionDays: 30, viewLogRetentionDays: 90 },
server: { isProduction: process.env.NODE_ENV === 'production' },
},
}));
// Hourly: erases trashed views past their retention, and removes view-log
// entries past theirs (0 for either keeps it). Returns a function that stops it.
const stopRetention = startRetention({
adminRepo: dbManager.admin,
logRepo: dbManager.logs,
getAppIds: () => allowed.appId,
trashDays: 30,
viewLogDays: 90,
});There is no published client package yet; the snippets below are the integration surface. See Client-Side Integration.
<script>
// Track page view
fetch('https://your-server.com/registerView?appId=blog&deviceSize=medium');
</script>// Generate a session ID (store in sessionStorage).
// Use crypto.randomUUID(), not Math.random(): Math.random() is not a CSPRNG,
// its output is short and predictable, and collisions merge two visitors'
// sessions into one.
const sessionId = sessionStorage.getItem('sessionId') || crypto.randomUUID();
sessionStorage.setItem('sessionId', sessionId);
// Track page view with full context
fetch(`https://your-server.com/registerView?` + new URLSearchParams({
appId: 'blog',
deviceSize: window.innerWidth < 768 ? 'small' :
window.innerWidth < 1200 ? 'medium' : 'large',
page: window.location.pathname,
title: document.title,
referrer: document.referrer,
sessionId: sessionId
}));async function trackEvent(eventType, eventData) {
await fetch('https://your-server.com/event', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
appId: 'blog',
eventType,
eventData,
sessionId: sessionStorage.getItem('sessionId'),
page: window.location.pathname
})
});
}
// Track button click
document.querySelector('#subscribe-btn').addEventListener('click', () => {
trackEvent('button_click', {button: 'subscribe', location: 'header'});
});# Run all tests with coverage (auto-generates TEST_REPORT.md)
npm test
# Run tests in watch mode (for development)
npm run test:watch
# Run tests and persist database for inspection
npm run test:persist
# Run tests for CI/CD (no report generation)
npm run test:ci
# Run only the admin UI tests in a real browser (Playwright)
npx playwright install chromium # once
npm run test:uinpm test includes the Playwright suite, so run npx playwright install chromium once before the first run.
Automatic Management:
- ✅ Creates fresh
viewcounterdb_testdatabase before each test run - ✅ Populates with realistic test data
- ✅ Automatically cleaned up after tests complete
Persist Database for Debugging:
# Keep test database after tests
npm run test:persist
# Or set environment variable
PERSIST_TEST_DB=true npm testWhen persisted, you can inspect the database:
USE viewcounterdb_test;
SHOW TABLES;
SELECT * FROM test_app_1;To manually remove:
DROP DATABASE viewcounterdb_test;Automatically generated after every test run:
- ✅ Terminal output: Immediate test results and coverage
- ✅ TEST_REPORT.md: Comprehensive markdown summary (auto-generated)
- ✅ test-report.html: Visual test results with dark theme
- ✅ coverage/index.html: Interactive code coverage report
All reports are created in the project root directory.
The test suite includes:
- ✅ UserAgentParser: Browser, OS, and device detection
- ✅ ReferrerParser: Traffic source categorization
- ✅ Health Check: Server status monitoring
- ✅ View Registration: Basic and enhanced tracking
- ✅ Custom Events: Event tracking with metadata
- ✅ Statistics: Aggregated analytics
- ✅ Trends: Time-based analytics
- ✅ Referrers: Traffic source analysis
- ✅ Browsers: Browser/OS/device breakdown
- ✅ Pages: Page view statistics
- ✅ Sessions: Session journey tracking
- ✅ Rate Limiting: Request throttling
All endpoints are tested with:
- ✓ Valid inputs
- ✓ Invalid inputs
- ✓ Missing parameters
- ✓ Edge cases
- ✓ Security validation
Publishing to npm is a manual, deliberate step — an npm version number can never be reused, so it is not wired to run on merge.
npm version patch|minor|major # bump package.json + CHANGELOG in one commit
git push # land the bump
gh workflow run release.yml # test, tag, publish with provenance, releaseThe package is published as @harshankur/viewcounter (scoped). npm rejects
the unscoped viewcounter as too similar to the existing view-counter; a
scope is its own namespace, so the collision does not apply.
No secret is involved. Authentication is OIDC via npm Trusted Publishing:
the package is bound to this repository and to release.yml specifically, and
the runner exchanges a short-lived id-token for a registry credential at publish
time. There is no NPM_TOKEN to rotate or leak, and nothing to be caught by
npm's deprecation of 2FA-bypassing tokens. Every release carries a signed SLSA
provenance attestation, verifiable with:
npm audit signaturesTo publish automatically on every version bump instead, uncomment the push:
trigger in .github/workflows/release.yml.
MIT - Do whatever you want with this, just don't sue us.