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. ~1.3KB gzipped.
<script defer data-site="example.com" src="https://bettermeter.com/api/script"></script>Optional: add a no-JS tracking pixel before the closing </body> tag to detect bots and crawlers that don't execute JavaScript:
<img src="https://bettermeter.com/api/pixel?s=example.com" alt="" style="position:absolute;width:0;height:0;overflow:hidden" />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-heartbeatServer-Side Bot Detection
Most bots (Googlebot, GPTBot, ClaudeBot, etc.) don't execute JavaScript, so the browser tracker never fires for them. To detect bot and crawler traffic, add one line to your Next.js middleware:
import { reportBotVisit } from "@bettermeter/node/middleware";
export function middleware(request) {
reportBotVisit(request, "example.com");
// ... rest of your middleware
}Install the SDK:
npm install @bettermeter/nodeEdge-compatible, non-blocking, zero latency impact. Detects 25+ known bots including AI crawlers, search engines, and monitoring services. Bot visits appear in your Crawlers dashboard automatically.
CLI 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 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.
Server-Side Bot Detection
Most bots don't execute JavaScript, so the browser tracker never fires for them. Use reportBotVisit() in your server middleware to detect bots at the request level.
// Next.js middleware
import { reportBotVisit } from "@bettermeter/node/middleware";
export function middleware(request) {
reportBotVisit(request, "my-site.com");
// ... rest of your middleware
}requestrequiredsiteIdrequiredoptions.apiUrlEdge-compatible (no Node.js dependencies). Non-blocking -- adds zero latency to responses. Automatically skips static assets, API routes, and Next.js internals.
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 login -t <apiKey> -u <dashboardUrl>Output 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'Authentication
login -t <key> -u <url>Authenticate with API key and dashboard URLlogoutRemove stored credentialswhoamiShow current authenticated userReal-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 countscampaignsCampaign URL attribution (UTM + click IDs)channelsChannel breakdown (Direct, Paid Search, Organic, etc.)exportFull 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 activityPulse AI
pulse insightsAnomalies, trends, opportunities, milestonespulse healthProduct health score (0-100, grade A-F)pulse briefingDaily/weekly briefing (-p/--period daily|weekly)pulse forecastTraffic forecast (-m/--metric, -d/--days)pulse compareCompare two periods (-r/--range, --from2, --to2)pulse alertsList monitoring alert rulespulse alerts:createCreate alert (-t/--type, -n/--name, -c/--condition)pulse alerts:deleteDelete an alert (-i/--id)pulse notificationsRecent notifications (--unread for unread only)Search Rankings and AI Visibility
brand-report <domain>Generate search rankings report (-q/--queries)brand-config <domain>View/update brand monitoring configbrand-compare <domain>Compare rankings vs competitors (-q, -c)brand-alerts <domain>Manage ranking alerts (-a list|create|delete)ai-mentions <domain>AI chatbot brand mentions (-q/--queries, -p/--providers)backlinks <domain>Backlink profile: domain rank, referring domainsSite Management
sites listList all sitessites add <domain>Add a new site (-n/--name for display name)sites remove <siteId>Remove a sitesites info <siteId>Show site details and tracking snippetinstall <siteId>Get tracker snippet for a siteTeam Management
members list -s <siteId>List site membersmembers add <email>Add a member (-s, -r viewer|editor|admin, --all-sites)members remove <id>Remove a member (-s)members update-role <id>Update member role (-s, -r)Billing
billingShow current plan, usage, and billing info (--json)Options
-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 analytics tools accept site_id (string), range (today|7d|30d|90d|12m), and limit (number). The bettermeter_export MCP tool supports json and md formats (the CLI export command also supports csv). Usage examples for AI assistants:
bettermeter_live_visitorsbettermeter_statsbettermeter_ai_trafficbettermeter_cli_commandsbettermeter_mcp_clientsbettermeter_api_endpoints with range=7dbettermeter_pulse_healthbettermeter_pulse_insightsbettermeter_pulse_briefing with period=weeklyFull Tool List
Real-Time
bettermeter_live_visitorsWeb Analytics
bettermeter_statsbettermeter_pagesbettermeter_sourcesbettermeter_ai_trafficbettermeter_botsbettermeter_timeseriesbettermeter_countriesbettermeter_devicesbettermeter_browsersbettermeter_visitorsbettermeter_visitorbettermeter_eventsbettermeter_campaignsbettermeter_channelsbettermeter_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 Management
bettermeter_members_listbettermeter_members_addbettermeter_members_removebettermeter_members_update_roleSearch Rankings and AI Visibility
bettermeter_brand_reportbettermeter_brand_configbettermeter_brand_comparebettermeter_brand_alertsbettermeter_ai_mentionsbettermeter_backlinksBilling
bettermeter_billingPulse AI
bettermeter_pulse_insightsbettermeter_pulse_healthbettermeter_pulse_briefingbettermeter_pulse_forecastbettermeter_pulse_comparebettermeter_pulse_alerts_listbettermeter_pulse_alerts_createbettermeter_pulse_alerts_deletebettermeter_pulse_notificationsAPI 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/visitors/[visitorId]Visitor profile with event timelineGET /api/analytics/eventsCustom eventsGET /api/analytics/campaignsCampaign URL attribution (UTM + click IDs)GET /api/analytics/campaigns/[campaign]Single campaign detailGET /api/analytics/channelsChannel breakdownGET /api/analytics/session-statsSession statisticsGET /api/analytics/sessionsSession listCLI 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 activitySearch Rankings and AI Visibility
GET /api/analytics/brandSearch rankings visibility reportGET /api/analytics/brand/historyHistorical ranking dataGET /api/analytics/brand/compareCompetitor comparisonGET /api/analytics/brand/alertsRanking alert rulesGET /api/analytics/brand/exportExport ranking dataGET /api/analytics/backlinksBacklink profileGET /api/analytics/ai-mentionsAI chatbot brand mentionsGET /api/analytics/ai-mentions/historyAI mention historyPulse AI
GET /api/pulse/insightsAnomalies, trends, opportunitiesGET /api/pulse/healthProduct health score (0-100)GET /api/pulse/briefingDaily or weekly briefingGET /api/pulse/forecastTraffic/usage forecastGET /api/pulse/comparePeriod comparisonGET /api/pulse/alertsList monitoring alertsPOST /api/pulse/alertsCreate alert ruleDELETE /api/pulse/alertsDelete alert ruleGET /api/pulse/notificationsGet notificationsPATCH /api/pulse/notificationsMark notifications as readPOST /api/pulse/chatPulse AI chat (streaming)Pulse AI
Pulse AI is BetterMeter's proactive analytics intelligence layer. Instead of waiting for you to check dashboards, Pulse automatically detects anomalies, surfaces insights, scores product health, forecasts trends, and delivers daily or weekly briefings.
Capabilities
Alert Types
Create monitoring alerts that generate notifications when conditions are met:
traffic_spikeAlert when traffic exceeds a thresholdtraffic_dropAlert when traffic drops below a thresholderror_rateAlert when error rate exceeds a thresholdbot_surgeAlert on unusual bot/crawler activity spikesnew_ai_crawlerAlert when a new AI crawler is detectedperformanceAlert on performance degradationAPI Endpoints
GET /api/pulse/insightsAnomalies, trends, opportunities, milestonesGET /api/pulse/healthProduct health score (0-100) with category breakdownGET /api/pulse/briefingDaily or weekly briefing summaryGET /api/pulse/forecastTraffic/usage forecast with confidence intervalsGET /api/pulse/comparePeriod-over-period deep comparisonGET /api/pulse/alertsList monitoring alert rulesPOST /api/pulse/alertsCreate a new alert ruleDELETE /api/pulse/alertsDelete an alert ruleGET /api/pulse/notificationsGet recent notificationsPATCH /api/pulse/notificationsMark notifications as readCLI Commands
pulse insightsGet proactive insights and anomaliespulse healthProduct health score with gradepulse briefingDaily or weekly briefing (-p/--period daily|weekly)pulse forecastTraffic forecast (-m/--metric, -d/--days)pulse compareCompare two periods (-r/--range, --from2, --to2)pulse alertsList monitoring alertspulse alerts:createCreate alert (-t/--type, -n/--name, -c/--condition)pulse alerts:deleteDelete an alert (-i/--id)pulse notificationsRecent notifications (--unread for unread only)MCP Tools
bettermeter_pulse_insightsbettermeter_pulse_healthbettermeter_pulse_briefingbettermeter_pulse_forecastbettermeter_pulse_comparebettermeter_pulse_alerts_listbettermeter_pulse_alerts_createbettermeter_pulse_alerts_deletebettermeter_pulse_notificationsProxy 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>
<img src="/bm/p?s=example.com" alt="" style="position:absolute;width:0;height:0;overflow:hidden" />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