@senzops/web 1.1.0 → 1.3.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # **@senzops/web**
2
2
 
3
- The official, lightweight, and privacy-conscious web analytics SDK for **Senzor**.
3
+ The official, lightweight, and privacy-conscious Web Analytics & Real User Monitoring (RUM) SDK for **Senzor**.
4
4
 
5
- **Senzor Web** is a tiny (< 2KB gzipped) TypeScript agent designed to track page views, visitor sessions, and engagement duration without impacting your website's performance. It works seamlessly with Single Page Applications (SPAs) like React, Next.js, and Vue.
5
+ **Senzor Web** is a tiny (< 4KB gzipped) TypeScript agent designed to track page views, visitor sessions, Core Web Vitals, frontend exceptions, and distributed traces without impacting your website's performance. It works seamlessly with Single Page Applications (SPAs) like React, Next.js, and Vue.
6
6
 
7
7
  ## **🚀 Installation**
8
8
 
@@ -19,11 +19,19 @@ yarn add @senzops/web
19
19
  Add this to the <head> of your website:
20
20
 
21
21
  ```html
22
- <script src="https://cdn.jsdelivr.net/gh/senzops/web-agent/dist/index.global.js"></script>
22
+ <script src="[https://cdn.jsdelivr.net/gh/senzops/web-agent/dist/index.global.js](https://cdn.jsdelivr.net/gh/senzops/web-agent/dist/index.global.js)"></script>
23
23
  <script>
24
- window.Senzor.init({
25
- webId: "YOUR_WEB_ID_HERE",
26
- });
24
+ // Opt-in to Web Analytics (Marketing/Product)
25
+ window.Senzor.init({
26
+ webId: "YOUR_WEB_ID_HERE",
27
+ });
28
+
29
+ // Opt-in to Web APM / RUM (Engineering)
30
+ window.Senzor.initRum({
31
+ apiKey: "YOUR_RUM_API_KEY_HERE",
32
+ sampleRate: 0.1, // 10% of sessions
33
+ allowedOrigins: ["https://api.yourdomain.com"] // For distributed tracing
34
+ });
27
35
  </script>
28
36
  ```
29
37
 
@@ -31,21 +39,29 @@ Add this to the <head> of your website:
31
39
 
32
40
  ### **In React / Next.js**
33
41
 
34
- Initialize the agent once in your root layout or main app component.
42
+ Initialize the agent once in your root layout or main app component. Senzor cleanly separates **Web Analytics** (100% sampling, privacy-focused) from **Web APM / RUM** (performance tracing, configurable sampling). You can use either, or both\!
35
43
 
36
44
  ```jsx
37
45
  import { useEffect } from "react";
38
46
  import { Senzor } from "@senzops/web";
39
47
 
40
48
  export default function App({ Component, pageProps }) {
41
- useEffect(() => {
42
- Senzor.init({
43
- webId: "req_123456789", // Get this from your Senzor Dashboard
44
- // endpoint: '[https://custom-api.com](https://custom-api.com)' // Optional: For self-hosting
49
+ useEffect(() => {
50
+ // 1. Web Analytics (Page views, Bounce rates, Sessions)
51
+ Senzor.init({
52
+ webId: "req_123456789", // Get this from your Senzor Dashboard
53
+ });
54
+
55
+ // 2. Web APM / RUM (Core Web Vitals, Network Spans, JS Errors)
56
+ Senzor.initRum({
57
+ apiKey: "rum\_987654321", // Get this from your Senzor Dashboard
58
+ sampleRate: 1.0, // Trace 100% of sessions (Adjust for high-traffic sites)
59
+ allowedOrigins: ["https://api.mycompany.com"] // Inject W3C trace headers here
45
60
  });
46
- }, []);
47
61
 
48
- return <Component {...pageProps} />;
62
+ }, []);
63
+
64
+ return <Component {...pageProps} />;
49
65
  }
50
66
  ```
51
67
 
