BetterMeter
Documentation
Documentation

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.

01

Getting Started

BetterMeter tracks four types of sources. Each uses a lightweight integration that sends events to the same privacy-preserving pipeline.

Websites
<script> tag
~1KB, no cookies
CLI Tools
Node SDK
trackCommand()
MCP Servers
Node SDK
wrapMcpServer()
APIs
Node SDK
expressMiddleware()

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.

02

Web Tracking

Add a single script tag to your site. No cookies, no CNAME configuration, no complex setup. ~1.3KB gzipped.

HTML -- add to <head>
<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:

HTML -- add before </body>
<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:

JavaScript
// Track custom events
window.bettermeter.track("signup", { plan: "pro" });

// Identify users (optional)
window.bettermeter.identify("user_123");

Attributes

data-siterequired
string
Your domain as registered in BetterMeter
data-api
string
Custom API endpoint for proxy setups (e.g., /api/collect)
data-no-heartbeat
flag
Disable live visitor heartbeat tracking for this page

Server-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:

middleware.ts
import { reportBotVisit } from "@bettermeter/node/middleware";

export function middleware(request) {
  reportBotVisit(request, "example.com");
  // ... rest of your middleware
}

Install the SDK:

Terminal
npm install @bettermeter/node

Edge-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.

03

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.

Install
npm install @bettermeter/node
Auto-track with Commander.js
import { 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());
Manual tracking
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.

04

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.

Auto-track all tools
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)
});
Manual tracking
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.

05

API Tracking

Track API endpoint usage, latency, error rates, and caller patterns with Express middleware or manual calls.

Express middleware
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());
Manual tracking
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).

06

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);
siteIdrequired
string
Domain or identifier registered in BetterMeter
apiKeyrequired
string
API key (Bearer token) from dashboard settings
apiUrl
string
BetterMeter API URL. Default: "https://bettermeter.com"
disabled
boolean
Disable all tracking. Default: false
batch
boolean
Queue events and flush on interval. Default: false
batchInterval
number
Flush interval in ms. Default: 5000

trackCommand(options)

commandrequired
string
Command name
subcommand
string
Subcommand (e.g., "deploy preview")
flags
string[]
Flag names used (values stripped)
version
string
CLI version
durationMs
number
Execution time in milliseconds
exitCode
number
Process exit code (0 = success)
isCi
boolean
Running in CI environment
userId
string
Custom user identifier
properties
object
Additional custom properties

trackTool(options)

toolrequired
string
MCP tool name
client
string
AI client name (e.g., "claude-code", "cursor")
protocolVersion
string
MCP protocol version
durationMs
number
Execution time in milliseconds
success
boolean
Whether the call succeeded
errorType
string
Error classification (e.g., "validation_error")
inputTokens
number
Input token count
outputTokens
number
Output token count
userId
string
Custom user identifier
properties
object
Additional custom properties

trackApi(options)

methodrequired
string
HTTP method (GET, POST, etc.)
endpointrequired
string
Endpoint pattern (use :param for dynamic segments)
statusCode
number
HTTP response status code
durationMs
number
Response time in milliseconds
userId
string
Custom user identifier
properties
object
Additional custom properties

Auto-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.

middleware.ts
// Next.js middleware
import { reportBotVisit } from "@bettermeter/node/middleware";

export function middleware(request) {
  reportBotVisit(request, "my-site.com");
  // ... rest of your middleware
}
requestrequired
Request
Incoming request (NextRequest, Request, etc.)
siteIdrequired
string
Your BetterMeter site ID
options.apiUrl
string
BetterMeter API URL. Default: https://bettermeter.com

Edge-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.

07

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.

Install & authenticate
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 use

Visual 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
Example: visual vs JSON
# 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 URL
logoutRemove stored credentials
whoamiShow current authenticated user

Real-Time

live -s <siteId>Live visitor count (--json for raw output)

Web Analytics

statsOverview: visitors, pageviews, sessions + % change
pagesTop pages by visitor count
sourcesTraffic sources (--filter all|ai|traditional)
ai-trafficAI referral breakdown by platform
botsBot/crawler traffic (--category all|ai-crawler|search|monitoring|scraper)
timeseriesDaily visitor/pageview trend
countriesVisitors by country
devicesDevice breakdown
browsersBrowser breakdown
visitorsRecent visitors with activity summary
eventsCustom events with counts
campaignsCampaign 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 duration
cli-commandsTop commands by invocation count
cli-timeseriesDaily CLI activity

MCP Analytics

mcp-overviewInvocations, callers, success rate, avg duration
mcp-toolsTop MCP tools by invocation count
mcp-clientsClient breakdown (Claude, Cursor, etc.)
mcp-timeseriesDaily MCP activity

API Analytics

