@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 +10 -0
- package/schema.sql +32 -0
- package/src/db/adapter.js +54 -0
- package/src/db/d1.js +417 -0
- package/src/handler.js +267 -0
- package/src/index.js +4 -0
- package/src/tracker.js +198 -0
- package/src/ulid.js +28 -0
- package/test/sessions.test.mjs +494 -0
- package/test/sqli-propkey.test.mjs +38 -0
package/package.json
ADDED
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
|
+
}
|