@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/src/handler.js ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Core analytics handler — platform-agnostic.
3
+ *
4
+ * Consumers provide { db, validateWrite, validateRead } to plug in
5
+ * their own auth and database layer.
6
+ */
7
+
8
+ import { TRACKER_JS } from './tracker.js';
9
+
10
+ const CORS_HEADERS = {
11
+ 'Access-Control-Allow-Origin': '*',
12
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
13
+ 'Access-Control-Allow-Headers': 'Content-Type, X-API-Key',
14
+ 'Access-Control-Allow-Credentials': 'false',
15
+ };
16
+
17
+ function json(data, status = 200) {
18
+ return new Response(JSON.stringify(data), {
19
+ status,
20
+ headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
21
+ });
22
+ }
23
+
24
+ /**
25
+ * Create an analytics request handler.
26
+ *
27
+ * @param {Object} opts
28
+ * @param {import('./db/adapter.js').DbAdapter} opts.db
29
+ * @param {(request: Request, body: any) => { valid: boolean, error?: string }} opts.validateWrite — required
30
+ * @param {(request: Request, url: URL) => { valid: boolean }} opts.validateRead — required
31
+ * @param {boolean} [opts.useQueue=false]
32
+ * @param {Object} [opts.healthExtra={}]
33
+ * @returns {(request: Request) => Promise<{ response: Response, writeOps?: Promise[], queueMessages?: any[] }>}
34
+ */
35
+ export function createAnalyticsHandler({ db, validateWrite, validateRead, useQueue = false, healthExtra = {} }) {
36
+ if (!validateWrite) throw new Error('validateWrite is required — provide an auth function for write endpoints');
37
+ if (!validateRead) throw new Error('validateRead is required — provide an auth function for read endpoints');
38
+
39
+ return async function handleRequest(request) {
40
+ const url = new URL(request.url);
41
+ const path = url.pathname;
42
+
43
+ // CORS preflight
44
+ if (request.method === 'OPTIONS') {
45
+ return { response: new Response(null, { headers: CORS_HEADERS }) };
46
+ }
47
+
48
+ try {
49
+ // POST /track
50
+ if (path === '/track' && request.method === 'POST') {
51
+ return await handleTrack(request, db, validateWrite, useQueue);
52
+ }
53
+
54
+ // POST /track/batch
55
+ if (path === '/track/batch' && request.method === 'POST') {
56
+ return await handleTrackBatch(request, db, validateWrite, useQueue);
57
+ }
58
+
59
+ // GET /projects
60
+ if (path === '/projects' && request.method === 'GET') {
61
+ return await handleListProjects(request, url, db, validateRead);
62
+ }
63
+
64
+ // GET /stats
65
+ if (path === '/stats' && request.method === 'GET') {
66
+ return await handleStats(request, url, db, validateRead);
67
+ }
68
+
69
+ // GET /sessions
70
+ if (path === '/sessions' && request.method === 'GET') {
71
+ return await handleSessions(request, url, db, validateRead);
72
+ }
73
+
74
+ // GET /events
75
+ if (path === '/events' && request.method === 'GET') {
76
+ return await handleEvents(request, url, db, validateRead);
77
+ }
78
+
79
+ // POST /query
80
+ if (path === '/query' && request.method === 'POST') {
81
+ return await handleQuery(request, url, db, validateRead);
82
+ }
83
+
84
+ // GET /properties
85
+ if (path === '/properties' && request.method === 'GET') {
86
+ return await handleProperties(request, url, db, validateRead);
87
+ }
88
+
89
+ // GET /health
90
+ if (path === '/health') {
91
+ return { response: json({ status: 'ok', service: 'agent-analytics', ...healthExtra }) };
92
+ }
93
+
94
+ // GET /tracker.js
95
+ if (path === '/tracker.js') {
96
+ return {
97
+ response: new Response(TRACKER_JS, {
98
+ headers: { 'Content-Type': 'application/javascript', ...CORS_HEADERS },
99
+ }),
100
+ };
101
+ }
102
+
103
+ return { response: json({ error: 'not found' }, 404) };
104
+ } catch (err) {
105
+ console.error('Error:', err);
106
+ return { response: json({ error: 'internal error' }, 500) };
107
+ }
108
+ };
109
+ }
110
+
111
+ // --- Individual handlers ---
112
+
113
+ async function handleTrack(request, db, validateWrite, useQueue) {
114
+ const body = await request.json();
115
+ const { project, event, properties, user_id, session_id, timestamp } = body;
116
+
117
+ if (!project || !event) {
118
+ return { response: json({ error: 'project and event required' }, 400) };
119
+ }
120
+
121
+ const auth = validateWrite(request, body);
122
+ if (!auth.valid) {
123
+ return { response: json({ error: auth.error || 'forbidden' }, 403) };
124
+ }
125
+
126
+ const eventData = { project, event, properties, user_id, session_id, timestamp: timestamp || Date.now() };
127
+
128
+ if (useQueue) {
129
+ return { response: json({ ok: true }), queueMessages: [eventData] };
130
+ }
131
+
132
+ const writeOp = db.trackEvent(eventData)
133
+ .catch(err => console.error('Track write failed:', err));
134
+
135
+ return { response: json({ ok: true }), writeOps: [writeOp] };
136
+ }
137
+
138
+ async function handleTrackBatch(request, db, validateWrite, useQueue) {
139
+ const body = await request.json();
140
+ const { events } = body;
141
+
142
+ if (!Array.isArray(events) || events.length === 0) {
143
+ return { response: json({ error: 'events array required' }, 400) };
144
+ }
145
+ if (events.length > 100) {
146
+ return { response: json({ error: 'max 100 events per batch' }, 400) };
147
+ }
148
+
149
+ const auth = validateWrite(request, body);
150
+ if (!auth.valid) {
151
+ return { response: json({ error: auth.error || 'forbidden' }, 403) };
152
+ }
153
+
154
+ const normalized = events.map(e => ({
155
+ project: e.project,
156
+ event: e.event,
157
+ properties: e.properties,
158
+ user_id: e.user_id,
159
+ session_id: e.session_id,
160
+ timestamp: e.timestamp || Date.now(),
161
+ }));
162
+
163
+ if (useQueue) {
164
+ return { response: json({ ok: true, count: events.length }), queueMessages: normalized };
165
+ }
166
+
167
+ const writeOp = db.trackBatch(normalized)
168
+ .catch(err => console.error('Batch write failed:', err));
169
+
170
+ return { response: json({ ok: true, count: events.length }), writeOps: [writeOp] };
171
+ }
172
+
173
+ async function handleListProjects(request, url, db, validateRead) {
174
+ const auth = validateRead(request, url);
175
+ if (!auth.valid) {
176
+ return { response: json({ error: 'unauthorized - API key required' }, 401) };
177
+ }
178
+
179
+ const projects = await db.listProjects();
180
+ return { response: json({ projects }) };
181
+ }
182
+
183
+ async function handleStats(request, url, db, validateRead) {
184
+ const auth = validateRead(request, url);
185
+ if (!auth.valid) {
186
+ return { response: json({ error: 'unauthorized - API key required' }, 401) };
187
+ }
188
+
189
+ const project = url.searchParams.get('project');
190
+ if (!project) return { response: json({ error: 'project required' }, 400) };
191
+
192
+ const since = url.searchParams.get('since') || undefined;
193
+ const groupBy = url.searchParams.get('groupBy') || 'day';
194
+ const stats = await db.getStats({ project, since, groupBy });
195
+
196
+ return { response: json({ project, ...stats }) };
197
+ }
198
+
199
+ async function handleEvents(request, url, db, validateRead) {
200
+ const auth = validateRead(request, url);
201
+ if (!auth.valid) {
202
+ return { response: json({ error: 'unauthorized - API key required' }, 401) };
203
+ }
204
+
205
+ const project = url.searchParams.get('project');
206
+ if (!project) return { response: json({ error: 'project required' }, 400) };
207
+
208
+ const event = url.searchParams.get('event');
209
+ const session_id = url.searchParams.get('session_id');
210
+ const since = url.searchParams.get('since') || undefined;
211
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit')) || 100, 1), 1000);
212
+
213
+ const events = await db.getEvents({ project, event, session_id, since, limit });
214
+ return { response: json({ project, events }) };
215
+ }
216
+
217
+ async function handleQuery(request, url, db, validateRead) {
218
+ const auth = validateRead(request, url);
219
+ if (!auth.valid) {
220
+ return { response: json({ error: 'unauthorized - API key required' }, 401) };
221
+ }
222
+
223
+ const body = await request.json();
224
+ if (!body.project) return { response: json({ error: 'project required' }, 400) };
225
+
226
+ try {
227
+ const result = await db.query(body);
228
+ return { response: json({ project: body.project, ...result }) };
229
+ } catch (err) {
230
+ console.error('Query error:', err);
231
+ return { response: json({ error: 'query failed' }, 400) };
232
+ }
233
+ }
234
+
235
+ async function handleSessions(request, url, db, validateRead) {
236
+ const auth = validateRead(request, url);
237
+ if (!auth.valid) {
238
+ return { response: json({ error: 'unauthorized - API key required' }, 401) };
239
+ }
240
+
241
+ const project = url.searchParams.get('project');
242
+ if (!project) return { response: json({ error: 'project required' }, 400) };
243
+
244
+ const since = url.searchParams.get('since') || undefined;
245
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit')) || 100, 1), 1000);
246
+ const user_id = url.searchParams.get('user_id');
247
+ const is_bounce_raw = url.searchParams.get('is_bounce');
248
+ const is_bounce = is_bounce_raw !== null ? Number(is_bounce_raw) : undefined;
249
+
250
+ const sessions = await db.getSessions({ project, since, user_id, is_bounce, limit });
251
+ return { response: json({ project, sessions }) };
252
+ }
253
+
254
+ async function handleProperties(request, url, db, validateRead) {
255
+ const auth = validateRead(request, url);
256
+ if (!auth.valid) {
257
+ return { response: json({ error: 'unauthorized - API key required' }, 401) };
258
+ }
259
+
260
+ const project = url.searchParams.get('project');
261
+ if (!project) return { response: json({ error: 'project required' }, 400) };
262
+
263
+ const since = url.searchParams.get('since') || undefined;
264
+ const result = await db.getProperties({ project, since });
265
+
266
+ return { response: json({ project, ...result }) };
267
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createAnalyticsHandler } from './handler.js'
2
+ export { D1Adapter, validatePropertyKey } from './db/d1.js'
3
+ export { today, daysAgo, parseSince, parseSinceMs } from './db/adapter.js'
4
+ export { TRACKER_JS } from './tracker.js'
package/src/tracker.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Embedded tracker.js - client-side analytics snippet
3
+ * Served as plain JavaScript from GET /tracker.js
4
+ *
5
+ * Auth token is passed in the request body for server-side validation.
6
+ * No custom headers = no CORS preflight = zero issues.
7
+ */
8
+ export const TRACKER_JS = `
9
+ (function() {
10
+ 'use strict';
11
+
12
+ var script = document.currentScript;
13
+ var ENDPOINT = script && script.src
14
+ ? new URL(script.src).origin + '/track'
15
+ : '/track';
16
+ var PROJECT = (script && script.dataset.project) || 'default';
17
+ var TOKEN = (script && script.dataset.token) || null;
18
+
19
+ // --- Anon ID ---
20
+ function getAnonId() {
21
+ var id = localStorage.getItem('aa_uid');
22
+ if (!id) {
23
+ id = 'anon_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
24
+ localStorage.setItem('aa_uid', id);
25
+ }
26
+ return id;
27
+ }
28
+ var userId = getAnonId();
29
+
30
+ // --- Session ID (30min inactivity timeout) ---
31
+ var SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
32
+ function getSessionId() {
33
+ var now = Date.now();
34
+ var lastActivity = parseInt(sessionStorage.getItem('aa_last_activity') || '0', 10);
35
+ var sid = sessionStorage.getItem('aa_sid');
36
+ if (!sid || (lastActivity && (now - lastActivity) > SESSION_TIMEOUT)) {
37
+ sid = 'sess_' + Math.random().toString(36).substr(2, 9) + now.toString(36);
38
+ sessionStorage.setItem('aa_sid', sid);
39
+ }
40
+ sessionStorage.setItem('aa_last_activity', String(now));
41
+ return sid;
42
+ }
43
+
44
+ // --- UTM params ---
45
+ function getUtm() {
46
+ var p = new URLSearchParams(location.search);
47
+ var u = {}, keys = ['utm_source','utm_medium','utm_campaign','utm_content','utm_term'];
48
+ for (var i = 0; i < keys.length; i++) {
49
+ var v = p.get(keys[i]);
50
+ if (v) u[keys[i]] = v;
51
+ }
52
+ return u;
53
+ }
54
+ var utm = getUtm();
55
+
56
+ // --- Browser & OS detection ---
57
+ function detect(ua) {
58
+ var b = 'Unknown', bv = '', os = 'Unknown';
59
+ var m;
60
+ if (m = ua.match(/(Edge|Edg)\\/([\\d.]+)/)) { b = 'Edge'; bv = m[2]; }
61
+ else if (m = ua.match(/OPR\\/([\\d.]+)/)) { b = 'Opera'; bv = m[1]; }
62
+ else if (m = ua.match(/Chrome\\/([\\d.]+)/)) { b = 'Chrome'; bv = m[1]; }
63
+ else if (m = ua.match(/Safari\\/([\\d.]+)/) ) {
64
+ if (m = ua.match(/Version\\/([\\d.]+)/)) { b = 'Safari'; bv = m[1]; }
65
+ }
66
+ else if (m = ua.match(/Firefox\\/([\\d.]+)/)) { b = 'Firefox'; bv = m[1]; }
67
+
68
+ if (/iPhone|iPad|iPod/.test(ua)) os = 'iOS';
69
+ else if (/Windows/.test(ua)) os = 'Windows';
70
+ else if (/Android/.test(ua)) os = 'Android';
71
+ else if (/CrOS/.test(ua)) os = 'ChromeOS';
72
+ else if (/Mac OS X/.test(ua)) os = 'macOS';
73
+ else if (/Linux/.test(ua)) os = 'Linux';
74
+
75
+ return { browser: b, browser_version: bv.split('.')[0], os: os };
76
+ }
77
+ var ua = navigator.userAgent || '';
78
+ var dev = detect(ua);
79
+
80
+ // --- Device type ---
81
+ function deviceType() {
82
+ var w = screen.width;
83
+ if (/Tablet|iPad/i.test(ua) || (w >= 768 && w < 1024 && !/Mobi/i.test(ua))) return 'tablet';
84
+ if (/Mobi/i.test(ua) || w < 768) return 'mobile';
85
+ return 'desktop';
86
+ }
87
+ dev.device = deviceType();
88
+
89
+ // --- Event queue ---
90
+ var queue = [];
91
+ var flushTimer = null;
92
+
93
+ function send(url, data) {
94
+ if (navigator.sendBeacon) {
95
+ if (navigator.sendBeacon(url, new Blob([data], {type: 'text/plain'}))) return;
96
+ }
97
+ fetch(url, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: data,
101
+ keepalive: true,
102
+ credentials: 'omit'
103
+ }).catch(function() {});
104
+ }
105
+
106
+ function flush() {
107
+ if (!queue.length) return;
108
+ var batch = queue.splice(0);
109
+ if (batch.length === 1) {
110
+ send(ENDPOINT, JSON.stringify(batch[0]));
111
+ } else {
112
+ send(ENDPOINT.replace('/track', '/track/batch'), JSON.stringify({ events: batch }));
113
+ }
114
+ }
115
+
116
+ function scheduleFlush() {
117
+ if (!flushTimer) flushTimer = setTimeout(function() { flushTimer = null; flush(); }, 5000);
118
+ }
119
+
120
+ // Flush on visibility hidden / beforeunload
121
+ document.addEventListener('visibilitychange', function() {
122
+ if (document.visibilityState === 'hidden') flush();
123
+ });
124
+ window.addEventListener('beforeunload', flush);
125
+
126
+ // --- Common properties ---
127
+ function baseProps(extra) {
128
+ var p = {
129
+ url: location.href,
130
+ path: location.pathname,
131
+ hostname: location.hostname,
132
+ referrer: document.referrer,
133
+ title: document.title,
134
+ screen: screen.width + 'x' + screen.height,
135
+ language: navigator.language || '',
136
+ browser: dev.browser,
137
+ browser_version: dev.browser_version,
138
+ os: dev.os,
139
+ device: dev.device
140
+ };
141
+ // Merge UTM
142
+ for (var k in utm) p[k] = utm[k];
143
+ // Merge extra
144
+ if (extra) for (var k2 in extra) p[k2] = extra[k2];
145
+ return p;
146
+ }
147
+
148
+ var aa = {
149
+ track: function(event, properties) {
150
+ queue.push({
151
+ project: PROJECT,
152
+ token: TOKEN,
153
+ event: event,
154
+ properties: baseProps(properties),
155
+ user_id: userId,
156
+ session_id: getSessionId(),
157
+ timestamp: Date.now()
158
+ });
159
+ scheduleFlush();
160
+ },
161
+
162
+ identify: function(id) {
163
+ userId = id;
164
+ localStorage.setItem('aa_uid', id);
165
+ },
166
+
167
+ page: function(name) {
168
+ this.track('page_view', { page: name || document.title });
169
+ }
170
+ };
171
+
172
+ // --- SPA route tracking ---
173
+ var lastPath = location.pathname + location.search;
174
+ function onRoute() {
175
+ var cur = location.pathname + location.search;
176
+ if (cur !== lastPath) {
177
+ lastPath = cur;
178
+ utm = getUtm(); // re-parse UTM on navigation
179
+ aa.page();
180
+ }
181
+ }
182
+ window.addEventListener('popstate', onRoute);
183
+ // Monkey-patch pushState / replaceState
184
+ ['pushState', 'replaceState'].forEach(function(fn) {
185
+ var orig = history[fn];
186
+ history[fn] = function() {
187
+ var r = orig.apply(this, arguments);
188
+ onRoute();
189
+ return r;
190
+ };
191
+ });
192
+
193
+ // Auto track initial page view
194
+ aa.page();
195
+
196
+ window.aa = aa;
197
+ })();
198
+ `;
package/src/ulid.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Minimal ULID generator — zero dependencies.
3
+ * Time-sortable, 26 chars, Crockford Base32.
4
+ */
5
+
6
+ const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
7
+
8
+ function encodeTime(now, len) {
9
+ let str = '';
10
+ for (let i = len; i > 0; i--) {
11
+ str = ENCODING[now % 32] + str;
12
+ now = Math.floor(now / 32);
13
+ }
14
+ return str;
15
+ }
16
+
17
+ function encodeRandom(len) {
18
+ let str = '';
19
+ const bytes = crypto.getRandomValues(new Uint8Array(len));
20
+ for (let i = 0; i < len; i++) {
21
+ str += ENCODING[bytes[i] % 32];
22
+ }
23
+ return str;
24
+ }
25
+
26
+ export function ulid() {
27
+ return encodeTime(Date.now(), 10) + encodeRandom(16);
28
+ }