api-overviewInvocations, callers, error rate, avg duration
api-endpointsTop endpoints by invocation count
api-timeseriesDaily API activity

Pulse AI

pulse insightsAnomalies, trends, opportunities, milestones
pulse 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 rules
pulse 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 config
brand-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 domains

Site Management

sites listList all sites
sites add <domain>Add a new site (-n/--name for display name)
sites remove <siteId>Remove a site
sites info <siteId>Show site details and tracking snippet
install <siteId>Get tracker snippet for a site

Team Management

members list -s <siteId>List site members
members 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
string
Site ID (domain)
-r, --range
string
Date range: today, 7d, 30d, 90d, 12m. Default: 30d
-l, --limit
number
Max results. Default: 10
--json
flag
Output raw JSON instead of visual charts and tables
08

MCP Tools Reference

BetterMeter runs as an MCP server for AI assistants. Every CLI command has a matching MCP tool with the bettermeter_ prefix.

.mcp.json
{
  "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:

"How many live visitors right now?"bettermeter_live_visitors
"How's my traffic?"bettermeter_stats
"Show AI referral breakdown"bettermeter_ai_traffic
"Which CLI commands are most used?"bettermeter_cli_commands
"What AI clients call my MCP server?"bettermeter_mcp_clients
"Top API endpoints this week"bettermeter_api_endpoints with range=7d
"How healthy is my product?"bettermeter_pulse_health
"Any anomalies I should know about?"bettermeter_pulse_insights
"Give me a weekly briefing"bettermeter_pulse_briefing with period=weekly

Full Tool List

Real-Time

bettermeter_live_visitors

Web Analytics

bettermeter_statsbettermeter_pagesbettermeter_sourcesbettermeter_ai_trafficbettermeter_botsbettermeter_timeseriesbettermeter_countriesbettermeter_devicesbettermeter_browsersbettermeter_visitorsbettermeter_visitorbettermeter_eventsbettermeter_campaignsbettermeter_channelsbettermeter_export

CLI Analytics

bettermeter_cli_overviewbettermeter_cli_commandsbettermeter_cli_timeseries

MCP Analytics

bettermeter_mcp_overviewbettermeter_mcp_toolsbettermeter_mcp_clientsbettermeter_mcp_timeseries

API Analytics

bettermeter_api_overviewbettermeter_api_endpointsbettermeter_api_timeseries

Site Management

bettermeter_sites_listbettermeter_sites_addbettermeter_sites_removebettermeter_sites_infobettermeter_install

Team Management

bettermeter_members_listbettermeter_members_addbettermeter_members_removebettermeter_members_update_role

Search Rankings and AI Visibility

bettermeter_brand_reportbettermeter_brand_configbettermeter_brand_comparebettermeter_brand_alertsbettermeter_ai_mentionsbettermeter_backlinks

Billing

bettermeter_billing

Pulse AI

bettermeter_pulse_insightsbettermeter_pulse_healthbettermeter_pulse_briefingbettermeter_pulse_forecastbettermeter_pulse_comparebettermeter_pulse_alerts_listbettermeter_pulse_alerts_createbettermeter_pulse_alerts_deletebettermeter_pulse_notifications
09

API 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.
Event payload
{
  "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 tracking
POST /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 + % change
GET /api/analytics/pagesTop pages by visitor count
GET /api/analytics/sourcesTraffic sources with AI detection
GET /api/analytics/timeseriesDaily visitor/pageview trend
GET /api/analytics/ai-trafficAI referral breakdown by platform
GET /api/analytics/botsBot/crawler traffic
GET /api/analytics/countriesVisitors by country
GET /api/analytics/devicesDevice type breakdown
GET /api/analytics/browsersBrowser breakdown
GET /api/analytics/visitorsVisitor list with activity
GET /api/analytics/visitors/[visitorId]Visitor profile with event timeline
GET /api/analytics/eventsCustom events
GET /api/analytics/campaignsCampaign URL attribution (UTM + click IDs)
GET /api/analytics/campaigns/[campaign]Single campaign detail
GET /api/analytics/channelsChannel breakdown
GET /api/analytics/session-statsSession statistics
GET /api/analytics/sessionsSession list

CLI Analytics

GET /api/analytics/cli-overviewInvocations, callers, success rate
GET /api/analytics/cli-commandsTop commands
GET /api/analytics/cli-timeseriesDaily CLI activity

MCP Analytics

GET /api/analytics/mcp-overviewInvocations, callers, success rate
GET /api/analytics/mcp-toolsTop tools
GET /api/analytics/mcp-clientsClient breakdown
GET /api/analytics/mcp-timeseriesDaily MCP activity

API Analytics

GET /api/analytics/api-overviewInvocations, callers, error rate
GET /api/analytics/api-endpointsTop endpoints
GET /api/analytics/api-timeseriesDaily API activity

Search Rankings and AI Visibility

GET /api/analytics/brandSearch rankings visibility report
GET /api/analytics/brand/historyHistorical ranking data
GET /api/analytics/brand/compareCompetitor comparison
GET /api/analytics/brand/alertsRanking alert rules
GET /api/analytics/brand/exportExport ranking data
GET /api/analytics/backlinksBacklink profile
GET /api/analytics/ai-mentionsAI chatbot brand mentions
GET /api/analytics/ai-mentions/historyAI mention history

Pulse AI

GET /api/pulse/insightsAnomalies, trends, opportunities
GET /api/pulse/healthProduct health score (0-100)
GET /api/pulse/briefingDaily or weekly briefing
GET /api/pulse/forecastTraffic/usage forecast
GET /api/pulse/comparePeriod comparison
GET /api/pulse/alertsList monitoring alerts
POST /api/pulse/alertsCreate alert rule
DELETE /api/pulse/alertsDelete alert rule
GET /api/pulse/notificationsGet notifications
PATCH /api/pulse/notificationsMark notifications as read
POST /api/pulse/chatPulse AI chat (streaming)
10

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

Anomaly Detection
Detects traffic spikes, drops, error rate changes, bot surges, and new referrer sources
Proactive Insights
Surfaces trends, growth opportunities, and milestones before you ask
Health Score
Unified 0-100 score (grade A-F) across traffic, engagement, errors, and growth
Forecasting
Extrapolates traffic and usage trends with confidence intervals
Period Comparison
Deep side-by-side analysis of any two time periods with delta metrics
Briefings
Daily or weekly summaries with key metrics, highlights, and action items

Alert Types

Create monitoring alerts that generate notifications when conditions are met:

traffic_spikeAlert when traffic exceeds a threshold
traffic_dropAlert when traffic drops below a threshold
error_rateAlert when error rate exceeds a threshold
bot_surgeAlert on unusual bot/crawler activity spikes
new_ai_crawlerAlert when a new AI crawler is detected
performanceAlert on performance degradation

API Endpoints

GET /api/pulse/insightsAnomalies, trends, opportunities, milestones
GET /api/pulse/healthProduct health score (0-100) with category breakdown
GET /api/pulse/briefingDaily or weekly briefing summary
GET /api/pulse/forecastTraffic/usage forecast with confidence intervals
GET /api/pulse/comparePeriod-over-period deep comparison
GET /api/pulse/alertsList monitoring alert rules
POST /api/pulse/alertsCreate a new alert rule
DELETE /api/pulse/alertsDelete an alert rule
GET /api/pulse/notificationsGet recent notifications
PATCH /api/pulse/notificationsMark notifications as read

CLI Commands

pulse insightsGet proactive insights and anomalies
pulse healthProduct health score with grade
pulse 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 alerts
pulse 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_notifications
11

Proxy 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 pixel

Then update your tracking snippet to use the proxied paths:

Proxied snippet
<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

next.config.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

nuxt.config.ts
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

svelte.config.js
const config = {
  kit: {
    // SvelteKit doesn't have built-in rewrites.
    // Use a server hook instead:
  },
};
src/hooks.server.ts
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

src/pages/bm/[...proxy].ts
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

app/routes/bm.$.tsx
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

nginx.conf
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

.htaccess or httpd.conf
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_http

Caddy

Caddyfile
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

worker.js
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)

vercel.json
{
  "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

netlify.toml
[[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 = true

WordPress

Add these rewrite rules to your theme's functions.php or a custom plugin:

functions.php
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 → Save

Django

urls.py + views.py
# 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 + controller
# 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
end

Verification

After setting up the proxy, verify it works:

Test
# 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"}'
12

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

Field
Web
CLI
MCP
API
eventName
pageview
cli.command
mcp.tool
api.request
eventSource
web
cli
mcp
api
pathname
URL path
/command
/tool_name
/endpoint
referrer
document.referrer
(empty)
mcp://client
(empty)
properties
custom props
command, flags, duration, exit_code, os
tool, client, duration, success
method, endpoint, status_code, duration
13

Privacy

BetterMeter is designed from the ground up to respect user privacy. No consent banners needed. GDPR compliant by default.

01

No cookies

Zero cookies, zero local storage, zero fingerprinting. The tracker script is stateless.

02

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.

03

No PII collection

The SDK tracks command names, tool names, and endpoint patterns -- never arguments, file paths, request bodies, or personal data.

04

Fire-and-forget

Analytics calls are async and never throw. A network failure silently drops the event. Analytics should never break the host application.

05

Opt-out

Set disabled: true in the SDK config or BETTERMETER_DISABLED=1 as an environment variable.

06

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