Skip to content
Conduit

skill

litemetrics

Open-source analytics SDK you can integrate into your product. Give your users a beautiful analytics dashboard in 5 minutes.

metehankurucu/litemetricscommunitylistedunreviewed

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

Skills do not use an MCP config. Follow the steps in the README below.

README

synced Oct 25, 2026

Why Litemetrics?

Embed into your productDrop 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 colors10 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 readyEach 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 & MongoDBChoose 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 deployDocker 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)

  1. Click the Deploy on Railway button above
  2. Add a database plugin — Postgres, ClickHouse, or MongoDB
  3. Set DB_ADAPTER and the matching URL (POSTGRES_URL, CLICKHOUSE_URL, or MONGODB_URL) plus ADMIN_SECRET
  4. Done — dashboard, API, and tracker served from one container

Environment Variables

VariableDescriptionDefault
DB_ADAPTERDatabase adapter (clickhouse, postgres, or mongodb)clickhouse
CLICKHOUSE_URLClickHouse connection URLhttp://localhost:8123
POSTGRES_URLPostgres connection stringpostgres://postgres:postgres@localhost:5432/litemetrics
MONGODB_URLMongoDB connection stringmongodb://localhost:27017/litemetrics
ADMIN_SECRETSecret for admin access and site management(none)
PORTServer port3002
GEOIPEnable GeoIP lookuptrue
TRUST_PROXYTrust X-Forwarded-For headerstrue
BOT_FILTER_MODEServer-wide bot filter default: off / standard / strict / shadowstandard
BOT_RATE_WINDOW_MSSliding-window size for the per-IP rate limiter (ms)60000
BOT_RATE_MAXMax collect requests per window per IP before rate-limit fires (one request can carry up to 100 events)60
BOT_VELOCITY_WINDOW_MSSliding-window size for the per-visitor velocity layer (ms)10000
BOT_VELOCITY_MAX_PAGEVIEWSMax pageviews one visitor may send per window before the velocity layer fires. 0 switches this layer off on its own60
BOT_VELOCITY_MAX_KEYSCap on tracked siteId:visitorId windows (LRU-evicts the oldest)50000
BOT_LOG_MAX_PER_MINDetail [bot-filter] log lines allowed per minute; the overflow is counted as suppressed= on the [collect] summary20
COLLECT_ERROR_LOG_MAX_PER_MINDetail [collect-error] log lines allowed per minute; every failure is still counted in err_codes= on the [collect] summary5

DATABASE_URL and LITEMETRICS_ADMIN_SECRET also 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

PackageDescription
@litemetrics/uiPre-built React dashboard components (10 themes, dark mode, CSS variables)
@litemetrics/trackerBrowser tracker (~3 KB gzipped)
@litemetrics/nodeServer collector, ClickHouse/Postgres/MongoDB adapters, query API
@litemetrics/reactReact provider and hooks
@litemetrics/react-nativeReact Native / Expo provider
@litemetrics/clientTyped client for reading analytics data
@litemetrics/cliCLI tool for querying analytics and managing sites
@litemetrics/coreShared types and constants

Apps

AppDescription
@litemetrics/dashboardAnalytics dashboard (React + Vite + Tailwind)
@litemetrics/serverSelf-hosted server (serves dashboard + API)
@litemetrics/mobileNative mobile analytics app (Expo Router + React Native)
@litemetrics/landingLanding 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=auto and a subtype: link_click, outbound_click, file_download, button_click, scroll_depth, rage_click.
  • Link, outbound and download rows carry element_text and element_selector. Outbound rows store the destination as host + path + query, so a wa.me/1555... or api.whatsapp.com/send?phone=... click stays recoverable instead of collapsing to a bare path.
  • A click on an element carrying data-litemetrics-event is 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 _fbp cookie is read (never set) and forwarded only alongside a captured click ID.
  • Manual track() events default to event_source=manual and event_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 isbot list 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 and Accept-Language and Referer clears 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. With TRUST_PROXY on (the default), the per-IP key is the first X-Forwarded-For entry, falling back to X-Real-IP. Unless the proxy in front of the collector overwrites X-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 under standard hides the address's traffic from the default reports — the per-IP counterpart of the forged-visitorId case below. Some hosts document only X-Real-IP as the client address, Railway among them; check what yours sets.
  • Layer 4 (visitor velocity) — sliding-window cap on how many pageviews a single visitorId may 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 per siteId:visitorId, so it is keyed to a visitor rather than to an address: a shared NAT is not pooled by its IP. A visitorId is a device fingerprint for one day, not a person, and the client sends it. The browser tracker derives it from hostname|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 in standard hides that visitor's next pageviews from the default reports and in strict drops 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, raise BOT_VELOCITY_MAX_PAGEVIEWS or run shadow first. 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):

ModeBehavior
offAll filtering disabled
standard (default)Layer 1 drops the event; Layers 2, 3 and 4 flag it (kept in DB, hidden from queries)
strictEvery layer drops the event
shadowEvery 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:

reasonLayerMeaning
empty-uasignature / heuristicNo User-Agent header at all. Usually a misconfigured SDK, not a crawler
ua-signaturesignatureThe UA matched the isbot list. Usually a real crawler — but also catches HTTP client defaults like okhttp/*
no-browser-signalsheuristicBrowser, engine, Accept-Language and Referer were all absent
rate-limitrate-limitThe per-IP sliding window overflowed
velocityvelocityOne 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=-
FieldMeaning
reqs / ok / 3xx / 4xx / 5xxRequests in the minute, split by response status class
abortedRequests 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_maxResponse time in ms (- when the minute saw no requests)
bot_dropped / bot_flaggedBot-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
reasonsDrop reasons for the minute, by count (see the table above)
bot_sitesSites by bot hit count — not request volume, which is reqs
suppressedDetail bot-filter lines withheld by BOT_LOG_MAX_PER_MIN
err_codesTop 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

Development

git clone https://github.com/metehankurucu/litemetrics.git
cd litemetrics
bun install
bun run build

License

MIT

↑↓ navigate openesc close