Track everything.
Know everything.
BetterMeter is a privacy-first analytics platform for the AI era. Track your websites, CLI tools, MCP servers, and APIs — all from one place.
Getting Started
BetterMeter tracks four types of sources. Each uses a lightweight integration that sends events to the same privacy-preserving pipeline.
All sources flow through the same pipeline: event → processing (referrer parsing, bot detection, geo lookup, privacy hashing) → database. You view everything in the dashboard, CLI, or via MCP tools.
Web Tracking
Add a single script tag to your site. No cookies, no CNAME configuration, no complex setup. Under 1KB gzipped.
<script defer data-site="example.com" src="https://bettermeter.com/api/script"></script>
<noscript><img src="https://bettermeter.com/api/pixel?s=example.com" alt="" /></noscript>The tracker automatically captures pageviews, SPA navigation (History API), and tab returns. For custom events and user identification:
// Track custom events
window.bettermeter.track("signup", { plan: "pro" });
// Identify users (optional)
window.bettermeter.identify("user_123");Attributes
data-siterequireddata-apidata-no-heartbeatCLI Tracking
Track how developers use your CLI tool. See which commands are popular, how long they take, and what errors occur — without collecting PII.
npm install @bettermeter/nodeimport { BetterMeter } from "@bettermeter/node";
import { Command } from "commander";
const bm = new BetterMeter({
siteId: "my-cli-tool",
apiKey: "bm_...",
});
const program = new Command();
// Wraps all commands — tracks name, flags, exit code
bm.wrapCommander(program, { version: "1.0.0" });
program.command("deploy").action(() => { /* ... */ });
program.parse();
// Flush on exit
process.on("SIGTERM", () => bm.shutdown());bm.trackCommand({
command: "deploy",
subcommand: "preview",
flags: ["--prod", "--verbose"],
version: "2.1.0",
durationMs: 4500,
exitCode: 0,
isCi: !!process.env.CI,
});What gets tracked
Command names and flag names only. Flag values, arguments, and file paths are never sent. The SDK captures OS and architecture for environment analytics.
MCP Server Tracking
Track which tools AI clients call on your MCP server, how often, which clients (Claude, Cursor, Windsurf) drive the most usage, and error rates.
import { BetterMeter } from "@bettermeter/node";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const bm = new BetterMeter({
siteId: "my-mcp-server",
apiKey: "bm_...",
});
const server = new McpServer({
name: "my-server",
version: "1.0.0",
});
// Wraps server.tool() — auto-tracks every invocation
bm.wrapMcpServer(server);
// Tools registered after wrapping are automatically tracked
server.tool("search_docs", { query: z.string() }, async (args) => {
// ... your tool logic (unchanged)
});bm.trackTool({
tool: "search_docs",
client: "claude-code",
protocolVersion: "2024-11-05",
durationMs: 250,
success: true,
inputTokens: 150,
outputTokens: 800,
});What gets tracked
Tool names, client names, duration, success/failure, and optional token counts. Tool input parameters and output content are never sent.
API Tracking
Track API endpoint usage, latency, error rates, and caller patterns with Express middleware or manual calls.
import { BetterMeter } from "@bettermeter/node";
import express from "express";
const bm = new BetterMeter({
siteId: "my-api",
apiKey: "bm_...",
});
const app = express();
// Auto-track all requests
app.use(bm.expressMiddleware());bm.trackApi({
method: "POST",
endpoint: "/api/users", // Use patterns, not actual paths with IDs
statusCode: 201,
durationMs: 45,
});What gets tracked
HTTP method, endpoint pattern, status code, and duration. Request/response bodies, headers, query parameters, and path parameters are never sent. Use endpoint patterns (/api/users/:id) not actual paths (/api/users/abc123).
SDK Reference
The @bettermeter/node SDK is zero-dependency (uses built-in fetch and crypto). Requires Node.js 18+.
Constructor
const bm = new BetterMeter(config);siteIdrequiredapiKeyrequiredapiUrldisabledbatchbatchIntervaltrackCommand(options)
commandrequiredsubcommandflagsversiondurationMsexitCodeisCiuserIdpropertiestrackTool(options)
toolrequiredclientprotocolVersiondurationMssuccesserrorTypeinputTokensoutputTokensuserIdpropertiestrackApi(options)
methodrequiredendpointrequiredstatusCodedurationMsuserIdpropertiesAuto-wrappers
wrapCommander(program, options?)
Hooks into Commander.js postAction to auto-track all commands.
wrapMcpServer(server)
Monkey-patches server.tool() to wrap all handlers with timing and error tracking.
expressMiddleware()
Returns an Express/Connect middleware that tracks every request on res.end.
Lifecycle
flush(): Promise<void>
Send all queued events immediately.
shutdown(): Promise<void>
Stop batch timer and flush remaining events. Call before process exit.
CLI Reference
The BetterMeter CLI lets you query analytics from the terminal with beautiful visual output. All commands accept -s/--site, -r/--range, -l/--limit, and --json.
npm install -g bettermeter
bettermeter loginOutput Formats
By default, the CLI renders rich visual output with ASCII art charts, colored text, sparklines, and box-drawn stat cards. All output uses Unicode characters compatible with every modern terminal. Colors auto-detect terminal capabilities and respect the NO_COLOR environment variable.
Default (visual)Line charts, bar charts, sparklines, styled tables with color--jsonRaw JSON data — ideal for scripting, piping to jq, or programmatic useVisual output includes:
- Line charts for timeseries data (daily visitors, invocations)
- Horizontal bar charts for ranked lists (pages, sources, countries)
- Sparklines inline with overview stats for quick trend visualization
- Box-drawn stat cards with colored change indicators for overviews
- Styled tables with box-drawing borders for detailed data
# Visual output (default)
bettermeter stats -s example.com
# JSON output for scripting
bettermeter stats -s example.com --json | jq '.visitors'Real-Time
live -s <siteId>Live visitor count (--json for raw output)Web Analytics
statsOverview: visitors, pageviews, sessions + % changepagesTop pages by visitor countsourcesTraffic sources (--filter all|ai|traditional)ai-trafficAI referral breakdown by platformbotsBot/crawler traffic (--category all|ai-crawler|search|monitoring|scraper)timeseriesDaily visitor/pageview trendcountriesVisitors by countrydevicesDevice breakdownbrowsersBrowser breakdownvisitorsRecent visitors with activity summaryeventsCustom events with countsexportFull report (--format json|csv|md)CLI Analytics
cli-overviewInvocations, callers, success rate, avg durationcli-commandsTop commands by invocation countcli-timeseriesDaily CLI activityMCP Analytics
mcp-overviewInvocations, callers, success rate, avg durationmcp-toolsTop MCP tools by invocation countmcp-clientsClient breakdown (Claude, Cursor, etc.)mcp-timeseriesDaily MCP activityAPI Analytics
api-overviewInvocations, callers, error rate, avg durationapi-endpointsTop endpoints by invocation countapi-timeseriesDaily API activityOptions
-s, --siterequired-r, --range-l, --limit--jsonMCP Tools Reference
BetterMeter runs as an MCP server for AI assistants. Every CLI command has a matching MCP tool with the bettermeter_ prefix.
{
"mcpServers": {
"bettermeter": {
"command": "npx",
"args": ["bettermeter"]
}
}
}All tools accept site_id (string), range (today|7d|30d|90d|12m), and limit (number). Usage examples for AI assistants:
bettermeter_live_visitorsbettermeter_statsbettermeter_ai_trafficbettermeter_cli_commandsbettermeter_mcp_clientsbettermeter_api_endpoints with range=7dFull Tool List
Real-Time
bettermeter_live_visitorsWeb Analytics
bettermeter_statsbettermeter_pagesbettermeter_sourcesbettermeter_ai_trafficbettermeter_botsbettermeter_timeseriesbettermeter_countriesbettermeter_devicesbettermeter_browsersbettermeter_visitorsbettermeter_visitorbettermeter_eventsbettermeter_exportCLI Analytics
bettermeter_cli_overviewbettermeter_cli_commandsbettermeter_cli_timeseriesMCP Analytics
bettermeter_mcp_overviewbettermeter_mcp_toolsbettermeter_mcp_clientsbettermeter_mcp_timeseriesAPI Analytics
bettermeter_api_overviewbettermeter_api_endpointsbettermeter_api_timeseriesSite Management
bettermeter_sites_listbettermeter_sites_addbettermeter_sites_removebettermeter_sites_infobettermeter_installTeam & Brand
bettermeter_members_listbettermeter_members_addbettermeter_members_removebettermeter_members_update_rolebettermeter_brand_reportAPI Reference
All analytics endpoints accept GET requests with query parameters. Authenticate with Authorization: Bearer <api_key>.
Event Ingestion
POST /api/eventIngest an event (web, CLI, MCP, or API). Returns 202.{
"site_id": "example.com",
"event_name": "pageview", // or "cli.command", "mcp.tool", "api.request"
"event_source": "web", // "web" | "cli" | "mcp" | "api"
"url": "https://example.com/page",
"pathname": "/page",
"hostname": "example.com",
"referrer": "https://google.com",
"screen_width": 1920,
"timezone": "America/New_York",
"user_id": "optional_user_id",
"properties": { "key": "value" }
}Real-Time & Heartbeat
GET /api/analytics/liveLive visitor count. Returns { live: number }. Accepts ?siteId=...POST /api/heartbeatReceive browser heartbeats for live visitor trackingPOST /api/hStealth alias for /api/heartbeat (ad-blocker resistant)Query Endpoints
All accept ?siteId=...&from=YYYY-MM-DD&to=YYYY-MM-DD.
Web Analytics
GET /api/analytics/overviewVisitors, pageviews, sessions + % changeGET /api/analytics/pagesTop pages by visitor countGET /api/analytics/sourcesTraffic sources with AI detectionGET /api/analytics/timeseriesDaily visitor/pageview trendGET /api/analytics/ai-trafficAI referral breakdown by platformGET /api/analytics/botsBot/crawler trafficGET /api/analytics/countriesVisitors by countryGET /api/analytics/devicesDevice type breakdownGET /api/analytics/browsersBrowser breakdownGET /api/analytics/visitorsVisitor list with activityGET /api/analytics/eventsCustom eventsCLI Analytics
GET /api/analytics/cli-overviewInvocations, callers, success rateGET /api/analytics/cli-commandsTop commandsGET /api/analytics/cli-timeseriesDaily CLI activityMCP Analytics
GET /api/analytics/mcp-overviewInvocations, callers, success rateGET /api/analytics/mcp-toolsTop toolsGET /api/analytics/mcp-clientsClient breakdownGET /api/analytics/mcp-timeseriesDaily MCP activityAPI Analytics
GET /api/analytics/api-overviewInvocations, callers, error rateGET /api/analytics/api-endpointsTop endpointsGET /api/analytics/api-timeseriesDaily API activityProxy Setup
Ad blockers sometimes block third-party analytics scripts. By proxying BetterMeter through your own domain, the script and events appear as first-party requests — making them invisible to blockers. This works because the browser sees requests to yourdomain.com/bm/... instead of bettermeter.com/api/....
How it works
You set up URL rewrites on your server so that three paths on your domain forward to BetterMeter:
/bm/s→ https://bettermeter.com/api/sTracker script/bm/e→ https://bettermeter.com/api/eEvent endpoint/bm/p→ https://bettermeter.com/api/pNoscript pixelThen update your tracking snippet to use the proxied paths:
<script defer data-site="example.com" data-api="/bm/e" src="/bm/s"></script>
<noscript><img src="/bm/p?s=example.com" alt="" /></noscript>The data-api attribute tells the tracker to send events to your proxy endpoint instead of directly to BetterMeter. The src loads the script from your proxy. To the browser (and ad blockers), everything is a same-origin request.
Platform guides
Next.js
module.exports = {
async rewrites() {
return [
{ source: "/bm/s", destination: "https://bettermeter.com/api/s" },
{ source: "/bm/e", destination: "https://bettermeter.com/api/e" },
{ source: "/bm/p", destination: "https://bettermeter.com/api/p" },
];
},
};Nuxt
export default defineNuxtConfig({
routeRules: {
"/bm/s": { proxy: "https://bettermeter.com/api/s" },
"/bm/e": { proxy: "https://bettermeter.com/api/e" },
"/bm/p": { proxy: "https://bettermeter.com/api/p" },
},
});SvelteKit
const config = {
kit: {
// SvelteKit doesn't have built-in rewrites.
// Use a server hook instead:
},
};import type { Handle } from "@sveltejs/kit";
const PROXY_MAP: Record<string, string> = {
"/bm/s": "https://bettermeter.com/api/s",
"/bm/e": "https://bettermeter.com/api/e",
"/bm/p": "https://bettermeter.com/api/p",
};
export const handle: Handle = async ({ event, resolve }) => {
const target = PROXY_MAP[event.url.pathname];
if (target) {
const url = new URL(target);
url.search = event.url.search;
const res = await fetch(url, {
method: event.request.method,
headers: event.request.headers,
body: event.request.method !== "GET" ? event.request.body : undefined,
// @ts-expect-error — needed for streaming
duplex: "half",
});
return new Response(res.body, {
status: res.status,
headers: res.headers,
});
}
return resolve(event);
};Astro
import type { APIRoute } from "astro";
const PROXY_MAP: Record<string, string> = {
s: "https://bettermeter.com/api/s",
e: "https://bettermeter.com/api/e",
p: "https://bettermeter.com/api/p",
};
export const ALL: APIRoute = async ({ params, request }) => {
const target = PROXY_MAP[params.proxy ?? ""];
if (!target) return new Response("Not found", { status: 404 });
const url = new URL(target);
url.search = new URL(request.url).search;
return fetch(url, {
method: request.method,
headers: request.headers,
body: request.method !== "GET" ? request.body : undefined,
// @ts-expect-error — needed for streaming
duplex: "half",
});
};Remix / React Router
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
const PROXY_MAP: Record<string, string> = {
s: "https://bettermeter.com/api/s",
e: "https://bettermeter.com/api/e",
p: "https://bettermeter.com/api/p",
};
async function proxy({ request, params }: LoaderFunctionArgs | ActionFunctionArgs) {
const target = PROXY_MAP[params["*"] ?? ""];
if (!target) return new Response("Not found", { status: 404 });
const url = new URL(target);
url.search = new URL(request.url).search;
return fetch(url, {
method: request.method,
headers: request.headers,
body: request.method !== "GET" ? request.body : undefined,
// @ts-expect-error — needed for streaming
duplex: "half",
});
}
export const loader = proxy;
export const action = proxy;Nginx
location /bm/s {
proxy_pass https://bettermeter.com/api/s;
proxy_ssl_server_name on;
proxy_set_header Host bettermeter.com;
}
location /bm/e {
proxy_pass https://bettermeter.com/api/e;
proxy_ssl_server_name on;
proxy_set_header Host bettermeter.com;
}
location /bm/p {
proxy_pass https://bettermeter.com/api/p;
proxy_ssl_server_name on;
proxy_set_header Host bettermeter.com;
}Apache
RewriteEngine On
RewriteRule ^bm/s$ https://bettermeter.com/api/s [P,L]
RewriteRule ^bm/e$ https://bettermeter.com/api/e [P,L]
RewriteRule ^bm/p$ https://bettermeter.com/api/p [P,L]
# Requires mod_proxy and mod_proxy_http enabled:
# a2enmod proxy proxy_httpCaddy
example.com {
handle_path /bm/s {
reverse_proxy https://bettermeter.com/api/s {
header_up Host bettermeter.com
}
}
handle_path /bm/e {
reverse_proxy https://bettermeter.com/api/e {
header_up Host bettermeter.com
}
}
handle_path /bm/p {
reverse_proxy https://bettermeter.com/api/p {
header_up Host bettermeter.com
}
}
}Cloudflare Workers
export default {
async fetch(request) {
const url = new URL(request.url);
const map = {
"/bm/s": "https://bettermeter.com/api/s",
"/bm/e": "https://bettermeter.com/api/e",
"/bm/p": "https://bettermeter.com/api/p",
};
const target = map[url.pathname];
if (!target) return fetch(request);
const dest = new URL(target);
dest.search = url.search;
return fetch(dest.toString(), {
method: request.method,
headers: { ...Object.fromEntries(request.headers), Host: "bettermeter.com" },
body: request.method !== "GET" ? request.body : undefined,
});
},
};Vercel (without Next.js)
{
"rewrites": [
{ "source": "/bm/s", "destination": "https://bettermeter.com/api/s" },
{ "source": "/bm/e", "destination": "https://bettermeter.com/api/e" },
{ "source": "/bm/p", "destination": "https://bettermeter.com/api/p" }
]
}Netlify
[[redirects]]
from = "/bm/s"
to = "https://bettermeter.com/api/s"
status = 200
force = true
[[redirects]]
from = "/bm/e"
to = "https://bettermeter.com/api/e"
status = 200
force = true
[[redirects]]
from = "/bm/p"
to = "https://bettermeter.com/api/p"
status = 200
force = trueWordPress
Add these rewrite rules to your theme's functions.php or a custom plugin:
add_action('init', function() {
add_rewrite_rule('^bm/s$', 'index.php?bm_proxy=s', 'top');
add_rewrite_rule('^bm/e$', 'index.php?bm_proxy=e', 'top');
add_rewrite_rule('^bm/p$', 'index.php?bm_proxy=p', 'top');
});
add_filter('query_vars', function($vars) {
$vars[] = 'bm_proxy';
return $vars;
});
add_action('template_redirect', function() {
$proxy = get_query_var('bm_proxy');
if (!$proxy) return;
$map = [
's' => 'https://bettermeter.com/api/s',
'e' => 'https://bettermeter.com/api/e',
'p' => 'https://bettermeter.com/api/p',
];
if (!isset($map[$proxy])) return;
$url = $map[$proxy];
if ($_SERVER['QUERY_STRING']) $url .= '?' . $_SERVER['QUERY_STRING'];
$args = ['method' => $_SERVER['REQUEST_METHOD'], 'timeout' => 10];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$args['body'] = file_get_contents('php://input');
$args['headers'] = ['Content-Type' => $_SERVER['CONTENT_TYPE'] ?? 'application/json'];
}
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) { status_header(502); exit; }
status_header(wp_remote_retrieve_response_code($response));
foreach (wp_remote_retrieve_headers($response) as $k => $v) {
if (in_array(strtolower($k), ['content-type', 'cache-control'])) header("$k: $v");
}
echo wp_remote_retrieve_body($response);
exit;
});
// After adding, flush rewrite rules: Settings → Permalinks → SaveDjango
# urls.py
from django.urls import path
from . import views
urlpatterns = [
path("bm/<slug:endpoint>", views.bm_proxy),
]
# views.py
import requests
from django.http import HttpResponse
PROXY_MAP = {
"s": "https://bettermeter.com/api/s",
"e": "https://bettermeter.com/api/e",
"p": "https://bettermeter.com/api/p",
}
def bm_proxy(request, endpoint):
target = PROXY_MAP.get(endpoint)
if not target:
return HttpResponse("Not found", status=404)
url = target + ("?" + request.META["QUERY_STRING"] if request.META.get("QUERY_STRING") else "")
resp = requests.request(
method=request.method, url=url,
headers={"Host": "bettermeter.com", "Content-Type": request.content_type},
data=request.body if request.method == "POST" else None,
timeout=10,
)
return HttpResponse(resp.content, status=resp.status_code, content_type=resp.headers.get("Content-Type"))Rails
# config/routes.rb
match "/bm/:endpoint", to: "bm_proxy#proxy", via: [:get, :post], constraints: { endpoint: /[sep]/ }
# app/controllers/bm_proxy_controller.rb
class BmProxyController < ApplicationController
skip_before_action :verify_authenticity_token
PROXY_MAP = { "s" => "/api/s", "e" => "/api/e", "p" => "/api/p" }.freeze
def proxy
path = PROXY_MAP[params[:endpoint]]
return head(:not_found) unless path
uri = URI("https://bettermeter.com#{path}")
uri.query = request.query_string.presence
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = (request.post? ? Net::HTTP::Post : Net::HTTP::Get).new(uri)
req["Host"] = "bettermeter.com"
if request.post?
req.body = request.body.read
req["Content-Type"] = request.content_type
end
resp = http.request(req)
render body: resp.body, status: resp.code.to_i, content_type: resp["Content-Type"]
end
endVerification
After setting up the proxy, verify it works:
# Should return the tracker JavaScript
curl -s https://yourdomain.com/bm/s | head -5
# Should return 202 (event accepted)
curl -s -o /dev/null -w "%{http_code}" -X POST https://yourdomain.com/bm/e \
-H "Content-Type: application/json" \
-d '{"site_id":"yourdomain.com","event_name":"test"}'Data Model
All four event sources share a single Event table. The eventSource column distinguishes them. Source-specific metadata lives in the properties JSON column.
Field Mapping
Privacy
BetterMeter is designed from the ground up to respect user privacy. No consent banners needed. GDPR compliant by default.
No cookies
Zero cookies, zero local storage, zero fingerprinting. The tracker script is stateless.
Hashed identifiers
IP addresses are SHA-256 hashed with the current date included in the hash input. The raw IP is never stored. Daily visitor hashes rotate every 24 hours. A stable visitor ID (hashed without date) enables visitor profiles within a site but cannot be reversed to reveal the original IP.
No PII collection
The SDK tracks command names, tool names, and endpoint patterns — never arguments, file paths, request bodies, or personal data.
Fire-and-forget
Analytics calls are async and never throw. A network failure silently drops the event. Analytics should never break the host application.
Opt-out
Set disabled: true in the SDK config or BETTERMETER_DISABLED=1 as an environment variable.
Open pipeline
Every event goes through the same processEvent() function. You can audit exactly what is collected and stored.
BetterMeter Analytics — Built for the new internet