@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/rum.ts ADDED
@@ -0,0 +1,446 @@
1
+ import { generateHex, generateUUID, getBrowserContext, getPayloadSize, extractHeaders } 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
+ // --- BATCHING QUEUES ---
23
+ private spanQueue: any[] = [];
24
+ private errorQueue: any[] = [];
25
+ private vitals: any = {};
26
+ private breadcrumbs: any[] = [];
27
+ private frustrations = { rageClicks: 0, deadClicks: 0, errorCount: 0 };
28
+ private clickHistory: { x: number; y: number; time: number }[] = [];
29
+
30
+ private flushInterval: any;
31
+ private readonly MAX_BATCH_SIZE = 50;
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
+ this.isSampled = Math.random() <= (this.config.sampleRate ?? 1.0);
45
+
46
+ this.manageSession();
47
+ this.startNewTrace(true);
48
+
49
+ this.setupErrorListeners();
50
+ this.setupPerformanceObservers();
51
+ this.setupUXListeners();
52
+ if (this.isSampled) this.patchNetwork();
53
+
54
+ // Background Worker: Flushes the queue every 5 seconds
55
+ this.flushInterval = setInterval(() => this.flush(), 5000);
56
+ this.setupRoutingListeners();
57
+ }
58
+
59
+ private manageSession() {
60
+ if (!sessionStorage.getItem('sz_rum_sid')) {
61
+ sessionStorage.setItem('sz_rum_sid', generateUUID());
62
+ }
63
+ this.sessionId = sessionStorage.getItem('sz_rum_sid') as string;
64
+ }
65
+
66
+ private startNewTrace(isInitialLoad: boolean) {
67
+ this.traceId = generateHex(32);
68
+ this.traceStartTime = Date.now();
69
+ this.isInitialLoad = isInitialLoad;
70
+ this.vitals = {};
71
+ this.frustrations = { rageClicks: 0, deadClicks: 0, errorCount: 0 };
72
+ }
73
+
74
+ private addBreadcrumb(type: string, message: string, data?: any) {
75
+ this.breadcrumbs.push({ type, message, data, time: Date.now() });
76
+ if (this.breadcrumbs.length > 20) this.breadcrumbs.shift();
77
+ }
78
+
79
+ // --- 1. UX Frustration Detection ---
80
+ private setupUXListeners() {
81
+ document.addEventListener('click', (e) => {
82
+ const target = e.target as HTMLElement;
83
+ const tag = target.tagName ? target.tagName.toLowerCase() : '';
84
+
85
+ this.addBreadcrumb('click', `Clicked ${tag}${target.id ? '#' + target.id : ''}${target.className ? '.' + target.className.split(' ')[0] : ''}`);
86
+
87
+ const interactiveElements = ['a', 'button', 'input', 'select', 'textarea', 'label'];
88
+ const isInteractive = interactiveElements.includes(tag) || target.closest('button') || target.closest('a') || target.hasAttribute('role') || target.onclick;
89
+ if (!isInteractive) this.frustrations.deadClicks++;
90
+
91
+ const now = Date.now();
92
+ this.clickHistory.push({ x: e.clientX, y: e.clientY, time: now });
93
+ this.clickHistory = this.clickHistory.filter(c => now - c.time < 1000);
94
+
95
+ if (this.clickHistory.length >= 3) {
96
+ const first = this.clickHistory[0];
97
+ let isRage = true;
98
+ for (let i = 1; i < this.clickHistory.length; i++) {
99
+ const dx = Math.abs(this.clickHistory[i].x - first.x);
100
+ const dy = Math.abs(this.clickHistory[i].y - first.y);
101
+ if (dx > 50 || dy > 50) isRage = false;
102
+ }
103
+ if (isRage) {
104
+ this.frustrations.rageClicks++;
105
+ this.addBreadcrumb('frustration', 'Rage Click Detected');
106
+ this.clickHistory = [];
107
+ }
108
+ }
109
+ }, { capture: true, passive: true });
110
+ }
111
+
112
+ // --- 2. Google Core Web Vitals ---
113
+ private setupPerformanceObservers() {
114
+ if (!this.isSampled || typeof PerformanceObserver === 'undefined') return;
115
+
116
+ try {
117
+ new PerformanceObserver((entryList) => {
118
+ for (const entry of entryList.getEntriesByName('first-contentful-paint')) {
119
+ this.vitals.fcp = entry.startTime;
120
+ }
121
+ }).observe({ type: 'paint', buffered: true });
122
+
123
+ new PerformanceObserver((entryList) => {
124
+ const entries = entryList.getEntries();
125
+ const lastEntry = entries[entries.length - 1];
126
+ if (lastEntry) this.vitals.lcp = lastEntry.startTime;
127
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
128
+
129
+ let clsScore = 0;
130
+ new PerformanceObserver((entryList) => {
131
+ for (const entry of entryList.getEntries()) {
132
+ if (!(entry as any).hadRecentInput) {
133
+ clsScore += (entry as any).value;
134
+ this.vitals.cls = clsScore;
135
+ }
136
+ }
137
+ }).observe({ type: 'layout-shift', buffered: true });
138
+
139
+ new PerformanceObserver((entryList) => {
140
+ for (const entry of entryList.getEntries()) {
141
+ const evt = entry as any;
142
+ const delay = evt.duration || (evt.processingStart && evt.startTime ? evt.processingStart - evt.startTime : 0);
143
+ if (!this.vitals.inp || delay > this.vitals.inp) {
144
+ this.vitals.inp = delay;
145
+ }
146
+ }
147
+ }).observe({ type: 'event', buffered: true, durationThreshold: 40 } as any);
148
+
149
+ } catch (e) { }
150
+ }
151
+
152
+ private getNavigationTimings() {
153
+ if (typeof performance === 'undefined') return {};
154
+ const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
155
+ if (!nav) return {};
156
+
157
+ return {
158
+ dns: Math.max(0, nav.domainLookupEnd - nav.domainLookupStart),
159
+ tcp: Math.max(0, nav.connectEnd - nav.connectStart),
160
+ ssl: nav.secureConnectionStart ? Math.max(0, nav.requestStart - nav.secureConnectionStart) : 0,
161
+ ttfb: Math.max(0, nav.responseStart - nav.requestStart),
162
+ domInteractive: Math.max(0, nav.domInteractive - nav.startTime),
163
+ domComplete: Math.max(0, nav.domComplete - nav.startTime),
164
+ };
165
+ }
166
+
167
+ // --- 3. Distributed Tracing & Verbose Network Meta ---
168
+ private shouldAttachTraceHeader(url: string): boolean {
169
+ if (!this.config.allowedOrigins || this.config.allowedOrigins.length === 0) return false;
170
+ try {
171
+ const targetUrl = new URL(url, window.location.origin);
172
+ return this.config.allowedOrigins.some(allowed => {
173
+ if (typeof allowed === 'string') return targetUrl.origin.includes(allowed);
174
+ if (allowed instanceof RegExp) return allowed.test(targetUrl.origin);
175
+ return false;
176
+ });
177
+ } catch { return false; }
178
+ }
179
+
180
+ private patchNetwork() {
181
+ const self = this;
182
+
183
+ // --- Patch XHR ---
184
+ const originalXhrOpen = XMLHttpRequest.prototype.open;
185
+ const originalXhrSend = XMLHttpRequest.prototype.send;
186
+ const originalXhrSetReqHeader = XMLHttpRequest.prototype.setRequestHeader;
187
+
188
+ XMLHttpRequest.prototype.open = function (method: string, url: string, ...rest: any[]) {
189
+ (this as any).__szMethod = method.toUpperCase();
190
+ (this as any).__szUrl = url;
191
+ (this as any).__szHeaders = {};
192
+ return originalXhrOpen.apply(this, [method, url, ...rest] as any);
193
+ };
194
+
195
+ XMLHttpRequest.prototype.setRequestHeader = function (header: string, value: string) {
196
+ if (!(this as any).__szHeaders) (this as any).__szHeaders = {};
197
+ (this as any).__szHeaders[header] = value;
198
+ return originalXhrSetReqHeader.apply(this, [header, value]);
199
+ };
200
+
201
+ XMLHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
202
+ const xhr = this as any;
203
+ const spanId = generateHex(16);
204
+ const startTime = Date.now() - self.traceStartTime;
205
+ const method = xhr.__szMethod;
206
+ let fullUrl = xhr.__szUrl;
207
+
208
+ try { fullUrl = new URL(xhr.__szUrl, window.location.origin).toString(); } catch (e) { }
209
+
210
+ if (self.shouldAttachTraceHeader(fullUrl)) {
211
+ xhr.setRequestHeader('traceparent', `00-${self.traceId}-${spanId}-01`);
212
+ }
213
+
214
+ xhr.addEventListener('loadend', () => {
215
+ const duration = (Date.now() - self.traceStartTime) - startTime;
216
+
217
+ let responseHeaders = {};
218
+ try {
219
+ const rawHeaders = xhr.getAllResponseHeaders();
220
+ responseHeaders = rawHeaders.trim().split(/[\r\n]+/).reduce((acc: any, line: string) => {
221
+ const parts = line.split(': ');
222
+ const header = parts.shift();
223
+ const value = parts.join(': ');
224
+ if (header) acc[header] = value;
225
+ return acc;
226
+ }, {});
227
+ } catch (e) { }
228
+
229
+ const meta: any = {
230
+ url: fullUrl,
231
+ method,
232
+ library: 'xhr',
233
+ status: xhr.status,
234
+ responseType: xhr.responseType,
235
+ requestPayloadSize: getPayloadSize(body),
236
+ requestHeaders: xhr.__szHeaders,
237
+ responseHeaders
238
+ };
239
+
240
+ try {
241
+ if (xhr.responseType === '' || xhr.responseType === 'text') {
242
+ meta.responsePayloadSize = xhr.responseText?.length;
243
+ }
244
+ } catch (e) { }
245
+
246
+ // Queue Span
247
+ self.spanQueue.push({
248
+ spanId,
249
+ name: `${method} ${new URL(fullUrl, window.location.origin).pathname}`,
250
+ type: 'http',
251
+ startTime,
252
+ duration,
253
+ status: xhr.status,
254
+ meta
255
+ });
256
+
257
+ if (self.spanQueue.length >= self.MAX_BATCH_SIZE) self.flush();
258
+ });
259
+
260
+ return originalXhrSend.call(this, body);
261
+ };
262
+
263
+ // --- Patch Fetch ---
264
+ const originalFetch = window.fetch;
265
+ window.fetch = async function (...args) {
266
+ const requestInfo = args[0];
267
+ const init = args[1];
268
+
269
+ let url = '';
270
+ let method = 'GET';
271
+
272
+ if (typeof requestInfo === 'string' || requestInfo instanceof URL) {
273
+ url = requestInfo.toString();
274
+ method = (init?.method || 'GET').toUpperCase();
275
+ } else if (requestInfo instanceof Request) {
276
+ url = requestInfo.url;
277
+ method = requestInfo.method.toUpperCase();
278
+ }
279
+
280
+ let fullUrl = url;
281
+ try { fullUrl = new URL(url, window.location.origin).toString(); } catch (e) { }
282
+
283
+ const spanId = generateHex(16);
284
+ const startTime = Date.now() - self.traceStartTime;
285
+
286
+ let reqHeadersObj = extractHeaders(init?.headers || (requestInfo instanceof Request ? requestInfo.headers : {}));
287
+
288
+ if (self.shouldAttachTraceHeader(fullUrl)) {
289
+ const traceHeader = `00-${self.traceId}-${spanId}-01`;
290
+ if (requestInfo instanceof Request) {
291
+ const currentHeaders = new Headers(requestInfo.headers);
292
+ currentHeaders.set('traceparent', traceHeader);
293
+ args[1] = { ...(init || {}), headers: currentHeaders };
294
+ } else {
295
+ const currentHeaders = new Headers(init?.headers || {});
296
+ currentHeaders.set('traceparent', traceHeader);
297
+ args[1] = { ...(init || {}), headers: currentHeaders };
298
+ }
299
+ reqHeadersObj['traceparent'] = traceHeader;
300
+ }
301
+
302
+ const captureSpan = (status: number, response?: Response, errorMsg?: string) => {
303
+ const duration = (Date.now() - self.traceStartTime) - startTime;
304
+
305
+ const meta: any = {
306
+ url: fullUrl,
307
+ method,
308
+ library: 'fetch',
309
+ status,
310
+ requestPayloadSize: getPayloadSize(init?.body),
311
+ requestHeaders: reqHeadersObj,
312
+ };
313
+
314
+ if (response) {
315
+ meta.statusText = response.statusText;
316
+ meta.type = response.type;
317
+ meta.redirected = response.redirected;
318
+ meta.responseHeaders = extractHeaders(response.headers);
319
+ }
320
+
321
+ if (errorMsg) meta.error = errorMsg;
322
+
323
+ self.spanQueue.push({
324
+ spanId,
325
+ name: `${method} ${new URL(fullUrl, window.location.origin).pathname}`,
326
+ type: 'http',
327
+ startTime,
328
+ duration,
329
+ status,
330
+ meta
331
+ });
332
+
333
+ if (self.spanQueue.length >= self.MAX_BATCH_SIZE) self.flush();
334
+ };
335
+
336
+ try {
337
+ const response = await originalFetch.apply(this, args);
338
+ captureSpan(response.status, response);
339
+ return response;
340
+ } catch (error) {
341
+ captureSpan(0, undefined, error instanceof Error ? error.message : String(error));
342
+ throw error;
343
+ }
344
+ };
345
+ }
346
+
347
+ // --- 4. Universal Error Engine Hooks ---
348
+ private setupErrorListeners() {
349
+ const handleGlobalError = (errorObj: Error, type: string) => {
350
+ this.frustrations.errorCount++;
351
+ const message = errorObj.message || String(errorObj);
352
+
353
+ this.errorQueue.push({
354
+ errorClass: errorObj.name || 'Error',
355
+ message: message,
356
+ stackTrace: errorObj.stack || '',
357
+ traceId: this.isSampled ? this.traceId : undefined,
358
+ context: {
359
+ type,
360
+ ...getBrowserContext(),
361
+ breadcrumbs: [...this.breadcrumbs]
362
+ },
363
+ timestamp: new Date().toISOString()
364
+ });
365
+
366
+ this.flush(); // Flush immediately on critical error
367
+ };
368
+
369
+ window.addEventListener('error', (event) => {
370
+ if (event.error) handleGlobalError(event.error, 'Uncaught Exception');
371
+ });
372
+
373
+ window.addEventListener('unhandledrejection', (event) => {
374
+ handleGlobalError(event.reason instanceof Error ? event.reason : new Error(String(event.reason)), 'Unhandled Promise Rejection');
375
+ });
376
+ }
377
+
378
+ // --- 5. Lifecycle & Beaconing ---
379
+ private setupRoutingListeners() {
380
+ const originalPushState = history.pushState;
381
+ history.pushState = (...args) => {
382
+ this.flush();
383
+ originalPushState.apply(history, args);
384
+ this.startNewTrace(false);
385
+ this.addBreadcrumb('navigation', window.location.pathname);
386
+ };
387
+
388
+ window.addEventListener('popstate', () => {
389
+ this.flush();
390
+ this.startNewTrace(false);
391
+ this.addBreadcrumb('navigation', window.location.pathname);
392
+ });
393
+
394
+ document.addEventListener('visibilitychange', () => {
395
+ if (document.visibilityState === 'hidden') this.flush();
396
+ });
397
+
398
+ window.addEventListener('pagehide', () => this.flush());
399
+ }
400
+
401
+ private flush() {
402
+ if (this.spanQueue.length === 0 && this.errorQueue.length === 0 && !this.isInitialLoad) return;
403
+
404
+ const spansToSend = this.spanQueue.splice(0, this.MAX_BATCH_SIZE);
405
+ const errorsToSend = this.errorQueue.splice(0, 20);
406
+
407
+ const payload: any = { traces: [], errors: errorsToSend };
408
+
409
+ if (this.isSampled) {
410
+ payload.traces.push({
411
+ traceId: this.traceId,
412
+ sessionId: this.sessionId,
413
+ traceType: this.isInitialLoad ? 'initial_load' : 'route_change',
414
+ path: window.location.pathname,
415
+ referrer: document.referrer || '',
416
+ vitals: { ...this.vitals },
417
+ timings: this.isInitialLoad ? this.getNavigationTimings() : {},
418
+ frustration: { ...this.frustrations },
419
+ ...getBrowserContext(),
420
+ spans: spansToSend,
421
+ duration: Date.now() - this.traceStartTime,
422
+ timestamp: new Date(this.traceStartTime).toISOString()
423
+ });
424
+ }
425
+
426
+ this.isInitialLoad = false;
427
+
428
+ if (payload.traces.length > 0 || payload.errors.length > 0) {
429
+ const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
430
+
431
+ const separator = this.endpoint.includes('?') ? '&' : '?';
432
+ const authUrl = `${this.endpoint}${separator}apiKey=${this.config.apiKey}`;
433
+
434
+ if (navigator.sendBeacon && blob.size < 60000) {
435
+ navigator.sendBeacon(authUrl, blob);
436
+ } else {
437
+ fetch(authUrl, {
438
+ method: 'POST',
439
+ body: blob,
440
+ keepalive: true,
441
+ headers: { 'x-service-api-key': this.config.apiKey }
442
+ }).catch(() => { });
443
+ }
444
+ }
445
+ }
446
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,52 @@
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 ArrayBuffer) return body.byteLength;
38
+ return undefined; // FormData/ReadableStreams cannot be synchronously measured
39
+ };
40
+
41
+ export const extractHeaders = (headers: any): Record<string, string> => {
42
+ const result: Record<string, string> = {};
43
+ if (!headers) return result;
44
+ if (headers instanceof Headers) {
45
+ headers.forEach((val, key) => (result[key] = val));
46
+ } else if (Array.isArray(headers)) {
47
+ headers.forEach(([key, val]) => (result[key] = val));
48
+ } else if (typeof headers === 'object') {
49
+ Object.assign(result, headers);
50
+ }
51
+ return result;
52
+ };