@litemetrics/node 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @litemetrics/node
2
+
3
+ Self-hosted analytics server for Litemetrics. Includes event collection, query API, site management, GeoIP enrichment, and bot filtering.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @litemetrics/node
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```ts
14
+ import express from 'express';
15
+ import { createCollector } from '@litemetrics/node';
16
+
17
+ const app = express();
18
+ app.use(express.json());
19
+
20
+ const collector = await createCollector({
21
+ db: { url: 'http://localhost:8123' }, // ClickHouse (default)
22
+ adminSecret: 'your-admin-secret',
23
+ geoip: true,
24
+ cors: { origins: [] }, // Allow all origins
25
+ });
26
+
27
+ // Event collection endpoint (receives tracker data)
28
+ app.post('/api/collect', collector.handler());
29
+
30
+ // Query API (stats, time series, retention)
31
+ app.get('/api/stats', collector.queryHandler());
32
+
33
+ // Event & user listing
34
+ app.all('/api/events', collector.eventsHandler());
35
+ app.all('/api/users/*', collector.usersHandler());
36
+
37
+ // Site management (CRUD)
38
+ app.all('/api/sites/*', collector.sitesHandler());
39
+
40
+ // Serve tracker script
41
+ app.use(express.static('node_modules/@litemetrics/tracker/dist'));
42
+
43
+ app.listen(3000);
44
+ ```
45
+
46
+ ## Database Adapters
47
+
48
+ ### ClickHouse (Default)
49
+
50
+ ```ts
51
+ const collector = await createCollector({
52
+ db: { url: 'http://localhost:8123' },
53
+ });
54
+ ```
55
+
56
+ Uses `MergeTree` for events and `ReplacingMergeTree` for sites. Tables are auto-created on init.
57
+
58
+ ### MongoDB
59
+
60
+ ```ts
61
+ const collector = await createCollector({
62
+ db: { adapter: 'mongodb', url: 'mongodb://localhost:27017/litemetrics' },
63
+ });
64
+ ```
65
+
66
+ ## Features
67
+
68
+ - **Event Collection** - Receives batched events from the browser tracker
69
+ - **Bot Filtering** - Automatically drops events from known bots and crawlers
70
+ - **GeoIP Enrichment** - Resolves country/city from IP using MaxMind GeoLite2
71
+ - **User-Agent Parsing** - Extracts browser, OS, and device type
72
+ - **Hostname Filtering** - Only count events from allowed hostnames per site
73
+ - **Query API** - 12 built-in metrics (pageviews, visitors, sessions, top pages, etc.)
74
+ - **Time Series** - Hourly/daily/weekly/monthly breakdowns
75
+ - **Retention Analysis** - Weekly cohort retention
76
+ - **Site Management** - Multi-tenant CRUD with secret key auth
77
+ - **Server-Side Tracking** - Track events and identify users from your backend
78
+
79
+ ## Server-Side Tracking
80
+
81
+ ```ts
82
+ // Track events from your backend
83
+ await collector.track('site-id', 'Purchase', { amount: 99 }, { userId: 'user-123' });
84
+
85
+ // Identify users
86
+ await collector.identify('site-id', 'user-123', { plan: 'pro' });
87
+ ```
88
+
89
+ ## Metrics
90
+
91
+ | Metric | Description |
92
+ |--------|-------------|
93
+ | `pageviews` | Total page views |
94
+ | `visitors` | Unique visitors |
95
+ | `sessions` | Unique sessions |
96
+ | `events` | Custom events count |
97
+ | `top_pages` | Most visited pages |
98
+ | `top_referrers` | Top traffic sources |
99
+ | `top_countries` | Visitors by country |
100
+ | `top_cities` | Visitors by city |
101
+ | `top_events` | Most common custom events |
102
+ | `top_browsers` | Browser breakdown |
103
+ | `top_os` | OS breakdown |
104
+ | `top_devices` | Device type breakdown |
105
+
106
+ ## License
107
+
108
+ MIT
package/dist/index.cjs CHANGED
@@ -32,7 +32,8 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  ClickHouseAdapter: () => ClickHouseAdapter,
34
34
  MongoDBAdapter: () => MongoDBAdapter,
