@senzops/web 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,530 +1,18 @@
1
- // ============================================================================
2
- // --- SHARED UTILITIES ---
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;
490
-
491
- if (payload.traces.length > 0 || payload.errors.length > 0) {
492
- const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
493
-
494
- // FIX: Append API Key to URL because sendBeacon CANNOT send custom headers
495
- const separator = this.endpoint.includes('?') ? '&' : '?';
496
- const authUrl = `${this.endpoint}${separator}apiKey=${this.config.apiKey}`;
497
-
498
- if (navigator.sendBeacon) {
499
- navigator.sendBeacon(authUrl, blob);
500
- } else {
501
- // Fallback fetch: We pass both query param AND header for absolute redundancy
502
- fetch(authUrl, {
503
- method: 'POST',
504
- body: blob,
505
- keepalive: true,
506
- headers: {
507
- 'x-service-api-key': this.config.apiKey
508
- }
509
- }).catch(() => { });
510
- }
511
- }
512
- }
513
- }
514
-
515
- // ============================================================================
516
- // --- EXPORTS & INITIALIZATION ---
517
- // ============================================================================
1
+ import { SenzorAnalyticsAgent, AnalyticsConfig } from './analytics';
2
+ import { SenzorRumAgent, RumConfig } from './rum';
518
3
 
519
4
  export const Analytics = new SenzorAnalyticsAgent();
520
5
  export const RUM = new SenzorRumAgent();
521
6
 
522
- // Maintain backwards compatibility for existing users
7
+ // Maintain backwards compatibility
523
8
  export const Senzor = {
524
9
  init: (config: AnalyticsConfig) => Analytics.init(config),
525
10
  initRum: (config: RumConfig) => RUM.init(config)
526
11
  };
527
12
 
13
+ // Auto-attach to window for script tag users
528
14
  if (typeof window !== 'undefined') {
529
15
  (window as any).Senzor = Senzor;
530
- }
16
+ }
17
+
18
+ export type { AnalyticsConfig, RumConfig };