openvibers/openvibe.live Commits
Analytics: count bot API calls at ingest, not as raw rows (J3) (#615)
Changes
@@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL<!-- versions:start -->- openvibe-contracts: v0.289.1- openvibe-sdk: v0.82.0--- openvibe-shared: v4.112.1++- openvibe-shared: v4.113.0<!-- versions:end -->@@@ -7,7 +7,7 @@"domain": "openvibe.live (www, ingest, whip)","contracts": "openvibe-contracts v0.289.1","sdk": "openvibe-sdk v0.82.0",-- "shared": "openvibe-shared v4.112.1",++ "shared": "openvibe-shared v4.113.0","capabilities": ["live.chat_context.read","live.chat_effects.write",nnew file mode 100644@@@ -0,0 +1,21 @@++-- phase: expand++-- J3 (2026-11-06): bot API calls are counted at ingest instead of written as one analytics_events row each++-- (openvibe-shared's countBotApiAtIngest, server/analytics/store.js). The counters are the hour's and day's totals++-- by endpoint group, status class and bot family; the shared tracker adds them to analytics_hourly and++-- analytics_daily when it computes those, so a day's api_calls, bot_hits and error_count stay what the raw rows++-- gave before.++-- Expand-only: new tables, the existing analytics tables are unchanged. The runtime role creates none.++CREATE TABLE IF NOT EXISTS analytics_bot_api_hourly (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ service text NOT NULL, hour text NOT NULL,++ endpoint_group text NOT NULL, status_class text NOT NULL, bot_family text NOT NULL,++ hits bigint NOT NULL DEFAULT 0,++ UNIQUE (service, hour, endpoint_group, status_class, bot_family)++);++CREATE TABLE IF NOT EXISTS analytics_bot_api_daily (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ service text NOT NULL, date text NOT NULL,++ endpoint_group text NOT NULL, status_class text NOT NULL, bot_family text NOT NULL,++ hits bigint NOT NULL DEFAULT 0,++ UNIQUE (service, date, endpoint_group, status_class, bot_family)++);@@@ -25,7 +25,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.113.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -2884,9 +2884,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.112.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",-- "integrity": "sha512-WuiE0SP+GFh6QDLzbnicjX0Dkgf96d+jXxZDSNfN1tmoEUxYr/2xQDmbGXpNavJjR7fqh5DJ8jSAXgE0KPQfIw==",++ "version": "4.113.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.113.0",++ "integrity": "sha512-sYZ5bwT2YdBCDhckJEEhRUM+dgu35v5cvxAFAugMpxrPMbp31uvGgl+bNQYZsZZya65y+1pyRG44FHQyzgcTcA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -44,7 +44,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.113.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -1,8 +1,9 @@'use strict';/*** Page-view analytics (openvibe-shared/analytics/pg, ADR-021) on Live's own PostgreSQL database (plan T4): the tables-- * are migrations/0001_analytics.sql. record() stays synchronous — the tracker buffers and flushes in the background —-- * so no request waits on an analytics write. A restore drill runs no timers (LIVE_DRILL).++ * are migrations/0001_analytics.sql and, for the ingest counters, 0026_analytics_bot_api_rollups.sql. record() stays++ * synchronous — the tracker buffers and flushes in the background — so no request waits on an analytics write. A++ * restore drill runs no timers (LIVE_DRILL).*/const analyticsModule = require('openvibe-shared/analytics');const { AnalyticsTrackerPg, pruneRawEventsPg } = require('openvibe-shared/analytics/pg');@@@ -19,10 +20,10 @@ function processDb() {});}--// Crawler and bot rows are two thirds of the raw table (2026-10-22: 1.65M of 2.47M rows in 30 days, 1.49M of them bot--// API calls; the table was 1.1 GB, 70% of ov_live). They are counted in the hourly and daily rollups when their day--// closes (the tracker recomputes only today and yesterday), so after a week only the admin's bot breakdown would read--// them: they are kept 7 days, the window its suspicious-sessions check already uses, and people's events 30.++// Bot API calls are counted into the hourly and daily rollups at ingest (countBotApiAtIngest), so no raw row is++// written for them any more (plan J3: they were 1.49M of 2.47M raw rows on 2026-10-22 and about 80k a day on++// 2026-11-06). Page views by bots, and a bot API call the shared tracker could not count, still get a raw row: those++// are kept 7 days, the window the admin's bot breakdown and its suspicious-sessions check use, and people's events 30.const BOT_RAW_DAYS = 7;const DAY_MS = 24 * 60 * 60 * 1000;const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);@@@ -39,13 +40,17 @@ async function pruneBotEvents(db, { days = BOT_RAW_DAYS, now = Date.now, batch =return { removed, cutoff };}--function createAnalyticsStore({ db = processDb(), drill = false, timers = !drill } = {}) {-- const tracker = new AnalyticsTrackerPg(db, 'live', { retention: false, timers });++function createAnalyticsStore({ db = processDb(), drill = false, timers = !drill, countBotApiAtIngest = true } = {}) {++ const tracker = new AnalyticsTrackerPg(db, 'live', { retention: false, timers, countBotApiAtIngest });let closing;return {tracker,-- // The owner's migration made the table; the runtime role has no DDL rights.-- async ready() { await db.value('SELECT 1 FROM analytics_events LIMIT 1'); },++ // The owner's migrations made the tables; the runtime role has no DDL rights.++ async ready() {++ await db.value('SELECT 1 FROM analytics_events LIMIT 1');++ await db.value('SELECT 1 FROM analytics_bot_api_hourly LIMIT 1');++ await db.value('SELECT 1 FROM analytics_bot_api_daily LIMIT 1');++ },async prune() {const all = await pruneRawEventsPg(db, { days: analyticsModule.retention.MAX_DAYS });const bots = await pruneBotEvents(db);nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * J3: Live counts bot API calls at ingest (openvibe-shared's countBotApiAtIngest, server/analytics/store.js), so a++ * bot's API call makes no raw analytics_events row; page views by bots still do, and the option-off path is++ * unchanged. The counters survive the 7-day raw-row prune, and the hour's and day's rollups keep the raw rollup's++ * api_calls, bot_hits and error_count.++ *++ * node test/analytics-bot-ingest.test.js (with the test preload; `ov test` loads it)++ */++const assert = require('assert');++const db = require('../server/db/database');++const { createAnalyticsStore } = require('../server/analytics/store');++++const BOT_UA = 'curl/8.5.0';++const HUMAN_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';++const req = (path, ua, ip) => ({ path, method: 'GET', ip, headers: { 'user-agent': ua }, user: null });++const res = (status = 200) => ({ statusCode: status });++++async function main() {++ await db.initDb();++ const d = db.getDb();++ const store = createAnalyticsStore({ timers: false });++ try {++ await store.ready(); // includes the 0026 counter tables++ const t = store.tracker;++++ assert.strictEqual(t.record(req('/api/j3/streams/abc', BOT_UA, '203.0.113.10'), res(200), '/api/j3/streams/abc', 10), true);++ assert.strictEqual(t.record(req('/api/j3/streams/abc', BOT_UA, '203.0.113.10'), res(404), '/api/j3/streams/abc', 10), true);++ assert.strictEqual(t.record(req('/j3/about', BOT_UA, '203.0.113.10'), res(200), '/j3/about', 10), true);++ assert.strictEqual(t.record(req('/api/j3/vods/xyz', HUMAN_UA, '198.51.100.20'), res(200), '/api/j3/vods/xyz', 10), true);++ await t.flush();++++ const raw = await d.many("SELECT event_type, is_bot FROM analytics_events WHERE service = 'live' ORDER BY id");++ assert.deepStrictEqual(raw.map((r) => [r.event_type, r.is_bot]), [['pageview', 1], ['api_call', 0]], 'no raw row for a bot API call; the bot page view and the person\'s call are rows');++++ const counters = await d.many(`SELECT endpoint_group, status_class, bot_family, SUM(hits)::bigint AS hits++ FROM analytics_bot_api_hourly WHERE service = 'live' GROUP BY 1, 2, 3 ORDER BY 1, 2`);++ assert.deepStrictEqual(counters.map((r) => [r.endpoint_group, r.status_class, r.bot_family, Number(r.hits)]), [++ ['/api/j3/streams', '2xx', 'known_crawler', 1],++ ['/api/j3/streams', '4xx', 'known_crawler', 1],++ ]);++ assert.strictEqual(Number(await d.value("SELECT SUM(hits) FROM analytics_bot_api_daily WHERE service = 'live'")), 2);++++ await t.aggregate();++ const hour = await d.maybe("SELECT pageviews, api_calls, bot_hits, error_count FROM analytics_hourly WHERE service = 'live' ORDER BY hour DESC LIMIT 1");++ assert.deepStrictEqual([hour.pageviews, hour.api_calls, hour.bot_hits, hour.error_count].map(Number), [1, 3, 3, 1], 'the hour keeps the raw rollup\'s counters');++ const day = await d.maybe("SELECT pageviews, api_calls, bot_hits, error_count FROM analytics_daily WHERE service = 'live' ORDER BY date DESC LIMIT 1");++ assert.deepStrictEqual([day.pageviews, day.api_calls, day.bot_hits, day.error_count].map(Number), [1, 3, 3, 1], 'the day too');++++ const stats = await t.getStats({ hours: 6 });++ // A sub-day summary's pageviews are people's only; bots are in total_bot_hits, including the counted API calls.++ assert.deepStrictEqual([stats.summary.total_pageviews, stats.summary.total_bot_hits, stats.summary.total_errors].map(Number), [0, 3, 1]);++ assert.deepStrictEqual(stats.botBreakdown.map((r) => [r.bot_type, Number(r.hits)]), [['known_crawler', 3]]);++ const bots = await t.getBotAnalysis(7);++ assert.deepStrictEqual(bots.botTypes.map((r) => [r.bot_type, Number(r.hits)]), [['known_crawler', 3]]);++ assert.deepStrictEqual(bots.botApi.map((r) => [r.endpoint_group, r.status_class, Number(r.hits)]), [['/api/j3/streams', '2xx', 1], ['/api/j3/streams', '4xx', 1]]);++++ // Option off keeps the old behaviour: a raw row, no counter.++ const off = createAnalyticsStore({ timers: false, countBotApiAtIngest: false });++ try {++ off.tracker.record(req('/api/j3/off', BOT_UA, '203.0.113.30'), res(200), '/api/j3/off', 10);++ await off.tracker.flush();++ } finally { await off.close(); }++ assert.strictEqual(Number(await d.value("SELECT COUNT(*) FROM analytics_events WHERE service = 'live' AND path = '/api/j3/off' AND is_bot = 1")), 1, 'option off writes the raw row');++ assert.strictEqual(Number(await d.value("SELECT SUM(hits) FROM analytics_bot_api_daily WHERE service = 'live'")), 2, 'option off adds no counter');++++ // The counters outlive the raw rows: the 7-day bot prune leaves them, so the admin's families stay readable.++ const tenDaysAgo = new Date(Date.now() - 10 * 86400000).toISOString().replace('T', ' ').slice(0, 19);++ await d.exec("INSERT INTO analytics_events (service, event_type, path, is_bot, created_at) VALUES ('live', 'api_call', '/j3/old-bot', 1, $1)", [tenDaysAgo]);++ await d.exec("INSERT INTO analytics_bot_api_daily (service, date, endpoint_group, status_class, bot_family, hits) VALUES ('live', '2026-10-01', '/j3', '2xx', 'known_crawler', 5)");++ const pruned = await store.prune();++ assert.strictEqual(pruned.bots.removed, 1, JSON.stringify(pruned));++ assert.strictEqual(Number(await d.value("SELECT COUNT(*) FROM analytics_events WHERE path = '/j3/old-bot'")), 0);++ assert.strictEqual(Number(await d.value("SELECT hits FROM analytics_bot_api_daily WHERE date = '2026-10-01' AND endpoint_group = '/j3'")), 5, 'the counter survives the raw prune');++ } finally {++ await store.close();++ }++ console.log('analytics bot ingest: all checks passed');++}++++main().catch((err) => { console.error(err); process.exitCode = 1; });@@@ -23,7 +23,7 @@ async function check(name, fn) {const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';const SUBJECT = 'usr_01J8ZQ4K7M2N3P4Q5R6S7T8V9W';--const ANALYTICS_TABLES = ['analytics_events', 'analytics_hourly', 'analytics_daily', 'analytics_visitor_days', 'analytics_day_salts'];++const ANALYTICS_TABLES = ['analytics_events', 'analytics_hourly', 'analytics_daily', 'analytics_bot_api_hourly', 'analytics_bot_api_daily', 'analytics_visitor_days', 'analytics_day_salts'];(async () => {// ── Path templating ──────────────────────────────────────
@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
-- openvibe-shared: v4.112.1
+- openvibe-shared: v4.113.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.289.1",
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.82.0",
"sdk": "openvibe-sdk v0.82.0",
- "shared": "openvibe-shared v4.112.1",
+ "shared": "openvibe-shared v4.113.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+-- phase: expand
+-- J3 (2026-11-06): bot API calls are counted at ingest instead of written as one analytics_events row each
+-- (openvibe-shared's countBotApiAtIngest, server/analytics/store.js). The counters are the hour's and day's totals
+-- by endpoint group, status class and bot family; the shared tracker adds them to analytics_hourly and
+-- analytics_daily when it computes those, so a day's api_calls, bot_hits and error_count stay what the raw rows
+-- gave before.
+-- Expand-only: new tables, the existing analytics tables are unchanged. The runtime role creates none.
+CREATE TABLE IF NOT EXISTS analytics_bot_api_hourly (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ service text NOT NULL, hour text NOT NULL,
+ endpoint_group text NOT NULL, status_class text NOT NULL, bot_family text NOT NULL,
+ hits bigint NOT NULL DEFAULT 0,
+ UNIQUE (service, hour, endpoint_group, status_class, bot_family)
+);
+CREATE TABLE IF NOT EXISTS analytics_bot_api_daily (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ service text NOT NULL, date text NOT NULL,
+ endpoint_group text NOT NULL, status_class text NOT NULL, bot_family text NOT NULL,
+ hits bigint NOT NULL DEFAULT 0,
+ UNIQUE (service, date, endpoint_group, status_class, bot_family)
+);
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.113.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -2884,9 +2884,9 @@
@@ -2884,9 +2884,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.112.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",
- "integrity": "sha512-WuiE0SP+GFh6QDLzbnicjX0Dkgf96d+jXxZDSNfN1tmoEUxYr/2xQDmbGXpNavJjR7fqh5DJ8jSAXgE0KPQfIw==",
+ "version": "4.113.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.113.0",
+ "integrity": "sha512-sYZ5bwT2YdBCDhckJEEhRUM+dgu35v5cvxAFAugMpxrPMbp31uvGgl+bNQYZsZZya65y+1pyRG44FHQyzgcTcA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.113.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'use strict';
/**
/**
* Page-view analytics (openvibe-shared/analytics/pg, ADR-021) on Live's own PostgreSQL database (plan T4): the tables
* Page-view analytics (openvibe-shared/analytics/pg, ADR-021) on Live's own PostgreSQL database (plan T4): the tables
- * are migrations/0001_analytics.sql. record() stays synchronous — the tracker buffers and flushes in the background —
- * so no request waits on an analytics write. A restore drill runs no timers (LIVE_DRILL).
+ * are migrations/0001_analytics.sql and, for the ingest counters, 0026_analytics_bot_api_rollups.sql. record() stays
+ * synchronous — the tracker buffers and flushes in the background — so no request waits on an analytics write. A
+ * restore drill runs no timers (LIVE_DRILL).
*/
*/
const analyticsModule = require('openvibe-shared/analytics');
const analyticsModule = require('openvibe-shared/analytics');
const { AnalyticsTrackerPg, pruneRawEventsPg } = require('openvibe-shared/analytics/pg');
const { AnalyticsTrackerPg, pruneRawEventsPg } = require('openvibe-shared/analytics/pg');
@@ -19,10 +20,10 @@ function processDb() {
@@ -19,10 +20,10 @@ function processDb() {
});
});
}
}
-// Crawler and bot rows are two thirds of the raw table (2026-10-22: 1.65M of 2.47M rows in 30 days, 1.49M of them bot
-// API calls; the table was 1.1 GB, 70% of ov_live). They are counted in the hourly and daily rollups when their day
-// closes (the tracker recomputes only today and yesterday), so after a week only the admin's bot breakdown would read
-// them: they are kept 7 days, the window its suspicious-sessions check already uses, and people's events 30.
+// Bot API calls are counted into the hourly and daily rollups at ingest (countBotApiAtIngest), so no raw row is
+// written for them any more (plan J3: they were 1.49M of 2.47M raw rows on 2026-10-22 and about 80k a day on
+// 2026-11-06). Page views by bots, and a bot API call the shared tracker could not count, still get a raw row: those
+// are kept 7 days, the window the admin's bot breakdown and its suspicious-sessions check use, and people's events 30.
const BOT_RAW_DAYS = 7;
const BOT_RAW_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
@@ -39,13 +40,17 @@ async function pruneBotEvents(db, { days = BOT_RAW_DAYS, now = Date.now, batch =
@@ -39,13 +40,17 @@ async function pruneBotEvents(db, { days = BOT_RAW_DAYS, now = Date.now, batch =
return { removed, cutoff };
return { removed, cutoff };
}
}
-function createAnalyticsStore({ db = processDb(), drill = false, timers = !drill } = {}) {
- const tracker = new AnalyticsTrackerPg(db, 'live', { retention: false, timers });
+function createAnalyticsStore({ db = processDb(), drill = false, timers = !drill, countBotApiAtIngest = true } = {}) {
+ const tracker = new AnalyticsTrackerPg(db, 'live', { retention: false, timers, countBotApiAtIngest });
let closing;
let closing;
return {
return {
tracker,
tracker,
- // The owner's migration made the table; the runtime role has no DDL rights.
- async ready() { await db.value('SELECT 1 FROM analytics_events LIMIT 1'); },
+ // The owner's migrations made the tables; the runtime role has no DDL rights.
+ async ready() {
+ await db.value('SELECT 1 FROM analytics_events LIMIT 1');
+ await db.value('SELECT 1 FROM analytics_bot_api_hourly LIMIT 1');
+ await db.value('SELECT 1 FROM analytics_bot_api_daily LIMIT 1');
+ },
async prune() {
async prune() {
const all = await pruneRawEventsPg(db, { days: analyticsModule.retention.MAX_DAYS });
const all = await pruneRawEventsPg(db, { days: analyticsModule.retention.MAX_DAYS });
const bots = await pruneBotEvents(db);
const bots = await pruneBotEvents(db);
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * J3: Live counts bot API calls at ingest (openvibe-shared's countBotApiAtIngest, server/analytics/store.js), so a
+ * bot's API call makes no raw analytics_events row; page views by bots still do, and the option-off path is
+ * unchanged. The counters survive the 7-day raw-row prune, and the hour's and day's rollups keep the raw rollup's
+ * api_calls, bot_hits and error_count.
+ *
+ * node test/analytics-bot-ingest.test.js (with the test preload; `ov test` loads it)
+ */
+const assert = require('assert');
+const db = require('../server/db/database');
+const { createAnalyticsStore } = require('../server/analytics/store');
+
+const BOT_UA = 'curl/8.5.0';
+const HUMAN_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
+const req = (path, ua, ip) => ({ path, method: 'GET', ip, headers: { 'user-agent': ua }, user: null });
+const res = (status = 200) => ({ statusCode: status });
+
+async function main() {
+ await db.initDb();
+ const d = db.getDb();
+ const store = createAnalyticsStore({ timers: false });
+ try {
+ await store.ready(); // includes the 0026 counter tables
+ const t = store.tracker;
+
+ assert.strictEqual(t.record(req('/api/j3/streams/abc', BOT_UA, '203.0.113.10'), res(200), '/api/j3/streams/abc', 10), true);
+ assert.strictEqual(t.record(req('/api/j3/streams/abc', BOT_UA, '203.0.113.10'), res(404), '/api/j3/streams/abc', 10), true);
+ assert.strictEqual(t.record(req('/j3/about', BOT_UA, '203.0.113.10'), res(200), '/j3/about', 10), true);
+ assert.strictEqual(t.record(req('/api/j3/vods/xyz', HUMAN_UA, '198.51.100.20'), res(200), '/api/j3/vods/xyz', 10), true);
+ await t.flush();
+
+ const raw = await d.many("SELECT event_type, is_bot FROM analytics_events WHERE service = 'live' ORDER BY id");
+ assert.deepStrictEqual(raw.map((r) => [r.event_type, r.is_bot]), [['pageview', 1], ['api_call', 0]], 'no raw row for a bot API call; the bot page view and the person\'s call are rows');
+
+ const counters = await d.many(`SELECT endpoint_group, status_class, bot_family, SUM(hits)::bigint AS hits
+ FROM analytics_bot_api_hourly WHERE service = 'live' GROUP BY 1, 2, 3 ORDER BY 1, 2`);
+ assert.deepStrictEqual(counters.map((r) => [r.endpoint_group, r.status_class, r.bot_family, Number(r.hits)]), [
+ ['/api/j3/streams', '2xx', 'known_crawler', 1],
+ ['/api/j3/streams', '4xx', 'known_crawler', 1],
+ ]);
+ assert.strictEqual(Number(await d.value("SELECT SUM(hits) FROM analytics_bot_api_daily WHERE service = 'live'")), 2);
+
+ await t.aggregate();
+ const hour = await d.maybe("SELECT pageviews, api_calls, bot_hits, error_count FROM analytics_hourly WHERE service = 'live' ORDER BY hour DESC LIMIT 1");
+ assert.deepStrictEqual([hour.pageviews, hour.api_calls, hour.bot_hits, hour.error_count].map(Number), [1, 3, 3, 1], 'the hour keeps the raw rollup\'s counters');
+ const day = await d.maybe("SELECT pageviews, api_calls, bot_hits, error_count FROM analytics_daily WHERE service = 'live' ORDER BY date DESC LIMIT 1");
+ assert.deepStrictEqual([day.pageviews, day.api_calls, day.bot_hits, day.error_count].map(Number), [1, 3, 3, 1], 'the day too');
+
+ const stats = await t.getStats({ hours: 6 });
+ // A sub-day summary's pageviews are people's only; bots are in total_bot_hits, including the counted API calls.
+ assert.deepStrictEqual([stats.summary.total_pageviews, stats.summary.total_bot_hits, stats.summary.total_errors].map(Number), [0, 3, 1]);
+ assert.deepStrictEqual(stats.botBreakdown.map((r) => [r.bot_type, Number(r.hits)]), [['known_crawler', 3]]);
+ const bots = await t.getBotAnalysis(7);
+ assert.deepStrictEqual(bots.botTypes.map((r) => [r.bot_type, Number(r.hits)]), [['known_crawler', 3]]);
+ assert.deepStrictEqual(bots.botApi.map((r) => [r.endpoint_group, r.status_class, Number(r.hits)]), [['/api/j3/streams', '2xx', 1], ['/api/j3/streams', '4xx', 1]]);
+
+ // Option off keeps the old behaviour: a raw row, no counter.
+ const off = createAnalyticsStore({ timers: false, countBotApiAtIngest: false });
+ try {
+ off.tracker.record(req('/api/j3/off', BOT_UA, '203.0.113.30'), res(200), '/api/j3/off', 10);
+ await off.tracker.flush();
+ } finally { await off.close(); }
+ assert.strictEqual(Number(await d.value("SELECT COUNT(*) FROM analytics_events WHERE service = 'live' AND path = '/api/j3/off' AND is_bot = 1")), 1, 'option off writes the raw row');
+ assert.strictEqual(Number(await d.value("SELECT SUM(hits) FROM analytics_bot_api_daily WHERE service = 'live'")), 2, 'option off adds no counter');
+
+ // The counters outlive the raw rows: the 7-day bot prune leaves them, so the admin's families stay readable.
+ const tenDaysAgo = new Date(Date.now() - 10 * 86400000).toISOString().replace('T', ' ').slice(0, 19);
+ await d.exec("INSERT INTO analytics_events (service, event_type, path, is_bot, created_at) VALUES ('live', 'api_call', '/j3/old-bot', 1, $1)", [tenDaysAgo]);
+ await d.exec("INSERT INTO analytics_bot_api_daily (service, date, endpoint_group, status_class, bot_family, hits) VALUES ('live', '2026-10-01', '/j3', '2xx', 'known_crawler', 5)");
+ const pruned = await store.prune();
+ assert.strictEqual(pruned.bots.removed, 1, JSON.stringify(pruned));
+ assert.strictEqual(Number(await d.value("SELECT COUNT(*) FROM analytics_events WHERE path = '/j3/old-bot'")), 0);
+ assert.strictEqual(Number(await d.value("SELECT hits FROM analytics_bot_api_daily WHERE date = '2026-10-01' AND endpoint_group = '/j3'")), 5, 'the counter survives the raw prune');
+ } finally {
+ await store.close();
+ }
+ console.log('analytics bot ingest: all checks passed');
+}
+
+main().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -23,7 +23,7 @@ async function check(name, fn) {
@@ -23,7 +23,7 @@ async function check(name, fn) {
const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
const SUBJECT = 'usr_01J8ZQ4K7M2N3P4Q5R6S7T8V9W';
const SUBJECT = 'usr_01J8ZQ4K7M2N3P4Q5R6S7T8V9W';
-const ANALYTICS_TABLES = ['analytics_events', 'analytics_hourly', 'analytics_daily', 'analytics_visitor_days', 'analytics_day_salts'];
+const ANALYTICS_TABLES = ['analytics_events', 'analytics_hourly', 'analytics_daily', 'analytics_bot_api_hourly', 'analytics_bot_api_daily', 'analytics_visitor_days', 'analytics_day_salts'];
(async () => {
(async () => {
// ── Path templating ──────────────────────────────────────
// ── Path templating ──────────────────────────────────────