35
- createCollector: () => createCollector
35
+ createCollector: () => createCollector,
36
+ isBot: () => isBot
36
37
  });
37
38
  module.exports = __toCommonJS(index_exports);
38
39
 
@@ -230,6 +231,10 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
230
231
  ORDER BY (site_id)
231
232
  SETTINGS index_granularity = 8192
232
233
  `;
234
+ function toCHDateTime(d) {
235
+ const iso = typeof d === "string" ? d : d.toISOString();
236
+ return iso.replace("T", " ").replace("Z", "");
237
+ }
233
238
  var ClickHouseAdapter = class {
234
239
  client;
235
240
  constructor(url) {
@@ -253,7 +258,7 @@ var ClickHouseAdapter = class {
253
258
  const rows = events.map((e) => ({
254
259
  site_id: e.siteId,
255
260
  type: e.type,
256
- timestamp: new Date(e.timestamp).toISOString(),
261
+ timestamp: toCHDateTime(new Date(e.timestamp)),
257
262
  session_id: e.sessionId,
258
263
  visitor_id: e.visitorId,
259
264
  url: e.url ?? null,
@@ -293,8 +298,8 @@ var ClickHouseAdapter = class {
293
298
  const limit = q.limit ?? 10;
294
299
  const params = {
295
300
  siteId,
296
- from: dateRange.from,
297
- to: dateRange.to,
301
+ from: toCHDateTime(dateRange.from),
302
+ to: toCHDateTime(dateRange.to),
298
303
  limit
299
304
  };
300
305
  let data = [];
@@ -542,8 +547,8 @@ var ClickHouseAdapter = class {
542
547
  }
543
548
  const rows = await this.queryRows(sql, {
544
549
  siteId: params.siteId,
545
- from: dateRange.from,
546
- to: dateRange.to
550
+ from: toCHDateTime(dateRange.from),
551
+ to: toCHDateTime(dateRange.to)
547
552
  });
548
553
  const mappedRows = rows.map((r) => ({
549
554
  _id: this.convertClickHouseBucket(r.bucket, granularity),
@@ -609,7 +614,7 @@ var ClickHouseAdapter = class {
609
614
  GROUP BY visitor_id`,
610
615
  {
611
616
  siteId: params.siteId,
612
- since: startDate.toISOString()
617
+ since: toCHDateTime(startDate)
613
618
  }
614
619
  );
615
620
  const cohortMap = /* @__PURE__ */ new Map();
@@ -676,8 +681,8 @@ var ClickHouseAdapter = class {
676
681
  dateTo: params.dateTo
677
682
  });
678
683
  conditions.push(`timestamp >= {from:String} AND timestamp <= {to:String}`);
679
- queryParams.from = dateRange.from;
680
- queryParams.to = dateRange.to;
684
+ queryParams.from = toCHDateTime(dateRange.from);
685
+ queryParams.to = toCHDateTime(dateRange.to);
681
686
  }
682
687
  const where = conditions.join(" AND ");
