analytics-platform 0.0.0-stage → 0.2.2

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.
@@ -0,0 +1,105 @@
1
+ // Analytics module: what only the server sees for sure (payments, sign-ups,
2
+ // plan changes), next to the browser tracker's visits. Not pageviews: the
3
+ // tracker counts those, and counting them here too would count them twice.
4
+ // - track(): events, batched to POST /events/server-events. Each gets an
5
+ // event_uid, so a retried batch can be told apart from new events.
6
+ // - identify(): links the browser's visit to your user ID (/events/identify).
7
+ // - lead(): adds or updates a lead (/events/lead).
8
+ // Pass `req` (or session_id) to link an event to the visit: the page sends
9
+ // window.analyticsSession.getId() in the x-analytics-session header.
10
+ "use strict";
11
+
12
+ const { randomUUID } = require("node:crypto");
13
+ const { sessionOf } = require("./core.js");
14
+
15
+ const BATCH = 100;
16
+ const QUEUE_MAX = 1000;
17
+ const FLUSH_MS = 2000;
18
+ const RETRY_MS = 15000;
19
+
20
+ function createAnalytics(core) {
21
+ const log = (...args) => core.log("analytics", ...args);
22
+ let queue = [];
23
+ let timer = null;
24
+ let sending = Promise.resolve();
25
+
26
+ function schedule(ms = FLUSH_MS) {
27
+ if (timer || !queue.length) return;
28
+ timer = setTimeout(() => {
29
+ timer = null;
30
+ flush();
31
+ }, ms);
32
+ timer.unref?.();
33
+ }
34
+
35
+ async function sendBatch(events) {
36
+ const res = await core.send("analytics", "/events/server-events", { body: { events } });
37
+ // Not taken: back to the front of the queue (as room allows), tried again later.
38
+ if (!res || res.status === 429 || res.status >= 500) {
39
+ queue = [...events, ...queue].slice(0, QUEUE_MAX);
40
+ schedule(RETRY_MS);
41
+ return;
42
+ }
43
+ if (!res.ok) log("events refused:", await res.text().catch(() => ""));
44
+ }
45
+
46
+ function flush() {
47
+ clearTimeout(timer);
48
+ timer = null;
49
+ const batches = [];
50
+ while (queue.length) batches.push(queue.splice(0, BATCH));
51
+ sending = sending.then(() => Promise.all(batches.map(sendBatch)));
52
+ return sending;
53
+ }
54
+
55
+ // name: the event's name; fields: value, numeric_value, currency, url,
56
+ // event_label, variant, id, session_id, timestamp, event_uid (see the
57
+ // server events docs). Returns the event_uid, or "" when not queued.
58
+ function track(name, fields = {}, { req } = {}) {
59
+ try {
60
+ if (!name || typeof name !== "string" || !core.isOn("analytics")) return "";
61
+ const event = {
62
+ timestamp: new Date().toISOString(),
63
+ session_id: sessionOf(req),
64
+ ...fields,
65
+ event_name: name.slice(0, 100),
66
+ event_uid: fields.event_uid || randomUUID(),
67
+ };
68
+ if (typeof event.numeric_value === "number") event.numeric_value = String(event.numeric_value);
69
+ if (queue.length >= QUEUE_MAX) {
70
+ log("queue full, event dropped");
71
+ return "";
72
+ }
73
+ queue.push(event);
74
+ if (queue.length >= BATCH) flush();
75
+ else schedule();
76
+ return event.event_uid;
77
+ } catch (error) {
78
+ log("could not queue event:", error?.message);
79
+ return "";
80
+ }
81
+ }
82
+
83
+ // Links the visit (req or session_id) to your user ID. Resolves true when stored.
84
+ async function identify(userId, { req, session_id } = {}) {
85
+ const session = session_id || sessionOf(req);
86
+ if (!userId || !session || !core.isOn("analytics")) return false;
87
+ const res = await core.send("analytics", "/events/identify", { body: { user_id: String(userId), session_id: session } });
88
+ return Boolean(res?.ok);
89
+ }
90
+
91
+ // fields: email or phone, and name, status, tags, note, deal fields...
92
+ // (the leads docs). Resolves to { status, lead_id }, or null.
93
+ async function lead(fields = {}, { req } = {}) {
94
+ if (!core.isOn("analytics")) return null;
95
+ const res = await core.send("analytics", "/events/lead", { body: { session_id: sessionOf(req), ...fields } });
96
+ if (!res) return null;
97
+ const data = await res.json().catch(() => null);
98
+ if (!res.ok) log("lead refused:", data?.error || res.status);
99
+ return res.ok ? data : null;
100
+ }
101
+
102
+ return { track, identify, lead, flush, close: flush };
103
+ }
104
+
105
+ module.exports = { createAnalytics };
package/lib/audit.js ADDED
@@ -0,0 +1,57 @@
1
+ // Audit log module: who did what on your site, sent to POST /events/audit
2
+ // in small batches (Security → Audit log). audit("user.role_changed", {
3
+ // actor, target, details, req }); req adds the actor's address and visit.
4
+ // What goes in (names, emails) is up to you. Sending never throws.
5
+ "use strict";
6
+
7
+ const { sessionOf } = require("./core.js");
8
+
9
+ const BATCH = 100;
10
+ const QUEUE_MAX = 1000;
11
+ const FLUSH_MS = 5000;
12
+
13
+ // clientIp(req): the firewall's way of finding the visitor's address.
14
+ function createAudit(core, { clientIp } = {}) {
15
+ let queue = [];
16
+ let timer = null;
17
+ let sending = Promise.resolve();
18
+
19
+ function flush() {
20
+ clearTimeout(timer);
21
+ timer = null;
22
+ const batches = [];
23
+ while (queue.length) batches.push(queue.splice(0, BATCH));
24
+ sending = sending.then(() => Promise.all(batches.map((entries) => core.send("audit", "/events/audit", { body: { entries } }))));
25
+ return sending;
26
+ }
27
+
28
+ // action: what happened, e.g. "user.login", "settings.changed" (letters,
29
+ // numbers and . _ : -). Options: actor (who), target (what it happened
30
+ // to), details (any JSON), req.
31
+ function audit(action, { actor = "", target = "", details, req } = {}) {
32
+ try {
33
+ if (!core.isOn("audit") || typeof action !== "string" || !action) return;
34
+ if (queue.length >= QUEUE_MAX) return core.log("audit", "queue full, entry dropped");
35
+ queue.push({
36
+ at: new Date().toISOString(),
37
+ action: action.slice(0, 64),
38
+ actor: String(actor).slice(0, 200),
39
+ target: String(target).slice(0, 300),
40
+ details,
41
+ actor_ip: req ? (clientIp ? clientIp(req) : req.ip || req.socket?.remoteAddress || "") : "",
42
+ session_id: sessionOf(req),
43
+ });
44
+ if (queue.length >= BATCH) flush();
45
+ else if (!timer) {
46
+ timer = setTimeout(flush, FLUSH_MS);
47
+ timer.unref?.();
48
+ }
49
+ } catch (error) {
50
+ core.log("audit", "could not record:", error?.message);
51
+ }
52
+ }
53
+
54
+ return { audit, flush, close: flush };
55
+ }
56
+
57
+ module.exports = { createAudit };
package/lib/bans.js ADDED
@@ -0,0 +1,78 @@
1
+ // Bot protection on the server itself: an address that floods (over the rate
2
+ // limit) or fails too many logins is banned here straight away, before the
3
+ // next rules arrive; the report that goes back bans it on the site's other
4
+ // servers too (the rules' "banned" list). Also the rules' `probes`: addresses
5
+ // of files no visitor asks for (.env, .git/...). No Node built-ins (edge-safe).
6
+ "use strict";
7
+
8
+ const { parseIp } = require("./ip.js");
9
+
10
+ const MAX = 50000;
11
+
12
+ // One key per address however it's spelled: "1.2.3.4" and "::ffff:1.2.3.4"
13
+ // (and IPv6 written two ways) are the same visitor.
14
+ function keyOf(ip) {
15
+ const address = parseIp(ip);
16
+ return address ? `${address.family}:${address.n}` : String(ip);
17
+ }
18
+
19
+ function createLocalBans() {
20
+ const bans = new Map(); // address -> when the ban ends (ms)
21
+ const logins = new Map(); // address -> { start, count }
22
+
23
+ function banned(address) {
24
+ const ip = keyOf(address);
25
+ const until = bans.get(ip);
26
+ if (!until) return false;
27
+ if (until > Date.now()) return true;
28
+ bans.delete(ip);
29
+ return false;
30
+ }
31
+
32
+ function ban(address, seconds) {
33
+ if (!(seconds > 0)) return;
34
+ const ip = keyOf(address);
35
+ if (bans.size >= MAX) bans.clear();
36
+ bans.set(ip, Math.max(bans.get(ip) || 0, Date.now() + seconds * 1000));
37
+ }
38
+
39
+ // One failed login; login: the rules' { limit, window, ban } (seconds).
40
+ // True when it got the address banned.
41
+ function loginFailed(address, login) {
42
+ if (!login) return false;
43
+ const ip = keyOf(address);
44
+ const now = Date.now();
45
+ let entry = logins.get(ip);
46
+ if (!entry || now - entry.start >= login.window * 1000) {
47
+ if (!entry && logins.size >= MAX) logins.clear();
48
+ entry = { start: now, count: 0 };
49
+ logins.set(ip, entry);
50
+ }
51
+ entry.count += 1;
52
+ if (entry.count < login.limit) return false;
53
+ ban(address, login.ban);
54
+ logins.delete(ip);
55
+ return true;
56
+ }
57
+
58
+ return { banned, ban, loginFailed };
59
+ }
60
+
61
+ // The rules' probes -> { exact: Set, prefix: [...], ban (seconds) }, or null.
62
+ function compileProbes(probes) {
63
+ if (!probes || typeof probes !== "object") return null;
64
+ const paths = (list) => (Array.isArray(list) ? list.filter((p) => typeof p === "string" && p).map((p) => p.toLowerCase()) : []);
65
+ const exact = new Set(paths(probes.exact));
66
+ const prefix = paths(probes.prefix);
67
+ if (!exact.size && !prefix.length) return null;
68
+ return { exact, prefix, ban: Number(probes.ban) > 0 ? Number(probes.ban) : 0 };
69
+ }
70
+
71
+ // True when the request's path (no query, lower-cased, "//" as "/") is a probe.
72
+ function isProbe(path, probes) {
73
+ if (!probes) return false;
74
+ const clean = String(path || "").split(/[?#]/)[0].toLowerCase().replace(/\/{2,}/g, "/");
75
+ return probes.exact.has(clean) || probes.prefix.some((p) => clean.startsWith(p));
76
+ }
77
+
78
+ module.exports = { createLocalBans, compileProbes, isProbe };
package/lib/core.js ADDED
@@ -0,0 +1,122 @@
1
+ // What every module shares: the site's credentials, sending to the
2
+ // collector, logging, and the dashboard's module switches (Site settings →
3
+ // Server, GET /events/platform/config about once a minute). A module the
4
+ // dashboard turns off stays loaded but does nothing; until the switches
5
+ // arrive, or when they can't be fetched, every loaded module stays on.
6
+ // Sending never throws into the app.
7
+ "use strict";
8
+
9
+ const VERSION = "0.2.2";
10
+ const TIMEOUT_MS = 5000;
11
+ const MIN_SYNC_S = 30;
12
+ const RETRY_S = 60;
13
+
14
+ function createCore(options) {
15
+ const siteId = String(options.siteId || "");
16
+ const secret = String(options.secret || "");
17
+ const endpoint = String(options.endpoint || "").replace(/\/+$/, "");
18
+ const valid = /^[a-f0-9]{24}$/i.test(siteId) && Boolean(secret) && Boolean(endpoint);
19
+ const debug = Boolean(options.debug);
20
+ // options.client names the integration for the dashboard (default "node").
21
+ const userAgent = `analytics-platform-${options.client || "node"}/${VERSION}`;
22
+
23
+ const log = (module, ...args) => {
24
+ if (debug) console.warn(`[analytics-platform:${module}]`, ...args);
25
+ };
26
+
27
+ // JSON to the collector. auth: "body" puts site_id and sitesecret in the
28
+ // body (the event endpoints); "bearer" sends the secret as a header (the
29
+ // firewall). Resolves to the Response, or null when it couldn't be sent.
30
+ async function send(module, path, { method = "POST", body, auth = "body", headers = {} } = {}) {
31
+ if (!valid) return null;
32
+ const payload = body === undefined ? undefined
33
+ : JSON.stringify(auth === "body" ? { site_id: siteId, sitesecret: secret, ...body } : body);
34
+ try {
35
+ const res = await fetch(`${endpoint}${path}`, {
36
+ method,
37
+ headers: {
38
+ ...(payload === undefined ? {} : { "content-type": "application/json" }),
39
+ ...(auth === "bearer" ? { authorization: `Bearer ${secret}` } : {}),
40
+ "user-agent": userAgent,
41
+ ...headers,
42
+ },
43
+ body: payload,
44
+ signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
45
+ });
46
+ if (!res.ok && res.status !== 304) log(module, `collector answered ${res.status} to ${path}`);
47
+ return res;
48
+ } catch (error) {
49
+ log(module, `could not reach the collector (${path}):`, error?.message);
50
+ return null;
51
+ }
52
+ }
53
+
54
+ // ---- Module switches ----
55
+ let switches = {};
56
+ let securityHeaders = [];
57
+ let flags = {};
58
+ let maintenance = { on: false };
59
+ let etag = "";
60
+ let timer = null;
61
+ let loaded = [];
62
+
63
+ // False only when the dashboard turned the module off.
64
+ const isOn = (name) => switches[name] !== false;
65
+
66
+ async function syncSwitches() {
67
+ let wait = RETRY_S;
68
+ try {
69
+ const res = await send("core", `/events/platform/config?site_id=${encodeURIComponent(siteId)}`, {
70
+ method: "GET", auth: "bearer", headers: { "x-platform-modules": loaded.join(","), ...(etag ? { "if-none-match": etag } : {}) },
71
+ });
72
+ if (res?.status === 200) {
73
+ const data = await res.json();
74
+ if (data?.v === 1 && data.modules && typeof data.modules === "object") {
75
+ switches = data.modules;
76
+ // Only well-formed headers (no line breaks can get through).
77
+ securityHeaders = (Array.isArray(data.headers) ? data.headers : []).filter((pair) => Array.isArray(pair)
78
+ && /^[A-Za-z][A-Za-z0-9-]{0,63}$/.test(pair[0]) && typeof pair[1] === "string" && !/[\r\n]/.test(pair[1]) && pair[1].length <= 4000);
79
+ flags = data.flags && typeof data.flags === "object" ? data.flags : {};
80
+ maintenance = data.maintenance && typeof data.maintenance === "object" ? data.maintenance : { on: false };
81
+ etag = res.headers.get("etag") || "";
82
+ }
83
+ wait = Math.max(MIN_SYNC_S, Number(data?.sync) || RETRY_S);
84
+ } else if (res?.status === 304) wait = RETRY_S;
85
+ } catch (error) {
86
+ log("core", "could not read module switches:", error?.message);
87
+ }
88
+ if (timer !== false) {
89
+ timer = setTimeout(syncSwitches, wait * 1000);
90
+ timer.unref?.();
91
+ }
92
+ }
93
+
94
+ // Starts fetching the switches; `names` are the modules the code loaded.
95
+ function start(names) {
96
+ loaded = names;
97
+ return valid ? syncSwitches() : Promise.resolve();
98
+ }
99
+
100
+ function stop() {
101
+ clearTimeout(timer);
102
+ timer = false;
103
+ }
104
+
105
+ // The security headers picked on the dashboard, as [name, value] pairs.
106
+ const headers = () => securityHeaders;
107
+
108
+ return {
109
+ siteId, secret, endpoint, valid, debug, log, send, userAgent, version: VERSION, isOn, headers, start, stop,
110
+ flags: () => flags,
111
+ maintenance: () => maintenance,
112
+ };
113
+ }
114
+
115
+ // The browser's visit, when the page passed it on (window.analyticsSession.getId()).
116
+ const SESSION_HEADER = "x-analytics-session";
117
+ function sessionOf(req) {
118
+ const value = req?.headers?.[SESSION_HEADER];
119
+ return typeof value === "string" ? value : "";
120
+ }
121
+
122
+ module.exports = { createCore, sessionOf, VERSION };
@@ -0,0 +1,216 @@
1
+ // Crawlers module: counts the search engines and AI bots that fetch the site
2
+ // and sends one row a minute per bot, path and status to POST /events/crawlers
3
+ // (the protocol is sdk/protocols/crawlers.md). This traffic is invisible to
4
+ // the browser tracker, because crawlers don't run JavaScript: the server is
5
+ // the only place it can be counted.
6
+ //
7
+ // Only requests whose user agent names a known crawler are counted, so
8
+ // visitors are never counted twice. Requests the firewall turned away count
9
+ // too, with the status the crawler got. Nothing about a visitor is sent; bot
10
+ // addresses go with the batch only so the collector can check a crawler is
11
+ // itself (reverse DNS), and are not stored.
12
+ "use strict";
13
+
14
+ const os = require("node:os");
15
+
16
+ const FLUSH_MS = 60 * 1000;
17
+ const MAX_KEYS = 300;
18
+ const MAX_ROWS = 500;
19
+ const MAX_IPS_PER_BOT = 5;
20
+ const MAX_PATH = 400;
21
+ const OTHER = "(other)";
22
+
23
+ // The catalogue of the protocol, most specific first: "ChatGPT-User" must be
24
+ // tried before "GPTBot", "Claude-User" before "ClaudeBot". Keep in step with
25
+ // the collector (expresscollect/Analytics/crawlers.js CATALOGUE).
26
+ const BOTS = [
27
+ // Fetched because a person asked an AI about the page.
28
+ ["ChatGPT-User", /ChatGPT-User/i],
29
+ ["OAI-SearchBot", /OAI-SearchBot/i],
30
+ ["Perplexity-User", /Perplexity-User/i],
31
+ ["Claude-User", /Claude-User/i],
32
+ ["Claude-SearchBot", /Claude-SearchBot/i],
33
+ ["MistralAI-User", /MistralAI-User/i],
34
+ ["MistralAI-SearchBot", /MistralAI-SearchBot/i],
35
+ ["Meta-ExternalFetcher", /meta-externalfetcher/i],
36
+ // Bulk collection, for training or a dataset.
37
+ ["GPTBot", /GPTBot/i],
38
+ ["ClaudeBot", /ClaudeBot|Claude-Web/i],
39
+ ["anthropic-ai", /anthropic-ai/i],
40
+ ["CCBot", /CCBot/i],
41
+ ["Bytespider", /Bytespider/i],
42
+ ["Meta-ExternalAgent", /meta-externalagent|facebookbot/i],
43
+ ["Amazonbot", /Amazonbot/i],
44
+ ["Diffbot", /Diffbot/i],
45
+ ["omgili", /omgili/i],
46
+ ["AI2Bot", /AI2Bot/i],
47
+ ["Timpibot", /Timpibot/i],
48
+ ["Webzio-Extended", /Webzio-Extended/i],
49
+ ["PerplexityBot", /PerplexityBot/i],
50
+ ["DuckAssistBot", /DuckAssistBot/i],
51
+ ["YouBot", /YouBot/i],
52
+ // Search engines. Google's tools before Googlebot itself.
53
+ ["Google-InspectionTool", /Google-InspectionTool/i],
54
+ ["Storebot-Google", /Storebot-Google/i],
55
+ ["AdsBot-Google", /AdsBot-Google/i],
56
+ ["Googlebot", /Googlebot|Google-Site-Verification/i],
57
+ ["bingbot", /bingbot|BingPreview|adidxbot/i],
58
+ ["YandexBot", /Yandex(Bot|Mobile|Images|Webmaster)?/i],
59
+ ["Baiduspider", /Baiduspider/i],
60
+ ["DuckDuckBot", /DuckDuckBot|DuckDuckGo-Favicons/i],
61
+ ["Applebot", /Applebot/i],
62
+ ["PetalBot", /PetalBot|AspiegelBot/i],
63
+ ["SeznamBot", /SeznamBot/i],
64
+ ["Yeti", /Yeti\//i],
65
+ ["MojeekBot", /MojeekBot/i],
66
+ ["Qwantbot", /Qwantbot|Qwantify/i],
67
+ // SEO and backlink tools.
68
+ ["AhrefsBot", /AhrefsBot|AhrefsSiteAudit/i],
69
+ ["SemrushBot", /SemrushBot|SiteAuditBot/i],
70
+ ["DotBot", /DotBot/i],
71
+ ["MJ12bot", /MJ12bot/i],
72
+ ["BLEXBot", /BLEXBot/i],
73
+ ["DataForSeoBot", /DataForSeoBot/i],
74
+ ["Barkrowler", /Barkrowler/i],
75
+ ["ScreamingFrog", /Screaming\s?Frog/i],
76
+ ["ZoominfoBot", /ZoominfoBot/i],
77
+ // Link previews.
78
+ ["facebookexternalhit", /facebookexternalhit/i],
79
+ ["Twitterbot", /Twitterbot/i],
80
+ ["LinkedInBot", /LinkedInBot/i],
81
+ ["Slackbot", /Slackbot|Slack-ImgProxy/i],
82
+ ["Discordbot", /Discordbot/i],
83
+ ["TelegramBot", /TelegramBot/i],
84
+ ["WhatsApp", /WhatsApp/i],
85
+ ["Pinterestbot", /Pinterest(bot)?\//i],
86
+ ["redditbot", /redditbot/i],
87
+ // Uptime and speed checks.
88
+ ["UptimeRobot", /UptimeRobot/i],
89
+ ["Pingdom", /Pingdom/i],
90
+ ["BetterUptime", /Better\s?Uptime|betteruptime/i],
91
+ ["Lighthouse", /Chrome-Lighthouse|Google Page Speed/i],
92
+ ["GTmetrix", /GTmetrix/i],
93
+ // Archives.
94
+ ["ia_archiver", /ia_archiver/i],
95
+ ["archive.org_bot", /archive\.org_bot|Wayback/i],
96
+ ];
97
+
98
+ // Anything else that names itself a bot: counted as "Other bot", so a new
99
+ // crawler shows up as traffic instead of disappearing.
100
+ const GENERIC = /bot\b|crawler|crawling|spider|scrapy|curl\/|wget\/|python-requests|headlesschrome|http-client|okhttp|java\/|go-http-client/i;
101
+
102
+ // The crawler's catalogue name, or "" when the user agent isn't one.
103
+ function detect(userAgent) {
104
+ const ua = String(userAgent || "");
105
+ if (!ua) return "";
106
+ for (const [name, pattern] of BOTS) if (pattern.test(ua)) return name;
107
+ return GENERIC.test(ua) ? "Other bot" : "";
108
+ }
109
+
110
+ // Options: serverName (default the host name), clientIp(req) (the firewall's,
111
+ // so the address is found the same way behind a proxy).
112
+ function createCrawlers(core, options = {}) {
113
+ const log = (...args) => core.log("crawlers", ...args);
114
+ const serverName = String(options.serverName ?? os.hostname()).slice(0, 100);
115
+ const runtime = `Node ${process.versions.node}`;
116
+ const addressOf = typeof options.clientIp === "function"
117
+ ? options.clientIp
118
+ : (req) => req.ip || req.socket?.remoteAddress || "";
119
+ let rows = new Map();
120
+ const keysIn = new Map(); // minute -> rows it has
121
+ let agents = new Map();
122
+ let sending = Promise.resolve();
123
+
124
+ // Records one finished request from a crawler.
125
+ function record({ bot, path, status, bytes = 0, ms = 0, ip = "" }) {
126
+ if (!bot || !core.isOn("crawlers")) return;
127
+ const minute = Math.floor(Date.now() / 60000) * 60000;
128
+ const clean = String(path || "/").split(/[?#]/)[0].slice(0, MAX_PATH) || "/";
129
+ let key = `${minute}|${bot}|${clean}|${status}`;
130
+ // Past MAX_KEYS a crawler asking for thousands of URLs becomes one row.
131
+ // The cap is per minute: rows of a minute not yet sent don't count.
132
+ const used = keysIn.get(minute) || 0;
133
+ if (!rows.has(key) && used >= MAX_KEYS) key = `${minute}|${bot}|${OTHER}|${status}`;
134
+ let row = rows.get(key);
135
+ if (!row) {
136
+ row = { minute, bot, path: key.split("|")[2], status, count: 0, bytes: 0, sum_ms: 0 };
137
+ rows.set(key, row);
138
+ keysIn.set(minute, used + 1);
139
+ }
140
+ row.count += 1;
141
+ row.bytes += Math.max(0, Number(bytes) || 0);
142
+ row.sum_ms += Math.max(0, Number(ms) || 0);
143
+ if (ip) {
144
+ let seen = agents.get(bot);
145
+ if (!seen) agents.set(bot, (seen = new Set()));
146
+ if (seen.size < MAX_IPS_PER_BOT) seen.add(ip);
147
+ }
148
+ }
149
+
150
+ // Express / Connect / node:http middleware. Runs before the firewall, so a
151
+ // crawler that was turned away is still counted, with the status it got.
152
+ function middleware(req, res, next = () => {}) {
153
+ const bot = detect(req.headers?.["user-agent"]);
154
+ if (!bot) return next();
155
+ const start = process.hrtime.bigint();
156
+ res.once("finish", () => {
157
+ try {
158
+ const length = Number(res.getHeader?.("content-length")) || 0;
159
+ record({
160
+ bot,
161
+ path: req.originalUrl || req.url || "/",
162
+ status: res.statusCode || 0,
163
+ bytes: length,
164
+ ms: Number(process.hrtime.bigint() - start) / 1e6,
165
+ ip: addressOf(req),
166
+ });
167
+ } catch (error) {
168
+ log("could not count a crawler request:", error?.message);
169
+ }
170
+ });
171
+ return next();
172
+ }
173
+
174
+ // Sends the rows of finished minutes (all of them with all = true).
175
+ function flush(all = false) {
176
+ const current = Math.floor(Date.now() / 60000) * 60000;
177
+ const ready = [];
178
+ for (const [key, row] of rows) {
179
+ if (all || row.minute < current) {
180
+ ready.push({ ...row, minute: new Date(row.minute).toISOString(), sum_ms: Math.round(row.sum_ms) });
181
+ rows.delete(key);
182
+ }
183
+ }
184
+ for (const minute of keysIn.keys()) if (all || minute < current) keysIn.delete(minute);
185
+ if (!ready.length) return sending;
186
+ const sent = new Set(ready.map((row) => row.bot));
187
+ const batchAgents = [...agents].filter(([bot]) => sent.has(bot)).map(([bot, ips]) => ({ bot, ips: [...ips] }));
188
+ // A bot with rows still waiting (this minute isn't over) keeps its
189
+ // addresses, so the next batch can be checked as well.
190
+ const waiting = new Set([...rows.values()].map((row) => row.bot));
191
+ agents = new Map([...agents].filter(([bot]) => waiting.has(bot)));
192
+ for (let i = 0; i < ready.length; i += MAX_ROWS) {
193
+ const batch = ready.slice(i, i + MAX_ROWS);
194
+ sending = sending.then(() => core.send("crawlers", "/events/crawlers", {
195
+ body: { server_name: serverName, runtime, rows: batch, agents: i === 0 ? batchAgents : [] },
196
+ }));
197
+ }
198
+ return sending;
199
+ }
200
+
201
+ const timer = core.valid ? setInterval(() => flush(), FLUSH_MS) : null;
202
+ timer?.unref?.();
203
+
204
+ return {
205
+ middleware,
206
+ record,
207
+ detect,
208
+ flush: () => flush(true),
209
+ close() {
210
+ clearInterval(timer);
211
+ return flush(true);
212
+ },
213
+ };
214
+ }
215
+
216
+ module.exports = { createCrawlers, detect, BOTS };