@cloff/sdk 1.0.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/index.ts ADDED
@@ -0,0 +1,1194 @@
1
+ import { createClient, SupabaseClient } from '@supabase/supabase-js';
2
+
3
+ export interface TrackerConfig {
4
+ supabaseUrl?: string;
5
+ supabaseAnonKey?: string;
6
+ debug?: boolean;
7
+ autoTrackPageViews?: boolean;
8
+ userId?: string | (() => string | null | undefined);
9
+ enableErrors?: boolean;
10
+ enableAPM?: boolean;
11
+ enableCanisterAPM?: boolean;
12
+ captureConsole?: boolean;
13
+ }
14
+
15
+ // Default central database credentials (this points to our host project)
16
+ const DEFAULT_SUPABASE_URL = 'https://edhkrzyiiznotsmblbrt.supabase.co';
17
+ const DEFAULT_SUPABASE_ANON_KEY = 'sb_publishable_EzjTe8cJu-8gZmdccQr4aA_iZY8WIxL';
18
+
19
+ export class Clof {
20
+ private apiKey: string;
21
+ private supabase: SupabaseClient;
22
+ private debug: boolean;
23
+ private sessionId: string | null = null;
24
+ private configUserId: string | (() => string | null | undefined) | null = null;
25
+
26
+ private enableErrors: boolean;
27
+ private enableAPM: boolean;
28
+ private enableCanisterAPM: boolean;
29
+ private captureConsole: boolean;
30
+ private breadcrumbs: Array<{ timestamp: string; message: string; category: string; metadata?: any }> = [];
31
+ private maxBreadcrumbs = 50;
32
+
33
+ private vitals = {
34
+ loadTime: null as number | null,
35
+ lcp: null as number | null,
36
+ cls: 0,
37
+ inp: null as number | null
38
+ };
39
+
40
+ constructor(apiKey: string, config?: TrackerConfig) {
41
+ if (!apiKey) {
42
+ throw new Error('Clof: API Key is required.');
43
+ }
44
+
45
+ this.apiKey = apiKey;
46
+ this.debug = config?.debug ?? false;
47
+ this.configUserId = config?.userId ?? null;
48
+ this.enableErrors = config?.enableErrors ?? true;
49
+ this.enableAPM = config?.enableAPM ?? true;
50
+ this.enableCanisterAPM = config?.enableCanisterAPM ?? true;
51
+ this.captureConsole = config?.captureConsole ?? false;
52
+
53
+ const url = config?.supabaseUrl ?? DEFAULT_SUPABASE_URL;
54
+ const anonKey = config?.supabaseAnonKey ?? DEFAULT_SUPABASE_ANON_KEY;
55
+
56
+ this.supabase = createClient(url, anonKey);
57
+
58
+ this.setupVitalsObserver();
59
+
60
+ if (this.enableErrors) {
61
+ this.setupErrorObservers();
62
+ this.setupClickObserver();
63
+ if (this.captureConsole) {
64
+ this.setupConsoleObserver();
65
+ }
66
+ }
67
+
68
+ if (this.enableErrors || this.enableAPM) {
69
+ this.setupFetchInterception();
70
+ }
71
+
72
+ if (config?.autoTrackPageViews && config.userId) {
73
+ this.setupAutoTracking(config.userId);
74
+ }
75
+
76
+ this.setupOnlineListener();
77
+ }
78
+
79
+ /**
80
+ * Helper to identify device form-factor from Navigator UA string
81
+ */
82
+ private getDeviceType(): string {
83
+ if (typeof window === 'undefined' || !navigator.userAgent) return 'Desktop';
84
+ const ua = navigator.userAgent;
85
+ if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) return 'Tablet';
86
+ if (/Mobile|iP(hone|od)|Android|BlackBerry|IEMobile|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/i.test(ua)) {
87
+ return 'Mobile';
88
+ }
89
+ return 'Desktop';
90
+ }
91
+
92
+ /**
93
+ * Helper to extract browser name from UA string
94
+ */
95
+ private getBrowser(): string {
96
+ if (typeof window === 'undefined' || !navigator.userAgent) return 'unknown';
97
+ const ua = navigator.userAgent;
98
+ if (/opr\/|opera/i.test(ua)) return 'Opera';
99
+ if (/edg/i.test(ua)) return 'Edge';
100
+ if (/chrome|crios/i.test(ua)) return 'Chrome';
101
+ if (/firefox|fxios/i.test(ua)) return 'Firefox';
102
+ if (/safari/i.test(ua)) return 'Safari';
103
+ return 'unknown';
104
+ }
105
+
106
+ /**
107
+ * Helper to extract OS name from UA string
108
+ */
109
+ private getOS(): string {
110
+ if (typeof window === 'undefined' || !navigator.userAgent) return 'unknown';
111
+ const ua = navigator.userAgent;
112
+ if (/iphone|ipad|ipod/i.test(ua)) return 'iOS';
113
+ if (/android/i.test(ua)) return 'Android';
114
+ if (/mac/i.test(ua)) return 'macOS';
115
+ if (/win/i.test(ua)) return 'Windows';
116
+ if (/linux/i.test(ua)) return 'Linux';
117
+ return 'unknown';
118
+ }
119
+
120
+ /**
121
+ * Helper to retrieve timezone identifier as location
122
+ */
123
+ private getLocation(): string {
124
+ try {
125
+ if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
126
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown';
127
+ }
128
+ } catch (e) {
129
+ // Fallback
130
+ }
131
+ return 'unknown';
132
+ }
133
+
134
+ /**
135
+ * Helper to parse UTM parameters from search URL
136
+ */
137
+ private getUTMParams() {
138
+ if (typeof window === 'undefined') return { utm_source: null, utm_medium: null, utm_campaign: null };
139
+ try {
140
+ const params = new URLSearchParams(window.location.search);
141
+ return {
142
+ utm_source: params.get('utm_source'),
143
+ utm_medium: params.get('utm_medium'),
144
+ utm_campaign: params.get('utm_campaign')
145
+ };
146
+ } catch (e) {
147
+ return { utm_source: null, utm_medium: null, utm_campaign: null };
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Generate UUID v4
153
+ */
154
+ private generateUUID(): string {
155
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
156
+ return crypto.randomUUID();
157
+ }
158
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
159
+ const r = (Math.random() * 16) | 0;
160
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
161
+ return v.toString(16);
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Resolve and ping the current user session
167
+ */
168
+ private async getSessionId(userId: string): Promise<string> {
169
+ if (this.sessionId) {
170
+ if (typeof window !== 'undefined') {
171
+ try {
172
+ sessionStorage.setItem('socio_dau_session_ts', String(Date.now()));
173
+ } catch (e) { }
174
+ }
175
+ return this.sessionId;
176
+ }
177
+
178
+ let sid: string | null = null;
179
+ let isNew = false;
180
+ const now = Date.now();
181
+ const expiry = 30 * 60 * 1000; // 30 minutes
182
+
183
+ if (typeof window !== 'undefined') {
184
+ try {
185
+ const cachedId = sessionStorage.getItem('socio_dau_session_id');
186
+ const cachedTs = sessionStorage.getItem('socio_dau_session_ts');
187
+
188
+ if (cachedId && cachedTs && now - Number(cachedTs) < expiry) {
189
+ sid = cachedId;
190
+ sessionStorage.setItem('socio_dau_session_ts', String(now));
191
+ } else {
192
+ sid = this.generateUUID();
193
+ sessionStorage.setItem('socio_dau_session_id', sid);
194
+ sessionStorage.setItem('socio_dau_session_ts', String(now));
195
+ isNew = true;
196
+ }
197
+ } catch (e) {
198
+ sid = this.generateUUID();
199
+ isNew = true;
200
+ }
201
+ } else {
202
+ sid = this.generateUUID();
203
+ isNew = true;
204
+ }
205
+
206
+ this.sessionId = sid;
207
+
208
+ if (isNew) {
209
+ await this.registerSession(sid, userId);
210
+ } else {
211
+ this.pingSession(sid, userId);
212
+ }
213
+
214
+ return sid;
215
+ }
216
+
217
+ private async registerSession(sid: string, userId: string) {
218
+ const utm = this.getUTMParams();
219
+ const deviceType = this.getDeviceType();
220
+ const browser = this.getBrowser();
221
+ const os = this.getOS();
222
+ const location = this.getLocation();
223
+
224
+ await this.executeRpc('start_or_ping_session', {
225
+ p_session_id: sid,
226
+ p_api_key: this.apiKey,
227
+ p_user_id: userId,
228
+ p_device_type: deviceType,
229
+ p_browser: browser,
230
+ p_os: os,
231
+ p_location: location,
232
+ p_utm_source: utm.utm_source,
233
+ p_utm_medium: utm.utm_medium,
234
+ p_utm_campaign: utm.utm_campaign
235
+ });
236
+ }
237
+
238
+ private async pingSession(sid: string, userId: string) {
239
+ await this.executeRpc('start_or_ping_session', {
240
+ p_session_id: sid,
241
+ p_api_key: this.apiKey,
242
+ p_user_id: userId,
243
+ p_device_type: this.getDeviceType(),
244
+ p_browser: this.getBrowser(),
245
+ p_os: this.getOS(),
246
+ p_location: this.getLocation()
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Observes page loading performance and layout stability
252
+ */
253
+ private setupVitalsObserver() {
254
+ if (typeof window === 'undefined') return;
255
+
256
+ const getLoadTime = () => {
257
+ try {
258
+ const perf = window.performance;
259
+ if (perf) {
260
+ const nav = perf.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
261
+ if (nav) {
262
+ this.vitals.loadTime = Math.round(nav.duration || nav.loadEventEnd - nav.startTime);
263
+ } else {
264
+ const timing = perf.timing;
265
+ if (timing) {
266
+ this.vitals.loadTime = timing.loadEventEnd - timing.navigationStart;
267
+ }
268
+ }
269
+ }
270
+ } catch (e) {
271
+ // Fallback
272
+ }
273
+ };
274
+
275
+ if (document.readyState === 'complete') {
276
+ getLoadTime();
277
+ } else {
278
+ window.addEventListener('load', () => setTimeout(getLoadTime, 0));
279
+ }
280
+
281
+ if (typeof PerformanceObserver !== 'undefined') {
282
+ try {
283
+ const lcpObserver = new PerformanceObserver((entryList) => {
284
+ const entries = entryList.getEntries();
285
+ const lastEntry = entries[entries.length - 1];
286
+ this.vitals.lcp = Math.round(lastEntry.startTime);
287
+ });
288
+ lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
289
+
290
+ const clsObserver = new PerformanceObserver((entryList) => {
291
+ for (const entry of entryList.getEntries()) {
292
+ if (!(entry as any).hadRecentInput) {
293
+ this.vitals.cls += (entry as any).value;
294
+ }
295
+ }
296
+ });
297
+ clsObserver.observe({ type: 'layout-shift', buffered: true });
298
+
299
+ const inpObserver = new PerformanceObserver((entryList) => {
300
+ const entries = entryList.getEntries();
301
+ const lastEntry = entries[entries.length - 1];
302
+ this.vitals.inp = Math.round(lastEntry.duration || (lastEntry as any).processingStart - lastEntry.startTime);
303
+ });
304
+ inpObserver.observe({ type: 'first-input', buffered: true });
305
+ } catch (e) {
306
+ this.logDebug('PerformanceObserver setup error:', e);
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Setup a click listener to log interaction breadcrumbs
313
+ */
314
+ private setupClickObserver() {
315
+ if (typeof window === 'undefined') return;
316
+ window.addEventListener('click', (e) => {
317
+ try {
318
+ const target = e.target as HTMLElement;
319
+ if (!target) return;
320
+
321
+ let desc = target.tagName.toLowerCase();
322
+ if (target.id) desc += `#${target.id}`;
323
+ if (target.className && typeof target.className === 'string') {
324
+ desc += `.${target.className.trim().split(/\s+/).join('.')}`;
325
+ }
326
+
327
+ const testId = target.getAttribute('data-testid');
328
+ const text = target.innerText?.slice(0, 30).trim();
329
+
330
+ this.addBreadcrumb(`Click on ${desc}`, 'click', {
331
+ testId,
332
+ text,
333
+ tagName: target.tagName
334
+ });
335
+ } catch (err) {
336
+ // Safe catch
337
+ }
338
+ }, { passive: true });
339
+ }
340
+
341
+ /**
342
+ * Intercept console warnings & errors to record breadcrumbs
343
+ */
344
+ private setupConsoleObserver() {
345
+ if (typeof window === 'undefined') return;
346
+ const self = this;
347
+ const originalWarn = console.warn;
348
+ const originalError = console.error;
349
+
350
+ console.warn = function (...args: any[]) {
351
+ try {
352
+ const msg = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ');
353
+ self.addBreadcrumb(msg, 'console', { level: 'warning' });
354
+ } catch (e) { }
355
+ originalWarn.apply(console, args);
356
+ };
357
+
358
+ console.error = function (...args: any[]) {
359
+ try {
360
+ const msg = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ');
361
+ self.addBreadcrumb(msg, 'console', { level: 'error' });
362
+ } catch (e) { }
363
+ originalError.apply(console, args);
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Listen to global unhandled exceptions and promise rejections
369
+ */
370
+ private setupErrorObservers() {
371
+ if (typeof window === 'undefined') return;
372
+
373
+ window.addEventListener('error', (event) => {
374
+ try {
375
+ this.captureException(event.error || event.message, {
376
+ filename: event.filename,
377
+ lineno: event.lineno,
378
+ colno: event.colno,
379
+ unhandled: true
380
+ });
381
+ } catch (e) {
382
+ this.logError('Error observer failed:', e);
383
+ }
384
+ });
385
+
386
+ window.addEventListener('unhandledrejection', (event) => {
387
+ try {
388
+ const reason = event.reason;
389
+ const msg = reason instanceof Error ? reason.message : String(reason);
390
+ this.captureException(reason || msg, {
391
+ unhandled: true,
392
+ rejection: true
393
+ });
394
+ } catch (e) {
395
+ this.logError('Unhandledrejection observer failed:', e);
396
+ }
397
+ });
398
+ }
399
+
400
+ /**
401
+ * Hook global fetch to auto-trace API durations and catch status >= 400 silent errors
402
+ */
403
+ private setupFetchInterception() {
404
+ if (typeof window === 'undefined' || !window.fetch) return;
405
+
406
+ const self = this;
407
+ const originalFetch = window.fetch;
408
+
409
+ window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
410
+ const url = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url);
411
+ const method = init?.method || 'GET';
412
+
413
+ // Prevent recursive requests if they hit our Supabase DB
414
+ if (url.includes('supabase.co') || url.includes('supabaseUrl')) {
415
+ return originalFetch.apply(this, [input, init]);
416
+ }
417
+
418
+ const startTime = performance.now();
419
+
420
+ try {
421
+ const response = await originalFetch.apply(this, [input, init]);
422
+ const duration = performance.now() - startTime;
423
+
424
+ const canisterMatch = url.match(/\/api\/v2\/canister\/([a-z0-9-]{5,})\/(query|call|read_state)/);
425
+ if (canisterMatch) {
426
+ const canisterId = canisterMatch[1];
427
+ const action = canisterMatch[2];
428
+ self.addBreadcrumb(`Canister Call: ${action} on ${canisterId} (${response.status})`, 'contract', {
429
+ canisterId,
430
+ method: action,
431
+ status: response.status >= 400 ? 'error' : 'success',
432
+ durationMs: Math.round(duration),
433
+ url
434
+ });
435
+ } else {
436
+ self.addBreadcrumb(`Fetch resolve: ${method} ${url} (${response.status})`, 'fetch', {
437
+ status: response.status,
438
+ durationMs: Math.round(duration)
439
+ });
440
+ }
441
+
442
+ if (self.enableAPM) {
443
+ self.trackPerformanceSpan(url, duration, 'api_call', {
444
+ method,
445
+ status: response.status
446
+ }).catch(() => { });
447
+ }
448
+
449
+ if (response.status >= 400 && self.enableErrors) {
450
+ self.logErrorToDatabase(
451
+ `HTTP ${response.status}: Fetch failed on ${method} ${url}`,
452
+ new Error(`Fetch failed: ${response.status}`).stack,
453
+ 'warning',
454
+ true,
455
+ {
456
+ url,
457
+ method,
458
+ status: response.status,
459
+ statusText: response.statusText
460
+ }
461
+ ).catch(() => { });
462
+ }
463
+
464
+ return response;
465
+ } catch (err: any) {
466
+ const duration = performance.now() - startTime;
467
+ const errMsg = err?.message || String(err);
468
+
469
+ const canisterMatch = url.match(/\/api\/v2\/canister\/([a-z0-9-]{5,})\/(query|call|read_state)/);
470
+ if (canisterMatch) {
471
+ const canisterId = canisterMatch[1];
472
+ const action = canisterMatch[2];
473
+ self.addBreadcrumb(`Canister Call network error: ${action} on ${canisterId} (${errMsg})`, 'contract', {
474
+ canisterId,
475
+ method: action,
476
+ status: 'error',
477
+ durationMs: Math.round(duration),
478
+ error: errMsg,
479
+ url
480
+ });
481
+ } else {
482
+ self.addBreadcrumb(`Fetch network error: ${method} ${url} (${errMsg})`, 'fetch', {
483
+ error: errMsg,
484
+ durationMs: Math.round(duration)
485
+ });
486
+ }
487
+
488
+ if (self.enableAPM) {
489
+ self.trackPerformanceSpan(url, duration, 'api_call', {
490
+ method,
491
+ error: errMsg
492
+ }).catch(() => { });
493
+ }
494
+
495
+ if (self.enableErrors) {
496
+ self.logErrorToDatabase(
497
+ `Network Error: Fetch failed on ${method} ${url} - ${errMsg}`,
498
+ err instanceof Error ? err.stack : new Error(`Network failure`).stack,
499
+ 'error',
500
+ true,
501
+ {
502
+ url,
503
+ method,
504
+ error: errMsg
505
+ }
506
+ ).catch(() => { });
507
+ }
508
+
509
+ throw err;
510
+ }
511
+ };
512
+ }
513
+
514
+ /**
515
+ * Estimate cycles consumed by a canister call using client-side heuristics.
516
+ * Query calls are free on ICP. Update calls incur:
517
+ * base_fee (5M) + est_instructions × 1 cycle/instruction + ingress_bytes × 2000 cycles/byte
518
+ */
519
+ static estimateCanisterCycles(callType: 'query' | 'update', durationMs: number, requestSize?: number): number {
520
+ if (callType === 'query') return 0;
521
+ const BASE_FEE = 5_000_000;
522
+ const INSTR_PER_MS = 500_000;
523
+ const BYTE_FEE = 2_000;
524
+ const estInstructions = Math.round(durationMs * INSTR_PER_MS);
525
+ const estBytes = (requestSize || 0) * BYTE_FEE;
526
+ return BASE_FEE + (estInstructions * 1) + estBytes;
527
+ }
528
+
529
+ /**
530
+ * Log a canister call action manually
531
+ */
532
+ public trackCanisterCall(canisterId: string, method: string, status: 'success' | 'error', durationMs?: number, metadata?: any) {
533
+ const estimatedCycles = durationMs ? Clof.estimateCanisterCycles('update', durationMs) : undefined;
534
+ this.addBreadcrumb(`Canister Call: ${method} on ${canisterId} (${status})`, 'contract', {
535
+ canisterId,
536
+ method,
537
+ status,
538
+ durationMs,
539
+ estimatedCycles,
540
+ ...metadata
541
+ });
542
+ }
543
+
544
+ /**
545
+ * Add manual trace breadcrumbs
546
+ */
547
+ public addBreadcrumb(message: string, category = 'manual', metadata?: any) {
548
+ if (!this.enableErrors) return;
549
+ this.breadcrumbs.push({
550
+ timestamp: new Date().toISOString(),
551
+ message,
552
+ category,
553
+ metadata
554
+ });
555
+ if (this.breadcrumbs.length > this.maxBreadcrumbs) {
556
+ this.breadcrumbs.shift();
557
+ }
558
+ this.logDebug(`Breadcrumb added: [${category}] ${message}`);
559
+ }
560
+
561
+ /**
562
+ * Track user activity (Legacy DAU Ping).
563
+ * Uses localStorage to deduplicate pings on the same day for a given user.
564
+ */
565
+ async track(userId: string | number, force = false): Promise<{ success: boolean; error?: string }> {
566
+ const cleanUserId = String(userId).trim();
567
+ if (!cleanUserId) {
568
+ this.logError('track() called with empty user ID');
569
+ return { success: false, error: 'User ID cannot be empty' };
570
+ }
571
+
572
+ const todayStr = new Date().toISOString().split('T')[0];
573
+ const cacheKey = `socio_dau_tracked_${this.apiKey}_${cleanUserId}`;
574
+
575
+ if (!force) {
576
+ try {
577
+ const lastTrackedDate = localStorage.getItem(cacheKey);
578
+ if (lastTrackedDate === todayStr) {
579
+ this.logDebug(`User ${cleanUserId} already tracked today. Skipping ping.`);
580
+ return { success: true };
581
+ }
582
+ } catch (err) {
583
+ this.logError('localStorage error:', err);
584
+ }
585
+ }
586
+
587
+ const deviceType = this.getDeviceType();
588
+ const location = this.getLocation();
589
+
590
+ this.logDebug(`Pinging activity for user ${cleanUserId} (${deviceType}, ${location})...`);
591
+
592
+ const res = await this.executeRpc('track_dau', {
593
+ p_api_key: this.apiKey,
594
+ p_user_id: cleanUserId,
595
+ p_device_type: deviceType,
596
+ p_location: location
597
+ });
598
+
599
+ if (res.success) {
600
+ try {
601
+ localStorage.setItem(cacheKey, todayStr);
602
+ } catch (e) { }
603
+ return { success: true };
604
+ }
605
+ return { success: false, error: res.error };
606
+ }
607
+
608
+ /**
609
+ * Track specific Page Views.
610
+ * Logs page navigation events, referrer, and captures performance Web Vitals.
611
+ */
612
+ async trackPageView(
613
+ userId: string | number,
614
+ options?: { path?: string; title?: string; referrer?: string }
615
+ ): Promise<{ success: boolean; error?: string }> {
616
+ const cleanUserId = String(userId).trim();
617
+ if (!cleanUserId) {
618
+ this.logError('trackPageView() called with empty user ID');
619
+ return { success: false, error: 'User ID cannot be empty' };
620
+ }
621
+
622
+ const path = options?.path ?? (typeof window !== 'undefined' ? window.location.pathname : '/');
623
+ const title = options?.title ?? (typeof document !== 'undefined' ? document.title : undefined);
624
+ const referrer = options?.referrer ?? (typeof document !== 'undefined' ? document.referrer : undefined);
625
+
626
+ this.addBreadcrumb(`Navigate to ${path}`, 'navigation', { title, referrer });
627
+
628
+ try {
629
+ const sid = await this.getSessionId(cleanUserId);
630
+
631
+ this.logDebug(`Logging page view: Session: ${sid}, Path: ${path}, Title: ${title}`);
632
+
633
+ const loadTime = this.vitals.loadTime;
634
+ const lcp = this.vitals.lcp;
635
+ const cls = this.vitals.cls;
636
+ const inp = this.vitals.inp;
637
+
638
+ return this.executeRpc('track_page_view_v2', {
639
+ p_session_id: sid,
640
+ p_path: path,
641
+ p_title: title,
642
+ p_referrer: referrer,
643
+ p_load_time_ms: loadTime ? Math.round(loadTime) : null,
644
+ p_lcp_ms: lcp ? Math.round(lcp) : null,
645
+ p_cls: cls ? Number(cls.toFixed(4)) : null,
646
+ p_inp_ms: inp ? Math.round(inp) : null
647
+ });
648
+ } catch (err: any) {
649
+ const errMsg = err?.message || String(err);
650
+ this.logError('Failed during trackPageView:', errMsg);
651
+ return { success: false, error: errMsg };
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Track Custom Events (actions, goals, conversions)
657
+ */
658
+ async trackEvent(
659
+ userId: string | number,
660
+ eventName: string,
661
+ properties?: Record<string, any>
662
+ ): Promise<{ success: boolean; error?: string }> {
663
+ const cleanUserId = String(userId).trim();
664
+ const cleanEventName = String(eventName).trim();
665
+ if (!cleanUserId) {
666
+ this.logError('trackEvent() called with empty user ID');
667
+ return { success: false, error: 'User ID cannot be empty' };
668
+ }
669
+ if (!cleanEventName) {
670
+ this.logError('trackEvent() called with empty event name');
671
+ return { success: false, error: 'Event name cannot be empty' };
672
+ }
673
+
674
+ this.addBreadcrumb(`Event triggered: ${cleanEventName}`, 'event', properties);
675
+
676
+ try {
677
+ const sid = await this.getSessionId(cleanUserId);
678
+ this.logDebug(`Logging custom event: Session: ${sid}, Event: ${cleanEventName}`);
679
+
680
+ return this.executeRpc('track_custom_event', {
681
+ p_session_id: sid,
682
+ p_event_name: cleanEventName,
683
+ p_properties: properties || null
684
+ });
685
+ } catch (err: any) {
686
+ const errMsg = err?.message || String(err);
687
+ this.logError('Failed during trackEvent:', errMsg);
688
+ return { success: false, error: errMsg };
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Log an exception manually to the database
694
+ */
695
+ public async captureException(
696
+ error: any,
697
+ extraContext?: any
698
+ ): Promise<{ success: boolean; error?: string }> {
699
+ if (!this.enableErrors) return { success: false, error: 'Error tracking disabled' };
700
+
701
+ const message = error instanceof Error ? error.message : String(error);
702
+ const stack = error instanceof Error ? error.stack : new Error().stack;
703
+
704
+ return this.logErrorToDatabase(message, stack, 'error', false, extraContext);
705
+ }
706
+
707
+ /**
708
+ * Log a custom severity warning or info message to the database
709
+ */
710
+ public async captureMessage(
711
+ message: string,
712
+ severity: 'error' | 'warning' | 'info' = 'error',
713
+ extraContext?: any
714
+ ): Promise<{ success: boolean; error?: string }> {
715
+ if (!this.enableErrors) return { success: false, error: 'Error tracking disabled' };
716
+
717
+ const stack = new Error().stack;
718
+ return this.logErrorToDatabase(message, stack, severity, true, extraContext);
719
+ }
720
+
721
+ /**
722
+ * Helper function to execute error logging RPC
723
+ */
724
+ private async logErrorToDatabase(
725
+ message: string,
726
+ stack: string | undefined,
727
+ severity: 'error' | 'warning' | 'info',
728
+ handled: boolean,
729
+ extraContext?: any
730
+ ): Promise<{ success: boolean; error?: string }> {
731
+ try {
732
+ const activeUserId = this.configUserId ? this.resolveUserId() : 'anonymous';
733
+ const sid = await this.getSessionId(activeUserId || 'anonymous');
734
+
735
+ const context = {
736
+ browser: this.getBrowser(),
737
+ os: this.getOS(),
738
+ device: this.getDeviceType(),
739
+ location: this.getLocation(),
740
+ url: typeof window !== 'undefined' ? window.location.href : 'unknown',
741
+ sdkVersion: '1.0.2',
742
+ extra: extraContext
743
+ };
744
+
745
+ return this.executeRpc('track_error', {
746
+ p_session_id: sid,
747
+ p_message: message,
748
+ p_stack: stack || null,
749
+ p_breadcrumbs: this.breadcrumbs,
750
+ p_context: context,
751
+ p_severity: severity,
752
+ p_handled: handled
753
+ });
754
+ } catch (err: any) {
755
+ const errMsg = err?.message || String(err);
756
+ this.logError('Failed to log error to database:', errMsg);
757
+ return { success: false, error: errMsg };
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Log latency of a duration in milliseconds manually
763
+ */
764
+ public async trackPerformanceSpan(
765
+ name: string,
766
+ durationMs: number,
767
+ entryType = 'custom',
768
+ metadata?: any
769
+ ): Promise<{ success: boolean; error?: string }> {
770
+ if (!this.enableAPM) return { success: false, error: 'APM disabled' };
771
+
772
+ try {
773
+ const activeUserId = this.configUserId ? this.resolveUserId() : 'anonymous';
774
+ const sid = await this.getSessionId(activeUserId || 'anonymous');
775
+
776
+ return this.executeRpc('track_performance_span', {
777
+ p_session_id: sid,
778
+ p_name: name,
779
+ p_duration_ms: durationMs,
780
+ p_entry_type: entryType,
781
+ p_metadata: metadata || null
782
+ });
783
+ } catch (err: any) {
784
+ const errMsg = err?.message || String(err);
785
+ this.logError('Failed to track performance span:', errMsg);
786
+ return { success: false, error: errMsg };
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Log latency of an ICP canister call with method-level granularity
792
+ */
793
+ public async trackCanisterSpan(
794
+ canisterId: string,
795
+ methodName: string,
796
+ callType: 'query' | 'update',
797
+ durationMs: number,
798
+ status: 'success' | 'reject' | 'error' = 'success',
799
+ errorMsg?: string,
800
+ requestSize?: number,
801
+ responseSize?: number
802
+ ): Promise<{ success: boolean; error?: string }> {
803
+ if (!this.enableCanisterAPM) return { success: false, error: 'Canister APM disabled' };
804
+
805
+ try {
806
+ const activeUserId = this.configUserId ? this.resolveUserId() : 'anonymous';
807
+ const sid = await this.getSessionId(activeUserId || 'anonymous');
808
+ const estimatedCycles = Clof.estimateCanisterCycles(callType, durationMs, requestSize);
809
+
810
+ return this.executeRpc('track_canister_call', {
811
+ p_session_id: sid,
812
+ p_canister_id: canisterId,
813
+ p_method_name: methodName,
814
+ p_call_type: callType,
815
+ p_duration_ms: durationMs,
816
+ p_status: status,
817
+ p_error_msg: errorMsg || null,
818
+ p_request_size: requestSize || null,
819
+ p_response_size: responseSize || null,
820
+ p_estimated_cycles: estimatedCycles
821
+ });
822
+ } catch (err: any) {
823
+ const errMsg = err?.message || String(err);
824
+ this.logError('Failed to track canister span:', errMsg);
825
+ return { success: false, error: errMsg };
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Wrap an @dfinity/agent HttpAgent to intercept canister calls for APM.
831
+ * Returns the same agent proxied — no @dfinity/agent dependency needed.
832
+ *
833
+ * Usage:
834
+ * import { HttpAgent } from '@dfinity/agent';
835
+ * const agent = new HttpAgent();
836
+ * const wrapped = tracker.wrapHttpAgent(agent);
837
+ * // use wrapped everywhere instead of agent
838
+ */
839
+ public wrapHttpAgent<T>(agent: T): T {
840
+ if (!this.enableCanisterAPM) return agent;
841
+
842
+ const self = this;
843
+ const proto = Object.getPrototypeOf(agent);
844
+
845
+ // Only wrap if not already wrapped
846
+ if ((agent as any).__socioDauWrapped) return agent;
847
+
848
+ // Wrap query
849
+ if (typeof (agent as any).query === 'function') {
850
+ const originalQuery = proto.query.bind(agent);
851
+ (agent as any).query = async function (
852
+ canisterId: any,
853
+ fields: { methodName: string; arg: any },
854
+ ...args: any[]
855
+ ) {
856
+ const startTime = performance.now();
857
+ const reqSize = fields.arg?.byteLength || 0;
858
+ try {
859
+ const result = await originalQuery(canisterId, fields, ...args);
860
+ const duration = performance.now() - startTime;
861
+ const cid = typeof canisterId === 'object' && canisterId.toText ? canisterId.toText() : String(canisterId);
862
+ self.trackCanisterSpan(cid, fields.methodName, 'query', duration, 'success', undefined, reqSize).catch(() => {});
863
+ return result;
864
+ } catch (err: any) {
865
+ const duration = performance.now() - startTime;
866
+ const cid = typeof canisterId === 'object' && canisterId.toText ? canisterId.toText() : String(canisterId);
867
+ self.trackCanisterSpan(cid, fields.methodName, 'query', duration, 'error', err?.message || String(err), reqSize).catch(() => {});
868
+ throw err;
869
+ }
870
+ };
871
+ }
872
+
873
+ // Wrap update call
874
+ if (typeof (agent as any).call === 'function') {
875
+ const originalCall = proto.call.bind(agent);
876
+ (agent as any).call = async function (
877
+ canisterId: any,
878
+ fields: { methodName: string; arg: any },
879
+ ...args: any[]
880
+ ) {
881
+ const startTime = performance.now();
882
+ const reqSize = fields.arg?.byteLength || 0;
883
+ try {
884
+ const result = await originalCall(canisterId, fields, ...args);
885
+ const duration = performance.now() - startTime;
886
+ const cid = typeof canisterId === 'object' && canisterId.toText ? canisterId.toText() : String(canisterId);
887
+ self.addBreadcrumb(`Canister update: ${fields.methodName} on ${cid} (${Math.round(duration)}ms)`, 'contract', {
888
+ canisterId: cid,
889
+ methodName: fields.methodName,
890
+ callType: 'update',
891
+ durationMs: Math.round(duration),
892
+ status: 'success',
893
+ requestSize: reqSize
894
+ });
895
+ self.trackCanisterSpan(cid, fields.methodName, 'update', duration, 'success', undefined, reqSize).catch(() => {});
896
+ return result;
897
+ } catch (err: any) {
898
+ const duration = performance.now() - startTime;
899
+ const cid = typeof canisterId === 'object' && canisterId.toText ? canisterId.toText() : String(canisterId);
900
+ const errMsg = err?.message || String(err);
901
+ self.addBreadcrumb(`Canister update error: ${fields.methodName} on ${cid} (${errMsg})`, 'contract', {
902
+ canisterId: cid,
903
+ methodName: fields.methodName,
904
+ callType: 'update',
905
+ durationMs: Math.round(duration),
906
+ status: 'error'
907
+ });
908
+ self.trackCanisterSpan(cid, fields.methodName, 'update', duration, 'error', errMsg, reqSize).catch(() => {});
909
+ throw err;
910
+ }
911
+ };
912
+ }
913
+
914
+ (agent as any).__socioDauWrapped = true;
915
+ return agent;
916
+ }
917
+
918
+ /**
919
+ * Start a performance span timer
920
+ */
921
+ public startSpan(name: string, entryType = 'custom') {
922
+ const startTime = performance.now();
923
+ const self = this;
924
+
925
+ return {
926
+ end: async (metadata?: any) => {
927
+ const endTime = performance.now();
928
+ const duration = endTime - startTime;
929
+ await self.trackPerformanceSpan(name, duration, entryType, metadata);
930
+ }
931
+ };
932
+ }
933
+
934
+ /**
935
+ * Measure latency of a function and resolve its result
936
+ */
937
+ public async measure<T>(
938
+ name: string,
939
+ fn: () => Promise<T> | T,
940
+ entryType = 'custom',
941
+ metadata?: any
942
+ ): Promise<T> {
943
+ const span = this.startSpan(name, entryType);
944
+ try {
945
+ const result = await fn();
946
+ await span.end({ ...metadata, success: true });
947
+ return result;
948
+ } catch (error: any) {
949
+ const errMsg = error instanceof Error ? error.message : String(error);
950
+ await span.end({ ...metadata, success: false, error: errMsg });
951
+ throw error;
952
+ }
953
+ }
954
+
955
+ /**
956
+ * Define a user funnel for conversion analysis.
957
+ * Stores the ordered event sequence to the database so the
958
+ * dashboard can visualize drop-off at each step.
959
+ *
960
+ * Example:
961
+ * tracker.defineFunnel('Swap Flow', [
962
+ * 'connect_wallet',
963
+ * 'click_swap_token',
964
+ * 'sign_transaction',
965
+ * 'swap_success'
966
+ * ])
967
+ */
968
+ async defineFunnel(
969
+ name: string,
970
+ steps: string[]
971
+ ): Promise<{ success: boolean; error?: string }> {
972
+ const cleanName = String(name).trim();
973
+ if (!cleanName) {
974
+ this.logError('defineFunnel() called with empty name');
975
+ return { success: false, error: 'Funnel name cannot be empty' };
976
+ }
977
+ if (!steps || steps.length < 2) {
978
+ this.logError('defineFunnel() called with fewer than 2 steps');
979
+ return { success: false, error: 'Funnel must have at least 2 steps' };
980
+ }
981
+
982
+ this.logDebug(`Defining funnel: ${cleanName} [${steps.join(' → ')}]`);
983
+ return this.executeRpc('upsert_funnel', {
984
+ p_api_key: this.apiKey,
985
+ p_owner_did: this.configUserId ? this.resolveUserId() : null,
986
+ p_name: cleanName,
987
+ p_steps: steps
988
+ });
989
+ }
990
+
991
+ private resolveUserId(): string | null {
992
+ if (typeof this.configUserId === 'function') {
993
+ const id = this.configUserId();
994
+ return id ? String(id) : null;
995
+ }
996
+ return this.configUserId ? String(this.configUserId) : null;
997
+ }
998
+
999
+ /**
1000
+ * Listen to Single-Page App (SPA) route changes and track page views automatically
1001
+ */
1002
+ private setupAutoTracking(userIdParam: string | (() => string | null | undefined)) {
1003
+ if (typeof window === 'undefined') return;
1004
+
1005
+ const getUserId = (): string | null => {
1006
+ if (typeof userIdParam === 'function') {
1007
+ const id = userIdParam();
1008
+ return id ? String(id) : null;
1009
+ }
1010
+ return userIdParam ? String(userIdParam) : null;
1011
+ };
1012
+
1013
+ const trackCurrent = () => {
1014
+ const uid = getUserId();
1015
+ if (uid) {
1016
+ this.trackPageView(uid).catch(err => {
1017
+ this.logError('Auto-track page view failed:', err);
1018
+ });
1019
+ }
1020
+ };
1021
+
1022
+ if (document.readyState === 'complete') {
1023
+ trackCurrent();
1024
+ } else {
1025
+ window.addEventListener('load', trackCurrent);
1026
+ }
1027
+
1028
+ const wrapHistory = (type: 'pushState' | 'replaceState') => {
1029
+ const original = window.history[type];
1030
+ return (...args: any[]) => {
1031
+ const result = original.apply(window.history, args as any);
1032
+ trackCurrent();
1033
+ return result;
1034
+ };
1035
+ };
1036
+
1037
+ try {
1038
+ window.history.pushState = wrapHistory('pushState');
1039
+ window.history.replaceState = wrapHistory('replaceState');
1040
+ } catch (e) {
1041
+ this.logError('Failed to monkeypatch history API:', e);
1042
+ }
1043
+
1044
+ window.addEventListener('popstate', trackCurrent);
1045
+ }
1046
+
1047
+ private setupOnlineListener() {
1048
+ if (typeof window !== 'undefined' && window.addEventListener) {
1049
+ window.addEventListener('online', () => {
1050
+ this.logDebug('Device came back online. Flushing queued telemetry events...');
1051
+ this.flushOfflineQueue();
1052
+ });
1053
+ setTimeout(() => {
1054
+ if (this.isOnline()) {
1055
+ this.flushOfflineQueue();
1056
+ }
1057
+ }, 1000);
1058
+ }
1059
+ }
1060
+
1061
+ private isOnline(): boolean {
1062
+ if (typeof window === 'undefined') return true;
1063
+ if (typeof navigator === 'undefined') return true;
1064
+ return navigator.onLine;
1065
+ }
1066
+
1067
+ private isNetworkError(error: any): boolean {
1068
+ const msg = String(error?.message || error || "").toLowerCase();
1069
+ return (
1070
+ msg.includes("failed to fetch") ||
1071
+ msg.includes("network error") ||
1072
+ msg.includes("load failed") ||
1073
+ msg.includes("networkerror") ||
1074
+ msg.includes("typeerror: fetch failed")
1075
+ );
1076
+ }
1077
+
1078
+ private getQueueKey(): string {
1079
+ return `socio_telemetry_queue_${this.apiKey}`;
1080
+ }
1081
+
1082
+ private getOfflineQueue(): Array<{ method: string; params: any }> {
1083
+ if (typeof window === 'undefined') return [];
1084
+ try {
1085
+ const val = localStorage.getItem(this.getQueueKey());
1086
+ return val ? JSON.parse(val) : [];
1087
+ } catch (e) {
1088
+ return [];
1089
+ }
1090
+ }
1091
+
1092
+ private saveOfflineQueue(queue: Array<{ method: string; params: any }>) {
1093
+ if (typeof window === 'undefined') return;
1094
+ try {
1095
+ if (queue.length > 500) {
1096
+ queue = queue.slice(-500);
1097
+ }
1098
+ localStorage.setItem(this.getQueueKey(), JSON.stringify(queue));
1099
+ } catch (e) {
1100
+ this.logError('Failed to save offline queue to localStorage', e);
1101
+ }
1102
+ }
1103
+
1104
+ private enqueueOfflineAction(methodName: string, params: any) {
1105
+ this.logDebug(`Queueing offline event: [${methodName}]`);
1106
+ const queue = this.getOfflineQueue();
1107
+ queue.push({ method: methodName, params });
1108
+ this.saveOfflineQueue(queue);
1109
+ }
1110
+
1111
+ private isFlushing = false;
1112
+
1113
+ private async flushOfflineQueue() {
1114
+ if (this.isFlushing || typeof window === 'undefined' || !this.isOnline()) return;
1115
+
1116
+ const queue = this.getOfflineQueue();
1117
+ if (queue.length === 0) return;
1118
+
1119
+ this.isFlushing = true;
1120
+ this.logDebug(`Flushing offline telemetry queue (${queue.length} events)...`);
1121
+
1122
+ let remainingQueue = [...queue];
1123
+
1124
+ try {
1125
+ while (remainingQueue.length > 0) {
1126
+ const item = remainingQueue[0];
1127
+
1128
+ const { data, error } = await this.supabase.rpc(item.method, item.params);
1129
+
1130
+ if (error) {
1131
+ if (this.isNetworkError(error)) {
1132
+ this.logDebug(`Network still down while flushing item: ${error.message}. Aborting flush.`);
1133
+ break;
1134
+ } else {
1135
+ this.logError(`Dropped failed telemetry item due to non-network error: ${error.message}`);
1136
+ }
1137
+ }
1138
+
1139
+ remainingQueue.shift();
1140
+ this.saveOfflineQueue(remainingQueue);
1141
+ }
1142
+ } catch (e) {
1143
+ this.logError('Error during offline queue flush:', e);
1144
+ } finally {
1145
+ this.isFlushing = false;
1146
+ }
1147
+ }
1148
+
1149
+ private async executeRpc(methodName: string, params: any): Promise<{ success: boolean; error?: string }> {
1150
+ if (typeof window !== 'undefined' && !this.isOnline()) {
1151
+ this.enqueueOfflineAction(methodName, params);
1152
+ return { success: true };
1153
+ }
1154
+
1155
+ try {
1156
+ const { data, error } = await this.supabase.rpc(methodName, params);
1157
+ if (error) {
1158
+ if (this.isNetworkError(error)) {
1159
+ this.enqueueOfflineAction(methodName, params);
1160
+ return { success: true };
1161
+ }
1162
+ this.logError(`${methodName} RPC error:`, error.message);
1163
+ return { success: false, error: error.message };
1164
+ }
1165
+
1166
+ const res = data as any;
1167
+ if (res && res.ok === false) {
1168
+ this.logError(`${methodName} execution failed:`, res.error);
1169
+ return { success: false, error: res.error };
1170
+ }
1171
+
1172
+ return { success: true };
1173
+ } catch (err: any) {
1174
+ if (this.isNetworkError(err)) {
1175
+ this.enqueueOfflineAction(methodName, params);
1176
+ return { success: true };
1177
+ }
1178
+ this.logError(`${methodName} catch error:`, err);
1179
+ return { success: false, error: err?.message || String(err) };
1180
+ }
1181
+ }
1182
+
1183
+ private logDebug(message: string, ...args: any[]) {
1184
+ if (this.debug) {
1185
+ console.log(`[SocioDAU Debug] ${message}`, ...args);
1186
+ }
1187
+ }
1188
+
1189
+ private logError(message: string, ...args: any[]) {
1190
+ console.error(`[Clof Error] ${message}`, ...args);
1191
+ }
1192
+ }
1193
+
1194
+ export { Clof as SocioDauTracker, Clof as ClofTracker };