webspresso 0.0.52 → 0.0.54
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/package.json +1 -1
- package/plugins/site-analytics/admin-component.js +30 -1
- package/plugins/site-analytics/api-handlers.js +32 -1
- package/plugins/site-analytics/client-error-handler.js +84 -0
- package/plugins/site-analytics/client-error-tracker.js +69 -0
- package/plugins/site-analytics/index.js +28 -1
- package/plugins/site-analytics/tracking.js +44 -4
package/package.json
CHANGED
|
@@ -218,6 +218,7 @@ var AnalyticsPage = {
|
|
|
218
218
|
analyticsApi('top-pages', days),
|
|
219
219
|
analyticsApi('bot-activity', days),
|
|
220
220
|
analyticsApi('countries', days),
|
|
221
|
+
analyticsApi('client-errors', days),
|
|
221
222
|
analyticsApi('recent', days),
|
|
222
223
|
]).then(function(results) {
|
|
223
224
|
vnode.state.stats = results[0];
|
|
@@ -225,7 +226,8 @@ var AnalyticsPage = {
|
|
|
225
226
|
vnode.state.topPages = results[2];
|
|
226
227
|
vnode.state.botActivity = results[3];
|
|
227
228
|
vnode.state.countries = results[4];
|
|
228
|
-
vnode.state.
|
|
229
|
+
vnode.state.clientErrors = results[5];
|
|
230
|
+
vnode.state.recent = results[6];
|
|
229
231
|
vnode.state.loading = false;
|
|
230
232
|
|
|
231
233
|
if (chartInstance) {
|
|
@@ -384,6 +386,33 @@ var AnalyticsPage = {
|
|
|
384
386
|
]),
|
|
385
387
|
]),
|
|
386
388
|
|
|
389
|
+
// Client Errors
|
|
390
|
+
m('div.bg-white.rounded-lg.shadow.mb-6', [
|
|
391
|
+
m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
|
|
392
|
+
m('h3.text-sm.font-semibold.text-gray-900.flex.items-center.gap-2', [
|
|
393
|
+
m('span', '⚠️'),
|
|
394
|
+
'Client Errors',
|
|
395
|
+
]),
|
|
396
|
+
m('span.text-xs.text-gray-400', 'Last ' + s.days + ' days'),
|
|
397
|
+
]),
|
|
398
|
+
m('div.p-4.max-h-64.overflow-y-auto', [
|
|
399
|
+
!s.clientErrors || s.clientErrors.length === 0
|
|
400
|
+
? m('p.text-gray-400.text-sm.text-center.py-6', 'No client errors')
|
|
401
|
+
: s.clientErrors.slice(0, 15).map(function(err) {
|
|
402
|
+
return m('div.border-b.border-gray-50.pb-3.mb-3.last:border-0.last:mb-0.last:pb-0', [
|
|
403
|
+
m('div.flex.items-start.gap-2', [
|
|
404
|
+
m('span.text-xs.px-1.5.py-0.5.rounded.bg-red-100.text-red-700', err.error_type || 'error'),
|
|
405
|
+
m('span.text-xs.text-gray-500', relativeTime(err.created_at)),
|
|
406
|
+
]),
|
|
407
|
+
m('p.text-sm.text-gray-800.font-mono.break-all.mt-1', {
|
|
408
|
+
title: err.stack || err.message,
|
|
409
|
+
}, (err.message || '').slice(0, 120) + (err.message && err.message.length > 120 ? '…' : '')),
|
|
410
|
+
err.path && m('p.text-xs.text-gray-500.mt-0.5', err.path),
|
|
411
|
+
]);
|
|
412
|
+
}),
|
|
413
|
+
]),
|
|
414
|
+
]),
|
|
415
|
+
|
|
387
416
|
// Country Stats
|
|
388
417
|
m('div.bg-white.rounded-lg.shadow.mb-6', [
|
|
389
418
|
m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* @param {string} [options.tableName='analytics_page_views']
|
|
12
12
|
*/
|
|
13
13
|
function createAnalyticsApiHandlers(options) {
|
|
14
|
-
const {
|
|
14
|
+
const {
|
|
15
|
+
knex,
|
|
16
|
+
tableName = 'analytics_page_views',
|
|
17
|
+
errorsTableName = 'analytics_client_errors',
|
|
18
|
+
} = options;
|
|
15
19
|
|
|
16
20
|
function parseDays(req) {
|
|
17
21
|
const days = parseInt(req.query.days) || 30;
|
|
@@ -208,6 +212,32 @@ function createAnalyticsApiHandlers(options) {
|
|
|
208
212
|
}
|
|
209
213
|
}
|
|
210
214
|
|
|
215
|
+
/**
|
|
216
|
+
* GET /client-errors - Recent client-side JS errors
|
|
217
|
+
*/
|
|
218
|
+
async function getClientErrors(req, res) {
|
|
219
|
+
try {
|
|
220
|
+
const days = parseDays(req);
|
|
221
|
+
const since = sinceDate(days);
|
|
222
|
+
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
|
|
223
|
+
|
|
224
|
+
const hasTable = await knex.schema.hasTable(errorsTableName);
|
|
225
|
+
if (!hasTable) {
|
|
226
|
+
return res.json([]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const rows = await knex(errorsTableName)
|
|
230
|
+
.select('id', 'error_type', 'message', 'stack', 'path', 'created_at')
|
|
231
|
+
.where('created_at', '>=', since)
|
|
232
|
+
.orderBy('created_at', 'desc')
|
|
233
|
+
.limit(limit);
|
|
234
|
+
|
|
235
|
+
res.json(rows);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
res.status(500).json({ error: e.message });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
211
241
|
/**
|
|
212
242
|
* GET /recent - Recent page views
|
|
213
243
|
*/
|
|
@@ -233,6 +263,7 @@ function createAnalyticsApiHandlers(options) {
|
|
|
233
263
|
getTopPages,
|
|
234
264
|
getBotActivity,
|
|
235
265
|
getCountries,
|
|
266
|
+
getClientErrors,
|
|
236
267
|
getRecent,
|
|
237
268
|
};
|
|
238
269
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Error Report Handler
|
|
3
|
+
* Receives error reports from browser and stores in DB
|
|
4
|
+
* @module plugins/site-analytics/client-error-handler
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TABLE = 'analytics_client_errors';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create the client errors table
|
|
11
|
+
*/
|
|
12
|
+
async function ensureErrorsTable(knex, tableName = DEFAULT_TABLE) {
|
|
13
|
+
const exists = await knex.schema.hasTable(tableName);
|
|
14
|
+
if (!exists) {
|
|
15
|
+
await knex.schema.createTable(tableName, (table) => {
|
|
16
|
+
table.bigIncrements('id').primary();
|
|
17
|
+
table.string('error_type', 50).index(); // 'error' | 'unhandledrejection'
|
|
18
|
+
table.string('message', 500).index();
|
|
19
|
+
table.text('stack');
|
|
20
|
+
table.string('path', 500).index();
|
|
21
|
+
table.string('referrer', 500);
|
|
22
|
+
table.text('user_agent');
|
|
23
|
+
table.string('source', 500); // script url
|
|
24
|
+
table.integer('line');
|
|
25
|
+
table.integer('column');
|
|
26
|
+
table.timestamp('created_at').defaultTo(knex.fn.now()).index();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create error report handler
|
|
33
|
+
* @param {Object} options
|
|
34
|
+
* @param {Object} options.knex - Knex instance
|
|
35
|
+
* @param {string} [options.tableName='analytics_client_errors']
|
|
36
|
+
*/
|
|
37
|
+
function createErrorReportHandler(options) {
|
|
38
|
+
const { knex, tableName = DEFAULT_TABLE } = options;
|
|
39
|
+
let tableReady = false;
|
|
40
|
+
|
|
41
|
+
return async function errorReportHandler(req, res) {
|
|
42
|
+
if (req.method !== 'POST') {
|
|
43
|
+
return res.status(405).json({ error: 'Method not allowed' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (!tableReady) {
|
|
48
|
+
await ensureErrorsTable(knex, tableName);
|
|
49
|
+
tableReady = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const body = req.body || {};
|
|
53
|
+
const { type, message, stack, path, referrer, userAgent, source, line, column } = body;
|
|
54
|
+
|
|
55
|
+
if (!message && !stack) {
|
|
56
|
+
return res.status(400).json({ error: 'message or stack required' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await knex(tableName).insert({
|
|
60
|
+
error_type: type || 'error',
|
|
61
|
+
message: String(message || '').slice(0, 500),
|
|
62
|
+
stack: stack ? String(stack).slice(0, 5000) : null,
|
|
63
|
+
path: path ? String(path).slice(0, 500) : null,
|
|
64
|
+
referrer: referrer ? String(referrer).slice(0, 500) : null,
|
|
65
|
+
user_agent: userAgent ? String(userAgent).slice(0, 1000) : null,
|
|
66
|
+
source: source ? String(source).slice(0, 500) : null,
|
|
67
|
+
line: line != null ? parseInt(line) : null,
|
|
68
|
+
column: column != null ? parseInt(column) : null,
|
|
69
|
+
created_at: knex.fn.now(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
res.status(202).json({ ok: true });
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.error('[site-analytics] Client error report failed:', e.message);
|
|
75
|
+
res.status(500).json({ error: 'Report failed' });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
createErrorReportHandler,
|
|
82
|
+
ensureErrorsTable,
|
|
83
|
+
DEFAULT_TABLE,
|
|
84
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Error Tracker
|
|
3
|
+
* Generates inline script to catch JS errors and unhandled rejections, reports to backend
|
|
4
|
+
* @module plugins/site-analytics/client-error-tracker
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate client-side error tracking script
|
|
9
|
+
* @param {Object} options
|
|
10
|
+
* @param {string} [options.endpoint='/_analytics/report-error'] - POST endpoint for error reports
|
|
11
|
+
* @returns {string} Inline script content
|
|
12
|
+
*/
|
|
13
|
+
function generateErrorTrackerScript(options = {}) {
|
|
14
|
+
const endpoint = options.endpoint || '/_analytics/report-error';
|
|
15
|
+
|
|
16
|
+
return `
|
|
17
|
+
(function() {
|
|
18
|
+
var endpoint = ${JSON.stringify(endpoint)};
|
|
19
|
+
var reported = {};
|
|
20
|
+
var MAX_STACK = 2000;
|
|
21
|
+
|
|
22
|
+
function report(type, message, stack, extra) {
|
|
23
|
+
var key = type + ':' + (message || '').slice(0, 100);
|
|
24
|
+
if (reported[key]) return;
|
|
25
|
+
reported[key] = true;
|
|
26
|
+
|
|
27
|
+
var payload = {
|
|
28
|
+
type: type,
|
|
29
|
+
message: String(message || 'Unknown error').slice(0, 500),
|
|
30
|
+
stack: stack ? String(stack).slice(0, MAX_STACK) : null,
|
|
31
|
+
path: window.location.pathname || '/',
|
|
32
|
+
referrer: document.referrer || null,
|
|
33
|
+
userAgent: navigator.userAgent || null
|
|
34
|
+
};
|
|
35
|
+
if (extra) {
|
|
36
|
+
for (var k in extra) payload[k] = extra[k];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
fetch(endpoint, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify(payload),
|
|
44
|
+
keepalive: true
|
|
45
|
+
}).catch(function(){});
|
|
46
|
+
} catch (e) {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
window.onerror = function(msg, url, line, col, err) {
|
|
50
|
+
report('error', err ? err.message : msg, err ? err.stack : null, { line: line, column: col, source: url });
|
|
51
|
+
return false;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
window.addEventListener('unhandledrejection', function(e) {
|
|
55
|
+
var msg = e.reason;
|
|
56
|
+
var stack = null;
|
|
57
|
+
if (msg && typeof msg === 'object') {
|
|
58
|
+
stack = msg.stack;
|
|
59
|
+
msg = msg.message || String(msg);
|
|
60
|
+
} else {
|
|
61
|
+
msg = String(msg);
|
|
62
|
+
}
|
|
63
|
+
report('unhandledrejection', msg, stack);
|
|
64
|
+
});
|
|
65
|
+
})();
|
|
66
|
+
`.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { generateErrorTrackerScript };
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
const { createTrackingMiddleware } = require('./tracking');
|
|
8
8
|
const { createAnalyticsApiHandlers } = require('./api-handlers');
|
|
9
9
|
const { generateAnalyticsComponent } = require('./admin-component');
|
|
10
|
+
const { generateErrorTrackerScript } = require('./client-error-tracker');
|
|
11
|
+
const { createErrorReportHandler } = require('./client-error-handler');
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Site Analytics Plugin Factory
|
|
@@ -15,6 +17,10 @@ const { generateAnalyticsComponent } = require('./admin-component');
|
|
|
15
17
|
* @param {string[]} [options.excludePaths=[]] - Extra paths to exclude from tracking
|
|
16
18
|
* @param {boolean} [options.trackBots=true] - Record bot visits (still counted separately)
|
|
17
19
|
* @param {string} [options.tableName='analytics_page_views'] - DB table name
|
|
20
|
+
* @param {number} [options.batchSize=20] - Flush page view queue when it reaches this size
|
|
21
|
+
* @param {number} [options.flushIntervalMs=3000] - Flush interval for low traffic
|
|
22
|
+
* @param {boolean} [options.trackClientErrors=true] - Capture and report client-side JS errors
|
|
23
|
+
* @param {string} [options.errorsTableName='analytics_client_errors'] - Client errors table
|
|
18
24
|
* @returns {Object} Plugin definition
|
|
19
25
|
*/
|
|
20
26
|
function siteAnalyticsPlugin(options = {}) {
|
|
@@ -23,6 +29,8 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
23
29
|
excludePaths = [],
|
|
24
30
|
trackBots = true,
|
|
25
31
|
tableName = 'analytics_page_views',
|
|
32
|
+
trackClientErrors = true,
|
|
33
|
+
errorsTableName = 'analytics_client_errors',
|
|
26
34
|
} = options;
|
|
27
35
|
|
|
28
36
|
if (!db) {
|
|
@@ -48,12 +56,26 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
48
56
|
excludePaths,
|
|
49
57
|
trackBots,
|
|
50
58
|
tableName,
|
|
59
|
+
batchSize: options.batchSize ?? 20,
|
|
60
|
+
flushIntervalMs: options.flushIntervalMs ?? 3000,
|
|
51
61
|
});
|
|
52
62
|
|
|
53
63
|
ctx.app.use(trackingMiddleware);
|
|
64
|
+
|
|
65
|
+
if (trackClientErrors) {
|
|
66
|
+
const script = generateErrorTrackerScript({ endpoint: '/_analytics/report-error' });
|
|
67
|
+
ctx.injectBody(`<script>${script}</script>`, { id: 'site-analytics-error-tracker', priority: 5 });
|
|
68
|
+
}
|
|
54
69
|
},
|
|
55
70
|
|
|
56
71
|
onRoutesReady(ctx) {
|
|
72
|
+
// Client error report endpoint (must be in onRoutesReady - addRoute only mounts there)
|
|
73
|
+
if (trackClientErrors) {
|
|
74
|
+
const knex = db.knex || db;
|
|
75
|
+
const errorHandler = createErrorReportHandler({ knex, tableName: errorsTableName });
|
|
76
|
+
ctx.addRoute('post', '/_analytics/report-error', errorHandler);
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
const adminApi = ctx.usePlugin('admin-panel');
|
|
58
80
|
if (!adminApi) {
|
|
59
81
|
console.warn('[site-analytics] admin-panel plugin not found, skipping admin page registration');
|
|
@@ -61,7 +83,11 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
const knex = db.knex || db;
|
|
64
|
-
const handlers = createAnalyticsApiHandlers({
|
|
86
|
+
const handlers = createAnalyticsApiHandlers({
|
|
87
|
+
knex,
|
|
88
|
+
tableName,
|
|
89
|
+
errorsTableName,
|
|
90
|
+
});
|
|
65
91
|
|
|
66
92
|
adminApi.registerModule({
|
|
67
93
|
id: 'analytics',
|
|
@@ -90,6 +116,7 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
90
116
|
{ method: 'get', path: '/top-pages', handler: handlers.getTopPages },
|
|
91
117
|
{ method: 'get', path: '/bot-activity', handler: handlers.getBotActivity },
|
|
92
118
|
{ method: 'get', path: '/countries', handler: handlers.getCountries },
|
|
119
|
+
{ method: 'get', path: '/client-errors', handler: handlers.getClientErrors },
|
|
93
120
|
{ method: 'get', path: '/recent', handler: handlers.getRecent },
|
|
94
121
|
],
|
|
95
122
|
},
|
|
@@ -36,6 +36,8 @@ function getClientIp(req) {
|
|
|
36
36
|
* @param {string[]} [options.excludePaths] - Paths to exclude from tracking
|
|
37
37
|
* @param {boolean} [options.trackBots=true] - Whether to record bot visits
|
|
38
38
|
* @param {string} [options.tableName='analytics_page_views'] - DB table name
|
|
39
|
+
* @param {number} [options.batchSize=20] - Flush when queue reaches this size
|
|
40
|
+
* @param {number} [options.flushIntervalMs=3000] - Flush interval for low traffic
|
|
39
41
|
*/
|
|
40
42
|
function createTrackingMiddleware(options) {
|
|
41
43
|
const {
|
|
@@ -43,10 +45,14 @@ function createTrackingMiddleware(options) {
|
|
|
43
45
|
excludePaths = [],
|
|
44
46
|
trackBots = true,
|
|
45
47
|
tableName = 'analytics_page_views',
|
|
48
|
+
batchSize = 20,
|
|
49
|
+
flushIntervalMs = 3000,
|
|
46
50
|
} = options;
|
|
47
51
|
|
|
48
52
|
const allExcludes = [...DEFAULT_EXCLUDE, ...excludePaths];
|
|
49
53
|
let tableReady = false;
|
|
54
|
+
const queue = [];
|
|
55
|
+
let flushScheduled = false;
|
|
50
56
|
|
|
51
57
|
async function ensureTable() {
|
|
52
58
|
if (tableReady) return;
|
|
@@ -101,6 +107,37 @@ function createTrackingMiddleware(options) {
|
|
|
101
107
|
return sessionId;
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
async function flushQueue() {
|
|
111
|
+
if (queue.length === 0) return;
|
|
112
|
+
const batch = queue.splice(0, queue.length);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await ensureTable();
|
|
116
|
+
for (const row of batch) {
|
|
117
|
+
row.created_at = knex.fn.now();
|
|
118
|
+
}
|
|
119
|
+
await knex(tableName).insert(batch);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error('[site-analytics] Batch insert failed:', e.message);
|
|
122
|
+
// Re-queue failed items (optional - could drop to avoid loops)
|
|
123
|
+
queue.unshift(...batch);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function scheduleFlush() {
|
|
128
|
+
if (flushScheduled || queue.length === 0) return;
|
|
129
|
+
flushScheduled = true;
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
flushScheduled = false;
|
|
132
|
+
flushQueue().catch(() => {});
|
|
133
|
+
}, flushIntervalMs);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Periodic flush (catches low-traffic case)
|
|
137
|
+
setInterval(() => {
|
|
138
|
+
if (queue.length > 0) flushQueue().catch(() => {});
|
|
139
|
+
}, flushIntervalMs).unref();
|
|
140
|
+
|
|
104
141
|
return async function trackingMiddleware(req, res, next) {
|
|
105
142
|
next();
|
|
106
143
|
|
|
@@ -111,8 +148,6 @@ function createTrackingMiddleware(options) {
|
|
|
111
148
|
if (allExcludes.some(prefix => path.startsWith(prefix))) return;
|
|
112
149
|
|
|
113
150
|
try {
|
|
114
|
-
await ensureTable();
|
|
115
|
-
|
|
116
151
|
const userAgent = req.headers['user-agent'] || '';
|
|
117
152
|
const ip = getClientIp(req);
|
|
118
153
|
const { isBot, botName } = detectBot(userAgent);
|
|
@@ -125,7 +160,7 @@ function createTrackingMiddleware(options) {
|
|
|
125
160
|
const country = detectCountry(req);
|
|
126
161
|
const referrer = req.headers['referer'] || req.headers['referrer'] || null;
|
|
127
162
|
|
|
128
|
-
|
|
163
|
+
queue.push({
|
|
129
164
|
session_id: sessionId,
|
|
130
165
|
visitor_id: visitorId,
|
|
131
166
|
path,
|
|
@@ -135,8 +170,13 @@ function createTrackingMiddleware(options) {
|
|
|
135
170
|
country,
|
|
136
171
|
is_bot: isBot,
|
|
137
172
|
bot_name: botName,
|
|
138
|
-
created_at: knex.fn.now(),
|
|
139
173
|
});
|
|
174
|
+
|
|
175
|
+
if (queue.length >= batchSize) {
|
|
176
|
+
flushQueue().catch(() => {});
|
|
177
|
+
} else {
|
|
178
|
+
scheduleFlush();
|
|
179
|
+
}
|
|
140
180
|
} catch (e) {
|
|
141
181
|
// Non-blocking: don't let tracking errors affect the app
|
|
142
182
|
}
|