@senzops/web 1.2.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,24 +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 normalizeUrl;
13
- private manageSession;
14
- private determineReferrer;
15
- private getIds;
16
- private trackPageView;
17
- private trackPing;
18
- private send;
19
- private fallbackSend;
20
- private setupListeners;
21
- }
22
- declare const Senzor: SenzorWebAgent;
23
-
24
- 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,24 +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 normalizeUrl;
13
- private manageSession;
14
- private determineReferrer;
15
- private getIds;
16
- private trackPageView;
17
- private trackPing;
18
- private send;
19
- private fallbackSend;
20
- private setupListeners;
21
- }
22
- declare const Senzor: SenzorWebAgent;
23
-
24
- 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 d(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,a=>{let e=Math.random()*16|0;return(a==="x"?e:e&3|8).toString(16)})}var o=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(e){if(this.initialized){console.warn("[Senzor] Agent already initialized.");return}if(this.initialized=!0,this.config={...this.config,...e},this.endpoint=this.config.endpoint||"https://api.senzor.dev/api/ingest/web",!this.config.webId){console.error("[Senzor] WebId is required.");return}this.manageSession(),this.trackPageView(),this.setupListeners()}normalizeUrl(e){return e?e.replace(/^https?:\/\//,""):""}manageSession(){let e=Date.now(),t=parseInt(localStorage.getItem("senzor_last_activity")||"0",10),r=1800*1e3;localStorage.getItem("senzor_vid")||localStorage.setItem("senzor_vid",d());let i=sessionStorage.getItem("senzor_sid"),n=e-t>r;!i||n?(i=d(),sessionStorage.setItem("senzor_sid",i),this.determineReferrer(!0)):this.determineReferrer(!1),localStorage.setItem("senzor_last_activity",e.toString())}determineReferrer(e){let t=document.referrer,r=window.location.hostname,i=sessionStorage.getItem("senzor_ref"),n=!1;if(t)try{new URL(t).hostname!==r&&(n=!0)}catch{n=!0}if(n){let s=this.normalizeUrl(t);s!==i&&sessionStorage.setItem("senzor_ref",s)}else e&&!i&&sessionStorage.setItem("senzor_ref","Direct")}getIds(){return localStorage.setItem("senzor_last_activity",Date.now().toString()),{visitorId:localStorage.getItem("senzor_vid")||"unknown",sessionId:sessionStorage.getItem("senzor_sid")||"unknown",referrer:sessionStorage.getItem("senzor_ref")||"Direct"}}trackPageView(){this.manageSession(),this.startTime=Date.now();let e={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};this.send(e)}trackPing(){let e=Math.floor((Date.now()-this.startTime)/1e3);if(e<1)return;let t={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:e};this.send(t)}send(e){if(navigator.sendBeacon){let t=new Blob([JSON.stringify(e)],{type:"application/json"});navigator.sendBeacon(this.endpoint,t)||this.fallbackSend(e)}else this.fallbackSend(e)}fallbackSend(e){fetch(this.endpoint,{method:"POST",body:JSON.stringify(e),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(t=>console.error("[Senzor] Telemetry Error:",t))}setupListeners(){let e=history.pushState;history.pushState=(...t)=>{this.trackPing(),e.apply(history,t),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()})}},c=new o;typeof window<"u"&&(window.Senzor=c);})();
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 a=Object.defineProperty;var g=Object.getOwnPropertyDescriptor;var h=Object.getOwnPropertyNames;var p=Object.prototype.hasOwnProperty;var f=(n,e)=>{for(var t in e)a(n,t,{get:e[t],enumerable:!0})},w=(n,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of h(e))!p.call(n,i)&&i!==t&&a(n,i,{get:()=>e[i],enumerable:!(r=g(e,i))||r.enumerable});return n};var m=n=>w(a({},"__esModule",{value:!0}),n);var u={};f(u,{Senzor:()=>l});module.exports=m(u);function c(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,n=>{let e=Math.random()*16|0;return(n==="x"?e:e&3|8).toString(16)})}var d=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(e){if(this.initialized){console.warn("[Senzor] Agent already initialized.");return}if(this.initialized=!0,this.config={...this.config,...e},this.endpoint=this.config.endpoint||"https://api.senzor.dev/api/ingest/web",!this.config.webId){console.error("[Senzor] WebId is required.");return}this.manageSession(),this.trackPageView(),this.setupListeners()}normalizeUrl(e){return e?e.replace(/^https?:\/\//,""):""}manageSession(){let e=Date.now(),t=parseInt(localStorage.getItem("senzor_last_activity")||"0",10),r=1800*1e3;localStorage.getItem("senzor_vid")||localStorage.setItem("senzor_vid",c());let i=sessionStorage.getItem("senzor_sid"),s=e-t>r;!i||s?(i=c(),sessionStorage.setItem("senzor_sid",i),this.determineReferrer(!0)):this.determineReferrer(!1),localStorage.setItem("senzor_last_activity",e.toString())}determineReferrer(e){let t=document.referrer,r=window.location.hostname,i=sessionStorage.getItem("senzor_ref"),s=!1;if(t)try{new URL(t).hostname!==r&&(s=!0)}catch{s=!0}if(s){let o=this.normalizeUrl(t);o!==i&&sessionStorage.setItem("senzor_ref",o)}else e&&!i&&sessionStorage.setItem("senzor_ref","Direct")}getIds(){return localStorage.setItem("senzor_last_activity",Date.now().toString()),{visitorId:localStorage.getItem("senzor_vid")||"unknown",sessionId:sessionStorage.getItem("senzor_sid")||"unknown",referrer:sessionStorage.getItem("senzor_ref")||"Direct"}}trackPageView(){this.manageSession(),this.startTime=Date.now();let e={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};this.send(e)}trackPing(){let e=Math.floor((Date.now()-this.startTime)/1e3);if(e<1)return;let t={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:e};this.send(t)}send(e){if(navigator.sendBeacon){let t=new Blob([JSON.stringify(e)],{type:"application/json"});navigator.sendBeacon(this.endpoint,t)||this.fallbackSend(e)}else this.fallbackSend(e)}fallbackSend(e){fetch(this.endpoint,{method:"POST",body:JSON.stringify(e),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(t=>console.error("[Senzor] Telemetry Error:",t))}setupListeners(){let e=history.pushState;history.pushState=(...t)=>{this.trackPing(),e.apply(history,t),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()})}},l=new d;typeof window<"u"&&(window.Senzor=l);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 d(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,a=>{let e=Math.random()*16|0;return(a==="x"?e:e&3|8).toString(16)})}var o=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(e){if(this.initialized){console.warn("[Senzor] Agent already initialized.");return}if(this.initialized=!0,this.config={...this.config,...e},this.endpoint=this.config.endpoint||"https://api.senzor.dev/api/ingest/web",!this.config.webId){console.error("[Senzor] WebId is required.");return}this.manageSession(),this.trackPageView(),this.setupListeners()}normalizeUrl(e){return e?e.replace(/^https?:\/\//,""):""}manageSession(){let e=Date.now(),t=parseInt(localStorage.getItem("senzor_last_activity")||"0",10),r=1800*1e3;localStorage.getItem("senzor_vid")||localStorage.setItem("senzor_vid",d());let i=sessionStorage.getItem("senzor_sid"),n=e-t>r;!i||n?(i=d(),sessionStorage.setItem("senzor_sid",i),this.determineReferrer(!0)):this.determineReferrer(!1),localStorage.setItem("senzor_last_activity",e.toString())}determineReferrer(e){let t=document.referrer,r=window.location.hostname,i=sessionStorage.getItem("senzor_ref"),n=!1;if(t)try{new URL(t).hostname!==r&&(n=!0)}catch{n=!0}if(n){let s=this.normalizeUrl(t);s!==i&&sessionStorage.setItem("senzor_ref",s)}else e&&!i&&sessionStorage.setItem("senzor_ref","Direct")}getIds(){return localStorage.setItem("senzor_last_activity",Date.now().toString()),{visitorId:localStorage.getItem("senzor_vid")||"unknown",sessionId:sessionStorage.getItem("senzor_sid")||"unknown",referrer:sessionStorage.getItem("senzor_ref")||"Direct"}}trackPageView(){this.manageSession(),this.startTime=Date.now();let e={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};this.send(e)}trackPing(){let e=Math.floor((Date.now()-this.startTime)/1e3);if(e<1)return;let t={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:e};this.send(t)}send(e){if(navigator.sendBeacon){let t=new Blob([JSON.stringify(e)],{type:"application/json"});navigator.sendBeacon(this.endpoint,t)||this.fallbackSend(e)}else this.fallbackSend(e)}fallbackSend(e){fetch(this.endpoint,{method:"POST",body:JSON.stringify(e),keepalive:!0,headers:{"Content-Type":"application/json"}}).catch(t=>console.error("[Senzor] Telemetry Error:",t))}setupListeners(){let e=history.pushState;history.pushState=(...t)=>{this.trackPing(),e.apply(history,t),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()})}},c=new o;typeof window<"u"&&(window.Senzor=c);export{c 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.2.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,23 +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
- title: string;
14
- referrer: string;
15
- width: number;
16
- timezone: string;
17
- duration?: number;
18
- }
1
+ // ============================================================================
2
+ // --- SHARED UTILITIES ---
3
+ // ============================================================================
19
4
 
20
- // Browser-Native UUID (No Node.js dependencies)
5
+ // Native UUID Generator (No dependencies)
21
6
  function generateUUID(): string {
22
7
  if (typeof crypto !== 'undefined' && crypto.randomUUID) {
23
8
  return crypto.randomUUID();
@@ -29,31 +14,47 @@ function generateUUID(): string {
29
14
  });
30
15
  }
31
16
 
32
- class SenzorWebAgent {
33
- private config: Config;
34
- private startTime: number;
35
- private endpoint: string;
36
- private initialized: boolean;
37
-
38
- constructor() {
39
- this.config = { webId: '', endpoint: 'https://api.senzor.dev/api/ingest/web' };
40
- this.startTime = Date.now();
41
- this.endpoint = '';
42
- 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);
43
22
  }
23
+ return result.slice(0, length);
24
+ }
44
25
 
