@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/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 };
|