683
688
  const [events, countRows] = await Promise.all([
@@ -783,15 +788,17 @@ var ClickHouseAdapter = class {
783
788
  }
784
789
  // ─── Site Management ──────────────────────────────────────
785
790
  async createSite(data) {
786
- const now = (/* @__PURE__ */ new Date()).toISOString();
791
+ const now = /* @__PURE__ */ new Date();
792
+ const nowISO = now.toISOString();
793
+ const nowCH = toCHDateTime(now);
787
794
  const site = {
788
795
  siteId: generateSiteId(),
789
796
  secretKey: generateSecretKey(),
790
797
  name: data.name,
791
798
  domain: data.domain,
792
799
  allowedOrigins: data.allowedOrigins,
793
- createdAt: now,
794
- updatedAt: now
800
+ createdAt: nowISO,
801
+ updatedAt: nowISO
795
802
  };
796
803
  await this.client.insert({
797
804
  table: SITES_TABLE,
@@ -801,8 +808,8 @@ var ClickHouseAdapter = class {
801
808
  name: site.name,
802
809
  domain: site.domain ?? null,
803
810
  allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
804
- created_at: now,
805
- updated_at: now,
811
+ created_at: nowCH,
812
+ updated_at: nowCH,
806
813
  version: 1,
807
814
  is_deleted: 0
808
815
  }],
@@ -847,7 +854,9 @@ var ClickHouseAdapter = class {
847
854
  );
848
855
  if (currentRows.length === 0) return null;
849
856
  const current = currentRows[0];
850
- const now = (/* @__PURE__ */ new Date()).toISOString();
857
+ const now = /* @__PURE__ */ new Date();
858
+ const nowISO = now.toISOString();
859
+ const nowCH = toCHDateTime(now);
851
860
  const newVersion = Number(current.version) + 1;
852
861
  const newName = data.name !== void 0 ? data.name : String(current.name);
853
862
  const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
@@ -860,8 +869,8 @@ var ClickHouseAdapter = class {
860
869
  name: newName,
861
870
  domain: newDomain,
862
871
  allowed_origins: newOrigins,
863
- created_at: String(current.created_at),
864
- updated_at: now,
872
+ created_at: toCHDateTime(String(current.created_at)),
873
+ updated_at: nowCH,
865
874
  version: newVersion,
866
875
  is_deleted: 0
867
876
  }],
@@ -874,7 +883,7 @@ var ClickHouseAdapter = class {
874
883
  domain: newDomain ?? void 0,
875
884
  allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
876
885
  createdAt: String(current.created_at),
877
- updatedAt: now
886
+ updatedAt: nowISO
878
887
  };
879
888
  }
880
889
  async deleteSite(siteId) {
@@ -886,7 +895,7 @@ var ClickHouseAdapter = class {
886
895
  );
887
896
  if (currentRows.length === 0) return false;
888
897
  const current = currentRows[0];
889
- const now = (/* @__PURE__ */ new Date()).toISOString();
898
+ const nowCH = toCHDateTime(/* @__PURE__ */ new Date());
890
899
  await this.client.insert({
891
900
  table: SITES_TABLE,
892
901
  values: [{
@@ -895,8 +904,8 @@ var ClickHouseAdapter = class {
895
904
  name: String(current.name),
896
905
  domain: current.domain ? String(current.domain) : null,
897
906
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
898
- created_at: String(current.created_at),
899
- updated_at: now,
907
+ created_at: toCHDateTime(String(current.created_at)),
908
+ updated_at: nowCH,
900
909
  version: Number(current.version) + 1,
901
910
  is_deleted: 1
902
911
  }],
@@ -913,7 +922,9 @@ var ClickHouseAdapter = class {
913
922
  );
914
923
  if (currentRows.length === 0) return null;
915
924
  const current = currentRows[0];
916
- const now = (/* @__PURE__ */ new Date()).toISOString();
925
+ const now = /* @__PURE__ */ new Date();
926
+ const nowISO = now.toISOString();
927
+ const nowCH = toCHDateTime(now);
917
928
  const newSecret = generateSecretKey();
918
929
  await this.client.insert({
919
930
  table: SITES_TABLE,
@@ -923,8 +934,8 @@ var ClickHouseAdapter = class {
923
934
  name: String(current.name),
924
935
  domain: current.domain ? String(current.domain) : null,
925
936
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
926
- created_at: String(current.created_at),
927
- updated_at: now,
937
+ created_at: toCHDateTime(String(current.created_at)),
938
+ updated_at: nowCH,
928
939
  version: Number(current.version) + 1,
929
940
  is_deleted: 0
930
941
  }],
@@ -937,7 +948,7 @@ var ClickHouseAdapter = class {
937
948
  domain: current.domain ? String(current.domain) : void 0,
938
949
  allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
939
950
  createdAt: String(current.created_at),
940
- updatedAt: now
951
+ updatedAt: nowISO
941
952
  };
942
953
  }
943
954
  // ─── Helpers ─────────────────────────────────────────────
@@ -1700,6 +1711,63 @@ function resolveDeviceType(type) {
1700
1711
  return "desktop";
1701
1712
  }
1702
1713
 
1714
+ // src/botfilter.ts
1715
+ var BOT_PATTERNS = [
1716
+ // Headless browsers
1717
+ /HeadlessChrome/i,
1718
+ /PhantomJS/i,
1719
+ /Selenium/i,
1720
+ /Puppeteer/i,
1721
+ /Playwright/i,
1722
+ // Common bots
1723
+ /bot\b/i,
1724
+ /spider/i,
1725
+ /crawl/i,
1726
+ /slurp/i,
1727
+ /mediapartners/i,
1728
+ /facebookexternalhit/i,
1729
+ /Twitterbot/i,
1730
+ /LinkedInBot/i,
1731
+ /WhatsApp/i,
1732
+ /Discordbot/i,
1733
+ /TelegramBot/i,
1734
+ /Applebot/i,
1735
+ /Baiduspider/i,
1736
+ /YandexBot/i,
1737
+ /DuckDuckBot/i,
1738
+ /Sogou/i,
1739
+ /Exabot/i,
1740
+ /ia_archiver/i,
1741
+ // HTTP libraries & API tools
1742
+ /PostmanRuntime/i,
1743
+ /axios/i,
1744
+ /node-fetch/i,
1745
+ /python-requests/i,
1746
+ /Go-http-client/i,
1747
+ /Java\//i,
1748
+ /libwww-perl/i,
1749
+ /wget/i,
1750
+ /curl/i,
1751
+ /httpie/i,
1752
+ // Monitoring / uptime
1753
+ /UptimeRobot/i,
1754
+ /Pingdom/i,
1755
+ /StatusCake/i,
1756
+ /Site24x7/i,
1757
+ /NewRelic/i,
1758
+ /Datadog/i,
1759
+ // Preview/embed
1760
+ /Slackbot/i,
1761
+ /Embedly/i,
1762
+ /Quora Link Preview/i,
1763
+ /redditbot/i,
1764
+ /Pinterestbot/i
1765
+ ];
1766
+ function isBot(ua) {
1767
+ if (!ua || ua.length === 0) return true;
1768
+ return BOT_PATTERNS.some((re) => re.test(ua));
1769
+ }
1770
+
1703
1771
  // src/collector.ts
1704
1772
  async function createCollector(config) {
1705
1773
  const db = createAdapter(config.db);
@@ -1772,9 +1840,36 @@ async function createCollector(config) {
1772
1840
  sendJson(res, 400, { ok: false, error: "Too many events (max 100)" });
1773
1841
  return;
1774
1842
  }
1775
- const ip = extractIp(req);
1776
1843
  const userAgent = req.headers?.["user-agent"] || "";
1844
+ if (isBot(userAgent)) {
1845
+ sendJson(res, 200, { ok: true });
1846
+ return;
1847
+ }
1848
+ const ip = extractIp(req);
1777
1849
  const enriched = enrichEvents(payload.events, ip, userAgent);
1850
+ const siteId = enriched[0]?.siteId;
1851
+ if (siteId) {
1852
+ const site = await db.getSite(siteId);
1853
+ if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
1854
+ const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
1855
+ const filtered = enriched.filter((event) => {
1856
+ if (!event.url) return true;
1857
+ try {
1858
+ const hostname = new URL(event.url).hostname.toLowerCase();
1859
+ return allowed.has(hostname);
1860
+ } catch {
1861
+ return true;
1862
+ }
1863
+ });
1864
+ if (filtered.length === 0) {
1865
+ sendJson(res, 200, { ok: true });
1866
+ return;
1867
+ }
1868
+ await db.insertEvents(filtered);
1869
+ sendJson(res, 200, { ok: true });
1870
+ return;
1871
+ }
1872
+ }
1778
1873
  await db.insertEvents(enriched);
1779
1874
  sendJson(res, 200, { ok: true });
1780
1875
  } catch (err) {
@@ -2115,6 +2210,7 @@ function sendJson(res, status, body) {
2115
2210
  0 && (module.exports = {
2116
2211
  ClickHouseAdapter,
2117
2212
  MongoDBAdapter,
2118
- createCollector
2213
+ createCollector,
2214
+ isBot
2119
2215
  });
2120
2216
  //# sourceMappingURL=index.cjs.map