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