@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/README.md +244 -0
- package/dist/index.d.ts +205 -0
- package/dist/index.js +1049 -0
- package/dist/widget.d.ts +18 -0
- package/dist/widget.js +623 -0
- package/package.json +30 -0
- package/src/index.ts +1194 -0
- package/src/widget.ts +664 -0
- package/tsconfig.json +15 -0
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 };
|