Installation
Get Echelon Analytics running in under a minute.
Prerequisites
- Deno 2.x (includes SQLite via
node:sqlite) - No external database — single SQLite file, zero config
Quick Start
🖥️ "No Deno yet? curl -fsSL https://deno.land/install.sh | sh and you're good to go on Linux and macOS." -🦭
git clone https://github.com/janit/ea.git cd ea/echelon-analytics deno task dev
The dev server starts on http://localhost:1947 with Vite HMR.
Add the Tracker
Drop this script tag into any page you want to track:
<script async src="https://your-host/ea.js" data-site="my-site"></script>
That's it. Pageviews, bounces, and sessions are tracked automatically.
Content Security Policy
If your site sends a Content-Security-Policy header, it must let the tracker reach your Echelon host: the host in script-src (to load /ea.js), in img-src (the /b.gif pixel) and in connect-src (for /c and /e), plus 'wasm-unsafe-eval' in script-src so the proof-of-work module can run:
script-src 'self' https://your-host 'wasm-unsafe-eval'; connect-src 'self' https://your-host; img-src 'self' https://your-host
Without 'wasm-unsafe-eval' the tracker still works but sends no token, so its requests carry the +30 missing-token penalty instead of being discarded at ingest. A view with no token and no interaction is later raised to the bot threshold by the engagement gate (see Bot Defense).
Development Commands
All commands run from echelon-analytics/:
# Development server (Vite HMR) deno task dev # Production build deno task build # Start production server (must build first) deno task start # Check formatting, lint, and type-check deno task check # Individual checks deno fmt --check . deno lint . deno check main.ts # Update Fresh framework deno task update # Start MCP server (read-only analytics for AI agents) deno task mcp
Docker
Multi-stage build with non-root user and health check:
# Build docker build -f confs/Dockerfile -t echelon-analytics . # Run docker run -p 1947:1947 -v echelon-data:/app/data echelon-analytics
The container exposes port 1947. Mount a volume at /app/data/ for the SQLite database (required for WAL mode to work correctly).
Docker Details
- Base image:
denoland/deno:2.7.12 - Uses
tinifor proper PID 1 signal handling - Non-root user
echelon(UID 1001) - Health check:
GET /api/healthevery 30s - Build args:
VERSION,GIT_HASH
Docker with Build Args
docker build -f confs/Dockerfile \ --build-arg VERSION=1.0.0 \ --build-arg GIT_HASH=$(git rev-parse --short HEAD) \ -t echelon-analytics .
Reverse Proxy
The application already sets Content-Security-Policy, X-Frame-Options, and X-Content-Type-Options on HTML responses. Do not duplicate those in your proxy — it causes double headers. The proxy should add headers the app does not set: Strict-Transport-Security, Referrer-Policy, Permissions-Policy, and strip the Server header.
Set ECHELON_TRUST_PROXY=true when behind a reverse proxy to read the real client IP from X-Forwarded-For / X-Real-IP.
Caddy
echelon.example.com {
reverse_proxy localhost:1947
encode gzip zstd
header {
# Only headers the app does NOT set.
# Do NOT add CSP, X-Frame-Options, or X-Content-Type-Options here.
Strict-Transport-Security "max-age=31536000; includeSubDomains"
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy "geolocation=(), microphone=(), camera=()"
-Server
}
@tracking {
path /b.gif /e /api/ingest
}
request_body @tracking {
max_size 64KB
}
}
A full example is in confs/Caddyfile.example.
Nginx
server {
listen 443 ssl;
server_name echelon.example.com;
location / {
proxy_pass http://127.0.0.1:1947;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Only headers the app does NOT set.
# Do NOT add CSP, X-Frame-Options, or X-Content-Type-Options here.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
server_tokens off;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml image/svg+xml;
}
Cloudflare
When deployed behind Cloudflare, set:
ECHELON_BEHIND_CLOUDFLARE=true ECHELON_TRUST_PROXY=true
This enables reading Cloudflare headers (cf-ipcountry, cf-connecting-ip) for geo data and bot scoring.
Authentication Setup
API Token
ECHELON_SECRET=your-secret-token-here
Use with Authorization: Bearer your-secret-token-here header.
Username/Password
# Generate a password hash
cd echelon-analytics
deno eval "import{hashPassword}from'./lib/auth.ts';console.log(await hashPassword('yourpassword'))"
# Set environment variables
ECHELON_USERNAME=admin
ECHELON_PASSWORD_HASH=pbkdf2$600000$<salt>$<hash>
Both auth modes can be used simultaneously.
🔐 "Did you know both API token and username/password auth can be used simultaneously? Use the bearer token for CI/CD pipelines and password login for your team's dashboard." -🦭
Upgrading
Upgrading to a version with validated visitors and the engagement gate (from one without them) changes three things on the first start:
- New indexes are built before the server listens. They cover
semantic_events; allow a few seconds per million stored events. Requests are not answered until it finishes. - The first sweep adds +15 to some recent views. Views from the last 30 minutes whose session sent only automatic events (bounce, session end, web vitals, scroll) used to be shielded from the no-interaction penalty by those events and no longer are.
- Do not turn on strict mode in the same deploy. Run the new version in its default mode first and compare validated visitors with unique visitors. With
ECHELON_REQUIRE_ENGAGEMENT=truethe first sweep removes every visit of the last three hours that did not interact.
Upgrading to a version with the device, cross-site and geography signals:
- The new signals apply at once, on every site. From the first request after the upgrade, with no opt-in, the Windows tablet, Linux desktop and cross-site signals and the larger datacenter list (now also DigitalOcean and Oracle Cloud) are scored. Only the geography signals wait for a home country.
- Write-time drops cannot be recovered. A request that scores at or over
ECHELON_BOT_DISCARD_THRESHOLD(default 50) when it arrives is not stored. To audit the new signals, run the first days withECHELON_BOT_DISCARD_THRESHOLD=0, which turns write-time discarding off: no request is then dropped for its score, and each is stored with its score and score detail (known-bot user agents, excluded visitors, rate-limited requests and duplicate beacons are still not stored). Rows scoring 50 or more are still left out of all statistics and rollups; they show on the admin bots pages and are purged on the bot retention schedule (ECHELON_BOT_RETENTION_DAYS). Set the threshold back once the scores look right. - An unused index is dropped. The first start removes
idx_se_sessionfromsemantic_events; no action is needed.
Single-Process Constraint
Echelon Analytics keeps all state in memory (sessions, rate limiter, burst maps, buffered writers). You must run with a single Deno worker — do not use the --parallel flag with deno serve.