skill
litemetrics
Open-source analytics SDK you can integrate into your product. Give your users a beautiful analytics dashboard in 5 minutes.
Set up
Skills are installed by placing their folder where your agent loads skills.
The download comes from github.com/metehankurucu/litemetrics itself, not from our server, so what you get is exactly what its author published.
Install instructions come from the README
README
synced Oct 25, 2026Why Litemetrics?
| Embed into your product | Drop a full analytics stack into your app — tracker, server, query API, and a pre-built dashboard UI. Your customers get analytics without you building anything. |
| Your brand, your colors | 10 built-in theme presets. CSS custom properties for full control. Dark mode included. Ship analytics that look like they belong in your app. |
| Multi-tenant ready | Each customer gets isolated analytics via site_id. One database, zero cross-contamination. Built for SaaS from day one. |
| Lightweight tracker | ~3 KB gzipped. Auto-tracks pageviews, sessions, scroll depth, button clicks, outbound links — or go fully manual. |
| ClickHouse, Postgres & MongoDB | Choose the database that fits. ClickHouse for speed at scale, Postgres for the DB you already run, MongoDB for simplicity. Swap with one env var, full feature parity. |
| One-click deploy | Docker Compose, Railway, or a single Docker container. Up and running in under a minute. |
Quick Start
1. Add a dashboard to your app
npm install @litemetrics/ui
import { LitemetricsProvider, AnalyticsDashboard } from '@litemetrics/ui';
function CustomerDashboard({ customerId }) {
return (
<LitemetricsProvider baseUrl="/api/stats" siteId={customerId}>
<AnalyticsDashboard theme="midnight" />
</LitemetricsProvider>
);
}
2. Add tracking to your site
<script src="https://your-server.com/litemetrics.js"></script>
<script>
Litemetrics.createTracker({
siteId: 'your-site-id',
endpoint: 'https://your-server.com/api/collect'
});
</script>
3. Add the server (Express)
npm install @litemetrics/node
import express from 'express';
import { createCollector } from '@litemetrics/node';
const app = express();
app.use(express.json());
const collector = await createCollector({
db: { url: 'http://localhost:8123' },
});
app.all('/api/collect', (req, res) => collector.handler()(req, res));
app.all('/api/stats', (req, res) => collector.queryHandler()(req, res));
app.listen(3002);
For Postgres:
db: { adapter: 'postgres', url: 'postgres://user:pass@localhost:5432/myapp' }For MongoDB:
db: { adapter: 'mongodb', url: 'mongodb://localhost:27017/myapp' }
4. Run the mobile app (Expo)
cd apps/mobile
bun install
bun run start
For platform-specific launches:
bun run ios
bun run android
5. Query from the terminal (CLI)
bun add -g @litemetrics/cli
# Configure once
export LITEMETRICS_URL=https://your-server.com
export LITEMETRICS_ADMIN_SECRET=your-secret
export LITEMETRICS_SITE_ID=your-site-id
# Overview of all metrics
litemetrics overview -p 7d --compare
# Top pages
litemetrics stats top_pages -p 30d -l 10
# Time series
litemetrics timeseries visitors -p 30d -g day
# JSON output for AI agents
litemetrics overview -p 7d -f json
See packages/cli/README.md for all commands.
Deploy
Docker Compose (recommended)
git clone https://github.com/metehankurucu/litemetrics.git
cd litemetrics
ADMIN_SECRET=your-secret docker compose up -d
Open http://localhost:3002 for the dashboard.
Prefer PostgreSQL over ClickHouse? Use the Postgres variant (the database stays on the internal network and is not exposed to the host):
POSTGRES_PASSWORD=change-me ADMIN_SECRET=your-secret \
docker compose -f docker-compose.postgres.yml up -d
Docker (standalone)
docker build -t litemetrics .
docker run -p 3002:3002 \
-e CLICKHOUSE_URL=http://your-clickhouse:8123 \
-e ADMIN_SECRET=your-secret \
litemetrics
Railway (one click)
- Click the Deploy on Railway button above
- Add a database plugin — Postgres, ClickHouse, or MongoDB
- Set
DB_ADAPTERand the matching URL (POSTGRES_URL,CLICKHOUSE_URL, orMONGODB_URL) plusADMIN_SECRET - Done — dashboard, API, and tracker served from one container
Environment Variables
| Variable | Description | Default |
|---|---|---|
DB_ADAPTER | Database adapter (clickhouse, postgres, or mongodb) | clickhouse |
CLICKHOUSE_URL | ClickHouse connection URL | http://localhost:8123 |
POSTGRES_URL | Postgres connection string | postgres://postgres:postgres@localhost:5432/litemetrics |
MONGODB_URL | MongoDB connection string | mongodb://localhost:27017/litemetrics |
ADMIN_SECRET | Secret for admin access and site management | (none) |
PORT | Server port | 3002 |
GEOIP | Enable GeoIP lookup | true |
TRUST_PROXY | Trust X-Forwarded-For headers | true |
BOT_FILTER_MODE | Server-wide bot filter default: off / standard / strict / shadow | standard |
BOT_RATE_WINDOW_MS | Sliding-window size for the per-IP rate limiter (ms) | 60000 |
BOT_RATE_MAX | Max collect requests per window per IP before rate-limit fires (one request can carry up to 100 events) | 60 |
BOT_VELOCITY_WINDOW_MS | Sliding-window size for the per-visitor velocity layer (ms) | 10000 |
BOT_VELOCITY_MAX_PAGEVIEWS | Max pageviews one visitor may send per window before the velocity layer fires. 0 switches this layer off on its own | 60 |
BOT_VELOCITY_MAX_KEYS | Cap on tracked siteId:visitorId windows (LRU-evicts the oldest) | 50000 |
BOT_LOG_MAX_PER_MIN | Detail [bot-filter] log lines allowed per minute; the overflow is counted as suppressed= on the [collect] summary | 20 |
COLLECT_ERROR_LOG_MAX_PER_MIN | Detail [collect-error] log lines allowed per minute; every failure is still counted in err_codes= on the [collect] summary | 5 |
DATABASE_URLandLITEMETRICS_ADMIN_SECRETalso work as aliases.
Architecture
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ │ POST │ │ GET │ │
│ @litemetrics/ │ /collect│ @litemetrics/ │ /stats │ @litemetrics/ │
│ tracker ├────────>│ node │<────────┤ dashboard │
│ │ │ (collector) │ │ │
│ ~3KB, browser │ events │ ClickHouse / │ queries │ React UI │
│ │ │ Postgres / │ │ │
│ │ │ MongoDB │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
Browser / App Your Server ▲ Dashboard
│
┌────┴─────────────┐
│ @litemetrics/ │
│ cli │
│ Terminal / AI │
└──────────────────┘
The tracker handles session management, visitor IDs, batching, and SPA detection client-side. The server stores events and runs queries. The CLI provides terminal access to all analytics data — designed for both humans and AI agents. The Docker image bundles everything into a single container.
Packages
| Package | Description |
|---|---|
@litemetrics/ui | Pre-built React dashboard components (10 themes, dark mode, CSS variables) |
@litemetrics/tracker | Browser tracker (~3 KB gzipped) |
@litemetrics/node | Server collector, ClickHouse/Postgres/MongoDB adapters, query API |
@litemetrics/react | React provider and hooks |
@litemetrics/react-native | React Native / Expo provider |
@litemetrics/client | Typed client for reading analytics data |
@litemetrics/cli | CLI tool for querying analytics and managing sites |
@litemetrics/core | Shared types and constants |
Apps
| App | Description |
|---|---|
@litemetrics/dashboard | Analytics dashboard (React + Vite + Tailwind) |
@litemetrics/server | Self-hosted server (serves dashboard + API) |
@litemetrics/mobile | Native mobile analytics app (Expo Router + React Native) |
@litemetrics/landing | Landing page (React + Vite + Tailwind) |
Metrics
pageviews · visitors · sessions · events · conversions · top_pages · top_referrers · top_countries · top_cities · top_events · top_conversions · top_exit_pages · top_transitions · top_scroll_pages · top_button_clicks · top_link_targets · top_devices · top_browsers · top_os · top_os_versions · top_device_models · top_app_versions · top_utm_sources · top_utm_mediums · top_utm_campaigns · top_utm_terms · top_utm_contents · top_channels · timeseries · retention
Auto Events & Insights
- Auto events are tagged with
event_source=autoand a subtype:link_click,outbound_click,file_download,button_click,scroll_depth,rage_click. - Link, outbound and download rows carry
element_textandelement_selector. Outbound rows store the destination ashost + path + query, so awa.me/1555...orapi.whatsapp.com/send?phone=...click stays recoverable instead of collapsing to a bare path. - A click on an element carrying
data-litemetrics-eventis recorded only as that declared event (event_source=manual,event_subtype=attribute); the auto click row is suppressed, so a labelled element is never double-counted. - Ad click IDs (
gclid,gbraid,wbraid,fbclid) are captured at landing and kept for 90 days, so a conversion fired days later still carries the click that paid for it. Meta's_fbpcookie is read (never set) and forwarded only alongside a captured click ID. - Manual
track()events default toevent_source=manualandevent_subtype=custom. - All metrics and time series support segmentation filters (geo, device, UTM, referrer, event metadata).
- The dashboard Insights view surfaces exit pages, transitions, scroll-heavy pages, and click hotspots.
Bot Filtering
Litemetrics ships with multi-layer bot filtering enabled by default. Bot traffic is excluded from every query unless you explicitly opt in.
- Tracker short-circuit — when
navigator.webdriver === true, the browser tracker becomes a no-op. Catches Selenium / Puppeteer / Playwright at the source before the event ever leaves the page. - Layer 1 (signature) — server-side match against the maintained
isbotlist of known crawlers / preview bots. - Layer 2 (heuristic) — catches scrubbed or empty user agents (no UA, bare
Mozilla/5.0, missing platform tokens, etc). A request that carries a real browser UA andAccept-LanguageandRefererclears this layer, which is why Layer 4 exists. - Layer 3 (rate limit) — sliding-window per-IP cap (
BOT_RATE_WINDOW_MS/BOT_RATE_MAX) for traffic that escapes Layers 1 and 2. It is evaluated before Layer 4, and deliberately: Layer 4 judges one visitor and stores the rest of the batch, so a request it flags still lands rows, and a request that lands rows has to be counted against its address. A Layer 1 or Layer 2 hit does skip this window, because neither ever admits an unflagged row: either the request returns and nothing is stored, or the whole batch is stored carrying the flag. Layer 4 is the one that stores unflagged rows alongside a hit, which is why it may not skip. The window counts collect requests, not events, and is shared across every site served by the process, so one IP browsing two of your sites reaches the cap twice as fast. WithTRUST_PROXYon (the default), the per-IP key is the firstX-Forwarded-Forentry, falling back toX-Real-IP. Unless the proxy in front of the collector overwritesX-Forwarded-For, that entry is whatever the client sent: a client can rotate it to get a fresh Layer 3 window on every request, or set it to someone else's address to spend that address's budget, which understandardhides the address's traffic from the default reports — the per-IP counterpart of the forged-visitorIdcase below. Some hosts document onlyX-Real-IPas the client address, Railway among them; check what yours sets. - Layer 4 (visitor velocity) — sliding-window cap on how many pageviews a single
visitorIdmay send (BOT_VELOCITY_WINDOW_MS/BOT_VELOCITY_MAX_PAGEVIEWS, default 60 per 10 seconds). Once a visitor is over the line, its pageviews stay flagged while it keeps above that rate, and it clears one window after it slows. The three layers above all read one request in isolation: two read headers, and the per-IP window counts requests, so a batched flood arrives as a handful of calls from an address that rotates. This layer measures the visitor instead. Only pageviews count toward it — rage clicks and scroll-depth events fire dozens of times a minute by design — and the window is persiteId:visitorId, so it is keyed to a visitor rather than to an address: a shared NAT is not pooled by its IP. AvisitorIdis a device fingerprint for one day, not a person, and the client sends it. The browser tracker derives it fromhostname|day|User-Agent|language|timezone|screen(packages/tracker/src/session.ts), which is a hash of things two people can easily share: same site, same day, same browser build, same locale, same timezone, same screen size. Two such visitors send the same id and DO share one window, so about 6 pageviews a second between them reaches the threshold. Because the id travels in the request body and its inputs are observable, it can also be forged: anyone can compute a common visitor's id and burn that window deliberately, which instandardhides that visitor's next pageviews from the default reports and instrictdrops them. Keying the window on the IP as well would close that, and would also hand the batching, address-rotating client this layer exists for a free pass, so it is not done. The React Native SDK is unaffected either way: it draws a random per-install id. If your own visitors cluster like that on a URL-syncing page, raiseBOT_VELOCITY_MAX_PAGEVIEWSor runshadowfirst. Once a visitor is over the line, all of its events in that batch are acted on, custom events included; only pageviews count toward the window.
App sites (type: 'app') never run Layers 1 or 2. Those are browser heuristics — a native app SDK sends no browser User-Agent, no Accept-Language and no Referer, so on an app site they only ever misfire (on Android, React Native's fetch goes out as okhttp/<version>, which isbot matches, and every Android event was being dropped). In standard, an app site runs Layer 4 only: the SDK's 5-second send timer and carrier NAT put many devices on one public address, so the per-IP window would hide real users once a handful of devices shared an address. strict and shadow still run Layer 3 too, alongside Layer 4 — dropping (strict) or flagging (shadow) an over-limit request. Layer 4 is not a browser heuristic — it measures how fast one visitor moves — so it applies to app traffic the same way the per-IP window does on web sites. This makes the site type load-bearing: a site that receives app SDK traffic must be created with type: 'app' (litemetrics sites create --type app, or POST / PUT /api/sites with {"type":"app"}), otherwise it is still filtered as browser traffic, and that now costs more than it used to. The SDK's own litemetrics-react-native/<version> (<platform>) User-Agent escapes Layer 1, but it resolves to no browser and no engine, and the SDK sets no Accept-Language or Referer. On Android nothing below the SDK adds them (OkHttp does not), so Layer 2 flags the request: on a mis-typed site standard hides that Android traffic from every report (recoverable with ?includeBots=true) and strict drops it outright. iOS has not been measured: its network stack (NSURLSession) may add Accept-Language on its own, in which case iOS requests do not trip Layer 2 and a mis-typed site shows its iOS traffic while hiding its Android traffic. The server logs [site-type-mismatch] site=<id> type=<type> platform=<platform> mode=<mode> once per site when it sees app SDK payloads on a non-app site; the payload alone never bypasses the filter.
Modes are configured server-wide via BOT_FILTER_MODE and overridable per-site (on app sites Layer 4 always applies; Layer 3 joins it in strict and shadow, see above):
| Mode | Behavior |
|---|---|
off | All filtering disabled |
standard (default) | Layer 1 drops the event; Layers 2, 3 and 4 flag it (kept in DB, hidden from queries) |
strict | Every layer drops the event |
shadow | Every layer flags only — useful for tuning before going live |
Each detection emits a structured audit log line:
[bot-filter] dropped layer=signature reason=ua-signature mode=standard events=1 site=site_abc ip=203.0.113.4 ua="okhttp/4.12.0"
layer says which of the four layers fired; reason says why, which is what makes a drop diagnosable from the log line alone:
reason | Layer | Meaning |
|---|---|---|
empty-ua | signature / heuristic | No User-Agent header at all. Usually a misconfigured SDK, not a crawler |
ua-signature | signature | The UA matched the isbot list. Usually a real crawler — but also catches HTTP client defaults like okhttp/* |
no-browser-signals | heuristic | Browser, engine, Accept-Language and Referer were all absent |
rate-limit | rate-limit | The per-IP sliding window overflowed |
velocity | velocity | One visitorId sent more pageviews inside the velocity window than a person can read |
ua is the raw User-Agent, sanitized to a single line and capped at 200 characters. Detail lines are capped at BOT_LOG_MAX_PER_MIN per minute so a bot storm cannot flush the rest of your log window; the overflow is counted as suppressed= on the summary line below.
Pass ?includeBots=true to /api/stats, /api/events, or /api/users to see flagged traffic. The dashboard exposes the same toggle on the Analytics page, and per-site bot mode lives on the Settings page.
Request Logs
/api/collect is the only high-volume route, so it is not logged per request — one line per request would fill a fixed-size platform log window in hours. Instead each wall-clock minute that saw traffic emits a single summary:
[collect] minute=2026-08-18T17:35 reqs=11 ok=8 3xx=0 4xx=0 5xx=0 aborted=3 dur_p50=302 dur_p95=712 dur_max=712 bot_dropped=5 bot_flagged=0 reasons=ua-signature:4,empty-ua:1 bot_sites=site_e2e:5 suppressed=2 err_codes=-
| Field | Meaning |
|---|---|
reqs / ok / 3xx / 4xx / 5xx | Requests in the minute, split by response status class |
aborted | Requests the client gave up on — body never finished arriving, or hung up before the answer went out. Its own class, not part of a status class: reqs = ok + 3xx + 4xx + 5xx + aborted |
dur_p50 / dur_p95 / dur_max | Response time in ms (- when the minute saw no requests) |
bot_dropped / bot_flagged | Bot-filter outcomes, counted per request; these totals survive even after the individual detail lines age out of the window. A Layer 4 drop is per visitor, so one bot_dropped can mean part of a batch went and the rest was stored: the per-hit events= field on the detail line below is what says how much |
reasons | Drop reasons for the minute, by count (see the table above) |
bot_sites | Sites by bot hit count — not request volume, which is reqs |
suppressed | Detail bot-filter lines withheld by BOT_LOG_MAX_PER_MIN |
err_codes | Top 10 collect failure keys as <stage>:<class>:<count>, plus other:N for omitted occurrences and untracked:N for occurrences beyond the 50-key tracking cap (- when there were none) |
A minute with no traffic emits nothing, and the open minute is flushed on SIGTERM / SIGINT so a redeploy does not lose it. Every other route keeps a per-request line with status and duration, with a trailing aborted marker when the client left before the answer:
14:59:31 GET /api/stats?siteId=site_x 200 42ms [secret]
A /api/collect request that ends in a 500 also writes a detail line saying where it failed and with what:
[collect-error] stage=insert class=ECONNRESET site=site_abc events=3 msg="connect ECONNRESET 10.0.0.4:8123"
stage is how far the request got (parse, validate, site, identity, insert), class is the driver's error code when it supplied one and the error class name otherwise, and events is the batch size, so a lost batch is countable. These lines are capped at COLLECT_ERROR_LOG_MAX_PER_MIN per minute. Withheld ones are not part of suppressed=, which counts bot-filter lines only: to see how many were withheld, subtract the printed lines from the err_codes= total for that minute. Sum every named count, other:N and untracked:N to obtain that total; both tail values count failure occurrences.
User-Agent, IP, site id and URL all come from the request, so each is sanitized to a single line before it reaches a log entry — otherwise one newline in a header would let a request forge its own log records. The same applies to a driver's error message: credentials in a connection string it quotes back are redacted to scheme://***@host before the line is written.
Docs
- Getting Started
- Integration Guide — React, React Native, Next.js, Vue, Python, PHP, Ruby, Go
- API Reference
- Dashboard
- CLI
- Mobile App
- Self-Hosting
- Scaling
Development
git clone https://github.com/metehankurucu/litemetrics.git
cd litemetrics
bun install
bun run build
License
MIT