45
- public init(config: Config) {
46
- if (this.initialized) {
47
- console.warn('[Senzor] Agent already initialized.');
48
- return;
49
- }
50
- 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;
51
49
 
50
+ public init(config: AnalyticsConfig) {
51
+ if (this.initialized) return;
52
+ this.initialized = true;
52
53
  this.config = { ...this.config, ...config };
53
- this.endpoint = this.config.endpoint || 'https://api.senzor.dev/api/ingest/web';
54
+ if (config.endpoint) this.endpoint = config.endpoint;
54
55
 
55
56
  if (!this.config.webId) {
56
- console.error('[Senzor] WebId is required.');
57
+ console.error('[Senzor] webId is required for Analytics.');
57
58
  return;
58
59
  }
59
60
 
@@ -62,169 +63,451 @@ class SenzorWebAgent {
62
63
  this.setupListeners();
63
64
  }
64
65
 
65
- // Helper to normalize referrer (strip protocol)
66
66
  private normalizeUrl(url: string): string {
67
- if (!url) return '';
68
- return url.replace(/^https?:\/\//, '');
67
+ return url ? url.replace(/^https?:\/\//, '') : '';
69
68
  }
70
69
 
71
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
75
-
76
- // Visitor ID (Persistent 1 Year)
77
- if (!localStorage.getItem('senzor_vid')) {
78
- localStorage.setItem('senzor_vid', generateUUID());
79
- }
80
-
81
- // Session ID
82
- let sessionId = sessionStorage.getItem('senzor_sid');
83
- const isExpired = (now - lastActivity > sessionTimeout);
72
+ const lastActivity = parseInt(localStorage.getItem('sz_wa_last') || '0', 10);
73
+ if (!localStorage.getItem('sz_wa_vid')) localStorage.setItem('sz_wa_vid', generateUUID());
84
74
 
85
- // Session logic
86
- if (!sessionId || isExpired) {
75
+ let sessionId = sessionStorage.getItem('sz_wa_sid');
76
+ if (!sessionId || (now - lastActivity > 30 * 60 * 1000)) {
87
77
  sessionId = generateUUID();
88
- sessionStorage.setItem('senzor_sid', sessionId);
78
+ sessionStorage.setItem('sz_wa_sid', sessionId);
89
79
  this.determineReferrer(true);
90
80
  } else {
91
- // Ongoing session: Check if external source changed
92
81
  this.determineReferrer(false);
93
82
  }
94
-
95
- localStorage.setItem('senzor_last_activity', now.toString());
83
+ localStorage.setItem('sz_wa_last', now.toString());
96
84
  }
97
85
 
98
86
  private determineReferrer(isNewSession: boolean) {
99
87
  const rawReferrer = document.referrer;
100
- const currentHost = window.location.hostname;
101
- let storedReferrer = sessionStorage.getItem('senzor_ref');
102
-
103
88
  let isExternal = false;
104
89
  if (rawReferrer) {
105
- try {
106
- const refUrl = new URL(rawReferrer);
107
- // Compare hosts
108
- if (refUrl.hostname !== currentHost) {
109
- isExternal = true;
110
- }
111
- } catch (e) {
112
- isExternal = true;
113
- }
90
+ try { isExternal = new URL(rawReferrer).hostname !== window.location.hostname; } catch (e) { isExternal = true; }
114
91
  }
115
92
 
116
93
  if (isExternal) {
117
- // Always overwrite if it's a new external source
118
94
  const cleanRef = this.normalizeUrl(rawReferrer);
119
- // Only update if different to avoid redundant writes
120
- if (cleanRef !== storedReferrer) {
121
- sessionStorage.setItem('senzor_ref', cleanRef);
122
- }
123
- } else if (isNewSession && !storedReferrer) {
124
- // New session with internal/no referrer = Direct
125
- sessionStorage.setItem('senzor_ref', 'Direct');
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');
126
98
  }
127
99
  }
128
100
 
129
101
  private getIds() {
130
- localStorage.setItem('senzor_last_activity', Date.now().toString());
102
+ localStorage.setItem('sz_wa_last', Date.now().toString());
131
103
  return {
132
- visitorId: localStorage.getItem('senzor_vid') || 'unknown',
133
- sessionId: sessionStorage.getItem('senzor_sid') || 'unknown',
134
- referrer: sessionStorage.getItem('senzor_ref') || 'Direct'
104
+ visitorId: localStorage.getItem('sz_wa_vid') || 'unknown',
105
+ sessionId: sessionStorage.getItem('sz_wa_sid') || 'unknown',
106
+ referrer: sessionStorage.getItem('sz_wa_ref') || 'Direct'
135
107
  };
136
108
  }
137
109
 
138
110
  private trackPageView() {
139
111
  this.manageSession();
140
112
  this.startTime = Date.now();
141
-
142
- const payload: Payload = {
143
- type: 'pageview',
144
- webId: this.config.webId,
145
- ...this.getIds(),
146
- url: window.location.href,
147
- path: window.location.pathname,
148
- title: document.title,
149
- width: window.innerWidth,
150
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
151
- referrer: this.getIds().referrer
152
- };
153
-
154
- 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 });
155
114
  }
156
115
 
157
116
  private trackPing() {
158
117
  const duration = Math.floor((Date.now() - this.startTime) / 1000);
159
- if (duration < 1) return;
160
-
161
- const payload: Payload = {
162
- type: 'ping',
163
- webId: this.config.webId,
164
- ...this.getIds(),
165
- url: window.location.href,
166
- path: window.location.pathname,
167
- title: document.title,
168
- width: window.innerWidth,
169
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
170
- referrer: this.getIds().referrer,
171
- duration: duration
172
- };
173
-
174
- 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 });
175
119
  }