@@ -53,42 +69,40 @@ export default function App({ Component, pageProps }) {
53
69
 
54
70
  The Senzor Agent is designed to be **"Fire and Forget"**. It operates asynchronously to ensure it never blocks the main thread or slows down page loads.
55
71
 
56
- ### **1. Identity & Sessions**
57
-
58
- - **Visitor ID:** When a user visits, we generate a random UUID and store it in localStorage. This allows us to track unique visitors over a 1-year period.
59
- - **Session ID:** We generate a UUID in sessionStorage. This persists across tab reloads but clears when the browser/tab is closed, allowing us to calculate **Bounce Rates** and **Session Duration**.
60
- - **Privacy:** We do **not** use cookies. All data is first-party.
72
+ ### **Part 1: Web Analytics (Marketing & Product)**
61
73
 
62
- ### **2. Event Tracking**
74
+ - **Identity & Sessions:** We generate random UUIDs in localStorage and sessionStorage. This allows us to track unique visitors and calculate Bounce Rates without using cookies.
75
+ - **Event Tracking:** Detects SPA route changes via history.pushState and popstate.
76
+ - **Duration Pings:** Calculates precise time-on-page by listening to visibilitychange and beforeunload, sending a final duration "ping" when the user leaves.
63
77
 
64
- The agent listens for specific browser events to capture accurate metrics:
78
+ ### **Part 2: Web APM & RUM (Engineering & Performance)**
65
79
 
66
- - **Initialization:** Sends a pageview event immediately.
67
- - **History API (pushState):** Automatically detects route changes in SPAs (e.g., clicking a Link in Next.js) and sends a new pageview.
68
- - **Visibility Change:** If a user minimizes the tab or switches to another tab, we pause the "Duration" timer and send a ping.
80
+ - **Core Web Vitals (CWV):** Passively listens via PerformanceObserver to capture Google Web Vitals (LCP, INP, CLS, FCP) without stalling the main thread.
81
+ - **Distributed Tracing:** Safely intercepts window.fetch and XMLHttpRequest. If a request targets an allowedOrigins domain, it injects a W3C traceparent header, connecting frontend clicks directly to your Node.js backend database queries.
82
+ - **UX Frustrations:** Detects "Rage Clicks" (rapid clicking on one element) and "Dead Clicks" (clicking non-interactive elements) to highlight poor UX.
83
+ - **Universal Error Engine:** Captures uncaughtException and unhandledRejection, attaching a "Breadcrumb" trail of the user's last 15 actions (clicks, navigations) leading up to the crash.
69
84
 
70
- ### **3. Duration & The "Ping"**
85
+ ### **Data Transmission**
71
86
 
72
- Calculating how long a user spends on a page is difficult because users often close tabs abruptly. Senzor solves this with a **Heartbeat/Ping mechanism**:
87
+ We prioritize data reliability using navigator.sendBeacon. It queues data to be sent by the browser even _after_ the tab is closed. If unavailable, it falls back to a standard fetch request with keepalive: true.
73
88
 
74
- 1. When a page loads, we start a timer (startTime).
75
- 2. When the user navigates away (beforeunload) or hides the tab (visibilitychange), we calculate duration = Now - startTime.
76
- 3. We send a ping event with this duration.
77
- 4. **The Backend** receives this ping and updates the _previous_ pageview entry in the database, incrementing its duration.
89
+ ## **⚙️ Configuration Options**
78
90
 
79
- ### **4. Data Transmission**
91
+ ### **Analytics Options (Senzor.init)**
80
92
 
81
- We prioritize data reliability using **navigator.sendBeacon**:
93
+ | Option | Type | Default | Description |
94
+ | :------- | :----- | :---------------- | :------------------------------------------- |
95
+ | webId | string | **Required** | The unique Analytics ID of your website. |
96
+ | endpoint | string | api.senzor.dev... | Ingestion API URL. Use this if self-hosting. |
82
97
 
83
- - **Reliability:** sendBeacon queues data to be sent by the browser even _after_ the page has unloaded/closed. This ensures we don't lose data when users close the tab.
84
- - **Fallback:** If sendBeacon is unavailable, we fall back to a standard fetch request with keepalive: true.
98
+ ### **RUM Options (Senzor.initRum)**
85
99
 
86
- ## **⚙️ Configuration Options**
87
-
88
- | Option | Type | Default | Description |
89
- | :------- | :----- | :---------------- | :---------------------------------------------------------------------- |
90
- | webId | string | **Required** | The unique ID of your website generated in the Senzor Dashboard. |
91
- | endpoint | string | api.senzor.dev... | URL of the ingestion API. Use this if you are self-hosting the backend. |
100
+ | Option | Type | Default | Description |
101
+ | :------------- | :----- | :---------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
+ | apiKey | string | **Required** | The unique RUM API Key of your website. |
103
+ | sampleRate | number | 1.0 | Percentage of performance traces to capture (0.0 to 1.0). _Errors are ALWAYS 100% captured regardless of this setting._ |
104
+ | allowedOrigins | array | [] | Array of strings or RegEx. The agent will only inject W3C traceparent headers into requests targeting these origins (prevents CORS failures on 3rd-party APIs). |
105
+ | endpoint | string | api.senzor.dev... | Ingestion API URL. Use this if self-hosting. |
92
106
 
93
107
  ## **📦 Development**
94
108
 
@@ -102,12 +116,12 @@ To build the agent locally:
102
116
  npm install
103
117
  ```
104
118
 
105
- 2. Build
119
+ 2. **Build**
106
120
  Uses tsup to bundle for ESM, CJS, and IIFE (Global variable).
107
121
 
108
- ```sh
109
- npm run build
110
- ```
122
+ ```sh
123
+ npm run build
124
+ ```
111
125
 
112
126
  3. **Output**
113
127
  - dist/index.js (CommonJS)
package/dist/index.d.mts CHANGED
@@ -1,22 +1,63 @@
1
- interface Config {
2
- webId: string;
3
- endpoint?: string;
4
- }
5
- declare class SenzorWebAgent {
6
- private config;
7
- private startTime;
8
- private endpoint;
9
- private initialized;
10
- constructor();
11
- init(config: Config): void;
12
- private checkSession;
13
- private getIds;
14
- private trackPageView;
15
- private trackPing;
16
- private send;
17
- private fallbackSend;
18
- private setupListeners;
19
- }
20
- declare const Senzor: SenzorWebAgent;
21
-
22
- export { Senzor };
1
+ interface AnalyticsConfig {
2
+ webId: string;
3
+ endpoint?: string;
4
+ }
5
+ declare class SenzorAnalyticsAgent {
6
+ private config;
7
+ private startTime;
8
+ private endpoint;
9
+ private initialized;
10
+ init(config: AnalyticsConfig): void;
11
+ private normalizeUrl;
12
+ private manageSession;
13
+ private determineReferrer;
14
+ private getIds;
15
+ private trackPageView;
16
+ private trackPing;
17
+ private send;
18
+ private fallbackSend;
19
+ private setupListeners;
20
+ }
21
+ interface RumConfig {
22
+ apiKey: string;
23
+ endpoint?: string;
24
+ sampleRate?: number;
25
+ allowedOrigins?: (string | RegExp)[];
26
+ }
27
+ declare class SenzorRumAgent {
28
+ private config;
29
+ private endpoint;
30
+ private initialized;
31
+ private isSampled;
32
+ private sessionId;
33
+ private traceId;
34
+ private traceStartTime;
35
+ private isInitialLoad;
36
+ private spans;
37
+ private errors;
38
+ private breadcrumbs;
39
+ private vitals;
40
+ private frustrations;
41
+ private clickHistory;
42
+ private flushInterval;
43
+ init(config: RumConfig): void;
44
+ private manageSession;
45
+ private startNewTrace;
46
+ private addBreadcrumb;
47
+ private setupUXListeners;
48
+ private setupPerformanceObservers;
49
+ private getNavigationTimings;
50
+ private shouldAttachTraceHeader;
51
+ private patchNetwork;
52
+ private setupErrorListeners;
53
+ private setupRoutingListeners;
54
+ private flush;
55
+ }
56
+ declare const Analytics: SenzorAnalyticsAgent;
57
+ declare const RUM: SenzorRumAgent;
58
+ declare const Senzor: {
59
+ init: (config: AnalyticsConfig) => void;
60
+ initRum: (config: RumConfig) => void;
61
+ };
62
+
63
+ export { Analytics, RUM, Senzor };
package/dist/index.d.ts CHANGED
@@ -1,22 +1,63 @@
1
- interface Config {
2
- webId: string;
3
- endpoint?: string;
4
- }
5
- declare class SenzorWebAgent {
6
- private config;
7
- private startTime;
8
- private endpoint;
9
- private initialized;
10
- constructor();
11
- init(config: Config): void;
12
- private checkSession;
13
- private getIds;
14
- private trackPageView;
15
- private trackPing;
16
- private send;
17
- private fallbackSend;
18
- private setupListeners;
19
- }
20
- declare const Senzor: SenzorWebAgent;
21
-
22
- export { Senzor };
1
+ interface AnalyticsConfig {
2
+ webId: string;
3
+ endpoint?: string;
4
+ }
5
+ declare class SenzorAnalyticsAgent {
6
+ private config;
7
+ private startTime;
8
+ private endpoint;
9
+ private initialized;
10
+ init(config: AnalyticsConfig): void;
11
+ private normalizeUrl;
12
+ private manageSession;
13
+ private determineReferrer;
14
+ private getIds;
15
+ private trackPageView;
16
+ private trackPing;
17
+ private send;
18
+ private fallbackSend;
19
+ private setupListeners;
20
+ }
21
+ interface RumConfig {
22
+ apiKey: string;
23
+ endpoint?: string;
24
+ sampleRate?: number;
25
+ allowedOrigins?: (string | RegExp)[];
26
+ }
27
+ declare class SenzorRumAgent {
28
+ private config;
29
+ private endpoint;
30
+ private initialized;
31
+ private isSampled;
32
+ private sessionId;
33
+ private traceId;
34
+ private traceStartTime;
35
+ private isInitialLoad;
36
+ private spans;
37
+ private errors;
38
+ private breadcrumbs;
39
+ private vitals;
40
+ private frustrations;
41
+ private clickHistory;
42
+ private flushInterval;
43
+ init(config: RumConfig): void;
44
+ private manageSession;
45
+ private startNewTrace;
46
+ private addBreadcrumb;
47
+ private setupUXListeners;
48
+ private setupPerformanceObservers;
49
+ private getNavigationTimings;
50
+ private shouldAttachTraceHeader;
51
+ private patchNetwork;
52
+ private setupErrorListeners;
53
+ private setupRoutingListeners;
54
+ private flush;
55
+ }
56
+ declare const Analytics: SenzorAnalyticsAgent;
57
+ declare const RUM: SenzorRumAgent;
58
+ declare const Senzor: {
59
+ init: (config: AnalyticsConfig) => void;
60
+ initRum: (config: RumConfig) => void;
61
+ };
62
+
63
+ export { Analytics, RUM, Senzor };
@@ -1 +1 @@
1
- (()=>{function r(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,n=>{let t=Math.random()*16|0;return(n==="x"?t:t&3|8).toString(16)})}var i=class{config;startTime;endpoint;initialized;constructor(){this.config={webId:"",endpoint:"https://api.senzor.dev/api/ingest/web"},this.startTime=Date.now(),this.endpoint="",this.initialized=!1}init(t){if(this.initialized){console.warn("[Senzor] Agent already initialized.");return}if(this.initialized=!0,this.config={...this.config,...t},this.endpoint=this.config.endpoint||"https://api.senzor.dev/api/ingest/web",!this.config.webId){console.error("[Senzor] WebId is required.");return}this.checkSession(),this.trackPageView(),this.setupListeners()}checkSession(){let t=Date.now(),e=parseInt(localStorage.getItem("senzor_last_activity")||"0",10),o=1800*1e3;localStorage.getItem("senzor_vid")||localStorage.setItem("senzor_vid",r()),(!localStorage.getItem("senzor_sid")||t-e>o)&&localStorage.setItem("senzor_sid",r()),localStorage.setItem("senzor_last_activity",t.toString())}getIds(){return localStorage.setItem("senzor_last_activity",Date.now().toString()),{visitorId:localStorage.getItem("senzor_vid")||"unknown",sessionId:localStorage.getItem("senzor_sid")||"unknown"}}trackPageView(){this.checkSession(),this.startTime=Date.now();let t={type:"pageview",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone};this.send(t)}trackPing(){let t=Math.floor((Date.now()-this.startTime)/1e3);if(t<1)return;let e={type:"ping",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,duration:t};this.send(e)}send(t){if(navigator.sendBeacon){let e=new Blob([JSON.stringify(t)],{type:"application/json"});navigator.sendBeacon(this.endpoint,e)||this.fallbackSend(t)}else this.fallbackSend(t)}fallbackSend(t){fetch(this.endpoint,{method:"POST",body:JSON.stringify(t),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(e=>console.error("[Senzor] Telemetry Error:",e))}setupListeners(){let t=history.pushState;history.pushState=(...e)=>{this.trackPing(),t.apply(history,e),this.trackPageView()},window.addEventListener("popstate",()=>{this.trackPing(),this.trackPageView()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?this.trackPing():(this.startTime=Date.now(),this.checkSession())}),window.addEventListener("beforeunload",()=>{this.trackPing()})}},s=new i;typeof window<"u"&&(window.Senzor=s);})();
1
+ (()=>{function f(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,a=>{let t=Math.random()*16|0;return(a==="x"?t:t&3|8).toString(16)})}function u(a){let t="";for(;t.length<a;)t+=Math.random().toString(16).slice(2);return t.slice(0,a)}var w=()=>{var a;return{userAgent:navigator.userAgent,url:window.location.href,deviceMemory:navigator.deviceMemory||void 0,connectionType:((a=navigator.connection)==null?void 0:a.effectiveType)||void 0}},m=class{config={webId:""};startTime=Date.now();endpoint="https://api.senzor.dev/api/ingest/web";initialized=!1;init(t){if(!this.initialized){if(this.initialized=!0,this.config={...this.config,...t},t.endpoint&&(this.endpoint=t.endpoint),!this.config.webId){console.error("[Senzor] webId is required for Analytics.");return}this.manageSession(),this.trackPageView(),this.setupListeners()}}normalizeUrl(t){return t?t.replace(/^https?:\/\//,""):""}manageSession(){let t=Date.now(),e=parseInt(localStorage.getItem("sz_wa_last")||"0",10);localStorage.getItem("sz_wa_vid")||localStorage.setItem("sz_wa_vid",f());let i=sessionStorage.getItem("sz_wa_sid");!i||t-e>1800*1e3?(i=f(),sessionStorage.setItem("sz_wa_sid",i),this.determineReferrer(!0)):this.determineReferrer(!1),localStorage.setItem("sz_wa_last",t.toString())}determineReferrer(t){let e=document.referrer,i=!1;if(e)try{i=new URL(e).hostname!==window.location.hostname}catch{i=!0}if(i){let n=this.normalizeUrl(e);n!==sessionStorage.getItem("sz_wa_ref")&&sessionStorage.setItem("sz_wa_ref",n)}else t&&!sessionStorage.getItem("sz_wa_ref")&&sessionStorage.setItem("sz_wa_ref","Direct")}getIds(){return localStorage.setItem("sz_wa_last",Date.now().toString()),{visitorId:localStorage.getItem("sz_wa_vid")||"unknown",sessionId:sessionStorage.getItem("sz_wa_sid")||"unknown",referrer:sessionStorage.getItem("sz_wa_ref")||"Direct"}}trackPageView(){this.manageSession(),this.startTime=Date.now(),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})}trackPing(){let t=Math.floor((Date.now()-this.startTime)/1e3);t>=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:t})}send(t){navigator.sendBeacon?navigator.sendBeacon(this.endpoint,new Blob([JSON.stringify(t)],{type:"application/json"}))||this.fallbackSend(t):this.fallbackSend(t)}fallbackSend(t){fetch(this.endpoint,{method:"POST",body:JSON.stringify(t),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(()=>{})}setupListeners(){let t=history.pushState;history.pushState=(...e)=>{this.trackPing(),t.apply(history,e),this.trackPageView()},window.addEventListener("popstate",()=>{this.trackPing(),this.trackPageView()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?this.trackPing():(this.startTime=Date.now(),this.manageSession())}),window.addEventListener("beforeunload",()=>this.trackPing())}},g=class{config={apiKey:"",sampleRate:1,allowedOrigins:[]};endpoint="https://api.senzor.dev/api/ingest/rum";initialized=!1;isSampled=!0;sessionId="";traceId="";traceStartTime=0;isInitialLoad=!0;spans=[];errors=[];breadcrumbs=[];vitals={};frustrations={rageClicks:0,deadClicks:0,errorCount:0};clickHistory=[];flushInterval;init(t){if(!this.initialized){if(this.initialized=!0,this.config={...this.config,...t},t.endpoint&&(this.endpoint=t.endpoint),!this.config.apiKey){console.error("[Senzor RUM] apiKey is required.");return}this.isSampled=Math.random()<=(this.config.sampleRate??1),this.manageSession(),this.startNewTrace(!0),this.setupErrorListeners(),this.setupPerformanceObservers(),this.setupUXListeners(),this.isSampled&&this.patchNetwork(),this.flushInterval=setInterval(()=>this.flush(),1e4),this.setupRoutingListeners()}}manageSession(){sessionStorage.getItem("sz_rum_sid")||sessionStorage.setItem("sz_rum_sid",f()),this.sessionId=sessionStorage.getItem("sz_rum_sid")}startNewTrace(t){this.traceId=u(32),this.traceStartTime=Date.now(),this.isInitialLoad=t,this.spans=[],this.vitals={},this.frustrations={rageClicks:0,deadClicks:0,errorCount:0}}addBreadcrumb(t,e){this.breadcrumbs.push({type:t,message:e,time:Date.now()}),this.breadcrumbs.length>15&&this.breadcrumbs.shift()}setupUXListeners(){document.addEventListener("click",t=>{let e=t.target,i=e.tagName?e.tagName.toLowerCase():"";this.addBreadcrumb("click",`Clicked ${i}${e.id?"#"+e.id:""}${e.className?"."+e.className.split(" ")[0]:""}`),["a","button","input","select","textarea","label"].includes(i)||e.closest("button")||e.closest("a")||e.hasAttribute("role")||e.onclick||this.frustrations.deadClicks++;let r=Date.now();if(this.clickHistory.push({x:t.clientX,y:t.clientY,time:r}),this.clickHistory=this.clickHistory.filter(o=>r-o.time<1e3),this.clickHistory.length>=3){let o=this.clickHistory[0],c=!0;for(let d=1;d<this.clickHistory.length;d++){let l=Math.abs(this.clickHistory[d].x-o.x),p=Math.abs(this.clickHistory[d].y-o.y);(l>50||p>50)&&(c=!1)}c&&(this.frustrations.rageClicks++,this.clickHistory=[])}},{capture:!0,passive:!0})}setupPerformanceObservers(){if(!(!this.isSampled||typeof PerformanceObserver>"u"))try{new PerformanceObserver(e=>{for(let i of e.getEntriesByName("first-contentful-paint"))this.vitals.fcp=i.startTime}).observe({type:"paint",buffered:!0}),new PerformanceObserver(e=>{let i=e.getEntries(),n=i[i.length-1];n&&(this.vitals.lcp=n.startTime)}).observe({type:"largest-contentful-paint",buffered:!0});let t=0;new PerformanceObserver(e=>{for(let i of e.getEntries())i.hadRecentInput||(t+=i.value,this.vitals.cls=t)}).observe({type:"layout-shift",buffered:!0}),new PerformanceObserver(e=>{for(let i of e.getEntries()){let n=i,s=n.duration||(n.processingStart&&n.startTime?n.processingStart-n.startTime:0);(!this.vitals.inp||s>this.vitals.inp)&&(this.vitals.inp=s)}}).observe({type:"event",buffered:!0,durationThreshold:40})}catch{}}getNavigationTimings(){if(typeof performance>"u")return{};let t=performance.getEntriesByType("navigation")[0];return t?{dns:Math.max(0,t.domainLookupEnd-t.domainLookupStart),tcp:Math.max(0,t.connectEnd-t.connectStart),ssl:t.secureConnectionStart?Math.max(0,t.requestStart-t.secureConnectionStart):0,ttfb:Math.max(0,t.responseStart-t.requestStart),domInteractive:Math.max(0,t.domInteractive-t.startTime),domComplete:Math.max(0,t.domComplete-t.startTime)}:{}}shouldAttachTraceHeader(t){if(!this.config.allowedOrigins||this.config.allowedOrigins.length===0)return!1;try{let e=new URL(t,window.location.origin);return this.config.allowedOrigins.some(i=>typeof i=="string"?e.origin.includes(i):i instanceof RegExp?i.test(e.origin):!1)}catch{return!1}}patchNetwork(){let t=this,e=XMLHttpRequest.prototype.open,i=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open=function(s,r,...o){return this.__szMethod=s,this.__szUrl=r,e.apply(this,[s,r,...o])},XMLHttpRequest.prototype.send=function(s){let r=this,o=u(16),c=Date.now()-t.traceStartTime;return t.shouldAttachTraceHeader(r.__szUrl)&&r.setRequestHeader("traceparent",`00-${t.traceId}-${o}-01`),r.addEventListener("loadend",()=>{t.spans.push({spanId:o,name:new URL(r.__szUrl,window.location.origin).pathname,type:"xhr",method:r.__szMethod,status:r.status,startTime:c,duration:Date.now()-t.traceStartTime-c})}),i.call(this,s)};let n=window.fetch;window.fetch=async function(...s){var l,p;let r=typeof s[0]=="string"?s[0]:s[0].url,o=(((l=s[1])==null?void 0:l.method)||s[0].method||"GET").toUpperCase(),c=u(16),d=Date.now()-t.traceStartTime;if(t.shouldAttachTraceHeader(r)){let h=new Headers(((p=s[1])==null?void 0:p.headers)||s[0].headers||{});h.set("traceparent",`00-${t.traceId}-${c}-01`),s[1]?s[1].headers=h:s[0]instanceof Request&&(s[0]=new Request(s[0],{headers:h}))}try{let h=await n.apply(this,s);return t.spans.push({spanId:c,name:new URL(r,window.location.origin).pathname,type:"fetch",method:o,status:h.status,startTime:d,duration:Date.now()-t.traceStartTime-d}),h}catch(h){throw t.spans.push({spanId:c,name:new URL(r,window.location.origin).pathname,type:"fetch",method:o,status:0,startTime:d,duration:Date.now()-t.traceStartTime-d}),h}}}setupErrorListeners(){let t=(e,i)=>{this.frustrations.errorCount++;let n=e.message||String(e);this.errors.push({errorClass:e.name||"Error",message:n,stackTrace:e.stack||"",traceId:this.isSampled?this.traceId:void 0,context:{type:i,...w(),breadcrumbs:[...this.breadcrumbs]},timestamp:new Date().toISOString()}),this.flush()};window.addEventListener("error",e=>{e.error&&t(e.error,"Uncaught Exception")}),window.addEventListener("unhandledrejection",e=>{t(e.reason instanceof Error?e.reason:new Error(String(e.reason)),"Unhandled Promise Rejection")})}setupRoutingListeners(){let t=history.pushState;history.pushState=(...e)=>{this.flush(),t.apply(history,e),this.startNewTrace(!1),this.addBreadcrumb("navigation",window.location.pathname)},window.addEventListener("popstate",()=>{this.flush(),this.startNewTrace(!1),this.addBreadcrumb("navigation",window.location.pathname)}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"&&this.flush()}),window.addEventListener("pagehide",()=>this.flush())}flush(){if(this.spans.length===0&&this.errors.length===0&&!this.isInitialLoad)return;let t={traces:[],errors:this.errors};if(this.isSampled&&t.traces.push({traceId:this.traceId,sessionId:this.sessionId,traceType:this.isInitialLoad?"initial_load":"route_change",path:window.location.pathname,referrer:document.referrer||"",vitals:{...this.vitals},timings:this.isInitialLoad?this.getNavigationTimings():{},frustration:{...this.frustrations},...w(),spans:[...this.spans],duration:Date.now()-this.traceStartTime,timestamp:new Date(this.traceStartTime).toISOString()}),this.spans=[],this.errors=[],this.frustrations={rageClicks:0,deadClicks:0,errorCount:0},this.isInitialLoad=!1,t.traces.length>0||t.errors.length>0){let e=new Blob([JSON.stringify(t)],{type:"application/json"});navigator.sendBeacon?navigator.sendBeacon(this.endpoint,e):fetch(this.endpoint,{method:"POST",body:e,keepalive:!0}).catch(()=>{})}}},v=new m,y=new g,S={init:a=>v.init(a),initRum:a=>y.init(a)};typeof window<"u"&&(window.Senzor=S);})();
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- var r=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var l=Object.getOwnPropertyNames;var h=Object.prototype.hasOwnProperty;var g=(i,t)=>{for(var e in t)r(i,e,{get:t[e],enumerable:!0})},p=(i,t,e,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of l(t))!h.call(i,o)&&o!==e&&r(i,o,{get:()=>t[o],enumerable:!(n=c(t,o))||n.enumerable});return i};var w=i=>p(r({},"__esModule",{value:!0}),i);var f={};g(f,{Senzor:()=>d});module.exports=w(f);function a(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,i=>{let t=Math.random()*16|0;return(i==="x"?t:t&3|8).toString(16)})}var s=class{config;startTime;endpoint;initialized;constructor(){this.config={webId:"",endpoint:"https://api.senzor.dev/api/ingest/web"},this.startTime=Date.now(),this.endpoint="",this.initialized=!1}init(t){if(this.initialized){console.warn("[Senzor] Agent already initialized.");return}if(this.initialized=!0,this.config={...this.config,...t},this.endpoint=this.config.endpoint||"https://api.senzor.dev/api/ingest/web",!this.config.webId){console.error("[Senzor] WebId is required.");return}this.checkSession(),this.trackPageView(),this.setupListeners()}checkSession(){let t=Date.now(),e=parseInt(localStorage.getItem("senzor_last_activity")||"0",10),n=1800*1e3;localStorage.getItem("senzor_vid")||localStorage.setItem("senzor_vid",a()),(!localStorage.getItem("senzor_sid")||t-e>n)&&localStorage.setItem("senzor_sid",a()),localStorage.setItem("senzor_last_activity",t.toString())}getIds(){return localStorage.setItem("senzor_last_activity",Date.now().toString()),{visitorId:localStorage.getItem("senzor_vid")||"unknown",sessionId:localStorage.getItem("senzor_sid")||"unknown"}}trackPageView(){this.checkSession(),this.startTime=Date.now();let t={type:"pageview",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone};this.send(t)}trackPing(){let t=Math.floor((Date.now()-this.startTime)/1e3);if(t<1)return;let e={type:"ping",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,duration:t};this.send(e)}send(t){if(navigator.sendBeacon){let e=new Blob([JSON.stringify(t)],{type:"application/json"});navigator.sendBeacon(this.endpoint,e)||this.fallbackSend(t)}else this.fallbackSend(t)}fallbackSend(t){fetch(this.endpoint,{method:"POST",body:JSON.stringify(t),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(e=>console.error("[Senzor] Telemetry Error:",e))}setupListeners(){let t=history.pushState;history.pushState=(...e)=>{this.trackPing(),t.apply(history,e),this.trackPageView()},window.addEventListener("popstate",()=>{this.trackPing(),this.trackPageView()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?this.trackPing():(this.startTime=Date.now(),this.checkSession())}),window.addEventListener("beforeunload",()=>{this.trackPing()})}},d=new s;typeof window<"u"&&(window.Senzor=d);0&&(module.exports={Senzor});
1
+ var u=Object.defineProperty;var I=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var _=Object.prototype.hasOwnProperty;var k=(r,t)=>{for(var e in t)u(r,e,{get:t[e],enumerable:!0})},T=(r,t,e,i)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of x(t))!_.call(r,s)&&s!==e&&u(r,s,{get:()=>t[s],enumerable:!(i=I(t,s))||i.enumerable});return r};var L=r=>T(u({},"__esModule",{value:!0}),r);var R={};k(R,{Analytics:()=>y,RUM:()=>S,Senzor:()=>b});module.exports=L(R);function m(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,r=>{let t=Math.random()*16|0;return(r==="x"?t:t&3|8).toString(16)})}function f(r){let t="";for(;t.length<r;)t+=Math.random().toString(16).slice(2);return t.slice(0,r)}var v=()=>{var r;return{userAgent:navigator.userAgent,url:window.location.href,deviceMemory:navigator.deviceMemory||void 0,connectionType:((r=navigator.connection)==null?void 0:r.effectiveType)||void 0}},g=class{config={webId:""};startTime=Date.now();endpoint="https://api.senzor.dev/api/ingest/web";initialized=!1;init(t){if(!this.initialized){if(this.initialized=!0,this.config={...this.config,...t},t.endpoint&&(this.endpoint=t.endpoint),!this.config.webId){console.error("[Senzor] webId is required for Analytics.");return}this.manageSession(),this.trackPageView(),this.setupListeners()}}normalizeUrl(t){return t?t.replace(/^https?:\/\//,""):""}manageSession(){let t=Date.now(),e=parseInt(localStorage.getItem("sz_wa_last")||"0",10);localStorage.getItem("sz_wa_vid")||localStorage.setItem("sz_wa_vid",m());let i=sessionStorage.getItem("sz_wa_sid");!i||t-e>1800*1e3?(i=m(),sessionStorage.setItem("sz_wa_sid",i),this.determineReferrer(!0)):this.determineReferrer(!1),localStorage.setItem("sz_wa_last",t.toString())}determineReferrer(t){let e=document.referrer,i=!1;if(e)try{i=new URL(e).hostname!==window.location.hostname}catch{i=!0}if(i){let s=this.normalizeUrl(e);s!==sessionStorage.getItem("sz_wa_ref")&&sessionStorage.setItem("sz_wa_ref",s)}else t&&!sessionStorage.getItem("sz_wa_ref")&&sessionStorage.setItem("sz_wa_ref","Direct")}getIds(){return localStorage.setItem("sz_wa_last",Date.now().toString()),{visitorId:localStorage.getItem("sz_wa_vid")||"unknown",sessionId:sessionStorage.getItem("sz_wa_sid")||"unknown",referrer:sessionStorage.getItem("sz_wa_ref")||"Direct"}}trackPageView(){this.manageSession(),this.startTime=Date.now(),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})}trackPing(){let t=Math.floor((Date.now()-this.startTime)/1e3);t>=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:t})}send(t){navigator.sendBeacon?navigator.sendBeacon(this.endpoint,new Blob([JSON.stringify(t)],{type:"application/json"}))||this.fallbackSend(t):this.fallbackSend(t)}fallbackSend(t){fetch(this.endpoint,{method:"POST",body:JSON.stringify(t),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(()=>{})}setupListeners(){let t=history.pushState;history.pushState=(...e)=>{this.trackPing(),t.apply(history,e),this.trackPageView()},window.addEventListener("popstate",()=>{this.trackPing(),this.trackPageView()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?this.trackPing():(this.startTime=Date.now(),this.manageSession())}),window.addEventListener("beforeunload",()=>this.trackPing())}},w=class{config={apiKey:"",sampleRate:1,allowedOrigins:[]};endpoint="https://api.senzor.dev/api/ingest/rum";initialized=!1;isSampled=!0;sessionId="";traceId="";traceStartTime=0;isInitialLoad=!0;spans=[];errors=[];breadcrumbs=[];vitals={};frustrations={rageClicks:0,deadClicks:0,errorCount:0};clickHistory=[];flushInterval;init(t){if(!this.initialized){if(this.initialized=!0,this.config={...this.config,...t},t.endpoint&&(this.endpoint=t.endpoint),!this.config.apiKey){console.error("[Senzor RUM] apiKey is required.");return}this.isSampled=Math.random()<=(this.config.sampleRate??1),this.manageSession(),this.startNewTrace(!0),this.setupErrorListeners(),this.setupPerformanceObservers(),this.setupUXListeners(),this.isSampled&&this.patchNetwork(),this.flushInterval=setInterval(()=>this.flush(),1e4),this.setupRoutingListeners()}}manageSession(){sessionStorage.getItem("sz_rum_sid")||sessionStorage.setItem("sz_rum_sid",m()),this.sessionId=sessionStorage.getItem("sz_rum_sid")}startNewTrace(t){this.traceId=f(32),this.traceStartTime=Date.now(),this.isInitialLoad=t,this.spans=[],this.vitals={},this.frustrations={rageClicks:0,deadClicks:0,errorCount:0}}addBreadcrumb(t,e){this.breadcrumbs.push({type:t,message:e,time:Date.now()}),this.breadcrumbs.length>15&&this.breadcrumbs.shift()}setupUXListeners(){document.addEventListener("click",t=>{let e=t.target,i=e.tagName?e.tagName.toLowerCase():"";this.addBreadcrumb("click",`Clicked ${i}${e.id?"#"+e.id:""}${e.className?"."+e.className.split(" ")[0]:""}`),["a","button","input","select","textarea","label"].includes(i)||e.closest("button")||e.closest("a")||e.hasAttribute("role")||e.onclick||this.frustrations.deadClicks++;let a=Date.now();if(this.clickHistory.push({x:t.clientX,y:t.clientY,time:a}),this.clickHistory=this.clickHistory.filter(o=>a-o.time<1e3),this.clickHistory.length>=3){let o=this.clickHistory[0],c=!0;for(let d=1;d<this.clickHistory.length;d++){let l=Math.abs(this.clickHistory[d].x-o.x),p=Math.abs(this.clickHistory[d].y-o.y);(l>50||p>50)&&(c=!1)}c&&(this.frustrations.rageClicks++,this.clickHistory=[])}},{capture:!0,passive:!0})}setupPerformanceObservers(){if(!(!this.isSampled||typeof PerformanceObserver>"u"))try{new PerformanceObserver(e=>{for(let i of e.getEntriesByName("first-contentful-paint"))this.vitals.fcp=i.startTime}).observe({type:"paint",buffered:!0}),new PerformanceObserver(e=>{let i=e.getEntries(),s=i[i.length-1];s&&(this.vitals.lcp=s.startTime)}).observe({type:"largest-contentful-paint",buffered:!0});let t=0;new PerformanceObserver(e=>{for(let i of e.getEntries())i.hadRecentInput||(t+=i.value,this.vitals.cls=t)}).observe({type:"layout-shift",buffered:!0}),new PerformanceObserver(e=>{for(let i of e.getEntries()){let s=i,n=s.duration||(s.processingStart&&s.startTime?s.processingStart-s.startTime:0);(!this.vitals.inp||n>this.vitals.inp)&&(this.vitals.inp=n)}}).observe({type:"event",buffered:!0,durationThreshold:40})}catch{}}getNavigationTimings(){if(typeof performance>"u")return{};let t=performance.getEntriesByType("navigation")[0];return t?{dns:Math.max(0,t.domainLookupEnd-t.domainLookupStart),tcp:Math.max(0,t.connectEnd-t.connectStart),ssl:t.secureConnectionStart?Math.max(0,t.requestStart-t.secureConnectionStart):0,ttfb:Math.max(0,t.responseStart-t.requestStart),domInteractive:Math.max(0,t.domInteractive-t.startTime),domComplete:Math.max(0,t.domComplete-t.startTime)}:{}}shouldAttachTraceHeader(t){if(!this.config.allowedOrigins||this.config.allowedOrigins.length===0)return!1;try{let e=new URL(t,window.location.origin);return this.config.allowedOrigins.some(i=>typeof i=="string"?e.origin.includes(i):i instanceof RegExp?i.test(e.origin):!1)}catch{return!1}}patchNetwork(){let t=this,e=XMLHttpRequest.prototype.open,i=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open=function(n,a,...o){return this.__szMethod=n,this.__szUrl=a,e.apply(this,[n,a,...o])},XMLHttpRequest.prototype.send=function(n){let a=this,o=f(16),c=Date.now()-t.traceStartTime;return t.shouldAttachTraceHeader(a.__szUrl)&&a.setRequestHeader("traceparent",`00-${t.traceId}-${o}-01`),a.addEventListener("loadend",()=>{t.spans.push({spanId:o,name:new URL(a.__szUrl,window.location.origin).pathname,type:"xhr",method:a.__szMethod,status:a.status,startTime:c,duration:Date.now()-t.traceStartTime-c})}),i.call(this,n)};let s=window.fetch;window.fetch=async function(...n){var l,p;let a=typeof n[0]=="string"?n[0]:n[0].url,o=(((l=n[1])==null?void 0:l.method)||n[0].method||"GET").toUpperCase(),c=f(16),d=Date.now()-t.traceStartTime;if(t.shouldAttachTraceHeader(a)){let h=new Headers(((p=n[1])==null?void 0:p.headers)||n[0].headers||{});h.set("traceparent",`00-${t.traceId}-${c}-01`),n[1]?n[1].headers=h:n[0]instanceof Request&&(n[0]=new Request(n[0],{headers:h}))}try{let h=await s.apply(this,n);return t.spans.push({spanId:c,name:new URL(a,window.location.origin).pathname,type:"fetch",method:o,status:h.status,startTime:d,duration:Date.now()-t.traceStartTime-d}),h}catch(h){throw t.spans.push({spanId:c,name:new URL(a,window.location.origin).pathname,type:"fetch",method:o,status:0,startTime:d,duration:Date.now()-t.traceStartTime-d}),h}}}setupErrorListeners(){let t=(e,i)=>{this.frustrations.errorCount++;let s=e.message||String(e);this.errors.push({errorClass:e.name||"Error",message:s,stackTrace:e.stack||"",traceId:this.isSampled?this.traceId:void 0,context:{type:i,...v(),breadcrumbs:[...this.breadcrumbs]},timestamp:new Date().toISOString()}),this.flush()};window.addEventListener("error",e=>{e.error&&t(e.error,"Uncaught Exception")}),window.addEventListener("unhandledrejection",e=>{t(e.reason instanceof Error?e.reason:new Error(String(e.reason)),"Unhandled Promise Rejection")})}setupRoutingListeners(){let t=history.pushState;history.pushState=(...e)=>{this.flush(),t.apply(history,e),this.startNewTrace(!1),this.addBreadcrumb("navigation",window.location.pathname)},window.addEventListener("popstate",()=>{this.flush(),this.startNewTrace(!1),this.addBreadcrumb("navigation",window.location.pathname)}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"&&this.flush()}),window.addEventListener("pagehide",()=>this.flush())}flush(){if(this.spans.length===0&&this.errors.length===0&&!this.isInitialLoad)return;let t={traces:[],errors:this.errors};if(this.isSampled&&t.traces.push({traceId:this.traceId,sessionId:this.sessionId,traceType:this.isInitialLoad?"initial_load":"route_change",path:window.location.pathname,referrer:document.referrer||"",vitals:{...this.vitals},timings:this.isInitialLoad?this.getNavigationTimings():{},frustration:{...this.frustrations},...v(),spans:[...this.spans],duration:Date.now()-this.traceStartTime,timestamp:new Date(this.traceStartTime).toISOString()}),this.spans=[],this.errors=[],this.frustrations={rageClicks:0,deadClicks:0,errorCount:0},this.isInitialLoad=!1,t.traces.length>0||t.errors.length>0){let e=new Blob([JSON.stringify(t)],{type:"application/json"});navigator.sendBeacon?navigator.sendBeacon(this.endpoint,e):fetch(this.endpoint,{method:"POST",body:e,keepalive:!0}).catch(()=>{})}}},y=new g,S=new w,b={init:r=>y.init(r),initRum:r=>S.init(r)};typeof window<"u"&&(window.Senzor=b);0&&(module.exports={Analytics,RUM,Senzor});
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- function r(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,n=>{let t=Math.random()*16|0;return(n==="x"?t:t&3|8).toString(16)})}var i=class{config;startTime;endpoint;initialized;constructor(){this.config={webId:"",endpoint:"https://api.senzor.dev/api/ingest/web"},this.startTime=Date.now(),this.endpoint="",this.initialized=!1}init(t){if(this.initialized){console.warn("[Senzor] Agent already initialized.");return}if(this.initialized=!0,this.config={...this.config,...t},this.endpoint=this.config.endpoint||"https://api.senzor.dev/api/ingest/web",!this.config.webId){console.error("[Senzor] WebId is required.");return}this.checkSession(),this.trackPageView(),this.setupListeners()}checkSession(){let t=Date.now(),e=parseInt(localStorage.getItem("senzor_last_activity")||"0",10),o=1800*1e3;localStorage.getItem("senzor_vid")||localStorage.setItem("senzor_vid",r()),(!localStorage.getItem("senzor_sid")||t-e>o)&&localStorage.setItem("senzor_sid",r()),localStorage.setItem("senzor_last_activity",t.toString())}getIds(){return localStorage.setItem("senzor_last_activity",Date.now().toString()),{visitorId:localStorage.getItem("senzor_vid")||"unknown",sessionId:localStorage.getItem("senzor_sid")||"unknown"}}trackPageView(){this.checkSession(),this.startTime=Date.now();let t={type:"pageview",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone};this.send(t)}trackPing(){let t=Math.floor((Date.now()-this.startTime)/1e3);if(t<1)return;let e={type:"ping",webId:this.config.webId,...this.getIds(),url:window.location.href,path:window.location.pathname,referrer:document.referrer,width:window.innerWidth,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,duration:t};this.send(e)}send(t){if(navigator.sendBeacon){let e=new Blob([JSON.stringify(t)],{type:"application/json"});navigator.sendBeacon(this.endpoint,e)||this.fallbackSend(t)}else this.fallbackSend(t)}fallbackSend(t){fetch(this.endpoint,{method:"POST",body:JSON.stringify(t),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(e=>console.error("[Senzor] Telemetry Error:",e))}setupListeners(){let t=history.pushState;history.pushState=(...e)=>{this.trackPing(),t.apply(history,e),this.trackPageView()},window.addEventListener("popstate",()=>{this.trackPing(),this.trackPageView()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?this.trackPing():(this.startTime=Date.now(),this.checkSession())}),window.addEventListener("beforeunload",()=>{this.trackPing()})}},s=new i;typeof window<"u"&&(window.Senzor=s);export{s as Senzor};
1
+ function f(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,a=>{let t=Math.random()*16|0;return(a==="x"?t:t&3|8).toString(16)})}function u(a){let t="";for(;t.length<a;)t+=Math.random().toString(16).slice(2);return t.slice(0,a)}var w=()=>{var a;return{userAgent:navigator.userAgent,url:window.location.href,deviceMemory:navigator.deviceMemory||void 0,connectionType:((a=navigator.connection)==null?void 0:a.effectiveType)||void 0}},m=class{config={webId:""};startTime=Date.now();endpoint="https://api.senzor.dev/api/ingest/web";initialized=!1;init(t){if(!this.initialized){if(this.initialized=!0,this.config={...this.config,...t},t.endpoint&&(this.endpoint=t.endpoint),!this.config.webId){console.error("[Senzor] webId is required for Analytics.");return}this.manageSession(),this.trackPageView(),this.setupListeners()}}normalizeUrl(t){return t?t.replace(/^https?:\/\//,""):""}manageSession(){let t=Date.now(),e=parseInt(localStorage.getItem("sz_wa_last")||"0",10);localStorage.getItem("sz_wa_vid")||localStorage.setItem("sz_wa_vid",f());let i=sessionStorage.getItem("sz_wa_sid");!i||t-e>1800*1e3?(i=f(),sessionStorage.setItem("sz_wa_sid",i),this.determineReferrer(!0)):this.determineReferrer(!1),localStorage.setItem("sz_wa_last",t.toString())}determineReferrer(t){let e=document.referrer,i=!1;if(e)try{i=new URL(e).hostname!==window.location.hostname}catch{i=!0}if(i){let n=this.normalizeUrl(e);n!==sessionStorage.getItem("sz_wa_ref")&&sessionStorage.setItem("sz_wa_ref",n)}else t&&!sessionStorage.getItem("sz_wa_ref")&&sessionStorage.setItem("sz_wa_ref","Direct")}getIds(){return localStorage.setItem("sz_wa_last",Date.now().toString()),{visitorId:localStorage.getItem("sz_wa_vid")||"unknown",sessionId:sessionStorage.getItem("sz_wa_sid")||"unknown",referrer:sessionStorage.getItem("sz_wa_ref")||"Direct"}}trackPageView(){this.manageSession(),this.startTime=Date.now(),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})}trackPing(){let t=Math.floor((Date.now()-this.startTime)/1e3);t>=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:t})}send(t){navigator.sendBeacon?navigator.sendBeacon(this.endpoint,new Blob([JSON.stringify(t)],{type:"application/json"}))||this.fallbackSend(t):this.fallbackSend(t)}fallbackSend(t){fetch(this.endpoint,{method:"POST",body:JSON.stringify(t),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(()=>{})}setupListeners(){let t=history.pushState;history.pushState=(...e)=>{this.trackPing(),t.apply(history,e),this.trackPageView()},window.addEventListener("popstate",()=>{this.trackPing(),this.trackPageView()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?this.trackPing():(this.startTime=Date.now(),this.manageSession())}),window.addEventListener("beforeunload",()=>this.trackPing())}},g=class{config={apiKey:"",sampleRate:1,allowedOrigins:[]};endpoint="https://api.senzor.dev/api/ingest/rum";initialized=!1;isSampled=!0;sessionId="";traceId="";traceStartTime=0;isInitialLoad=!0;spans=[];errors=[];breadcrumbs=[];vitals={};frustrations={rageClicks:0,deadClicks:0,errorCount:0};clickHistory=[];flushInterval;init(t){if(!this.initialized){if(this.initialized=!0,this.config={...this.config,...t},t.endpoint&&(this.endpoint=t.endpoint),!this.config.apiKey){console.error("[Senzor RUM] apiKey is required.");return}this.isSampled=Math.random()<=(this.config.sampleRate??1),this.manageSession(),this.startNewTrace(!0),this.setupErrorListeners(),this.setupPerformanceObservers(),this.setupUXListeners(),this.isSampled&&this.patchNetwork(),this.flushInterval=setInterval(()=>this.flush(),1e4),this.setupRoutingListeners()}}manageSession(){sessionStorage.getItem("sz_rum_sid")||sessionStorage.setItem("sz_rum_sid",f()),this.sessionId=sessionStorage.getItem("sz_rum_sid")}startNewTrace(t){this.traceId=u(32),this.traceStartTime=Date.now(),this.isInitialLoad=t,this.spans=[],this.vitals={},this.frustrations={rageClicks:0,deadClicks:0,errorCount:0}}addBreadcrumb(t,e){this.breadcrumbs.push({type:t,message:e,time:Date.now()}),this.breadcrumbs.length>15&&this.breadcrumbs.shift()}setupUXListeners(){document.addEventListener("click",t=>{let e=t.target,i=e.tagName?e.tagName.toLowerCase():"";this.addBreadcrumb("click",`Clicked ${i}${e.id?"#"+e.id:""}${e.className?"."+e.className.split(" ")[0]:""}`),["a","button","input","select","textarea","label"].includes(i)||e.closest("button")||e.closest("a")||e.hasAttribute("role")||e.onclick||this.frustrations.deadClicks++;let r=Date.now();if(this.clickHistory.push({x:t.clientX,y:t.clientY,time:r}),this.clickHistory=this.clickHistory.filter(o=>r-o.time<1e3),this.clickHistory.length>=3){let o=this.clickHistory[0],c=!0;for(let d=1;d<this.clickHistory.length;d++){let l=Math.abs(this.clickHistory[d].x-o.x),p=Math.abs(this.clickHistory[d].y-o.y);(l>50||p>50)&&(c=!1)}c&&(this.frustrations.rageClicks++,this.clickHistory=[])}},{capture:!0,passive:!0})}setupPerformanceObservers(){if(!(!this.isSampled||typeof PerformanceObserver>"u"))try{new PerformanceObserver(e=>{for(let i of e.getEntriesByName("first-contentful-paint"))this.vitals.fcp=i.startTime}).observe({type:"paint",buffered:!0}),new PerformanceObserver(e=>{let i=e.getEntries(),n=i[i.length-1];n&&(this.vitals.lcp=n.startTime)}).observe({type:"largest-contentful-paint",buffered:!0});let t=0;new PerformanceObserver(e=>{for(let i of e.getEntries())i.hadRecentInput||(t+=i.value,this.vitals.cls=t)}).observe({type:"layout-shift",buffered:!0}),new PerformanceObserver(e=>{for(let i of e.getEntries()){let n=i,s=n.duration||(n.processingStart&&n.startTime?n.processingStart-n.startTime:0);(!this.vitals.inp||s>this.vitals.inp)&&(this.vitals.inp=s)}}).observe({type:"event",buffered:!0,durationThreshold:40})}catch{}}getNavigationTimings(){if(typeof performance>"u")return{};let t=performance.getEntriesByType("navigation")[0];return t?{dns:Math.max(0,t.domainLookupEnd-t.domainLookupStart),tcp:Math.max(0,t.connectEnd-t.connectStart),ssl:t.secureConnectionStart?Math.max(0,t.requestStart-t.secureConnectionStart):0,ttfb:Math.max(0,t.responseStart-t.requestStart),domInteractive:Math.max(0,t.domInteractive-t.startTime),domComplete:Math.max(0,t.domComplete-t.startTime)}:{}}shouldAttachTraceHeader(t){if(!this.config.allowedOrigins||this.config.allowedOrigins.length===0)return!1;try{let e=new URL(t,window.location.origin);return this.config.allowedOrigins.some(i=>typeof i=="string"?e.origin.includes(i):i instanceof RegExp?i.test(e.origin):!1)}catch{return!1}}patchNetwork(){let t=this,e=XMLHttpRequest.prototype.open,i=XMLHttpRequest.prototype.send;XMLHttpRequest.prototype.open=function(s,r,...o){return this.__szMethod=s,this.__szUrl=r,e.apply(this,[s,r,...o])},XMLHttpRequest.prototype.send=function(s){let r=this,o=u(16),c=Date.now()-t.traceStartTime;return t.shouldAttachTraceHeader(r.__szUrl)&&r.setRequestHeader("traceparent",`00-${t.traceId}-${o}-01`),r.addEventListener("loadend",()=>{t.spans.push({spanId:o,name:new URL(r.__szUrl,window.location.origin).pathname,type:"xhr",method:r.__szMethod,status:r.status,startTime:c,duration:Date.now()-t.traceStartTime-c})}),i.call(this,s)};let n=window.fetch;window.fetch=async function(...s){var l,p;let r=typeof s[0]=="string"?s[0]:s[0].url,o=(((l=s[1])==null?void 0:l.method)||s[0].method||"GET").toUpperCase(),c=u(16),d=Date.now()-t.traceStartTime;if(t.shouldAttachTraceHeader(r)){let h=new Headers(((p=s[1])==null?void 0:p.headers)||s[0].headers||{});h.set("traceparent",`00-${t.traceId}-${c}-01`),s[1]?s[1].headers=h:s[0]instanceof Request&&(s[0]=new Request(s[0],{headers:h}))}try{let h=await n.apply(this,s);return t.spans.push({spanId:c,name:new URL(r,window.location.origin).pathname,type:"fetch",method:o,status:h.status,startTime:d,duration:Date.now()-t.traceStartTime-d}),h}catch(h){throw t.spans.push({spanId:c,name:new URL(r,window.location.origin).pathname,type:"fetch",method:o,status:0,startTime:d,duration:Date.now()-t.traceStartTime-d}),h}}}setupErrorListeners(){let t=(e,i)=>{this.frustrations.errorCount++;let n=e.message||String(e);this.errors.push({errorClass:e.name||"Error",message:n,stackTrace:e.stack||"",traceId:this.isSampled?this.traceId:void 0,context:{type:i,...w(),breadcrumbs:[...this.breadcrumbs]},timestamp:new Date().toISOString()}),this.flush()};window.addEventListener("error",e=>{e.error&&t(e.error,"Uncaught Exception")}),window.addEventListener("unhandledrejection",e=>{t(e.reason instanceof Error?e.reason:new Error(String(e.reason)),"Unhandled Promise Rejection")})}setupRoutingListeners(){let t=history.pushState;history.pushState=(...e)=>{this.flush(),t.apply(history,e),this.startNewTrace(!1),this.addBreadcrumb("navigation",window.location.pathname)},window.addEventListener("popstate",()=>{this.flush(),this.startNewTrace(!1),this.addBreadcrumb("navigation",window.location.pathname)}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"&&this.flush()}),window.addEventListener("pagehide",()=>this.flush())}flush(){if(this.spans.length===0&&this.errors.length===0&&!this.isInitialLoad)return;let t={traces:[],errors:this.errors};if(this.isSampled&&t.traces.push({traceId:this.traceId,sessionId:this.sessionId,traceType:this.isInitialLoad?"initial_load":"route_change",path:window.location.pathname,referrer:document.referrer||"",vitals:{...this.vitals},timings:this.isInitialLoad?this.getNavigationTimings():{},frustration:{...this.frustrations},...w(),spans:[...this.spans],duration:Date.now()-this.traceStartTime,timestamp:new Date(this.traceStartTime).toISOString()}),this.spans=[],this.errors=[],this.frustrations={rageClicks:0,deadClicks:0,errorCount:0},this.isInitialLoad=!1,t.traces.length>0||t.errors.length>0){let e=new Blob([JSON.stringify(t)],{type:"application/json"});navigator.sendBeacon?navigator.sendBeacon(this.endpoint,e):fetch(this.endpoint,{method:"POST",body:e,keepalive:!0}).catch(()=>{})}}},v=new m,y=new g,S={init:a=>v.init(a),initRum:a=>y.init(a)};typeof window<"u"&&(window.Senzor=S);export{v as Analytics,y as RUM,S as Senzor};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@senzops/web",
3
- "version": "1.1.0",
4
- "description": "Senzor Web Analytics SDK",
3
+ "version": "1.3.0",
4
+ "description": "Senzor Web Analytics and RUM SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "scripts": {
package/src/index.ts CHANGED
@@ -1,22 +1,8 @@
1
- interface Config {
2
- webId: string;
3
- endpoint?: string;
4
- }
5
-
6
- interface Payload {
7
- type: 'pageview' | 'ping';
8
- webId: string;
9
- visitorId: string;
10
- sessionId: string;
11
- url: string;
12
- path: string;
13
- referrer: string;
14
- width: number;
15
- timezone: string;
16
- duration?: number;
17
- }
1
+ // ============================================================================
2
+ // --- SHARED UTILITIES ---
3
+ // ============================================================================
18
4
 
19
- // Browser-Native UUID (No Node.js dependencies)
5
+ // Native UUID Generator (No dependencies)
20
6
  function generateUUID(): string {
21
7
  if (typeof crypto !== 'undefined' && crypto.randomUUID) {
22
8
  return crypto.randomUUID();
@@ -28,164 +14,500 @@ function generateUUID(): string {
28
14
  });
29
15
  }
30
16
 
31
- class SenzorWebAgent {
32
- private config: Config;
33
- private startTime: number;
34
- private endpoint: string;
35
- private initialized: boolean;
36
-
37
- constructor() {
38
- this.config = { webId: '', endpoint: 'https://api.senzor.dev/api/ingest/web' };
39
- this.startTime = Date.now();
40
- this.endpoint = '';
41
- this.initialized = false;
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);
42
22
  }
23
+ return result.slice(0, length);
24
+ }
43
25
 
44
- public init(config: Config) {
45
- if (this.initialized) {
46
- console.warn('[Senzor] Agent already initialized.');
47
- return;
48
- }
49
- this.initialized = true;
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;
50
49
 
50
+ public init(config: AnalyticsConfig) {
51
+ if (this.initialized) return;
52
+ this.initialized = true;
51
53
  this.config = { ...this.config, ...config };
52
- this.endpoint = this.config.endpoint || 'https://api.senzor.dev/api/ingest/web';
54
+ if (config.endpoint) this.endpoint = config.endpoint;
53
55
 
54
56
  if (!this.config.webId) {
55
- console.error('[Senzor] WebId is required.');
57
+ console.error('[Senzor] webId is required for Analytics.');
56
58
  return;
57
59
  }
58
60
 
59
- // 1. Manage Session State
60
- this.checkSession();
61
-
62
- // 2. Track initial load
61
+ this.manageSession();
63
62
  this.trackPageView();
64
-
65
- // 3. Setup Listeners
66
63
  this.setupListeners();
67
64
  }
68
65
 
69
- // --- Standard Analytics Session Logic ---
70
- // A session ends after 30 minutes of inactivity.
71
- private checkSession() {
66
+ private normalizeUrl(url: string): string {
67
+ return url ? url.replace(/^https?:\/\//, '') : '';
68
+ }
69
+
70
+ private manageSession() {
72
71
  const now = Date.now();
73
- const lastActivity = parseInt(localStorage.getItem('senzor_last_activity') || '0', 10);
74
- const sessionTimeout = 30 * 60 * 1000; // 30 mins
72
+ const lastActivity = parseInt(localStorage.getItem('sz_wa_last') || '0', 10);
73
+ if (!localStorage.getItem('sz_wa_vid')) localStorage.setItem('sz_wa_vid', generateUUID());
75
74
 
76
- // 1. Visitor ID (Persistent 1 Year)
77
- if (!localStorage.getItem('senzor_vid')) {
78
- localStorage.setItem('senzor_vid', generateUUID());
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);
79
82
  }
83
+ localStorage.setItem('sz_wa_last', now.toString());
84
+ }
80
85
 
81
- // 2. Session ID
82
- // Create new if missing OR expired
83
- if (!localStorage.getItem('senzor_sid') || (now - lastActivity > sessionTimeout)) {
84
- localStorage.setItem('senzor_sid', generateUUID());
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; }
85
91
  }
86
92
 
87
- // Update Activity
88
- localStorage.setItem('senzor_last_activity', now.toString());
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
+ }
89
99
  }
90
100
 
91
101
  private getIds() {
92
- // Refresh activity timestamp on every hit
93
- localStorage.setItem('senzor_last_activity', Date.now().toString());
102
+ localStorage.setItem('sz_wa_last', Date.now().toString());
94
103
  return {
95
- visitorId: localStorage.getItem('senzor_vid') || 'unknown',
96
- sessionId: localStorage.getItem('senzor_sid') || 'unknown'
104
+ visitorId: localStorage.getItem('sz_wa_vid') || 'unknown',
105
+ sessionId: sessionStorage.getItem('sz_wa_sid') || 'unknown',
106
+ referrer: sessionStorage.getItem('sz_wa_ref') || 'Direct'
97
107
  };
98
108
  }
99
109
 
100
110
  private trackPageView() {
101
- // Ensure session is valid before tracking
102
- this.checkSession();
111
+ this.manageSession();
103
112
  this.startTime = Date.now();
104
-
105
- const payload: Payload = {
106
- type: 'pageview',
107
- webId: this.config.webId,
108
- ...this.getIds(),
109
- url: window.location.href,
110
- path: window.location.pathname,
111
- referrer: document.referrer,
112
- width: window.innerWidth,
113
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
114
- };
115
-
116
- this.send(payload);
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 });
117
114
  }
118
115
 
119
116
  private trackPing() {
120
117
  const duration = Math.floor((Date.now() - this.startTime) / 1000);
121
- if (duration < 1) return;
122
-
123
- const payload: Payload = {
124
- type: 'ping',
125
- webId: this.config.webId,
126
- ...this.getIds(),
127
- url: window.location.href,
128
- path: window.location.pathname,
129
- referrer: document.referrer,
130
- width: window.innerWidth,
131
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
132
- duration: duration
133
- };
134
-
135
- this.send(payload);
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 });
136
119
  }
137
120
 
138
- private send(data: Payload) {
121
+ private send(data: any) {
139
122
  if (navigator.sendBeacon) {
140
- const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
141
- const success = navigator.sendBeacon(this.endpoint, blob);
142
- if (!success) this.fallbackSend(data);
123
+ if (!navigator.sendBeacon(this.endpoint, new Blob([JSON.stringify(data)], { type: 'application/json' }))) this.fallbackSend(data);
143
124
  } else {
144
125
  this.fallbackSend(data);
145
126
  }
146
127
  }
147
128
 
148
- private fallbackSend(data: Payload) {
149
- fetch(this.endpoint, {
150
- method: 'POST',
151
- body: JSON.stringify(data),
152
- keepalive: true,
153
- headers: { 'Content-Type': 'application/json' }
154
- }).catch(err => console.error('[Senzor] Telemetry Error:', err));
129
+ private fallbackSend(data: any) {
130
+ fetch(this.endpoint, { method: 'POST', body: JSON.stringify(data), keepalive: true, headers: { 'Content-Type': 'application/json' } }).catch(() => { });
155
131
  }
156
132
 
157
133
  private setupListeners() {
158
- // SPA Support
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() {
159
441
  const originalPushState = history.pushState;
160
442
  history.pushState = (...args) => {
161
- this.trackPing(); // End previous page
443
+ this.flush(); // Flush previous page view
162
444
  originalPushState.apply(history, args);
163
- this.trackPageView(); // Start new page
445
+ this.startNewTrace(false);
446
+ this.addBreadcrumb('navigation', window.location.pathname);
164
447
  };
165
448
 
166
449
  window.addEventListener('popstate', () => {
167
- this.trackPing();
168
- this.trackPageView();
450
+ this.flush();
451
+ this.startNewTrace(false);
452
+ this.addBreadcrumb('navigation', window.location.pathname);
169
453
  });
170
454
 
171
- // Visibility & Unload
172
455
  document.addEventListener('visibilitychange', () => {
173
- if (document.visibilityState === 'hidden') {
174
- this.trackPing();
175
- } else {
176
- // User returned, restart timer (don't count background time)
177
- this.startTime = Date.now();
178
- this.checkSession(); // Verify session hasn't expired while tab was hidden
179
- }
456
+ if (document.visibilityState === 'hidden') this.flush();
180
457
  });
181
458
 
182
- window.addEventListener('beforeunload', () => {
183
- this.trackPing();
184
- });
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
+ }
185
496
  }
186
497
  }
187
498
 
188
- export const Senzor = new SenzorWebAgent();
499
+ // ============================================================================
500
+ // --- EXPORTS & INITIALIZATION ---
501
+ // ============================================================================
502
+
503
+ export const Analytics = new SenzorAnalyticsAgent();
504
+ export const RUM = new SenzorRumAgent();
505
+
506
+ // Maintain backwards compatibility for existing users
507
+ export const Senzor = {
508
+ init: (config: AnalyticsConfig) => Analytics.init(config),
509
+ initRum: (config: RumConfig) => RUM.init(config)
510
+ };
189
511
 
190
512
  if (typeof window !== 'undefined') {
191
513
  (window as any).Senzor = Senzor;