@agentuity/analytics 3.0.0-alpha.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/beacon.ts ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Analytics beacon - auto-initializing script
3
+ *
4
+ * Import this module to automatically start tracking:
5
+ * ```typescript
6
+ * import '@agentuity/analytics/beacon';
7
+ * ```
8
+ *
9
+ * Requires window.__AGENTUITY_ANALYTICS__ to be set by server.
10
+ */
11
+
12
+ import { isEnabled, getConfig, isDevmode } from './config';
13
+ import {
14
+ initClient,
15
+ updatePageView,
16
+ resetSession,
17
+ send,
18
+ track,
19
+ setupGlobal,
20
+ getPageView,
21
+ } from './client';
22
+ import { generateId, stripQueryString, getUTMParams, fetchGeo } from './util';
23
+ import type { PageViewData } from './types';
24
+
25
+ // Track if already initialized
26
+ let initialized = false;
27
+
28
+ /**
29
+ * Initialize page view data
30
+ */
31
+ function initPageView(): PageViewData {
32
+ const pv: PageViewData = {
33
+ id: generateId(),
34
+ timestamp: Date.now(),
35
+ timezone_offset: new Date().getTimezoneOffset(),
36
+ url: stripQueryString(location.href),
37
+ path: location.pathname,
38
+ referrer: stripQueryString(document.referrer),
39
+ title: document.title || '',
40
+ screen_width: screen.width || 0,
41
+ screen_height: screen.height || 0,
42
+ viewport_width: innerWidth || 0,
43
+ viewport_height: innerHeight || 0,
44
+ device_pixel_ratio: devicePixelRatio || 1,
45
+ user_agent: navigator.userAgent || '',
46
+ language: navigator.language || '',
47
+ scroll_depth: 0,
48
+ time_on_page: 0,
49
+ scroll_events: [],
50
+ custom_events: [],
51
+ };
52
+
53
+ // Add UTM params
54
+ const utm = getUTMParams();
55
+ for (const k in utm) {
56
+ pv[k] = utm[k];
57
+ }
58
+
59
+ // Capture navigation timing
60
+ if (typeof performance !== 'undefined' && performance.getEntriesByType) {
61
+ const nav = performance.getEntriesByType('navigation')[0] as
62
+ | PerformanceNavigationTiming
63
+ | undefined;
64
+ if (nav) {
65
+ pv.dom_ready = Math.round(nav.domContentLoadedEventEnd - nav.startTime);
66
+ pv.ttfb = Math.round(nav.responseStart - nav.requestStart);
67
+ if (nav.loadEventEnd > 0) {
68
+ pv.load_time = Math.round(nav.loadEventEnd - nav.startTime);
69
+ } else {
70
+ // Defer reading loadEventEnd
71
+ setTimeout(() => {
72
+ const navAfter = performance.getEntriesByType('navigation')[0] as
73
+ | PerformanceNavigationTiming
74
+ | undefined;
75
+ if (navAfter && navAfter.loadEventEnd > 0) {
76
+ updatePageView({
77
+ load_time: Math.round(navAfter.loadEventEnd - navAfter.startTime),
78
+ });
79
+ }
80
+ }, 0);
81
+ }
82
+ }
83
+ }
84
+
85
+ return pv;
86
+ }
87
+
88
+ /**
89
+ * Set up visibility change handlers
90
+ */
91
+ function setupVisibilityHandlers(): void {
92
+ document.addEventListener('visibilitychange', () => {
93
+ if (document.visibilityState === 'hidden') {
94
+ send();
95
+ } else if (document.visibilityState === 'visible') {
96
+ // User returned - start new attention session
97
+ resetSession();
98
+ }
99
+ });
100
+
101
+ window.addEventListener('pagehide', () => send());
102
+ window.addEventListener('beforeunload', () => send());
103
+ }
104
+
105
+ /**
106
+ * Set up scroll tracking
107
+ */
108
+ function setupScrollTracking(): void {
109
+ const config = getConfig();
110
+ if (config?.trackScroll === false) return;
111
+
112
+ const scrolled = new Set<number>();
113
+
114
+ function getScrollDepth(): number {
115
+ const st = window.scrollY || document.documentElement.scrollTop;
116
+ const sh = document.documentElement.scrollHeight - document.documentElement.clientHeight;
117
+ return sh <= 0 ? 100 : Math.min(100, Math.round((st / sh) * 100));
118
+ }
119
+
120
+ window.addEventListener(
121
+ 'scroll',
122
+ () => {
123
+ const depth = getScrollDepth();
124
+ updatePageView({ scroll_depth: depth });
125
+
126
+ for (const m of [25, 50, 75, 100]) {
127
+ if (depth >= m && !scrolled.has(m)) {
128
+ scrolled.add(m);
129
+ const pv = getPageView();
130
+ if (pv) {
131
+ pv.scroll_events.push({
132
+ depth: m,
133
+ timestamp: Date.now(),
134
+ });
135
+ }
136
+ }
137
+ }
138
+ },
139
+ { passive: true }
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Set up Web Vitals tracking
145
+ */
146
+ function setupWebVitals(): void {
147
+ const config = getConfig();
148
+ if (config?.trackWebVitals === false) return;
149
+ if (typeof PerformanceObserver === 'undefined') return;
150
+
151
+ // FCP
152
+ try {
153
+ const fcpObs = new PerformanceObserver((list) => {
154
+ for (const entry of list.getEntries()) {
155
+ if (entry.name === 'first-contentful-paint') {
156
+ updatePageView({ fcp: Math.round(entry.startTime) });
157
+ fcpObs.disconnect();
158
+ }
159
+ }
160
+ });
161
+ fcpObs.observe({ type: 'paint', buffered: true });
162
+ } catch {
163
+ /* Not supported */
164
+ }
165
+
166
+ // LCP
167
+ try {
168
+ new PerformanceObserver((list) => {
169
+ const entries = list.getEntries();
170
+ const last = entries[entries.length - 1];
171
+ if (last) {
172
+ updatePageView({ lcp: Math.round(last.startTime) });
173
+ }
174
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
175
+ } catch {
176
+ /* Not supported */
177
+ }
178
+
179
+ // CLS
180
+ try {
181
+ let clsValue = 0;
182
+ new PerformanceObserver((list) => {
183
+ for (const entry of list.getEntries()) {
184
+ const shift = entry as PerformanceEntry & { hadRecentInput?: boolean; value?: number };
185
+ if (!shift.hadRecentInput && shift.value) {
186
+ clsValue += shift.value;
187
+ }
188
+ }
189
+ updatePageView({ cls: Math.round(clsValue * 1000) / 1000 });
190
+ }).observe({ type: 'layout-shift', buffered: true });
191
+ } catch {
192
+ /* Not supported */
193
+ }
194
+
195
+ // INP
196
+ try {
197
+ let inpValue = 0;
198
+ new PerformanceObserver((list) => {
199
+ for (const entry of list.getEntries()) {
200
+ const event = entry as PerformanceEntry & { duration?: number };
201
+ if (event.duration && event.duration > inpValue) {
202
+ inpValue = event.duration;
203
+ }
204
+ }
205
+ updatePageView({ inp: Math.round(inpValue) });
206
+ }).observe({ type: 'event', buffered: true });
207
+ } catch {
208
+ /* Not supported */
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Set up SPA navigation tracking
214
+ */
215
+ function setupSPANavigation(): void {
216
+ const config = getConfig();
217
+ if (config?.trackSPANavigation === false) return;
218
+
219
+ let currentPath = location.pathname + location.search;
220
+ let lastHref = location.href;
221
+
222
+ function handleNav(): void {
223
+ const newPath = location.pathname + location.search;
224
+ if (newPath !== currentPath) {
225
+ send(true); // Force send on SPA nav
226
+ currentPath = newPath;
227
+ lastHref = location.href;
228
+ initClient(initPageView());
229
+ }
230
+ }
231
+
232
+ // Monkey-patch history
233
+ const origPush = history.pushState;
234
+ const origReplace = history.replaceState;
235
+
236
+ history.pushState = function (...args: [data: unknown, unused: string, url?: string | URL]) {
237
+ origPush.apply(this, args);
238
+ setTimeout(handleNav, 0);
239
+ };
240
+
241
+ history.replaceState = function (...args: [data: unknown, unused: string, url?: string | URL]) {
242
+ origReplace.apply(this, args);
243
+ setTimeout(handleNav, 0);
244
+ };
245
+
246
+ window.addEventListener('popstate', handleNav);
247
+
248
+ // Fallback: poll for URL changes
249
+ setInterval(() => {
250
+ if (location.href !== lastHref) {
251
+ lastHref = location.href;
252
+ handleNav();
253
+ }
254
+ }, 200);
255
+ }
256
+
257
+ /**
258
+ * Set up click tracking
259
+ */
260
+ function setupClickTracking(): void {
261
+ const config = getConfig();
262
+ if (config?.trackClicks === false) return;
263
+
264
+ document.addEventListener(
265
+ 'click',
266
+ (e) => {
267
+ const target = e.target as Element | null;
268
+ if (!target) return;
269
+
270
+ const el = target.closest('[data-analytics]');
271
+ if (!el) return;
272
+
273
+ const name = 'click:' + el.getAttribute('data-analytics');
274
+ track(name);
275
+ },
276
+ true
277
+ );
278
+ }
279
+
280
+ /**
281
+ * Set up error tracking
282
+ */
283
+ function setupErrorTracking(): void {
284
+ const config = getConfig();
285
+ if (config?.trackErrors === false) return;
286
+
287
+ window.addEventListener('error', (e) => {
288
+ track('error:js_error', {
289
+ message: e.message || 'Unknown',
290
+ filename: e.filename || '',
291
+ lineno: e.lineno || 0,
292
+ });
293
+ });
294
+
295
+ window.addEventListener('unhandledrejection', (e) => {
296
+ track('error:unhandled_rejection', {
297
+ message: e.reason instanceof Error ? e.reason.message : String(e.reason),
298
+ });
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Initialize the beacon
304
+ */
305
+ function init(): void {
306
+ if (initialized) return;
307
+ if (!isEnabled()) return;
308
+
309
+ initialized = true;
310
+
311
+ // Init page view
312
+ const pv = initPageView();
313
+ initClient(pv);
314
+
315
+ // Fetch geo (async)
316
+ fetchGeo();
317
+
318
+ // Set up all tracking
319
+ setupVisibilityHandlers();
320
+ setupScrollTracking();
321
+ setupWebVitals();
322
+ setupSPANavigation();
323
+ setupClickTracking();
324
+ setupErrorTracking();
325
+
326
+ // Set up global API
327
+ setupGlobal();
328
+
329
+ // Init on load if not ready
330
+ if (document.readyState === 'complete') {
331
+ // Already loaded
332
+ } else {
333
+ window.addEventListener('load', () => {
334
+ // Re-capture timing after load
335
+ updatePageView(initPageView());
336
+ });
337
+ }
338
+
339
+ if (isDevmode()) {
340
+ console.debug('[Agentuity Analytics] Beacon initialized');
341
+ }
342
+ }
343
+
344
+ // Auto-initialize on import
345
+ init();
package/src/client.ts ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Analytics client - programmatic API
3
+ */
4
+
5
+ import { isEnabled, getConfig, getEndpoint } from './config';
6
+ import { generateId, safeStringify, getVisitorId } from './util';
7
+ import type { AnalyticsClient, PageViewData, AnalyticsPayload } from './types';
8
+
9
+ /** Pending custom events */
10
+ let customEvents: Array<{ timestamp: number; name: string; data: string }> = [];
11
+
12
+ /** Current user ID */
13
+ let userId = '';
14
+
15
+ /** Current user traits */
16
+ let userTraits: Record<string, string> = {};
17
+
18
+ /** Current page view data */
19
+ let pageView: PageViewData | null = null;
20
+
21
+ /** Whether current page view was sent */
22
+ let sent = false;
23
+
24
+ /** Page view start time */
25
+ let pageStart = Date.now();
26
+
27
+ /**
28
+ * Initialize client with page view data
29
+ * Called by beacon or can be called manually
30
+ */
31
+ export function initClient(pv: PageViewData): void {
32
+ pageView = pv;
33
+ customEvents = [];
34
+ sent = false;
35
+ pageStart = Date.now();
36
+ }
37
+
38
+ /**
39
+ * Update page view data
40
+ */
41
+ export function updatePageView(updates: Partial<PageViewData>): void {
42
+ if (pageView) {
43
+ Object.assign(pageView, updates);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Get current page view data
49
+ */
50
+ export function getPageView(): PageViewData | null {
51
+ return pageView;
52
+ }
53
+
54
+ /**
55
+ * Reset session (keep page-level metrics, reset session metrics)
56
+ */
57
+ export function resetSession(): void {
58
+ if (pageView) {
59
+ pageView.id = generateId();
60
+ pageView.timestamp = Date.now();
61
+ pageView.scroll_events = [];
62
+ pageView.custom_events = customEvents;
63
+ pageView.scroll_depth = 0;
64
+ pageView.time_on_page = 0;
65
+ }
66
+ sent = false;
67
+ pageStart = Date.now();
68
+ }
69
+
70
+ /**
71
+ * Build payload for sending
72
+ */
73
+ function buildPayload(): AnalyticsPayload | null {
74
+ if (!pageView) return null;
75
+
76
+ const config = getConfig();
77
+ if (!config) return null;
78
+
79
+ return {
80
+ org_id: config.orgId,
81
+ project_id: config.projectId,
82
+ visitor_id: getVisitorId(),
83
+ user_id: userId,
84
+ user_traits: userTraits,
85
+ is_devmode: config.isDevmode ?? false,
86
+ pageview: {
87
+ ...pageView,
88
+ custom_events: customEvents,
89
+ time_on_page: Date.now() - pageStart,
90
+ },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Send analytics data
96
+ */
97
+ export function send(force = false): void {
98
+ if (sent && !force) return;
99
+ if (!isEnabled()) return;
100
+
101
+ const config = getConfig();
102
+ if (!config) return;
103
+
104
+ // Check sample rate
105
+ if (config.sampleRate !== undefined && config.sampleRate < 1) {
106
+ if (Math.random() > config.sampleRate) return;
107
+ }
108
+
109
+ sent = true;
110
+
111
+ const payload = buildPayload();
112
+ if (!payload) return;
113
+
114
+ // Dev mode: just log
115
+ if (config.isDevmode) {
116
+ console.debug('[Agentuity Analytics]', JSON.stringify(payload, null, 2));
117
+ return;
118
+ }
119
+
120
+ // Production: send to endpoint
121
+ const body = JSON.stringify(payload);
122
+ const endpoint = getEndpoint();
123
+
124
+ if (navigator.sendBeacon) {
125
+ navigator.sendBeacon(endpoint, body);
126
+ } else {
127
+ fetch(endpoint, {
128
+ method: 'POST',
129
+ body,
130
+ keepalive: true,
131
+ }).catch(() => {
132
+ // Silent failure
133
+ });
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Track a custom event
139
+ */
140
+ export function track(name: string, properties?: Record<string, unknown>): void {
141
+ if (!isEnabled()) return;
142
+ if (customEvents.length >= 1000) return;
143
+
144
+ customEvents.push({
145
+ timestamp: Date.now(),
146
+ name,
147
+ data: safeStringify(properties),
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Identify a user
153
+ */
154
+ export function identify(id: string, traits?: Record<string, unknown>): void {
155
+ userId = id;
156
+ if (traits) {
157
+ userTraits = {};
158
+ for (const [key, value] of Object.entries(traits)) {
159
+ userTraits[key] = String(value);
160
+ }
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Flush pending events
166
+ */
167
+ export function flush(): void {
168
+ send(true);
169
+ }
170
+
171
+ /**
172
+ * Get the analytics client
173
+ */
174
+ export function getClient(): AnalyticsClient {
175
+ return {
176
+ track,
177
+ identify,
178
+ flush,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Set up client as window.global
184
+ */
185
+ export function setupGlobal(): void {
186
+ if (typeof window !== 'undefined') {
187
+ window.agentuityAnalytics = getClient();
188
+ }
189
+ }
package/src/config.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Analytics configuration resolution
3
+ */
4
+
5
+ import type { AnalyticsConfig } from './types';
6
+
7
+ /** Window with Agentuity analytics globals */
8
+ declare global {
9
+ interface Window {
10
+ __AGENTUITY_ANALYTICS__?: AnalyticsConfig;
11
+ agentuityAnalytics?: import('./types').AnalyticsClient;
12
+ }
13
+ }
14
+
15
+ /** Default collect endpoint */
16
+ export const DEFAULT_ENDPOINT = '/_agentuity/webanalytics/collect';
17
+
18
+ /** Maximum custom events per page view */
19
+ export const MAX_CUSTOM_EVENTS = 1000;
20
+
21
+ /**
22
+ * Get analytics config from window global
23
+ */
24
+ export function getConfig(): AnalyticsConfig | null {
25
+ return window.__AGENTUITY_ANALYTICS__ ?? null;
26
+ }
27
+
28
+ /**
29
+ * Check if analytics is enabled
30
+ */
31
+ export function isEnabled(): boolean {
32
+ const config = getConfig();
33
+ return config?.enabled === true;
34
+ }
35
+
36
+ /**
37
+ * Check if running in dev mode
38
+ */
39
+ export function isDevmode(): boolean {
40
+ return getConfig()?.isDevmode ?? false;
41
+ }
42
+
43
+ /**
44
+ * Get the collect endpoint
45
+ */
46
+ export function getEndpoint(): string {
47
+ return getConfig()?.endpoint ?? DEFAULT_ENDPOINT;
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @agentuity/analytics - Browser analytics for Agentuity applications
3
+ *
4
+ * ## Usage
5
+ *
6
+ * ### Auto-init (drop-in)
7
+ * ```typescript
8
+ * // Just import - uses window.__AGENTUITY_ANALYTICS__ config
9
+ * import '@agentuity/analytics/beacon';
10
+ * ```
11
+ *
12
+ * ### Programmatic
13
+ * ```typescript
14
+ * import { init, track, identify, flush } from '@agentuity/analytics';
15
+ *
16
+ * init({
17
+ * orgId: 'your-org-id',
18
+ * projectId: 'your-project-id',
19
+ * });
20
+ *
21
+ * track('button_click', { button: 'signup' });
22
+ * identify('user-123', { email: 'user@example.com' });
23
+ * flush();
24
+ * ```
25
+ *
26
+ * ### With React
27
+ * ```typescript
28
+ * import { useEffect } from 'react';
29
+ * import { track } from '@agentuity/analytics';
30
+ *
31
+ * function SignupButton() {
32
+ * const handleClick = () => {
33
+ * track('signup_click');
34
+ * };
35
+ * return <button onClick={handleClick}>Sign Up</button>;
36
+ * }
37
+ * ```
38
+ */
39
+
40
+ // Types
41
+ export type {
42
+ AnalyticsConfig,
43
+ AnalyticsClient,
44
+ AnalyticsPayload,
45
+ PageViewData,
46
+ ScrollEvent,
47
+ AnalyticsCustomEvent,
48
+ GeoLocation,
49
+ } from './types';
50
+
51
+ // Config utilities
52
+ export {
53
+ getConfig,
54
+ isEnabled,
55
+ isDevmode,
56
+ getEndpoint,
57
+ DEFAULT_ENDPOINT,
58
+ } from './config';
59
+
60
+ // Utility functions
61
+ export {
62
+ generateId,
63
+ getVisitorId,
64
+ getUTMParams,
65
+ stripQueryString,
66
+ } from './util';
67
+
68
+ // Programmatic API
69
+ export {
70
+ initClient,
71
+ track,
72
+ identify,
73
+ flush,
74
+ send,
75
+ getClient,
76
+ } from './client';
77
+
78
+ /**
79
+ * Initialize analytics with explicit config
80
+ * Alternative to using window.__AGENTUITY_ANALYTICS__
81
+ */
82
+ export function init(config: import('./types').AnalyticsConfig): void {
83
+ if (typeof window !== 'undefined') {
84
+ window.__AGENTUITY_ANALYTICS__ = config;
85
+ // Import beacon to trigger auto-init
86
+ import('./beacon');
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Get the global analytics client
92
+ */
93
+ export function getAnalytics(): import('./types').AnalyticsClient | null {
94
+ if (typeof window !== 'undefined' && window.agentuityAnalytics) {
95
+ return window.agentuityAnalytics;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Check if user has opted out of analytics via localStorage.
102
+ */
103
+ export function isOptedOut(): boolean {
104
+ try {
105
+ return localStorage.getItem('agentuity_opt_out') === 'true';
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Set the analytics opt-out status in localStorage.
113
+ */
114
+ export function setOptOut(optOut: boolean): void {
115
+ try {
116
+ if (optOut) {
117
+ localStorage.setItem('agentuity_opt_out', 'true');
118
+ } else {
119
+ localStorage.removeItem('agentuity_opt_out');
120
+ }
121
+ } catch {
122
+ // localStorage not available
123
+ }
124
+ }