176
120
 
177
- private send(data: Payload) {
121
+ private send(data: any) {
178
122
  if (navigator.sendBeacon) {
179
- const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
180
- const success = navigator.sendBeacon(this.endpoint, blob);
181
- if (!success) this.fallbackSend(data);
123
+ if (!navigator.sendBeacon(this.endpoint, new Blob([JSON.stringify(data)], { type: 'application/json' }))) this.fallbackSend(data);
182
124
  } else {
183
125
  this.fallbackSend(data);
184
126
  }
185
127
  }
186
128
 
187
- private fallbackSend(data: Payload) {
188
- fetch(this.endpoint, {
189
- method: 'POST',
190
- body: JSON.stringify(data),
191
- keepalive: true,
192
- headers: { 'Content-Type': 'application/json' }
193
- }).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(() => { });
194
131
  }
195
132
 
196
133
  private setupListeners() {
197
- // 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() {
198
441
  const originalPushState = history.pushState;
199
442
  history.pushState = (...args) => {
200
- this.trackPing();
443
+ this.flush(); // Flush previous page view
201
444
  originalPushState.apply(history, args);
202
- this.trackPageView();
445
+ this.startNewTrace(false);
446
+ this.addBreadcrumb('navigation', window.location.pathname);
203
447
  };
204
448
 
205
449
  window.addEventListener('popstate', () => {
206
- this.trackPing();
207
- this.trackPageView();
450
+ this.flush();
451
+ this.startNewTrace(false);
452
+ this.addBreadcrumb('navigation', window.location.pathname);
208
453
  });
209
454
 
210
- // Visibility & Unload
211
455
  document.addEventListener('visibilitychange', () => {
212
- if (document.visibilityState === 'hidden') {
213
- this.trackPing();
214
- } else {
215
- // User returned, restart timer (don't count background time)
216
- this.startTime = Date.now();
217
- this.manageSession();
218
- }
456
+ if (document.visibilityState === 'hidden') this.flush();
219
457
  });
220
458
 
221
- window.addEventListener('beforeunload', () => {
222
- this.trackPing();
223
- });
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
+ }
224
496
  }
225
497
  }
226
498
 
227
- 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
+ };
228
511
 
229
512
  if (typeof window !== 'undefined') {
230
513
  (window as any).Senzor = Senzor;