@agent-analytics/core 0.1.0

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 ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@agent-analytics/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./ulid": "./src/ulid.js"
9
+ }
10
+ }
package/schema.sql ADDED
@@ -0,0 +1,32 @@
1
+ -- Agent Analytics Core — Full Schema
2
+
3
+ CREATE TABLE IF NOT EXISTS events (
4
+ id TEXT PRIMARY KEY,
5
+ project_id TEXT NOT NULL,
6
+ event TEXT NOT NULL,
7
+ properties TEXT,
8
+ user_id TEXT,
9
+ session_id TEXT,
10
+ timestamp INTEGER NOT NULL,
11
+ date TEXT NOT NULL
12
+ );
13
+
14
+ CREATE INDEX IF NOT EXISTS idx_events_project_date ON events(project_id, date);
15
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
16
+
17
+ CREATE TABLE IF NOT EXISTS sessions (
18
+ session_id TEXT PRIMARY KEY,
19
+ user_id TEXT,
20
+ project_id TEXT NOT NULL,
21
+ start_time INTEGER NOT NULL,
22
+ end_time INTEGER NOT NULL,
23
+ duration INTEGER DEFAULT 0,
24
+ entry_page TEXT,
25
+ exit_page TEXT,
26
+ event_count INTEGER DEFAULT 1,
27
+ is_bounce INTEGER DEFAULT 1,
28
+ date TEXT NOT NULL
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_sessions_project_date ON sessions(project_id, date);
32
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(project_id, user_id);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Database adapter interface
3
+ *
4
+ * All adapters must implement these methods. SQL lives here,
5
+ * not in the handlers. Handlers call semantic methods like
6
+ * db.trackEvent(), db.getStats(), etc.
7
+ */
8
+
9
+ // Shared date helpers
10
+ export function today() {
11
+ return new Date().toISOString().split('T')[0];
12
+ }
13
+
14
+ export function daysAgo(n) {
15
+ const d = new Date();
16
+ d.setDate(d.getDate() - n);
17
+ return d.toISOString().split('T')[0];
18
+ }
19
+
20
+ /**
21
+ * Parse `since` ISO timestamp into a date string (YYYY-MM-DD).
22
+ * Falls back to 7 days ago if missing/invalid.
23
+ */
24
+ export function parseSince(since) {
25
+ if (!since) return daysAgo(7);
26
+ const d = new Date(since);
27
+ if (isNaN(d.getTime())) return daysAgo(7);
28
+ return d.toISOString().split('T')[0];
29
+ }
30
+
31
+ /**
32
+ * Parse `since` into epoch ms (for timestamp-based queries like hourly).
33
+ */
34
+ export function parseSinceMs(since) {
35
+ if (!since) return Date.now() - 7 * 86400000;
36
+ const d = new Date(since);
37
+ if (isNaN(d.getTime())) return Date.now() - 7 * 86400000;
38
+ return d.getTime();
39
+ }
40
+
41
+ /**
42
+ * @typedef {Object} DbAdapter
43
+ * @property {function} trackEvent - Insert a single event
44
+ * @property {function} trackBatch - Insert multiple events
45
+ * @property {function} getStats - Aggregated stats for a project
46
+ * @property {function} getEvents - Raw event query
47
+ * @property {function} query - Flexible analytics query
48
+ * @property {function} getProperties - Discover event names and property keys
49
+ * @property {function} upsertSession - Upsert a session row
50
+ * @property {function} getSessions - List sessions with filters
51
+ * @property {function} getSessionStats - Aggregate session metrics
52
+ * @property {function} cleanupSessions - Delete sessions older than date
53
+ * @property {function} listProjects - List all projects (returns array of {id, name, token, created})
54
+ */
package/src/db/d1.js ADDED
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Cloudflare D1 database adapter
3
+ *
4
+ * Wraps D1 bindings with the standard adapter interface.
5
+ * All SQL queries live here — handlers never touch SQL directly.
6
+ */
7
+
8
+ import { today, daysAgo, parseSince, parseSinceMs } from './adapter.js';
9
+ import { ulid } from '../ulid.js';
10
+
11
+ export function validatePropertyKey(key) {
12
+ if (!key || key.length > 128 || !/^[a-zA-Z0-9_]+$/.test(key)) {
13
+ throw new Error('Invalid property filter key');
14
+ }
15
+ }
16
+
17
+ export class D1Adapter {
18
+ constructor(db) {
19
+ /** @type {import('@cloudflare/workers-types').D1Database} */
20
+ this.db = db;
21
+ }
22
+
23
+ /**
24
+ * Build a session upsert statement for a given event.
25
+ * @private
26
+ */
27
+ _sessionUpsertStmt(project, event_data) {
28
+ const ts = event_data.timestamp || Date.now();
29
+ const date = new Date(ts).toISOString().split('T')[0];
30
+ const page = (event_data.properties && typeof event_data.properties === 'object')
31
+ ? (event_data.properties.path || event_data.properties.url || null)
32
+ : null;
33
+ const count = event_data._count || 1;
34
+ return this.db.prepare(
35
+ `INSERT INTO sessions (session_id, user_id, project_id, start_time, end_time, duration, entry_page, exit_page, event_count, is_bounce, date)
36
+ VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, 1, ?)
37
+ ON CONFLICT(session_id) DO UPDATE SET
38
+ start_time = MIN(sessions.start_time, excluded.start_time),
39
+ end_time = MAX(sessions.end_time, excluded.end_time),
40
+ duration = MAX(sessions.end_time, excluded.end_time) - MIN(sessions.start_time, excluded.start_time),
41
+ entry_page = CASE WHEN excluded.start_time < sessions.start_time THEN excluded.entry_page ELSE sessions.entry_page END,
42
+ exit_page = CASE WHEN excluded.end_time >= sessions.end_time THEN excluded.exit_page ELSE sessions.exit_page END,
43
+ event_count = sessions.event_count + excluded.event_count,
44
+ is_bounce = CASE WHEN sessions.event_count + excluded.event_count > 1 THEN 0 ELSE 1 END`
45
+ ).bind(
46
+ event_data.session_id,
47
+ event_data.user_id || null,
48
+ project,
49
+ ts, ts,
50
+ page, page,
51
+ count,
52
+ date
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Insert a single event + upsert session atomically.
58
+ */
59
+ trackEvent({ project, event, properties, user_id, session_id, timestamp }) {
60
+ const ts = timestamp || Date.now();
61
+ const date = new Date(ts).toISOString().split('T')[0];
62
+ const eventStmt = this.db.prepare(
63
+ `INSERT INTO events (id, project_id, event, properties, user_id, session_id, timestamp, date)
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
65
+ ).bind(
66
+ ulid(),
67
+ project,
68
+ event,
69
+ properties ? JSON.stringify(properties) : null,
70
+ user_id || null,
71
+ session_id || null,
72
+ ts,
73
+ date
74
+ );
75
+
76
+ if (!session_id) {
77
+ return eventStmt.run();
78
+ }
79
+
80
+ const sessionStmt = this._sessionUpsertStmt(project, { session_id, user_id, timestamp: ts, properties });
81
+ return this.db.batch([eventStmt, sessionStmt]);
82
+ }
83
+
84
+ /**
85
+ * Batch insert events + upsert sessions atomically.
86
+ */
87
+ trackBatch(events) {
88
+ const stmts = [];
89
+ const eventInsert = this.db.prepare(
90
+ `INSERT INTO events (id, project_id, event, properties, user_id, session_id, timestamp, date)
91
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
92
+ );
93
+
94
+ // Insert all events
95
+ for (const e of events) {
96
+ const ts = e.timestamp || Date.now();
97
+ const date = new Date(ts).toISOString().split('T')[0];
98
+ stmts.push(eventInsert.bind(
99
+ ulid(),
100
+ e.project,
101
+ e.event,
102
+ e.properties ? JSON.stringify(e.properties) : null,
103
+ e.user_id || null,
104
+ e.session_id || null,
105
+ ts,
106
+ date
107
+ ));
108
+ }
109
+
110
+ // Upsert sessions for each event that has session_id
111
+ for (const e of events) {
112
+ if (!e.session_id) continue;
113
+ const ts = e.timestamp || Date.now();
114
+ stmts.push(this._sessionUpsertStmt(e.project, {
115
+ session_id: e.session_id,
116
+ user_id: e.user_id,
117
+ timestamp: ts,
118
+ properties: e.properties,
119
+ }));
120
+ }
121
+
122
+ return this.db.batch(stmts);
123
+ }
124
+
125
+ /**
126
+ * Upsert a session row directly.
127
+ */
128
+ upsertSession(sessionData) {
129
+ return this._sessionUpsertStmt(sessionData.project_id || sessionData.project, sessionData).run();
130
+ }
131
+
132
+ /**
133
+ * List sessions with optional filters.
134
+ */
135
+ async getSessions({ project, since, user_id, is_bounce, limit = 100 }) {
136
+ const fromDate = parseSince(since);
137
+ const safeLimit = Math.min(limit, 1000);
138
+
139
+ let query = `SELECT * FROM sessions WHERE project_id = ? AND date >= ?`;
140
+ const params = [project, fromDate];
141
+
142
+ if (user_id) {
143
+ query += ` AND user_id = ?`;
144
+ params.push(user_id);
145
+ }
146
+ if (is_bounce !== undefined && is_bounce !== null) {
147
+ query += ` AND is_bounce = ?`;
148
+ params.push(Number(is_bounce));
149
+ }
150
+
151
+ query += ` ORDER BY start_time DESC LIMIT ?`;
152
+ params.push(safeLimit);
153
+
154
+ const result = await this.db.prepare(query).bind(...params).all();
155
+ return result.results;
156
+ }
157
+
158
+ /**
159
+ * Aggregate session metrics for stats endpoint.
160
+ */
161
+ async getSessionStats({ project, since }) {
162
+ const fromDate = parseSince(since);
163
+ const row = await this.db.prepare(
164
+ `SELECT COUNT(*) as total_sessions,
165
+ SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounced_sessions,
166
+ SUM(duration) as total_duration,
167
+ SUM(event_count) as total_events,
168
+ COUNT(DISTINCT user_id) as unique_users
169
+ FROM sessions WHERE project_id = ? AND date >= ?`
170
+ ).bind(project, fromDate).first();
171
+
172
+ const total = row?.total_sessions || 0;
173
+ if (total === 0) {
174
+ return { total_sessions: 0, bounce_rate: 0, avg_duration: 0, pages_per_session: 0, sessions_per_user: 0 };
175
+ }
176
+
177
+ const uniqueUsers = row.unique_users || 1;
178
+ return {
179
+ total_sessions: total,
180
+ bounce_rate: (row.bounced_sessions || 0) / total,
181
+ avg_duration: Math.round((row.total_duration || 0) / total),
182
+ pages_per_session: Math.round(((row.total_events || 0) / total) * 10) / 10,
183
+ sessions_per_user: Math.round((total / uniqueUsers) * 10) / 10,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Delete sessions older than a given date.
189
+ */
190
+ cleanupSessions({ project, before_date }) {
191
+ return this.db.prepare(
192
+ `DELETE FROM sessions WHERE project_id = ? AND date < ?`
193
+ ).bind(project, before_date).run();
194
+ }
195
+
196
+ /**
197
+ * Aggregated stats with configurable time granularity.
198
+ * @param {Object} opts
199
+ * @param {string} opts.project
200
+ * @param {string} [opts.since] - ISO timestamp (default: 7 days ago)
201
+ * @param {string} [opts.groupBy] - hour | day | week | month (default: day)
202
+ */
203
+ async getStats({ project, since, groupBy = 'day' }) {
204
+ const fromDate = parseSince(since);
205
+ const fromMs = parseSinceMs(since);
206
+ const VALID_GROUP = ['hour', 'day', 'week', 'month'];
207
+ if (!VALID_GROUP.includes(groupBy)) groupBy = 'day';
208
+
209
+ // Build the time bucket expression
210
+ let bucketExpr, bucketLabel;
211
+ if (groupBy === 'hour') {
212
+ // Use timestamp (epoch ms) for hourly — gives YYYY-MM-DDTHH:00
213
+ bucketExpr = `strftime('%Y-%m-%dT%H:00', timestamp / 1000, 'unixepoch')`;
214
+ bucketLabel = 'hour';
215
+ } else if (groupBy === 'week') {
216
+ // ISO week start (Monday)
217
+ bucketExpr = `date(date, 'weekday 0', '-6 days')`;
218
+ bucketLabel = 'week';
219
+ } else if (groupBy === 'month') {
220
+ bucketExpr = `strftime('%Y-%m', date)`;
221
+ bucketLabel = 'month';
222
+ } else {
223
+ bucketExpr = `date`;
224
+ bucketLabel = 'date';
225
+ }
226
+
227
+ const timeSeriesQuery = groupBy === 'hour'
228
+ ? `SELECT ${bucketExpr} as bucket, COUNT(DISTINCT user_id) as unique_users, COUNT(*) as total_events
229
+ FROM events WHERE project_id = ? AND timestamp >= ?
230
+ GROUP BY bucket ORDER BY bucket`
231
+ : `SELECT ${bucketExpr} as bucket, COUNT(DISTINCT user_id) as unique_users, COUNT(*) as total_events
232
+ FROM events WHERE project_id = ? AND date >= ?
233
+ GROUP BY bucket ORDER BY bucket`;
234
+
235
+ const bindVal = groupBy === 'hour' ? fromMs : fromDate;
236
+
237
+ const [timeSeries, eventCounts, totals, sessions] = await Promise.all([
238
+ this.db.prepare(timeSeriesQuery).bind(project, bindVal).all(),
239
+
240
+ this.db.prepare(
241
+ `SELECT event, COUNT(*) as count, COUNT(DISTINCT user_id) as unique_users
242
+ FROM events WHERE project_id = ? AND date >= ?
243
+ GROUP BY event ORDER BY count DESC LIMIT 20`
244
+ ).bind(project, fromDate).all(),
245
+
246
+ this.db.prepare(
247
+ `SELECT COUNT(DISTINCT user_id) as unique_users, COUNT(*) as total_events
248
+ FROM events WHERE project_id = ? AND date >= ?`
249
+ ).bind(project, fromDate).first(),
250
+
251
+ this.getSessionStats({ project, since }),
252
+ ]);
253
+
254
+ return {
255
+ period: { from: fromDate, to: today(), groupBy },
256
+ totals,
257
+ timeSeries: timeSeries.results,
258
+ events: eventCounts.results,
259
+ sessions,
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Raw events query with optional event filter.
265
+ */
266
+ async getEvents({ project, event, session_id, since, limit = 100 }) {
267
+ const fromDate = parseSince(since);
268
+ const safeLimit = Math.min(limit, 1000);
269
+
270
+ let query = `SELECT * FROM events WHERE project_id = ? AND date >= ?`;
271
+ const params = [project, fromDate];
272
+
273
+ if (event) {
274
+ query += ` AND event = ?`;
275
+ params.push(event);
276
+ }
277
+
278
+ if (session_id) {
279
+ query += ` AND session_id = ?`;
280
+ params.push(session_id);
281
+ }
282
+
283
+ query += ` ORDER BY timestamp DESC LIMIT ?`;
284
+ params.push(safeLimit);
285
+
286
+ const result = await this.db.prepare(query).bind(...params).all();
287
+
288
+ return result.results.map(e => ({
289
+ ...e,
290
+ properties: e.properties ? JSON.parse(e.properties) : null,
291
+ }));
292
+ }
293
+
294
+ /**
295
+ * Flexible analytics query with metrics, filters, grouping.
296
+ */
297
+ async query({ project, metrics = ['event_count'], filters, date_from, date_to, group_by = [], order_by, order, limit = 100 }) {
298
+ const ALLOWED_METRICS = ['event_count', 'unique_users', 'session_count', 'bounce_rate', 'avg_duration'];
299
+ const ALLOWED_GROUP_BY = ['event', 'date', 'user_id', 'session_id'];
300
+
301
+ for (const m of metrics) {
302
+ if (!ALLOWED_METRICS.includes(m)) throw new Error(`invalid metric: ${m}. allowed: ${ALLOWED_METRICS.join(', ')}`);
303
+ }
304
+ for (const g of group_by) {
305
+ if (!ALLOWED_GROUP_BY.includes(g)) throw new Error(`invalid group_by: ${g}. allowed: ${ALLOWED_GROUP_BY.join(', ')}`);
306
+ }
307
+
308
+ // SELECT
309
+ const selectParts = [...group_by];
310
+ for (const m of metrics) {
311
+ if (m === 'event_count') selectParts.push('COUNT(*) as event_count');
312
+ if (m === 'unique_users') selectParts.push('COUNT(DISTINCT user_id) as unique_users');
313
+ if (m === 'session_count') selectParts.push('COUNT(DISTINCT session_id) as session_count');
314
+ if (m === 'bounce_rate') selectParts.push('COUNT(DISTINCT session_id) as _session_count_for_bounce');
315
+ if (m === 'avg_duration') selectParts.push('COUNT(DISTINCT session_id) as _session_count_for_duration');
316
+ }
317
+ if (selectParts.length === 0) selectParts.push('COUNT(*) as event_count');
318
+
319
+ // WHERE
320
+ const fromDate = date_from || parseSince(null);
321
+ const toDate = date_to || today();
322
+ const whereParts = ['project_id = ?', 'date >= ?', 'date <= ?'];
323
+ const params = [project, fromDate, toDate];
324
+
325
+ // Filters
326
+ if (filters && Array.isArray(filters)) {
327
+ const FILTER_OPS = { eq: '=', neq: '!=', gt: '>', lt: '<', gte: '>=', lte: '<=' };
328
+ const FILTERABLE_FIELDS = ['event', 'user_id', 'date'];
329
+
330
+ for (const f of filters) {
331
+ if (!f.field || !f.op || f.value === undefined) continue;
332
+ const sqlOp = FILTER_OPS[f.op];
333
+ if (!sqlOp) throw new Error(`invalid filter op: ${f.op}. allowed: ${Object.keys(FILTER_OPS).join(', ')}`);
334
+
335
+ if (FILTERABLE_FIELDS.includes(f.field)) {
336
+ whereParts.push(`${f.field} ${sqlOp} ?`);
337
+ params.push(f.value);
338
+ } else if (f.field.startsWith('properties.')) {
339
+ const propKey = f.field.replace('properties.', '');
340
+ validatePropertyKey(propKey);
341
+ whereParts.push(`json_extract(properties, '$.${propKey}') ${sqlOp} ?`);
342
+ params.push(f.value);
343
+ }
344
+ }
345
+ }
346
+
347
+ let sql = `SELECT ${selectParts.join(', ')} FROM events WHERE ${whereParts.join(' AND ')}`;
348
+
349
+ if (group_by.length > 0) sql += ` GROUP BY ${group_by.join(', ')}`;
350
+
351
+ // ORDER
352
+ const ALLOWED_ORDER = ['event_count', 'unique_users', 'date', 'event'];
353
+ const orderField = order_by && ALLOWED_ORDER.includes(order_by) ? order_by : (group_by.includes('date') ? 'date' : 'event_count');
354
+ const orderDir = order === 'asc' ? 'ASC' : 'DESC';
355
+ sql += ` ORDER BY ${orderField} ${orderDir}`;
356
+
357
+ const maxLimit = Math.min(limit, 1000);
358
+ sql += ` LIMIT ?`;
359
+ params.push(maxLimit);
360
+
361
+ const result = await this.db.prepare(sql).bind(...params).all();
362
+
363
+ return {
364
+ period: { from: fromDate, to: toDate },
365
+ metrics,
366
+ group_by,
367
+ rows: result.results,
368
+ count: result.results.length,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Discover event names and property keys for a project.
374
+ */
375
+ /**
376
+ * List all projects (distinct project_ids from events table).
377
+ */
378
+ async listProjects() {
379
+ const result = await this.db.prepare(
380
+ `SELECT project_id as id, MIN(date) as created, MAX(date) as last_active, COUNT(*) as event_count
381
+ FROM events GROUP BY project_id ORDER BY last_active DESC`
382
+ ).all();
383
+ return result.results;
384
+ }
385
+
386
+ async getProperties({ project, since }) {
387
+ const fromDate = parseSince(since);
388
+
389
+ const [events, sample] = await Promise.all([
390
+ this.db.prepare(
391
+ `SELECT event, COUNT(*) as count, COUNT(DISTINCT user_id) as unique_users,
392
+ MIN(date) as first_seen, MAX(date) as last_seen
393
+ FROM events WHERE project_id = ? AND date >= ?
394
+ GROUP BY event ORDER BY count DESC`
395
+ ).bind(project, fromDate).all(),
396
+
397
+ this.db.prepare(
398
+ `SELECT DISTINCT properties FROM events
399
+ WHERE project_id = ? AND properties IS NOT NULL AND date >= ?
400
+ ORDER BY timestamp DESC LIMIT 100`
401
+ ).bind(project, fromDate).all(),
402
+ ]);
403
+
404
+ const propKeys = new Set();
405
+ for (const row of sample.results) {
406
+ try {
407
+ const props = JSON.parse(row.properties);
408
+ Object.keys(props).forEach(k => propKeys.add(k));
409
+ } catch (e) { /* skip malformed JSON */ }
410
+ }
411
+
412
+ return {
413
+ events: events.results,
414
+ property_keys: [...propKeys].sort(),
415
+ };
416
+ }
417
+ }