@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/src/rum.ts ADDED
@@ -0,0 +1,443 @@
1
+ import { generateHex, generateUUID, getBrowserContext, getPayloadSize } from './utils';
2
+
3
+ export interface RumConfig {
4
+ apiKey: string;
5
+ endpoint?: string;
6
+ sampleRate?: number; // 0.0 to 1.0 (Defaults to 1.0)
7
+ allowedOrigins?: (string | RegExp)[]; // Origins allowed to receive W3C traceparent headers
8
+ }
9
+
10
+ export class SenzorRumAgent {
11
+ private config: RumConfig = { apiKey: '', sampleRate: 1.0, allowedOrigins: [] };
12
+ private endpoint: string = 'https://api.senzor.dev/api/ingest/rum';
13
+ private initialized: boolean = false;
14
+ private isSampled: boolean = true;
15
+
16
+ // State
17
+ private sessionId: string = '';
18
+ private traceId: string = '';
19
+ private traceStartTime: number = 0;
20
+ private isInitialLoad: boolean = true;
21
+
22
+ // Buffers
23
+ private spans: any[] = [];
24
+ private errors: any[] = [];
25
+ private breadcrumbs: any[] = [];
26
+ private vitals: any = {};
27
+ private frustrations = { rageClicks: 0, deadClicks: 0, errorCount: 0 };
28
+ private clickHistory: { x: number; y: number; time: number }[] = [];
29
+
30
+ // Intervals
31
+ private flushInterval: any;
32
+
33
+ public init(config: RumConfig) {
34
+ if (this.initialized) return;
35
+ this.initialized = true;
36
+ this.config = { ...this.config, ...config };
37
+ if (config.endpoint) this.endpoint = config.endpoint;
38
+
39
+ if (!this.config.apiKey) {
40
+ console.error('[Senzor RUM] apiKey is required.');
41
+ return;
42
+ }
43
+
44
+ // Determine Sampling (Errors are ALWAYS 100% sampled, only Traces drop)
45
+ this.isSampled = Math.random() <= (this.config.sampleRate ?? 1.0);
46
+
47
+ this.manageSession();
48
+ this.startNewTrace(true);
49
+
50
+ this.setupErrorListeners();
51
+ this.setupPerformanceObservers();
52
+ this.setupUXListeners();
53
+ if (this.isSampled) this.patchNetwork();
54
+
55
+ // Micro-batch flush every 10s
56
+ this.flushInterval = setInterval(() => this.flush(), 10000);
57
+
58
+ // SPA and Unload Listeners
59
+ this.setupRoutingListeners();
60
+ }
61
+
62
+ private manageSession() {
63
+ if (!sessionStorage.getItem('sz_rum_sid')) {
64
+ sessionStorage.setItem('sz_rum_sid', generateUUID());
65
+ }
66
+ this.sessionId = sessionStorage.getItem('sz_rum_sid') as string;
67
+ }
68
+
69
+ private startNewTrace(isInitialLoad: boolean) {
70
+ this.traceId = generateHex(32); // W3C Standard Trace ID
71
+ this.traceStartTime = Date.now();
72
+ this.isInitialLoad = isInitialLoad;
73
+ this.spans = [];
74
+ this.vitals = {};
75
+ this.frustrations = { rageClicks: 0, deadClicks: 0, errorCount: 0 };
76
+ }
77
+
78
+ // --- Breadcrumbs (For Error Context) ---
79
+ private addBreadcrumb(type: string, message: string, data?: any) {
80
+ this.breadcrumbs.push({ type, message, data, time: Date.now() });
81
+ if (this.breadcrumbs.length > 15) this.breadcrumbs.shift(); // Keep last 15 actions
82
+ }
83
+
84
+ // --- 1. UX Frustration Detection ---
85
+ private setupUXListeners() {
86
+ document.addEventListener('click', (e) => {
87
+ const target = e.target as HTMLElement;
88
+ const tag = target.tagName ? target.tagName.toLowerCase() : '';
89
+
90
+ // Breadcrumb
91
+ this.addBreadcrumb('click', `Clicked ${tag}${target.id ? '#' + target.id : ''}${target.className ? '.' + target.className.split(' ')[0] : ''}`);
92
+
93
+ // Dead Click Heuristic
94
+ const interactiveElements = ['a', 'button', 'input', 'select', 'textarea', 'label'];
95
+ const isInteractive = interactiveElements.includes(tag) || target.closest('button') || target.closest('a') || target.hasAttribute('role') || target.onclick;
96
+ if (!isInteractive) {
97
+ this.frustrations.deadClicks++;
98
+ }
99
+
100
+ // Rage Click Heuristic
101
+ const now = Date.now();
102
+ this.clickHistory.push({ x: e.clientX, y: e.clientY, time: now });
103
+ this.clickHistory = this.clickHistory.filter(c => now - c.time < 1000);
104
+
105
+ if (this.clickHistory.length >= 3) {
106
+ const first = this.clickHistory[0];
107
+ let isRage = true;
108
+ for (let i = 1; i < this.clickHistory.length; i++) {
109
+ const dx = Math.abs(this.clickHistory[i].x - first.x);
110
+ const dy = Math.abs(this.clickHistory[i].y - first.y);
111
+ if (dx > 50 || dy > 50) isRage = false;
112
+ }
113
+ if (isRage) {
114
+ this.frustrations.rageClicks++;
115
+ this.addBreadcrumb('frustration', 'Rage Click Detected');
116
+ this.clickHistory = []; // Reset
117
+ }
118
+ }
119
+ }, { capture: true, passive: true });
120
+ }
121
+
122
+ // --- 2. Google Core Web Vitals ---
123
+ private setupPerformanceObservers() {
124
+ if (!this.isSampled || typeof PerformanceObserver === 'undefined') return;
125
+
126
+ try {
127
+ new PerformanceObserver((entryList) => {
128
+ for (const entry of entryList.getEntriesByName('first-contentful-paint')) {
129
+ this.vitals.fcp = entry.startTime;
130
+ }
131
+ }).observe({ type: 'paint', buffered: true });
132
+
133
+ new PerformanceObserver((entryList) => {
134
+ const entries = entryList.getEntries();
135
+ const lastEntry = entries[entries.length - 1];
136
+ if (lastEntry) this.vitals.lcp = lastEntry.startTime;
137
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
138
+
139
+ let clsScore = 0;
140
+ new PerformanceObserver((entryList) => {
141
+ for (const entry of entryList.getEntries()) {
142
+ if (!(entry as any).hadRecentInput) {
143
+ clsScore += (entry as any).value;
144
+ this.vitals.cls = clsScore;
145
+ }
146
+ }
147
+ }).observe({ type: 'layout-shift', buffered: true });
148
+
149
+ new PerformanceObserver((entryList) => {
150
+ for (const entry of entryList.getEntries()) {
151
+ const evt = entry as any;
152
+ const delay = evt.duration || (evt.processingStart && evt.startTime ? evt.processingStart - evt.startTime : 0);
153
+ if (!this.vitals.inp || delay > this.vitals.inp) {
154
+ this.vitals.inp = delay;
155
+ }
156
+ }
157
+ }).observe({ type: 'event', buffered: true, durationThreshold: 40 } as any);
158
+
159
+ } catch (e) {
160
+ // Browser doesn't support specific observer type, degrade gracefully
161
+ }
162
+ }
163
+
164
+ private getNavigationTimings() {
165
+ if (typeof performance === 'undefined') return {};
166
+ const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
167
+ if (!nav) return {};
168
+
169
+ return {
170
+ dns: Math.max(0, nav.domainLookupEnd - nav.domainLookupStart),
171
+ tcp: Math.max(0, nav.connectEnd - nav.connectStart),
172
+ ssl: nav.secureConnectionStart ? Math.max(0, nav.requestStart - nav.secureConnectionStart) : 0,
173
+ ttfb: Math.max(0, nav.responseStart - nav.requestStart),
174
+ domInteractive: Math.max(0, nav.domInteractive - nav.startTime),
175
+ domComplete: Math.max(0, nav.domComplete - nav.startTime),
176
+ };
177
+ }
178
+
179
+ // --- 3. Distributed Tracing & Verbose Network Meta ---
180
+ private shouldAttachTraceHeader(url: string): boolean {
181
+ if (!this.config.allowedOrigins || this.config.allowedOrigins.length === 0) return false;
182
+ try {
183
+ const targetUrl = new URL(url, window.location.origin);
184
+ return this.config.allowedOrigins.some(allowed => {
185
+ if (typeof allowed === 'string') return targetUrl.origin.includes(allowed);
186
+ if (allowed instanceof RegExp) return allowed.test(targetUrl.origin);
187
+ return false;
188
+ });
189
+ } catch { return false; }
190
+ }
191
+
192
+ private patchNetwork() {
193
+ const self = this;
194
+
195
+ // --- Patch XHR ---
196
+ const originalXhrOpen = XMLHttpRequest.prototype.open;
197
+ const originalXhrSend = XMLHttpRequest.prototype.send;
198
+ const originalXhrSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
199
+
200
+ XMLHttpRequest.prototype.open = function (method: string, url: string, ...rest: any[]) {
201
+ (this as any).__szMethod = method.toUpperCase();
202
+ (this as any).__szUrl = url;
203
+ (this as any).__szHeaders = {};
204
+ return originalXhrOpen.apply(this, [method, url, ...rest] as any);
205
+ };
206
+
207
+ XMLHttpRequest.prototype.setRequestHeader = function (header: string, value: string) {
208
+ (this as any).__szHeaders[header] = value;
209
+ return originalXhrSetRequestHeader.call(this, header, value);
210
+ };
211
+
212
+ XMLHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
213
+ const xhr = this as any;
214
+ const spanId = generateHex(16);
215
+ const startTime = Date.now() - self.traceStartTime;
216
+ const method = xhr.__szMethod;
217
+ let fullUrl = xhr.__szUrl;
218
+
219
+ try {
220
+ fullUrl = new URL(xhr.__szUrl, window.location.origin).toString();
221
+ } catch (e) { /* ignore */ }
222
+
223
+ if (self.shouldAttachTraceHeader(fullUrl)) {
224
+ xhr.setRequestHeader('traceparent', `00-${self.traceId}-${spanId}-01`);
225
+ }
226
+
227
+ xhr.addEventListener('loadend', () => {
228
+ const duration = (Date.now() - self.traceStartTime) - startTime;
229
+
230
+ // Capture Verbose Metadata for XHR
231
+ const meta: any = {
232
+ url: fullUrl,
233
+ method: method,
234
+ library: 'xhr',
235
+ status: xhr.status,
236
+ responseType: xhr.responseType,
237
+ requestPayloadSize: getPayloadSize(body)
238
+ };
239
+
240
+ try {
241
+ const responseLength = xhr.responseText ? xhr.responseText.length : undefined;
242
+ if (responseLength) meta.responsePayloadSize = responseLength;
243
+ } catch (e) { /* Ignore responseText access errors on binary/blob */ }
244
+
245
+ self.spans.push({
246
+ spanId,
247
+ name: `${method} ${new URL(fullUrl, window.location.origin).pathname}`,
248
+ type: 'http',
249
+ startTime,
250
+ duration,
251
+ status: xhr.status,
252
+ meta
253
+ });
254
+ });
255
+
256
+ return originalXhrSend.call(this, body);
257
+ };
258
+
259
+ // --- Patch Fetch ---
260
+ const originalFetch = window.fetch;
261
+ window.fetch = async function (...args) {
262
+ const requestInfo = args[0];
263
+ const init = args[1];
264
+
265
+ let url = '';
266
+ let method = 'GET';
267
+
268
+ if (typeof requestInfo === 'string' || requestInfo instanceof URL) {
269
+ url = requestInfo.toString();
270
+ method = (init?.method || 'GET').toUpperCase();
271
+ } else if (requestInfo instanceof Request) {
272
+ url = requestInfo.url;
273
+ method = requestInfo.method.toUpperCase();
274
+ }
275
+
276
+ let fullUrl = url;
277
+ try { fullUrl = new URL(url, window.location.origin).toString(); } catch (e) { }
278
+
279
+ const spanId = generateHex(16);
280
+ const startTime = Date.now() - self.traceStartTime;
281
+
282
+ // Safely inject traceparent without breaking Streams
283
+ if (self.shouldAttachTraceHeader(fullUrl)) {
284
+ const traceHeader = `00-${self.traceId}-${spanId}-01`;
285
+ if (requestInfo instanceof Request) {
286
+ const currentHeaders = new Headers(requestInfo.headers);
287
+ currentHeaders.set('traceparent', traceHeader);
288
+ args[1] = { ...(init || {}), headers: currentHeaders };
289
+ } else {
290
+ const currentHeaders = new Headers(init?.headers || {});
291
+ currentHeaders.set('traceparent', traceHeader);
292
+ args[1] = { ...(init || {}), headers: currentHeaders };
293
+ }
294
+ }
295
+
296
+ try {
297
+ const response = await originalFetch.apply(this, args);
298
+ const duration = (Date.now() - self.traceStartTime) - startTime;
299
+
300
+ self.spans.push({
301
+ spanId,
302
+ name: `${method} ${new URL(fullUrl, window.location.origin).pathname}`,
303
+ type: 'http',
304
+ startTime,
305
+ duration,
306
+ status: response.status,
307
+ meta: {
308
+ url: fullUrl,
309
+ method,
310
+ library: 'fetch',
311
+ status: response.status,
312
+ statusText: response.statusText,
313
+ type: response.type,
314
+ redirected: response.redirected,
315
+ requestPayloadSize: getPayloadSize(init?.body)
316
+ }
317
+ });
318
+ return response;
319
+ } catch (error) {
320
+ const duration = (Date.now() - self.traceStartTime) - startTime;
321
+
322
+ self.spans.push({
323
+ spanId,
324
+ name: `${method} ${new URL(fullUrl, window.location.origin).pathname}`,
325
+ type: 'http',
326
+ startTime,
327
+ duration,
328
+ status: 0,
329
+ meta: {
330
+ url: fullUrl,
331
+ method,
332
+ library: 'fetch',
333
+ status: 0,
334
+ error: error instanceof Error ? error.message : String(error),
335
+ requestPayloadSize: getPayloadSize(init?.body)
336
+ }
337
+ });
338
+ throw error;
339
+ }
340
+ };
341
+ }
342
+
343
+ // --- 4. Universal Error Engine Hooks ---
344
+ private setupErrorListeners() {
345
+ const handleGlobalError = (errorObj: Error, type: string) => {
346
+ this.frustrations.errorCount++;
347
+ const message = errorObj.message || String(errorObj);
348
+
349
+ this.errors.push({
350
+ errorClass: errorObj.name || 'Error',
351
+ message: message,
352
+ stackTrace: errorObj.stack || '',
353
+ traceId: this.isSampled ? this.traceId : undefined,
354
+ context: {
355
+ type,
356
+ ...getBrowserContext(),
357
+ breadcrumbs: [...this.breadcrumbs] // Snapshot of actions leading up to crash
358
+ },
359
+ timestamp: new Date().toISOString()
360
+ });
361
+ this.flush(); // Flush immediately on error
362
+ };
363
+
364
+ window.addEventListener('error', (event) => {
365
+ if (event.error) handleGlobalError(event.error, 'Uncaught Exception');
366
+ });
367
+
368
+ window.addEventListener('unhandledrejection', (event) => {
369
+ handleGlobalError(event.reason instanceof Error ? event.reason : new Error(String(event.reason)), 'Unhandled Promise Rejection');
370
+ });
371
+ }
372
+
373
+ // --- 5. Lifecycle & Beaconing ---
374
+ private setupRoutingListeners() {
375
+ const originalPushState = history.pushState;
376
+ history.pushState = (...args) => {
377
+ this.flush(); // Flush previous page view
378
+ originalPushState.apply(history, args);
379
+ this.startNewTrace(false);
380
+ this.addBreadcrumb('navigation', window.location.pathname);
381
+ };
382
+
383
+ window.addEventListener('popstate', () => {
384
+ this.flush();
385
+ this.startNewTrace(false);
386
+ this.addBreadcrumb('navigation', window.location.pathname);
387
+ });
388
+
389
+ document.addEventListener('visibilitychange', () => {
390
+ if (document.visibilityState === 'hidden') this.flush();
391
+ });
392
+
393
+ window.addEventListener('pagehide', () => this.flush());
394
+ }
395
+
396
+ private flush() {
397
+ if (this.spans.length === 0 && this.errors.length === 0 && !this.isInitialLoad) return;
398
+
399
+ const payload: any = { traces: [], errors: this.errors };
400
+
401
+ if (this.isSampled) {
402
+ payload.traces.push({
403
+ traceId: this.traceId,
404
+ sessionId: this.sessionId,
405
+ traceType: this.isInitialLoad ? 'initial_load' : 'route_change',
406
+ path: window.location.pathname,
407
+ referrer: document.referrer || '',
408
+ vitals: { ...this.vitals },
409
+ timings: this.isInitialLoad ? this.getNavigationTimings() : {},
410
+ frustration: { ...this.frustrations },
411
+ ...getBrowserContext(), // URL, UserAgent
412
+ spans: [...this.spans],
413
+ duration: Date.now() - this.traceStartTime,
414
+ timestamp: new Date(this.traceStartTime).toISOString()
415
+ });
416
+ }
417
+
418
+ // Reset Buffers
419
+ this.spans = [];
420
+ this.errors = [];
421
+ this.frustrations = { rageClicks: 0, deadClicks: 0, errorCount: 0 };
422
+ this.isInitialLoad = false;
423
+
424
+ if (payload.traces.length > 0 || payload.errors.length > 0) {
425
+ const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
426
+
427
+ // Safely append API Key to URL for Beacon support
428
+ const separator = this.endpoint.includes('?') ? '&' : '?';
429
+ const authUrl = `${this.endpoint}${separator}apiKey=${this.config.apiKey}`;
430
+
431
+ if (navigator.sendBeacon) {
432
+ navigator.sendBeacon(authUrl, blob);
433
+ } else {
434
+ fetch(authUrl, {
435
+ method: 'POST',
436
+ body: blob,
437
+ keepalive: true,
438
+ headers: { 'x-service-api-key': this.config.apiKey }
439
+ }).catch(() => { });
440
+ }
441
+ }
442
+ }
443
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,40 @@
1
+ // Native UUID Generator (No external dependencies)
2
+ export function generateUUID(): string {
3
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
4
+ return crypto.randomUUID();
5
+ }
6
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
7
+ const r = (Math.random() * 16) | 0;
8
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
9
+ return v.toString(16);
10
+ });
11
+ }
12
+
13
+ // W3C Trace & Span ID Generators (Hex strings)
14
+ export function generateHex(length: number): string {
15
+ let result = '';
16
+ while (result.length < length) {
17
+ result += Math.random().toString(16).slice(2);
18
+ }
19
+ return result.slice(0, length);
20
+ }
21
+
22
+ // Retrieves safe browser metadata for tracing context
23
+ export const getBrowserContext = () => {
24
+ return {
25
+ userAgent: navigator.userAgent,
26
+ url: window.location.href, // Provides the dynamic URL
27
+ deviceMemory: (navigator as any).deviceMemory || undefined,
28
+ connectionType: (navigator as any).connection?.effectiveType || undefined
29
+ };
30
+ };
31
+
32
+ // Safely attempts to parse payload sizes for rich APM metadata
33
+ export const getPayloadSize = (body: any): number | undefined => {
34
+ if (!body) return 0;
35
+ if (typeof body === 'string') return body.length;
36
+ if (body instanceof Blob || body instanceof File) return body.size;
37
+ if (body instanceof FormData) return undefined; // FormData size cannot be synchronously calculated
38
+ if (body instanceof ArrayBuffer) return body.byteLength;
39
+ return undefined;
40
+ };