@senzops/web 1.3.0 → 1.3.2
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 +2 -2
- package/dist/index.d.mts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.global.js +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
- package/src/analytics.ts +148 -0
- package/src/index.ts +7 -503
- package/src/rum.ts +443 -0
- package/src/utils.ts +40 -0
package/src/index.ts
CHANGED
|
@@ -1,514 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// ============================================================================
|
|
4
|
-
|
|
5
|
-
// Native UUID Generator (No dependencies)
|
|
6
|
-
function generateUUID(): string {
|
|
7
|
-
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
8
|
-
return crypto.randomUUID();
|
|
9
|
-
}
|
|
10
|
-
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
11
|
-
const r = (Math.random() * 16) | 0;
|
|
12
|
-
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
13
|
-
return v.toString(16);
|
|
14
|
-
});
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// W3C Trace & Span ID Generators (Hex strings)
|
|
18
|
-
function generateHex(length: number): string {
|
|
19
|
-
let result = '';
|
|
20
|
-
while (result.length < length) {
|
|
21
|
-
result += Math.random().toString(16).slice(2);
|
|
22
|
-
}
|
|
23
|
-
return result.slice(0, length);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const getBrowserContext = () => {
|
|
27
|
-
return {
|
|
28
|
-
userAgent: navigator.userAgent,
|
|
29
|
-
url: window.location.href, // This provides the URL dynamically
|
|
30
|
-
deviceMemory: (navigator as any).deviceMemory || undefined,
|
|
31
|
-
connectionType: (navigator as any).connection?.effectiveType || undefined
|
|
32
|
-
};
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
// ============================================================================
|
|
36
|
-
// --- WEB ANALYTICS (MARKETING) MODULE ---
|
|
37
|
-
// ============================================================================
|
|
38
|
-
|
|
39
|
-
interface AnalyticsConfig {
|
|
40
|
-
webId: string;
|
|
41
|
-
endpoint?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
class SenzorAnalyticsAgent {
|
|
45
|
-
private config: AnalyticsConfig = { webId: '' };
|
|
46
|
-
private startTime: number = Date.now();
|
|
47
|
-
private endpoint: string = 'https://api.senzor.dev/api/ingest/web';
|
|
48
|
-
private initialized: boolean = false;
|
|
49
|
-
|
|
50
|
-
public init(config: AnalyticsConfig) {
|
|
51
|
-
if (this.initialized) return;
|
|
52
|
-
this.initialized = true;
|
|
53
|
-
this.config = { ...this.config, ...config };
|
|
54
|
-
if (config.endpoint) this.endpoint = config.endpoint;
|
|
55
|
-
|
|
56
|
-
if (!this.config.webId) {
|
|
57
|
-
console.error('[Senzor] webId is required for Analytics.');
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
this.manageSession();
|
|
62
|
-
this.trackPageView();
|
|
63
|
-
this.setupListeners();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
private normalizeUrl(url: string): string {
|
|
67
|
-
return url ? url.replace(/^https?:\/\//, '') : '';
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
private manageSession() {
|
|
71
|
-
const now = Date.now();
|
|
72
|
-
const lastActivity = parseInt(localStorage.getItem('sz_wa_last') || '0', 10);
|
|
73
|
-
if (!localStorage.getItem('sz_wa_vid')) localStorage.setItem('sz_wa_vid', generateUUID());
|
|
74
|
-
|
|
75
|
-
let sessionId = sessionStorage.getItem('sz_wa_sid');
|
|
76
|
-
if (!sessionId || (now - lastActivity > 30 * 60 * 1000)) {
|
|
77
|
-
sessionId = generateUUID();
|
|
78
|
-
sessionStorage.setItem('sz_wa_sid', sessionId);
|
|
79
|
-
this.determineReferrer(true);
|
|
80
|
-
} else {
|
|
81
|
-
this.determineReferrer(false);
|
|
82
|
-
}
|
|
83
|
-
localStorage.setItem('sz_wa_last', now.toString());
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
private determineReferrer(isNewSession: boolean) {
|
|
87
|
-
const rawReferrer = document.referrer;
|
|
88
|
-
let isExternal = false;
|
|
89
|
-
if (rawReferrer) {
|
|
90
|
-
try { isExternal = new URL(rawReferrer).hostname !== window.location.hostname; } catch (e) { isExternal = true; }
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (isExternal) {
|
|
94
|
-
const cleanRef = this.normalizeUrl(rawReferrer);
|
|
95
|
-
if (cleanRef !== sessionStorage.getItem('sz_wa_ref')) sessionStorage.setItem('sz_wa_ref', cleanRef);
|
|
96
|
-
} else if (isNewSession && !sessionStorage.getItem('sz_wa_ref')) {
|
|
97
|
-
sessionStorage.setItem('sz_wa_ref', 'Direct');
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
private getIds() {
|
|
102
|
-
localStorage.setItem('sz_wa_last', Date.now().toString());
|
|
103
|
-
return {
|
|
104
|
-
visitorId: localStorage.getItem('sz_wa_vid') || 'unknown',
|
|
105
|
-
sessionId: sessionStorage.getItem('sz_wa_sid') || 'unknown',
|
|
106
|
-
referrer: sessionStorage.getItem('sz_wa_ref') || 'Direct'
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private trackPageView() {
|
|
111
|
-
this.manageSession();
|
|
112
|
-
this.startTime = Date.now();
|
|
113
|
-
this.send({ type: 'pageview', webId: this.config.webId, ...this.getIds(), url: window.location.href, path: window.location.pathname, title: document.title, width: window.innerWidth, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, referrer: this.getIds().referrer });
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private trackPing() {
|
|
117
|
-
const duration = Math.floor((Date.now() - this.startTime) / 1000);
|
|
118
|
-
if (duration >= 1) this.send({ type: 'ping', webId: this.config.webId, ...this.getIds(), url: window.location.href, path: window.location.pathname, title: document.title, width: window.innerWidth, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, referrer: this.getIds().referrer, duration });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
private send(data: any) {
|
|
122
|
-
if (navigator.sendBeacon) {
|
|
123
|
-
if (!navigator.sendBeacon(this.endpoint, new Blob([JSON.stringify(data)], { type: 'application/json' }))) this.fallbackSend(data);
|
|
124
|
-
} else {
|
|
125
|
-
this.fallbackSend(data);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
private fallbackSend(data: any) {
|
|
130
|
-
fetch(this.endpoint, { method: 'POST', body: JSON.stringify(data), keepalive: true, headers: { 'Content-Type': 'application/json' } }).catch(() => { });
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private setupListeners() {
|
|
134
|
-
const originalPushState = history.pushState;
|
|
135
|
-
history.pushState = (...args) => { this.trackPing(); originalPushState.apply(history, args); this.trackPageView(); };
|
|
136
|
-
window.addEventListener('popstate', () => { this.trackPing(); this.trackPageView(); });
|
|
137
|
-
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') this.trackPing(); else { this.startTime = Date.now(); this.manageSession(); } });
|
|
138
|
-
window.addEventListener('beforeunload', () => this.trackPing());
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// ============================================================================
|
|
144
|
-
// --- RUM / WEB APM (ENGINEERING) MODULE ---
|
|
145
|
-
// ============================================================================
|
|
146
|
-
|
|
147
|
-
interface RumConfig {
|
|
148
|
-
apiKey: string;
|
|
149
|
-
endpoint?: string;
|
|
150
|
-
sampleRate?: number; // 0.0 to 1.0 (Defaults to 1.0)
|
|
151
|
-
allowedOrigins?: (string | RegExp)[]; // Origins allowed to receive W3C traceparent headers
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
class SenzorRumAgent {
|
|
155
|
-
private config: RumConfig = { apiKey: '', sampleRate: 1.0, allowedOrigins: [] };
|
|
156
|
-
private endpoint: string = 'https://api.senzor.dev/api/ingest/rum';
|
|
157
|
-
private initialized: boolean = false;
|
|
158
|
-
private isSampled: boolean = true;
|
|
159
|
-
|
|
160
|
-
// State
|
|
161
|
-
private sessionId: string = '';
|
|
162
|
-
private traceId: string = '';
|
|
163
|
-
private traceStartTime: number = 0;
|
|
164
|
-
private isInitialLoad: boolean = true;
|
|
165
|
-
|
|
166
|
-
// Buffers
|
|
167
|
-
private spans: any[] = [];
|
|
168
|
-
private errors: any[] = [];
|
|
169
|
-
private breadcrumbs: any[] = [];
|
|
170
|
-
private vitals: any = {};
|
|
171
|
-
private frustrations = { rageClicks: 0, deadClicks: 0, errorCount: 0 };
|
|
172
|
-
private clickHistory: { x: number; y: number; time: number }[] = [];
|
|
173
|
-
|
|
174
|
-
// Intervals
|
|
175
|
-
private flushInterval: any;
|
|
176
|
-
|
|
177
|
-
public init(config: RumConfig) {
|
|
178
|
-
if (this.initialized) return;
|
|
179
|
-
this.initialized = true;
|
|
180
|
-
this.config = { ...this.config, ...config };
|
|
181
|
-
if (config.endpoint) this.endpoint = config.endpoint;
|
|
182
|
-
|
|
183
|
-
if (!this.config.apiKey) {
|
|
184
|
-
console.error('[Senzor RUM] apiKey is required.');
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Determine Sampling (Errors are ALWAYS 100% sampled, only Traces drop)
|
|
189
|
-
this.isSampled = Math.random() <= (this.config.sampleRate ?? 1.0);
|
|
190
|
-
|
|
191
|
-
this.manageSession();
|
|
192
|
-
this.startNewTrace(true);
|
|
193
|
-
|
|
194
|
-
this.setupErrorListeners();
|
|
195
|
-
this.setupPerformanceObservers();
|
|
196
|
-
this.setupUXListeners();
|
|
197
|
-
if (this.isSampled) this.patchNetwork();
|
|
198
|
-
|
|
199
|
-
// Micro-batch flush every 10s
|
|
200
|
-
this.flushInterval = setInterval(() => this.flush(), 10000);
|
|
201
|
-
|
|
202
|
-
// SPA and Unload Listeners
|
|
203
|
-
this.setupRoutingListeners();
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
private manageSession() {
|
|
207
|
-
if (!sessionStorage.getItem('sz_rum_sid')) {
|
|
208
|
-
sessionStorage.setItem('sz_rum_sid', generateUUID());
|
|
209
|
-
}
|
|
210
|
-
this.sessionId = sessionStorage.getItem('sz_rum_sid') as string;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
private startNewTrace(isInitialLoad: boolean) {
|
|
214
|
-
this.traceId = generateHex(32); // W3C Standard Trace ID
|
|
215
|
-
this.traceStartTime = Date.now();
|
|
216
|
-
this.isInitialLoad = isInitialLoad;
|
|
217
|
-
this.spans = [];
|
|
218
|
-
this.vitals = {};
|
|
219
|
-
this.frustrations = { rageClicks: 0, deadClicks: 0, errorCount: 0 };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// --- Breadcrumbs (For Error Context) ---
|
|
223
|
-
private addBreadcrumb(type: string, message: string) {
|
|
224
|
-
this.breadcrumbs.push({ type, message, time: Date.now() });
|
|
225
|
-
if (this.breadcrumbs.length > 15) this.breadcrumbs.shift(); // Keep last 15 actions
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// --- 1. UX Frustration Detection ---
|
|
229
|
-
private setupUXListeners() {
|
|
230
|
-
document.addEventListener('click', (e) => {
|
|
231
|
-
const target = e.target as HTMLElement;
|
|
232
|
-
const tag = target.tagName ? target.tagName.toLowerCase() : '';
|
|
233
|
-
|
|
234
|
-
// Breadcrumb
|
|
235
|
-
this.addBreadcrumb('click', `Clicked ${tag}${target.id ? '#' + target.id : ''}${target.className ? '.' + target.className.split(' ')[0] : ''}`);
|
|
236
|
-
|
|
237
|
-
// Dead Click Heuristic (Clicked non-interactive element)
|
|
238
|
-
const interactiveElements = ['a', 'button', 'input', 'select', 'textarea', 'label'];
|
|
239
|
-
const isInteractive = interactiveElements.includes(tag) || target.closest('button') || target.closest('a') || target.hasAttribute('role') || target.onclick;
|
|
240
|
-
if (!isInteractive) {
|
|
241
|
-
this.frustrations.deadClicks++;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Rage Click Heuristic (>= 3 clicks within 50px radius in < 1 second)
|
|
245
|
-
const now = Date.now();
|
|
246
|
-
this.clickHistory.push({ x: e.clientX, y: e.clientY, time: now });
|
|
247
|
-
|
|
248
|
-
// Clean old history
|
|
249
|
-
this.clickHistory = this.clickHistory.filter(c => now - c.time < 1000);
|
|
250
|
-
|
|
251
|
-
if (this.clickHistory.length >= 3) {
|
|
252
|
-
const first = this.clickHistory[0];
|
|
253
|
-
let isRage = true;
|
|
254
|
-
for (let i = 1; i < this.clickHistory.length; i++) {
|
|
255
|
-
const dx = Math.abs(this.clickHistory[i].x - first.x);
|
|
256
|
-
const dy = Math.abs(this.clickHistory[i].y - first.y);
|
|
257
|
-
if (dx > 50 || dy > 50) isRage = false;
|
|
258
|
-
}
|
|
259
|
-
if (isRage) {
|
|
260
|
-
this.frustrations.rageClicks++;
|
|
261
|
-
this.clickHistory = []; // Reset after registering rage click
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}, { capture: true, passive: true });
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// --- 2. Google Core Web Vitals (Non-blocking) ---
|
|
268
|
-
private setupPerformanceObservers() {
|
|
269
|
-
if (!this.isSampled || typeof PerformanceObserver === 'undefined') return;
|
|
270
|
-
|
|
271
|
-
try {
|
|
272
|
-
// First Contentful Paint (FCP)
|
|
273
|
-
new PerformanceObserver((entryList) => {
|
|
274
|
-
for (const entry of entryList.getEntriesByName('first-contentful-paint')) {
|
|
275
|
-
this.vitals.fcp = entry.startTime;
|
|
276
|
-
}
|
|
277
|
-
}).observe({ type: 'paint', buffered: true });
|
|
278
|
-
|
|
279
|
-
// Largest Contentful Paint (LCP)
|
|
280
|
-
new PerformanceObserver((entryList) => {
|
|
281
|
-
const entries = entryList.getEntries();
|
|
282
|
-
const lastEntry = entries[entries.length - 1];
|
|
283
|
-
if (lastEntry) this.vitals.lcp = lastEntry.startTime;
|
|
284
|
-
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
|
285
|
-
|
|
286
|
-
// Cumulative Layout Shift (CLS)
|
|
287
|
-
let clsScore = 0;
|
|
288
|
-
new PerformanceObserver((entryList) => {
|
|
289
|
-
for (const entry of entryList.getEntries()) {
|
|
290
|
-
if (!(entry as any).hadRecentInput) {
|
|
291
|
-
clsScore += (entry as any).value;
|
|
292
|
-
this.vitals.cls = clsScore;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}).observe({ type: 'layout-shift', buffered: true });
|
|
296
|
-
|
|
297
|
-
// Interaction to Next Paint (INP / FID fallback)
|
|
298
|
-
new PerformanceObserver((entryList) => {
|
|
299
|
-
for (const entry of entryList.getEntries()) {
|
|
300
|
-
const evt = entry as any; // Safely bypass TS base-class limits for PerformanceEventTiming
|
|
301
|
-
const delay = evt.duration || (evt.processingStart && evt.startTime ? evt.processingStart - evt.startTime : 0);
|
|
302
|
-
if (!this.vitals.inp || delay > this.vitals.inp) {
|
|
303
|
-
this.vitals.inp = delay;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}).observe({ type: 'event', buffered: true, durationThreshold: 40 } as any);
|
|
307
|
-
|
|
308
|
-
} catch (e) {
|
|
309
|
-
// Browser doesn't support specific observer type, degrade gracefully
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
private getNavigationTimings() {
|
|
314
|
-
if (typeof performance === 'undefined') return {};
|
|
315
|
-
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
|
316
|
-
if (!nav) return {};
|
|
317
|
-
|
|
318
|
-
return {
|
|
319
|
-
dns: Math.max(0, nav.domainLookupEnd - nav.domainLookupStart),
|
|
320
|
-
tcp: Math.max(0, nav.connectEnd - nav.connectStart),
|
|
321
|
-
ssl: nav.secureConnectionStart ? Math.max(0, nav.requestStart - nav.secureConnectionStart) : 0,
|
|
322
|
-
ttfb: Math.max(0, nav.responseStart - nav.requestStart),
|
|
323
|
-
domInteractive: Math.max(0, nav.domInteractive - nav.startTime),
|
|
324
|
-
domComplete: Math.max(0, nav.domComplete - nav.startTime),
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// --- 3. Distributed Tracing (Patching) ---
|
|
329
|
-
private shouldAttachTraceHeader(url: string): boolean {
|
|
330
|
-
if (!this.config.allowedOrigins || this.config.allowedOrigins.length === 0) return false;
|
|
331
|
-
try {
|
|
332
|
-
const targetUrl = new URL(url, window.location.origin);
|
|
333
|
-
return this.config.allowedOrigins.some(allowed => {
|
|
334
|
-
if (typeof allowed === 'string') return targetUrl.origin.includes(allowed);
|
|
335
|
-
if (allowed instanceof RegExp) return allowed.test(targetUrl.origin);
|
|
336
|
-
return false;
|
|
337
|
-
});
|
|
338
|
-
} catch { return false; }
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
private patchNetwork() {
|
|
342
|
-
const self = this;
|
|
343
|
-
|
|
344
|
-
// Patch XHR
|
|
345
|
-
const originalXhrOpen = XMLHttpRequest.prototype.open;
|
|
346
|
-
const originalXhrSend = XMLHttpRequest.prototype.send;
|
|
347
|
-
|
|
348
|
-
XMLHttpRequest.prototype.open = function (method: string, url: string, ...rest: any[]) {
|
|
349
|
-
(this as any).__szMethod = method;
|
|
350
|
-
(this as any).__szUrl = url;
|
|
351
|
-
return originalXhrOpen.apply(this, [method, url, ...rest] as any);
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
XMLHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
|
|
355
|
-
const xhr = this as any;
|
|
356
|
-
const spanId = generateHex(16);
|
|
357
|
-
const startTime = Date.now() - self.traceStartTime;
|
|
358
|
-
|
|
359
|
-
if (self.shouldAttachTraceHeader(xhr.__szUrl)) {
|
|
360
|
-
xhr.setRequestHeader('traceparent', `00-${self.traceId}-${spanId}-01`);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
xhr.addEventListener('loadend', () => {
|
|
364
|
-
self.spans.push({
|
|
365
|
-
spanId, name: new URL(xhr.__szUrl, window.location.origin).pathname,
|
|
366
|
-
type: 'xhr', method: xhr.__szMethod, status: xhr.status,
|
|
367
|
-
startTime, duration: (Date.now() - self.traceStartTime) - startTime
|
|
368
|
-
});
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
return originalXhrSend.call(this, body);
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
// Patch Fetch
|
|
375
|
-
const originalFetch = window.fetch;
|
|
376
|
-
window.fetch = async function (...args) {
|
|
377
|
-
const url = typeof args[0] === 'string' ? args[0] : (args[0] as Request).url;
|
|
378
|
-
const method = (args[1]?.method || (args[0] as Request).method || 'GET').toUpperCase();
|
|
379
|
-
|
|
380
|
-
const spanId = generateHex(16);
|
|
381
|
-
const startTime = Date.now() - self.traceStartTime;
|
|
382
|
-
|
|
383
|
-
if (self.shouldAttachTraceHeader(url)) {
|
|
384
|
-
const headers = new Headers(args[1]?.headers || (args[0] as Request).headers || {});
|
|
385
|
-
headers.set('traceparent', `00-${self.traceId}-${spanId}-01`);
|
|
386
|
-
if (args[1]) args[1].headers = headers;
|
|
387
|
-
else if (args[0] instanceof Request) args[0] = new Request(args[0], { headers });
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
try {
|
|
391
|
-
const response = await originalFetch.apply(this, args);
|
|
392
|
-
self.spans.push({
|
|
393
|
-
spanId, name: new URL(url, window.location.origin).pathname,
|
|
394
|
-
type: 'fetch', method, status: response.status,
|
|
395
|
-
startTime, duration: (Date.now() - self.traceStartTime) - startTime
|
|
396
|
-
});
|
|
397
|
-
return response;
|
|
398
|
-
} catch (error) {
|
|
399
|
-
self.spans.push({
|
|
400
|
-
spanId, name: new URL(url, window.location.origin).pathname,
|
|
401
|
-
type: 'fetch', method, status: 0,
|
|
402
|
-
startTime, duration: (Date.now() - self.traceStartTime) - startTime
|
|
403
|
-
});
|
|
404
|
-
throw error;
|
|
405
|
-
}
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// --- 4. Universal Error Engine Hooks ---
|
|
410
|
-
private setupErrorListeners() {
|
|
411
|
-
const handleGlobalError = (errorObj: Error, type: string) => {
|
|
412
|
-
this.frustrations.errorCount++;
|
|
413
|
-
const message = errorObj.message || String(errorObj);
|
|
414
|
-
|
|
415
|
-
this.errors.push({
|
|
416
|
-
errorClass: errorObj.name || 'Error',
|
|
417
|
-
message: message,
|
|
418
|
-
stackTrace: errorObj.stack || '',
|
|
419
|
-
traceId: this.isSampled ? this.traceId : undefined,
|
|
420
|
-
context: {
|
|
421
|
-
type,
|
|
422
|
-
...getBrowserContext(),
|
|
423
|
-
breadcrumbs: [...this.breadcrumbs] // Snapshot of actions leading up to crash
|
|
424
|
-
},
|
|
425
|
-
timestamp: new Date().toISOString()
|
|
426
|
-
});
|
|
427
|
-
this.flush(); // Flush immediately on error
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
window.addEventListener('error', (event) => {
|
|
431
|
-
if (event.error) handleGlobalError(event.error, 'Uncaught Exception');
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
window.addEventListener('unhandledrejection', (event) => {
|
|
435
|
-
handleGlobalError(event.reason instanceof Error ? event.reason : new Error(String(event.reason)), 'Unhandled Promise Rejection');
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// --- 5. Lifecycle & Beaconing ---
|
|
440
|
-
private setupRoutingListeners() {
|
|
441
|
-
const originalPushState = history.pushState;
|
|
442
|
-
history.pushState = (...args) => {
|
|
443
|
-
this.flush(); // Flush previous page view
|
|
444
|
-
originalPushState.apply(history, args);
|
|
445
|
-
this.startNewTrace(false);
|
|
446
|
-
this.addBreadcrumb('navigation', window.location.pathname);
|
|
447
|
-
};
|
|
448
|
-
|
|
449
|
-
window.addEventListener('popstate', () => {
|
|
450
|
-
this.flush();
|
|
451
|
-
this.startNewTrace(false);
|
|
452
|
-
this.addBreadcrumb('navigation', window.location.pathname);
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
document.addEventListener('visibilitychange', () => {
|
|
456
|
-
if (document.visibilityState === 'hidden') this.flush();
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
window.addEventListener('pagehide', () => this.flush());
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
private flush() {
|
|
463
|
-
if (this.spans.length === 0 && this.errors.length === 0 && !this.isInitialLoad) return;
|
|
464
|
-
|
|
465
|
-
const payload: any = { traces: [], errors: this.errors };
|
|
466
|
-
|
|
467
|
-
// Only send performance trace if sampled
|
|
468
|
-
if (this.isSampled) {
|
|
469
|
-
payload.traces.push({
|
|
470
|
-
traceId: this.traceId,
|
|
471
|
-
sessionId: this.sessionId,
|
|
472
|
-
traceType: this.isInitialLoad ? 'initial_load' : 'route_change',
|
|
473
|
-
path: window.location.pathname,
|
|
474
|
-
referrer: document.referrer || '',
|
|
475
|
-
vitals: { ...this.vitals },
|
|
476
|
-
timings: this.isInitialLoad ? this.getNavigationTimings() : {},
|
|
477
|
-
frustration: { ...this.frustrations },
|
|
478
|
-
...getBrowserContext(), // Injects URL, userAgent, connectionType, etc.
|
|
479
|
-
spans: [...this.spans],
|
|
480
|
-
duration: Date.now() - this.traceStartTime,
|
|
481
|
-
timestamp: new Date(this.traceStartTime).toISOString()
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Reset Buffers
|
|
486
|
-
this.spans = [];
|
|
487
|
-
this.errors = [];
|
|
488
|
-
this.frustrations = { rageClicks: 0, deadClicks: 0, errorCount: 0 };
|
|
489
|
-
this.isInitialLoad = false; // Next flush on same page is an update, not initial load
|
|
490
|
-
|
|
491
|
-
if (payload.traces.length > 0 || payload.errors.length > 0) {
|
|
492
|
-
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
|
|
493
|
-
if (navigator.sendBeacon) navigator.sendBeacon(this.endpoint, blob);
|
|
494
|
-
else fetch(this.endpoint, { method: 'POST', body: blob, keepalive: true }).catch(() => { });
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// ============================================================================
|
|
500
|
-
// --- EXPORTS & INITIALIZATION ---
|
|
501
|
-
// ============================================================================
|
|
1
|
+
import { SenzorAnalyticsAgent, AnalyticsConfig } from './analytics';
|
|
2
|
+
import { SenzorRumAgent, RumConfig } from './rum';
|
|
502
3
|
|
|
503
4
|
export const Analytics = new SenzorAnalyticsAgent();
|
|
504
5
|
export const RUM = new SenzorRumAgent();
|
|
505
6
|
|
|
506
|
-
// Maintain backwards compatibility for existing
|
|
7
|
+
// Maintain backwards compatibility for existing setup scripts
|
|
507
8
|
export const Senzor = {
|
|
508
9
|
init: (config: AnalyticsConfig) => Analytics.init(config),
|
|
509
10
|
initRum: (config: RumConfig) => RUM.init(config)
|
|
510
11
|
};
|
|
511
12
|
|
|
13
|
+
// Auto-attach to window for script tag users
|
|
512
14
|
if (typeof window !== 'undefined') {
|
|
513
15
|
(window as any).Senzor = Senzor;
|
|
514
|
-
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type { AnalyticsConfig, RumConfig };
|